diff --git a/headless/README.md b/headless/README.md index e63372c..796abf0 100644 --- a/headless/README.md +++ b/headless/README.md @@ -321,3 +321,10 @@ suspend native culling so extended paint is retained. This currently disables culling for the view rather than selectively retaining one node. Geometry extensions must declare their paint extent; undeclared DOM overflow cannot be inferred. Edge/overlay paint indexing and region-based drawing remain separate work. + +`createProjectedPaintIndex` retains the grid per engine/view and caches each node's +paint result by projected node identity, immutable record identity and canvas +transform. Unchanged nodes reuse their geometry result; removed nodes release +cache entries. Viewport queries reuse the grid. `clear()` discards it. The cache +still scans projected nodes and synchronizes rectangle membership; it is not a +claim of constant-time full-frame updates or complete region-based drawing. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 1f5add8..184a301 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 {projectedPaintOverflow} from "@/lib/canvas-bindings/projected-paint"; +import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint"; import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames"; import {routeCanvasKey} from "@/lib/canvas-bindings/input-router"; import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query"; @@ -75,9 +74,10 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; } return projectionCache.current!.reuse(engine,root.document,view.canvasId,result); }, [engine, root.document, sessions, view,renderers,catalog]); - const paint=useMemo(()=>{const result=projectedPaintOverflow(projection,engine.editor.registry),index=createPaintIndex();index.sync(result.regions);return{index,unknown:result.unknown};},[projection,engine]); + const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]); + const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]); const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height); - const paintVisible=paint.unknown.length>0||!surfaceWidth||!surfaceHeight||paint.index.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0; + const paintVisible=paint.unknown.length>0||!surfaceWidth||!surfaceHeight||paint.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/index.ts b/src/lib/canvas-bindings/index.ts index 85358db..f119ddd 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -17,4 +17,4 @@ export type {FrameClock} from './frame-queue.ts'; export {createSessionFrameSource} from './session-frames.ts'; export {createPaintIndex} from './paint-index.ts'; export type {PaintRegion} from './paint-index.ts'; -export {projectedPaintOverflow} from './projected-paint.ts'; +export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-paint.ts'; diff --git a/src/lib/canvas-bindings/projected-paint.ts b/src/lib/canvas-bindings/projected-paint.ts index 73686ef..db7474e 100644 --- a/src/lib/canvas-bindings/projected-paint.ts +++ b/src/lib/canvas-bindings/projected-paint.ts @@ -2,7 +2,7 @@ import {rectangleGeometry} from '../canvas-core/geometry.ts'; import {toLocal,toWorld} from '../canvas-core/coordinates.ts'; import type {Registry} from '../canvas-core/types.ts'; import type {projectCanvas} from './projection.ts'; -import type {PaintRegion} from './paint-index.ts'; +import {createPaintIndex,type PaintRegion} from './paint-index.ts'; /** Extra node paint extents beyond native unrotated React Flow bounds. */ export function projectedPaintOverflow(projection:ReturnType,registry:Registry){ const regions:PaintRegion[]=[],unknown:string[]=[]; @@ -17,3 +17,24 @@ export function projectedPaintOverflow(projection:ReturnType; + type Entry={node:Projection['nodes'][number];element:unknown;transform:string;result:ReturnType}; + const entries=new Map(),index=createPaintIndex(); + return Object.freeze({ + sync(projection:Projection,registry:Registry){ + if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;} + const seen=new Set(),regions:PaintRegion[]=[],unknown:string[]=[]; + for(const node of projection.nodes){seen.add(node.id);const element=projection.document.elements.get(node.id),t=element&&projection.transforms.get(element.ownerCanvasId),transform=t?`${t.x},${t.y},${t.scale},${t.rotation}`:'missing',old=entries.get(node.id); + const result=old&&old.node===node&&old.element===element&&old.transform===transform?old.result:projectedPaintOverflow({...projection,nodes:[node]},registry); + if(result!==old?.result)computed++;entries.set(node.id,{node,element,transform,result});regions.push(...result.regions);unknown.push(...result.unknown); + } + for(const id of entries.keys())if(!seen.has(id))entries.delete(id);index.sync(regions);return Object.freeze({query:index.query,unknown:Object.freeze(unknown)}); + }, + stats:()=>Object.freeze({computed,entries:entries.size}), + clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;}, + }); +} diff --git a/tests/headless/paint-index.test.mjs b/tests/headless/paint-index.test.mjs index bb3cd91..fe22eae 100644 --- a/tests/headless/paint-index.test.mjs +++ b/tests/headless/paint-index.test.mjs @@ -6,3 +6,5 @@ import {projectedPaintOverflow} from '../../src/lib/canvas-bindings/projected-pa import {createRegistry,createDocument,builtinComponents} from '../../src/lib/canvas-core/index.ts'; import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts'; test('projected paint uses paint role independently of logical bounds and conservatively retains failed handlers',()=>{const text=builtinComponents().find(c=>c.id==='text'),roles=[],geometry={...text.geometry,bounds(context){roles.push(context.role);if(context.element.data.title==='broken')throw Error('broken');return context.role==='paint'?{x:-100,y:-50,width:400,height:200}:{x:0,y:0,width:100,height:100};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),node=(id,title)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title,text:''}}),document=createDocument('p',[{id:'p',parent:null}],[node('a','okay'),node('b','broken')]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),result=projectedPaintOverflow(projection,registry);assert.ok(roles.includes('paint'));assert.deepEqual(result.regions,[{id:'a',bounds:{x:-100,y:-50,width:400,height:200}}]);assert.deepEqual(result.unknown,['b']);assert.equal(document.elements.get('a').geometry.width,100);}); +import {createProjectedPaintIndex} from '../../src/lib/canvas-bindings/projected-paint.ts'; +test('view paint cache reuses unchanged records, invalidates transform/identity and drops removed rows',()=>{const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:45},data:{title:'a',text:''}},document=createDocument('p',[{id:'p',parent:null}],[node]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();cache.sync(projection,registry);assert.equal(cache.stats().computed,1);cache.sync({...projection},registry);assert.equal(cache.stats().computed,1);const transforms=new Map(projection.transforms),t=transforms.get('p');transforms.set('p',{...t,x:t.x+100});cache.sync({...projection,transforms},registry);assert.equal(cache.stats().computed,2);cache.sync(projection,registry);assert.equal(cache.stats().computed,3);const cleared=cache.sync({...projection,nodes:[]},registry);assert.equal(cache.stats().entries,0);assert.equal(cleared.query({x:-100,y:-100,width:500,height:500}).size,0);cache.clear();});