
First shout out to Path Splitter for the base functionality. DL Token Eraser (Jumpgate-safe) — erase DL with a token “brush” What it does:
Lets a GM erase portions of Dynamic Lighting walls (and other path layers) by moving a special Eraser token over them. The brush size = the token’s size. Supports Jumpgate ( pathv2 ) and the Foreground layer. Important behavior:
Erasing happens on token drop — i.e., after you release the mouse. It doesn’t live-erase while dragging. Dependencies (install these first)
VectorMath
MatrixMath
PathMath Install & Setup
Add the three dependencies above.
Add DL Token Eraser .
Create a token named “Eraser” (or create a Character named “Eraser” and use any token that represents it).
Resize that token to set your brush size.
As GM, open the menu:
!dleraser --menu
(All menus/messages are whispered to the GM.)
Basic Use
Place the Eraser token on any layer (Tokens, GM, Light/DL, Foreground, Map).
Move it over lines. When you let go of the token, the script removes the overlapped segments.
Toggle the script ON/OFF from the menu. OFF = zero background work.
Brush modes:
circle (smooth) or square (blocky). Switch in the menu.
Target layer (what gets erased):
Use layer aliases in the menu or via command:
light → Dynamic Lighting walls ( walls )
tokens → Objects ( objects )
gm → GM layer ( gmlayer )
fore → Foreground ( foreground )
map → Map ( map )
any → All path layers
Commands (GM)
Open menu: !dleraser --menu
Enable/disable: !dleraser --on / !dleraser --off
Mode: !dleraser --mode circle or --mode square
Target layer: !dleraser --layer light|tokens|gm|fore|map|any
Ribbon grid presets (Player Ribbon page):
!dleraser --grid 1 (1u = 70px)
!dleraser --grid 0.5
!dleraser --grid 0.25
!dleraser --grid 0.125
(Clamped to ~0.1–10 units to stay within safe bounds.)
Diagnostics (found under Help menu):
Scan (counts nearby paths)
Test (run on currently selected Eraser token)
Notes & Tips
Works with both classic path and Jumpgate pathv2 .
Foreground layer fully supported.
To “paint” erasures, do short drags and release repeatedly.
If performance dips, use OFF in the menu (no detection/timers while off).
That’s it! Drop the Eraser, adjust the size like a brush, and clean up DL lines quickly. /*
DL Token Eraser v1.8.5
----------------------------------------------------------------------
HOW TO USE (GM Quick Reference)
- Place/make a token named "Eraser" (or a Character named "Eraser").
- Resize the token to set the brush size. Move it over lines to erase.
- Toggle ON/OFF and change options from the menu:
!dleraser --menu
- Target layer aliases:
light(=DL walls), tokens(=objects), gm(=gmlayer), fore(=foreground), map, any
Example: !dleraser --layer light
- Modes: circle | square
Example: !dleraser --mode circle
- Ribbon grid presets (units; 1u=70px):
Buttons in the main menu (1u, 0.5u, 0.25u, 0.125u)
- Diagnostics (in Help): Scan, Test
Notes:
• OFF = no timers, no work.
• Supports Jumpgate (pathv2) and classic paths.
• All menus/output are whispered to GM only.
----------------------------------------------------------------------
*/
var DLTokenEraser = DLTokenEraser || (function(){
'use strict';
var KEY='DLTokenEraser.state';
var DEF={enabled:true, mode:'circle', layer:'light', debounceMs:120};
var LAYERS_ALIAS=['light','map','tokens','gm','fore']; // cycle order
var ALIAS_TO_API={ light:'walls', tokens:'objects', gm:'gmlayer', fore:'foreground', map:'map', any:'any' };
var API_TO_ALIAS={ walls:'light', objects:'tokens', gmlayer:'gm', foreground:'fore', map:'map' };
var POLY_SIDES = 32; // smoothness of circle brush polygon
var timers={};
var isJumpgate=(function(){ var c=null; return function(){ if(c===null){ c=(['jumpgate'].includes(Campaign().get('_release')));} return c; };})();
function S(){
if(!state[KEY]) state[KEY]={enabled:DEF.enabled, mode:DEF.mode, layer:DEF.layer};
if(!state[KEY].mode) state[KEY].mode=DEF.mode;
var L=state[KEY].layer;
if(['walls','objects','gmlayer','foreground','map'].indexOf(L)>=0){ state[KEY].layer = API_TO_ALIAS[L] || 'light'; }
if(!state[KEY].layer) state[KEY].layer=DEF.layer;
if(typeof state[KEY].enabled==='undefined') state[KEY].enabled=DEF.enabled;
return state[KEY];
}
function clearAllTimers(){ _.each(_.keys(timers), function(k){ try{ clearTimeout(timers[k]); }catch(e){} delete timers[k]; }); }
function isAlias(x){ return !!ALIAS_TO_API[x]; }
function aliasToApi(x){ return ALIAS_TO_API[x] || x; }
function validLayerAlias(x){ return isAlias(x) || x==='any'; }
function isEraser(g){
if(!g || g.get('type')!=='graphic') return false;
var n=(g.get('name')||'').trim().toLowerCase();
if(n==='eraser') return true;
var rep=g.get('represents');
if(rep){
var ch=getObj('character',rep);
if(ch && (ch.get('name')||'').trim().toLowerCase()==='eraser') return true;
}
return false;
}
function getPaths(pageid, layerAlias){
var out=[], target=aliasToApi(layerAlias);
(findObjs({_type:'path', _pageid:pageid})||[]).forEach(function(p){ if(target==='any'||p.get('layer')===target) out.push(p); });
(findObjs({_type:'pathv2', _pageid:pageid})||[]).forEach(function(p){ if(target==='any'||p.get('layer')===target) out.push(p); });
return out;
}
// ---- geometry
function circlePoly(g){
var cx=g.get('left'), cy=g.get('top'), r=Math.max(g.get('width'),g.get('height'))/2;
var pts=[]; for(var i=0;i<POLY_SIDES;i++){ var t=(i/POLY_SIDES)*Math.PI*2; pts.push({x:cx+r*Math.cos(t), y:cy+r*Math.sin(t)}); } return pts;
}
function squarePoly(g){ var hw=g.get('width')/2, hh=g.get('height')/2, cx=g.get('left'), cy=g.get('top');
return [{x:cx-hw,y:cy-hh},{x:cx+hw,y:cy-hh},{x:cx+hw,y:cy+hh},{x:cx-hw,y:cy+hh}]; }
function toVec(pt){ return [pt.x,pt.y,1]; }
function polySegs(pts){ var segs=[]; for(var i=0;i<pts.length;i++){ var a=pts[i], b=pts[(i+1)%pts.length]; segs.push([toVec(a),toVec(b)]);} return segs; }
function insideCircleXY(x,y,g){ var dx=x-g.get('left'), dy=y-g.get('top'); var r=Math.max(g.get('width'),g.get('height'))/2; return dx*dx+dy*dy<=r*r; }
function insideSquareXY(x,y,g){ var hw=g.get('width')/2, hh=g.get('height')/2, cx=g.get('left'), cy=g.get('top'); return x>=cx-hw && x<=cx+hw && y>=cy-hh && y<=cy+hh; }
function sampleMid(segPath){ if(!segPath.length) return null; var a=segPath[0][0], b=segPath[0][1]; return {x:(a[0]+b[0])/2, y:(a[1]+b[1])/2}; }
function splitSegPaths(mainSegments, splitterSegments){
var out=[], cur=[], hitAny=false;
_.each(mainSegments, function(seg1){
var xs=[];
_.each(splitterSegments, function(seg2){ var hit=PathMath.segmentIntersection(seg1, seg2); if(hit) xs.push(hit); });
if(xs.length){ hitAny=true; xs.sort(function(a,b){ return a[1]-b[1]; });
var last=seg1[0];
_.each(xs, function(h){ cur.push([last,h[0]]); out.push(cur); cur=[]; last=h[0]; });
cur.push([last, seg1[1]]);
} else { cur.push(seg1); }
});
out.push(cur);
return { segPaths: out, hadIntersection: hitAny };
}
// convex containment: if both endpoints of every segment are in the brush, the whole path is inside
function pathFullyInsideBrush(mainSegments, g){
var circleMode = (S().mode==='circle');
for(var i=0;i<mainSegments.length;i++){
var a=mainSegments[i][0], b=mainSegments[i][1];
var ax=a[0], ay=a[1], bx=b[0], by=b[1];
if(circleMode){
if(!insideCircleXY(ax,ay,g) || !insideCircleXY(bx,by,g)) return false;
} else {
if(!insideSquareXY(ax,ay,g) || !insideSquareXY(bx,by,g)) return false;
}
}
return true;
}
function createFromSegments(pageid, layerApi, stroke, sw, fill, name, segments){
var data = PathMath.segmentsToPath(segments);
var extras = { _pageid: pageid, layer: layerApi, stroke: stroke, stroke_width: sw, fill: (fill||'transparent'), name: (name||'') };
if(isJumpgate()){
var blob = _.extend({}, data, extras);
if(typeof blob.rotation === 'undefined') blob.rotation = 0;
if(!blob.shape) blob.shape = 'free';
return createObj('pathv2', blob);
} else {
var blob2 = _.extend({}, data, extras);
if(typeof blob2.rotation === 'undefined') blob2.rotation = 0;
return createObj('path', blob2);
}
}
// ---- core erase
function eraseUnder(g){
if(!S().enabled) return;
var pageid=g.get('pageid');
var poly=(S().mode==='square')?squarePoly(g):circlePoly(g);
var splitter=polySegs(poly);
// coarse cull
var minx=Infinity,miny=Infinity,maxx=-Infinity,maxy=-Infinity, PAD=50;
_.each(poly,function(pt){ minx=Math.min(minx,pt.x); miny=Math.min(miny,pt.y); maxx=Math.max(maxx,pt.x); maxy=Math.max(maxy,pt.y); });
minx-=PAD; miny-=PAD; maxx+=PAD; maxy+=PAD;
var targets=getPaths(pageid, S().layer);
_.each(targets, function(p){
var l=p.get('left'), t=p.get('top'), w=p.get('width'), h=p.get('height');
var x0=l-w/2, y0=t-h/2, x1=l+w/2, y1=t+h/2;
if(x1<minx || x0>maxx || y1<miny || y0>maxy) return;
var mainSegs=PathMath.toSegments(p); if(!mainSegs || !mainSegs.length) return;
var split = splitSegPaths(mainSegs, splitter);
var segPaths = split.segPaths;
var hadX = split.hadIntersection;
var keep=[];
_.each(segPaths, function(sp){
if(!sp.length) return;
var mp=sampleMid(sp); if(!mp) return;
var outside = (S().mode==='square')? !insideSquareXY(mp.x, mp.y, g) : !insideCircleXY(mp.x, mp.y, g);
if(outside) keep.push(sp);
});
if(!hadX && !pathFullyInsideBrush(mainSegs, g)) return;
var stroke=p.get('stroke'), sw=p.get('stroke_width'), fill=p.get('fill')||'transparent', name=p.get('name')||'';
var layerApi=p.get('layer');
try{ p.remove(); }catch(e){}
_.each(keep, function(sp){ createFromSegments(pageid, layerApi, stroke, sw, fill, name, sp); });
});
}
function deb(g){
if(!S().enabled) return;
var id=g.id; if(timers[id]) clearTimeout(timers[id]);
timers[id]=setTimeout(function(){ if(S().enabled) eraseUnder(g); delete timers[id]; }, S().debounceMs||DEF.debounceMs);
}
// ===== MENU / HELP =====
function b(lbl, cmd){ return '['+lbl+'](!dleraser '+cmd+')'; }
function renderMenu(){
var st=S(), onoff=st.enabled?'ON':'OFF', mode=st.mode, layerAlias=st.layer;
var toggleOnOff=b((st.enabled?'Disable':'Enable'),'--'+(st.enabled?'off':'on')+' --menu-refresh');
var toggleMode=b('Toggle Mode','--mode '+(mode==='circle'?'square':'circle')+' --menu-refresh');
var cycleLayer=b('Cycle Layer','--cycle layer --menu-refresh');
var setLight=b('light','--layer light --menu-refresh');
var setMap=b('map','--layer map --menu-refresh');
var setTokens=b('tokens','--layer tokens --menu-refresh');
var setGM=b('gm','--layer gm --menu-refresh');
var setFore=b('fore','--layer fore --menu-refresh');
// Ribbon presets (units)
var u1=b('1u','--grid 1 --menu-refresh');
var u05=b('0.5u','--grid 0.5 --menu-refresh');
var u025=b('0.25u','--grid 0.25 --menu-refresh');
var u0125=b('0.125u','--grid 0.125 --menu-refresh');
var help=b('Help','--help');
var msg='&{template:default} '+
'{{name=**DL Token Eraser**}}'+
'{{Status='+onoff+' '+toggleOnOff+'}}'+
'{{Mode='+mode+' '+toggleMode+'}}'+
'{{Target Layer='+layerAlias+' '+cycleLayer+'}}'+
'{{Set Layer='+setLight+' '+setMap+' '+setTokens+' '+setGM+' '+setFore+'}}'+
'{{Ribbon Grid (units)='+u1+' '+u05+' '+u025+' '+u0125+'}}'+
'{{Actions='+help+'}}'+
'{{Tip=Resize the **Eraser** token to change brush size; drag it over DL to erase.}}';
sendChat('DL Eraser','/w gm '+msg);
}
function renderHelp(){
var scan=b('Scan','--scan');
var test=b('Test (select token)','--test');
var msg='&{template:default} {{name=DL Token Eraser — Help}}'+
'{{Brush=Token size sets eraser diameter.}}'+
'{{Layers=**--layer light|tokens|gm|fore|map|any** (light=DL walls, fore=Foreground).}}'+
'{{Modes=**circle** (smooth) or **square** (blocky).}}'+
'{{Ribbon Grid=Main menu has 1u, 0.5u, 0.25u, 0.125u buttons (1u=70px).}}'+
'{{Diagnostics='+scan+' '+test+'}}'+
'{{Off=When OFF, the script does zero work (no timers, no detection).}}';
sendChat('DL Eraser','/w gm '+msg);
}
function cycleLayer(){
var st=S();
if(st.layer==='any'){ st.layer='light'; return; }
var i=LAYERS_ALIAS.indexOf(st.layer);
st.layer = (i<0) ? 'light' : LAYERS_ALIAS[(i+1)%LAYERS_ALIAS.length];
}
// ---- events
function onChangeGraphic(obj, prev){
if(!S().enabled) return;
if(!isEraser(obj)) return;
var moved=(obj.get('left')!==prev.left)||(obj.get('top')!==prev.top)||(obj.get('width')!==prev.width)||(obj.get('height')!==prev.height);
if(moved){
var id=obj.id; if(timers[id]) clearTimeout(timers[id]);
timers[id]=setTimeout(function(){ if(S().enabled) eraseUnder(obj); delete timers[id]; }, S().debounceMs||DEF.debounceMs);
}
}
function onChat(msg){
if(msg.type!=='api') return;
var parts=(msg.content||'').trim().split(/\s+--/);
if(parts[0]!=='!dleraser') return;
if(parts.length===1){ renderMenu(); return; }
var rendered=false;
_.each(parts.slice(1), function(raw){
var a=raw.trim(); var al=a.toLowerCase();
if(al==='on'){ S().enabled=true; }
else if(al==='off'){ S().enabled=false; clearAllTimers(); }
else if(al.startsWith('mode')){ var m=a.split(/\s+/)[1]; if(m==='circle'||m==='square') S().mode=m; }
else if(al.startsWith('layer')){ var L=a.split(/\s+/)[1]; if(validLayerAlias(L)) S().layer=L; }
else if(al==='cycle layer'){ cycleLayer(); }
else if(al==='menu'){ renderMenu(); rendered=true; }
else if(al==='menu-refresh'){ /* re-render at end */ }
else if(al==='help'){ renderHelp(); rendered=true; }
// Diagnostics (Help-only buttons still processed here)
else if(al==='scan'){
var pg=Campaign().get('playerpageid'), counts={};
['light','map','tokens','gm','fore','any'].forEach(function(alias){ counts[alias]=getPaths(pg,alias).length; });
sendChat('DL Eraser','/w gm '+'Scan (Jumpgate '+(isJumpgate()?'yes':'no')+'): '+
'light='+counts.light+', map='+counts.map+', tokens='+counts.tokens+', gm='+counts.gm+', fore='+counts.fore+', any='+counts.any+'.');
}
else if(al==='test'){
var sel=msg.selected||[], g=sel.length?getObj('graphic', sel[0]._id):null;
if(!g||!isEraser(g)){ sendChat('DL Eraser','/w gm '+'Select the “Eraser” token first.'); return; }
var pageid=g.get('pageid'), layerAlias=S().layer, paths=getPaths(pageid,layerAlias);
var poly=(S().mode==='square')?squarePoly(g):circlePoly(g);
var minx=Infinity,miny=Infinity,maxx=-Infinity,maxy=-Infinity, PAD=50;
_.each(poly,function(pt){ minx=Math.min(minx,pt.x); miny=Math.min(miny,pt.y); maxx=Math.max(maxx,pt.x); maxy=Math.max(maxy,pt.y); });
minx-=PAD; miny-=PAD; maxx+=PAD; maxy+=PAD;
var near=0; _.each(paths,function(p){ var l=p.get('left'),t=p.get('top'),w=p.get('width'),h=p.get('height');
var x0=l-w/2,y0=t-h/2,x1=l+w/2,y1=t+h/2; if(!(x1<minx||x0>maxx||y1<miny||y0>maxy)) near++; });
sendChat('DL Eraser','/w gm '+'Test: layer='+layerAlias+', mode='+S().mode+', near='+near+' / total='+paths.length+'.');
}
// Ribbon grid presets (apply to ribbon page)
else if(al.startsWith('grid')){
var u = parseFloat(a.split(/\s+/)[1]);
if(!isFinite(u)){ sendChat('DL Eraser','/w gm '+'Usage: **!dleraser --grid <units>** (0.1–10)'); return; }
var pid = Campaign().get('playerpageid');
var page = pid && getObj('page', pid);
if(!page){ sendChat('DL Eraser','/w gm '+'Could not find the ribbon page.'); return; }
var clamped = Math.max(0.1, Math.min(10, u));
page.set('snapping_increment', clamped);
var got = parseFloat(page.get('snapping_increment')) || clamped;
var gotPx = Math.round(got*70*100)/100;
sendChat('DL Eraser','/w gm '+'Ribbon grid set to **'+got+'u** (~'+gotPx+'px).');
}
});
if(!rendered){ renderMenu(); }
}
on('ready', function(){
S();
log('[DL Token Eraser] v1.8.5 loaded. All output whispered to GM.');
});
on('chat:message', onChat);
on('change:graphic', onChangeGraphic);
})();