/* Part-derived free-space masks and a route-ordered geometric guide. No driving model. */ (function(root,factory){ const api=factory(); if(typeof module==='object'&&module.exports)module.exports=api;else root.TrackMask=api; })(typeof window==='undefined'?this:window,function createMaskPlanner(){ const TILE=768,CELL=8,N=TILE/CELL,MARGIN=32,EPS=1e-7; const maskCache=new Map(); const dist=(a,b)=>Math.hypot(a[0]-b[0],a[1]-b[1]); function project(p,a,b){const dx=b[0]-a[0],dy=b[1]-a[1],d=dx*dx+dy*dy,t=d?Math.max(0,Math.min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/d)):0;return[a[0]+t*dx,a[1]+t*dy];} function inside(p,poly){let yes=false;for(let i=0,j=poly.length-1;ip[1])!==(b[1]>p[1])&&p[0]<(b[0]-a[0])*(p[1]-a[1])/(b[1]-a[1])+a[0])yes=!yes;}return yes;} function separation(a,b,c,d){const cross=(p,q,r)=>(q[0]-p[0])*(r[1]-p[1])-(q[1]-p[1])*(r[0]-p[0]);if(cross(a,b,c)*cross(a,b,d)<0&&cross(c,d,a)*cross(c,d,b)<0)return 0;return Math.min(dist(a,project(a,c,d)),dist(b,project(b,c,d)),dist(c,project(c,a,b)),dist(d,project(d,a,b)));} function shape(poly){return{poly,edges:poly.map((p,i)=>[p,poly[(i+1)%poly.length]]),box:[Math.min(...poly.map(p=>p[0])),Math.min(...poly.map(p=>p[1])),Math.max(...poly.map(p=>p[0])),Math.max(...poly.map(p=>p[1]))]};} function boxDistance(p,b){return Math.hypot(Math.max(b[0]-p[0],0,p[0]-b[2]),Math.max(b[1]-p[1],0,p[1]-b[3]));} function polygonClearance(p,obstacles,cap=256){let value=cap;for(const s of obstacles){if(boxDistance(p,s.box)>value)continue;if(inside(p,s.poly))return 0;for(const[a,b]of s.edges)value=Math.min(value,dist(p,project(p,a,b)));}return value;} function partMask(polygons){ if(!Array.isArray(polygons))throw Error('Native collision polygons are required for the road mask'); const key=JSON.stringify(polygons);if(maskCache.has(key))return maskCache.get(key); const obstacles=polygons.map(shape),free=new Uint8Array(N*N),radius=CELL*Math.SQRT2/2; // A blocked cell conservatively covers every polygon it touches. Obstacles // are a union: winding and overlaps cannot cancel walls or isolated posts. for(let y=0;yradius+EPS)free[y*N+x]=1; if(maskCache.size>=160)maskCache.delete(maskCache.keys().next().value); const result={cell_size:CELL,width:N,height:N,free};maskCache.set(key,result);return result; } function scene(track){ if(!track||!Array.isArray(track.tiles)||track.tiles.length<1||track.tiles.length>256)throw Error('Road mask requires 1–256 saved pieces'); const tiles=new Map();for(const t of track.tiles){if(!Number.isInteger(t.x)||!Number.isInteger(t.y)||!Array.isArray(t.polygons))throw Error('Native wall geometry is unavailable');const key=`${t.x},${t.y}`;if(tiles.has(key))throw Error('Duplicate road-mask tile');tiles.set(key,{...t,obstacles:t.polygons.map(shape),mask:partMask(t.polygons.map(poly=>poly.map(p=>[p[0]-t.x*TILE,p[1]-t.y*TILE]))),field:new Float64Array(N*N).fill(-1)});} const neighbors=new Map(); function obstacles(t){const key=`${t.x},${t.y}`;if(neighbors.has(key))return neighbors.get(key);const all=[];for(let y=t.y-1;y<=t.y+1;y++)for(let x=t.x-1;x<=t.x+1;x++){const own=tiles.get(`${x},${y}`);if(own)all.push(...own.obstacles);else all.push(shape([[x*TILE,y*TILE],[(x+1)*TILE,y*TILE],[(x+1)*TILE,(y+1)*TILE],[x*TILE,(y+1)*TILE]]));}neighbors.set(key,all);return all;} function clearance(p,t){return polygonClearance(p,obstacles(t));} function clear(a,b,t){ const x=t.x*TILE,y=t.y*TILE;if([a,b].some(p=>p[0]x+TILE+EPS||p[1]y+TILE+EPS))return false; const box=[Math.min(a[0],b[0])-MARGIN,Math.min(a[1],b[1])-MARGIN,Math.max(a[0],b[0])+MARGIN,Math.max(a[1],b[1])+MARGIN]; for(const s of obstacles(t)){if(s.box[0]>box[2]||s.box[2]box[3]||s.box[3]a.side===b.side&&a.index===b.index&&a.type===b.type,steps=[[0,-1],[0,1],[-1,0],[1,0]],seen=new Set(),route=[]; let t=s.tiles.get(track.start.join(',')),incoming=track.startConnection; while(t&&!seen.has(`${t.x},${t.y}`)){ if(t.connections?.length!==2||!t.connections.some(c=>equal(c,incoming)))throw Error('The saved route has an incompatible connection');seen.add(`${t.x},${t.y}`); const outgoing=t.connections.find(c=>!equal(c,incoming));if(!outgoing)throw Error('The saved route has no exit');route.push({tile:t,incoming,outgoing});const step=steps[outgoing.side];if(!step)throw Error('Invalid route side');t=s.tiles.get(`${t.x+step[0]},${t.y+step[1]}`);incoming={...outgoing,side:outgoing.side^1}; } if(!t||t.x!==track.start[0]||t.y!==track.start[1]||!equal(incoming,track.startConnection))throw Error('The saved route does not close at its saved start'); if(seen.size!==track.tiles.length)throw Error('The saved track contains pieces outside its single closed route');return route; } function portals(t,c,next,s){ const groups=[];let run=[]; for(let offset=0;offset<=TILE;offset+=CELL){const p=portPoint(t,c,offset),valid=s.clearance(p,t)>=MARGIN-EPS&&s.clearance(p,next)>=MARGIN-EPS;if(valid)run.push(offset);else if(run.length){groups.push(run);run=[];}} if(run.length)groups.push(run);if(!groups.length)throw Error('No shared opening keeps the 32-unit wall margin'); const center=[128,384,640][c.index],gap=g=>Math.max(g[0]-center,0,center-g[g.length-1]),closest=Math.min(...groups.map(gap)); // Split ports retain both real openings; normal ports associate with their // declared connection index. Adjacent unconnected boundaries are never edges. const selected=c.type===2?groups:groups.filter(g=>gap(g)<=closest+EPS); return selected.map(g=>portPoint(t,c,(g[0]+g[g.length-1])/2)); } class Heap{constructor(){this.items=[];}less(a,b){return a.f>1;if(!this.less(v,this.items[p]))break;this.items[i]=this.items[p];i=p;}this.items[i]=v;}pop(){if(!this.items.length)return null;const top=this.items[0],v=this.items.pop();if(this.items.length){let i=0;while(i*2+1=MARGIN-EPS&&s.clear(p,s.point(t,id),t))a.push(id);}return a;} function weight(d,a,b){return d*(1+1.5*(96/Math.max(MARGIN,(a+b)/2))**2);} for(const id of near(start)){const p=s.point(t,id);cost[id]=weight(dist(start,p),s.clearance(start,t),s.field(t,id));parent[id]=-1;heap.push({id,g:cost[id],f:cost[id]+dist(p,end)});} for(const id of near(end))goals.set(id,weight(dist(s.point(t,id),end),s.field(t,id),s.clearance(end,t))); let best=Infinity,last=-1,node;const directions=[[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,-1],[-1,1],[1,1]]; while((node=heap.pop())){ if(node.g!==cost[node.id])continue;if(node.f>=best)break;stats.expanded++;if(stats.expanded>6000000)throw Error('Road-mask search exceeds the bounded preview budget'); if(goals.has(node.id)&&node.g+goals.get(node.id)=N||yy<0||yy>=N)continue;const id=yy*N+xx,cb=s.field(t,id);if(cb=cost[id])continue;const b=s.point(t,id); // Exact sampled clearance is Lipschitz. Only near-boundary edges need // the more expensive continuous polygon test; neither mask nor EDT is // used as an unproven physical clearance guarantee. if(Math.min(ca,cb)-length/2=0;at=parent[at])points.push(s.point(t,at));points.push(start);points.reverse();return{points,cost:best}; } function simplify(points,t,s,spacing){const out=[points[0]];let i=0;while(iopenings[i+1].map(b=>path(route[i].tile,a,b,s,stats))),next=openings[i+1].map(()=>Infinity),previous=openings[i+1].map(()=>-1); for(let a=0;a=0;i--){const prev=parents[i][chosen];pieces[i]=matrices[i][prev][chosen].points;chosen=prev;} const length=pieces.reduce((sum,p)=>sum+p.slice(1).reduce((n,b,j)=>n+dist(p[j],b),0),0);let points,owners,anchors,spacing=Math.max(32,length/(2048-2*route.length)); for(let attempt=0;attempt<5;attempt++){ points=[];owners=[];anchors=[];pieces.forEach((p,i)=>{const row=simplify(p,route[i].tile,s,spacing);row.slice(0,-1).forEach((v,j)=>{points.push([...v]);owners.push(i);anchors.push(j===0);});});if(points.length<=2048)break;spacing*=1.5; } if(points.length>2048)throw Error('Exact mask route exceeds the 2,048-point preview budget'); const n=points.length,wrap=i=>(i+n)%n,bend=i=>{const a=points[wrap(i-1)],b=points[wrap(i)],c=points[wrap(i+1)];return(a[0]-2*b[0]+c[0])**2+(a[1]-2*b[1]+c[1])**2;},energy=()=>points.reduce((sum,_,i)=>sum+bend(i),0),before=energy(); for(let sweep=0;sweep<8;sweep++)for(let i=0;iv+.35*((a[k]+c[k])/2-v)); if(s.clearance(q,t)cost+EPS)points[i]=b; } for(let i=0;ip.length),segment_tiles:owners.map(i=>[route[i].tile.x,route[i].tile.y]),selected_portals:points.filter((_,i)=>anchors[i]),physical_mask:'tile square minus native polygon union',clearance_field:'exact sampled native polygon distance; continuous final certification',vehicle_model:false}; } return{partMask,line,cell_size:CELL,workerSource:()=>`(${createMaskPlanner.toString()})()`}; });