diff --git a/headless/README.md b/headless/README.md index d35c200..658af05 100644 --- a/headless/README.md +++ b/headless/README.md @@ -431,3 +431,14 @@ node paint bounds. Unrelated titles do not invalidate a protocol that declares only another data field. Omitting dependencies conservatively compares all data; components must declare every data field their geometry reads. The scene still gets scanned, and connection paint currently has its separate computation path. + +`createConnectionPaintIndex` retains curve hulls and grid membership across view +updates. Its signature includes the connection record/projection, numeric endpoint +anchors, transform and endpoint rectangles. Equal ACKs and unrelated node titles +do not call curve/stroke geometry again. Cached segment counts still consume the +query budget. Invalid geometry stays unknown and is retried; removed edges release +entries. Registry/project/canvas changes or `clear()` reset the cache. Hosts whose +geometry services capture changing external configuration must pass a changed +`geometryRevision` to `sync`. CanvasView now uses this index with native handles. +The edge list/signature comparison still gets scanned; this is not full dirty-region +drawing or selective offscreen mounting. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 240f3b3..46fbead 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -10,8 +10,7 @@ import { defaultCanvasRenderers } from "./canvas-renderers"; import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types"; import { equal } from "@/lib/canvas-core/values"; import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache"; -import {createPaintIndex} from "@/lib/canvas-bindings/paint-index"; -import {projectedConnectionPaintOverflow} from "@/lib/canvas-bindings/connection-paint"; +import {createConnectionPaintIndex} from "@/lib/canvas-bindings/connection-paint"; import {nativeRectVisible} from "@/lib/canvas-bindings/visibility"; import {createOverlayIndex} from "@/lib/canvas-bindings/overlays"; import {CanvasOverlays} from "./canvas-overlays"; @@ -92,8 +91,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const overlayVisible=!!overlaySnapshot.length&&(!surfaceBounds||overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation).some(entry=>!nativeVisible(entry.target.id))); const paintVisible=!surfaceWidth||!surfaceHeight||paint.unknown.some(id=>!nativeVisible(id))||[...paint.query(sceneViewport)].some(id=>!nativeVisible(id)); const nativeNodes=useStore(state=>state.nodes); - const connectionPaint=useMemo(()=>{const result=projectedConnectionPaintOverflow(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),index=createPaintIndex();index.sync(result.regions);return{index,unknown:result.unknown};},[projection,engine,flow,nativeNodes]); - const connectionPaintVisible=connectionPaint.unknown.length>0||!surfaceWidth||!surfaceHeight||connectionPaint.index.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0; + const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]); + const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]); + const connectionPaintVisible=connectionPaint.unknown.length>0||!surfaceWidth||!surfaceHeight||connectionPaint.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0; const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]); const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions }; const report = useCallback((result: Result | { enabled: boolean; code?: string }) => { diff --git a/src/lib/canvas-bindings/connection-paint.ts b/src/lib/canvas-bindings/connection-paint.ts index 655e1f4..dbe9562 100644 --- a/src/lib/canvas-bindings/connection-paint.ts +++ b/src/lib/canvas-bindings/connection-paint.ts @@ -1,28 +1,42 @@ import type {Registry} from '../canvas-core/types.ts'; import type {projectCanvas} from './projection.ts'; import type {HitServices} from './hit-query.ts'; -import type {PaintRegion} from './paint-index.ts'; +import {createPaintIndex,type PaintRegion} from './paint-index.ts'; +import {equal} from '../canvas-core/values.ts'; import {compatConnector} from './connectors.ts'; /** Conservative cubic control hull + half stroke, in the displayed scene space. * Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */ -export function projectedConnectionPaintOverflow(projection:ReturnType,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){ +function connectionPaint(projection:ReturnType,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map){ const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map(projection.nodes.map(node=>[node.id,node])); if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget'); - let remainingSegments=maxSegments; + let remainingSegments=maxSegments,computed=0; for(let index=0;index=maxEdges){unknown.push('paint_budget');break;}const edge=projection.edges[index]; try{const record=projection.document.elements.get(edge.id),source=nodes.get(edge.source),target=nodes.get(edge.target);if(!record||record.structure!=='connection'||!source||!target)throw Error(); const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left'); - const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform)throw Error(); + const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error(); const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined; + const signature={record,edge,from:{point:{...from.point},normal:{...from.normal}},to:{point:{...to.point},normal:{...to.normal}},transform:{...transform},source:{position:{...source.position},width:source.width,height:source.height},target:{position:{...target.position},width:target.width,height:target.height}},old=cache?.get(edge.id); + if(old&&equal(old.signature,signature)){if(old.segments>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=old.segments;if(old.region)regions.push(old.region);continue;} + computed++; const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined); - if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length)continue;if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length; + if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length; let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity; for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}} const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error(); const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)}; - if(!Object.values(native).every(Number.isFinite))throw Error();if(xnative.right||y+height>native.bottom)regions.push({id:edge.id,bounds:{x,y,width,height}}); - }catch{unknown.push(edge.id);} + if(!Object.values(native).every(Number.isFinite))throw Error();const region=xnative.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region}); + }catch{cache?.delete(edge.id);unknown.push(edge.id);} } - return{regions,unknown}; + return{regions,unknown,computed}; +} + +export function projectedConnectionPaintOverflow(projection:ReturnType,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);} +/** Host revision invalidates geometry services whose external configuration changes. */ +export function createConnectionPaintIndex(){ + const cache=new Map(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0; + return Object.freeze({sync(projection:ReturnType,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){ + if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;} + const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)}); + },stats:()=>Object.freeze({computed,entries:cache.size}),clear(){cache.clear();index.clear();owner=undefined;project=undefined;canvas=undefined;revision=undefined;}}); } diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index 061b8f6..b1353f3 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -18,7 +18,7 @@ export {createSessionFrameSource} from './session-frames.ts'; export {createPaintIndex} from './paint-index.ts'; export type {PaintRegion} from './paint-index.ts'; export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-paint.ts'; -export {projectedConnectionPaintOverflow} from './connection-paint.ts'; +export {projectedConnectionPaintOverflow,createConnectionPaintIndex} from './connection-paint.ts'; export {createOverlayIndex} from './overlays.ts'; export type {OverlayEntry,OverlayIndex} from './overlays.ts'; export {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from './pointer-capture.ts'; diff --git a/tests/headless/connection-paint.test.mjs b/tests/headless/connection-paint.test.mjs index 51a6429..d12525f 100644 --- a/tests/headless/connection-paint.test.mjs +++ b/tests/headless/connection-paint.test.mjs @@ -6,5 +6,9 @@ import {createPaintIndex} from '../../src/lib/canvas-bindings/paint-index.ts'; const registry=createRegistry(builtinComponents()),node=(id,x)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x,y:500,width:100,height:100,rotation:0}}),edge=(id='edge')=>({id,incarnation:1,ownerCanvasId:'p',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null}); const draw=(edges=[edge()])=>projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),...edges]),'p',registry,new Set(),()=>undefined); test('bowed cubic paint is indexed outside endpoint rectangles including stroke while document bounds remain unchanged',()=>{const projection=draw(),result=projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:100,y:-500},{x:500,y:-500},{x:500,y:550}]],strokeWidth:()=>10});assert.deepEqual(result.unknown,[]);assert.deepEqual(result.regions[0].bounds,{x:95,y:-505,width:410,height:1060});const index=createPaintIndex();index.sync(result.regions);assert.ok(index.query({x:250,y:0,width:10,height:10}).has('edge'));assert.equal(projection.document.elements.get('a').geometry.y,500);}); -test('straight contained paint needs no overflow; missing anchors and invalid geometry stay unknown',()=>{const projection=draw();assert.equal(projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2}).regions.length,0);for(const services of [{anchor:()=>undefined},{edgeGeometry:()=>[[{x:NaN,y:0}]],strokeWidth:()=>1},{strokeWidth:()=>Infinity}])assert.deepEqual(projectedConnectionPaintOverflow(projection,registry,services).unknown,['edge']);}); +test('straight contained paint needs no overflow; missing anchors and invalid geometry stay unknown',()=>{const projection=draw();assert.equal(projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2}).regions.length,0);for(const services of [{anchor:()=>undefined},{anchor:()=>({point:{x:NaN,y:0},normal:{x:1,y:0}})},{edgeGeometry:()=>[[{x:NaN,y:0}]],strokeWidth:()=>1},{strokeWidth:()=>Infinity}])assert.deepEqual(projectedConnectionPaintOverflow(projection,registry,services).unknown,['edge']);}); test('work budgets return unknown instead of treating omitted edges or excessive segments as invisible',()=>{const projection=draw([edge('one'),edge('two')]);assert.ok(projectedConnectionPaintOverflow(projection,registry,{},1).unknown.includes('paint_budget'));assert.deepEqual(projectedConnectionPaintOverflow(draw(),registry,{edgeGeometry:()=>[[],[]]},4096,1).unknown,['edge']);assert.throws(()=>projectedConnectionPaintOverflow(draw(),registry,{},0));}); +import {createConnectionPaintIndex} from '../../src/lib/canvas-bindings/connection-paint.ts'; +test('connection geometry cache skips equal ACKs and unrelated titles, invalidates measured anchors, stroke configuration and lifecycle',()=>{const cache=createConnectionPaintIndex();let geometryCalls=0,shift=0,width=2;const services={anchor:(node,_handle,end)=>({point:{x:node.position.x+(end==='source'?100:0)+shift,y:550},normal:{x:end==='source'?1:-1,y:0}}),edgeGeometry:(_edge,from,to)=>{geometryCalls++;return[[from.point,{x:200,y:550},{x:400,y:550},to.point]];},strokeWidth:()=>width};cache.sync(draw(),registry,services);assert.equal(geometryCalls,1);cache.sync(draw(),registry,services);assert.equal(geometryCalls,1);const p=draw();p.nodes[0].data.title='unrelated node title';cache.sync(p,registry,services);assert.equal(geometryCalls,1);shift=10;cache.sync(draw(),registry,services);assert.equal(geometryCalls,2);width=20;cache.sync(draw(),registry,services,'new-stroke-config');assert.equal(geometryCalls,3);const restored=projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),{...edge(),incarnation:2}]),'p',registry,new Set(),()=>undefined);cache.sync(restored,registry,services,'new-stroke-config');assert.equal(geometryCalls,4);cache.sync({...restored,edges:[]},registry,services,'new-stroke-config');assert.equal(cache.stats().entries,0);}); +test('cached segment costs still respect a smaller budget instead of claiming complete coverage',()=>{const cache=createConnectionPaintIndex(),projection=draw([edge('one'),edge('two')]),services={edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2};cache.sync(projection,registry,services);const computed=cache.stats().computed,limited=cache.sync(projection,registry,services,undefined,{maxSegments:1});assert.ok(limited.unknown.includes('paint_budget'));assert.equal(cache.stats().computed,computed);cache.clear();assert.equal(cache.stats().entries,0);}); +test('connection preview paths invalidate paint even while the committed connection record stays unchanged',()=>{const cache=createConnectionPaintIndex(),original=draw(),nearBow={x:250,y:-50,width:100,height:100};assert.equal(cache.sync(original,registry).query(nearBow).size,0);const preview=draw();preview.edges[0].data.path={type:'bezier-chain',knots:[{id:'preview-knot',mode:'corner',position:{x:300,y:-1000},in:{x:-100,y:0},out:{x:100,y:0}}]};const shown=cache.sync(preview,registry);assert.ok(shown.query(nearBow).has('edge'));assert.equal(preview.document.elements.get('edge').path,null);assert.equal(cache.sync(draw(),registry).query(nearBow).size,0);assert.equal(cache.stats().computed,3);});