Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×
Create a free account

[Script][DL Token Eraser] Use a token to erase portions of a drawn line, useful editing dynamic lighting lines

1757008825

Edited 1757010912
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); })();
1757014943
Gold
Forum Champion
This functionality is often requested,&nbsp; with at least 2 active Suggestions for Roll20 to add something like this as a feature. <a href="https://app.roll20.net/forum/search?q=erase%20dynamic%20walls&amp;c=" rel="nofollow">https://app.roll20.net/forum/search?q=erase%20dynamic%20walls&amp;c=</a> Thanks for creating a new API Mod script based on PathSplitter&nbsp;
Nice work, I look forward to trying it out!
I just tried this out in one of my games, and it works great! I created an "Eraser" NPC character sheet and gave it an invisible token with a "0" radius aura . The aura gives me a visible token which I can see "through" while&nbsp; I'm lining it up over what I want to erase.