Reuse captured paint dependencies for unchanged projected nodes

This commit is contained in:
2026-11-10 01:20:24 +08:00
parent 2529a801b2
commit 198c7f9863
4 changed files with 54 additions and 11 deletions
+11
View File
@@ -786,3 +786,14 @@ limits, atomic reference cycles, parser slices and durability backpressure keep
their existing roles. Rejected attempts do not train the success-cost estimate.
The limit is a scheduling hint, not another persistent queue or an assurance
that an indivisible transaction or subsequent renderer work fits one frame.
Node paint entries retain a detached dependency signature until an input changes.
Unchanged rows compare scalar geometry/transform/preview values and declared data
fields directly, without allocating replacement signature objects or dependency
arrays. Unspecified data dependencies still compare the complete data snapshot.
Nested mutable data is copied on capture, and renderer fallback changes the
selected protocol, so neither same object identity nor equal geometry alone
permits reuse. `stats().captured` counts actual dependency captures. Projection
retention also keeps the existing retained row when the incoming node is the same
object in the same lifetime. Both caches remain derived view-owned state; the
full membership scan and paint-grid reconciliation remain present.
+29 -11
View File
@@ -1,8 +1,8 @@
import {equal} from '../canvas-core/values.ts';
import {clone,equal} from '../canvas-core/values.ts';
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 {toLocal,toWorld,type Transform} from '../canvas-core/coordinates.ts';
import type {ElementRecord,Geometry,GeometryProtocol,Registry} from '../canvas-core/types.ts';
import type {projectCanvas,ProjectionNode} from './projection.ts';
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
/** Extra node paint extents beyond native unrotated React Flow bounds. */
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
@@ -19,24 +19,42 @@ export function projectedPaintOverflow(projection:ReturnType<typeof projectCanva
return{regions,unknown};
}
/** View-owned incremental paint grid; reuse unaffected node results across Root/session updates. */
type NodeProtocol=Extract<GeometryProtocol,{kind:'node'}>;
type PaintSignature={incarnation?:number;owner?:string;component?:string;version?:number;geometry?:Geometry;transform?:Transform;x:number;y:number;width?:number;height?:number;rotation:unknown;protocol:NodeProtocol;data:unknown};
const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation;
const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){
if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false;
const dependencies=protocol.dataDependencies;
if(dependencies===undefined)return equal(old.data,element?.data);
const values=old.data as unknown[];
for(let i=0;i<dependencies.length;i++)if(!equal(values[i],element?.data[dependencies[i]]))return false;
return true;
}
function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol):PaintSignature{
const dependencies=protocol.dataDependencies;
return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))};
}
/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */
export function createProjectedPaintIndex(){
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0;
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
type Projection=ReturnType<typeof projectCanvas>;
type Entry={signature:unknown;result:ReturnType<typeof projectedPaintOverflow>};
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
const entries=new Map<string,Entry>(),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<string>(),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),old=entries.get(node.id),declared=element&&!node.data.__headlessFallback&&registry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=declared?.kind==='node'?declared:rectangleGeometry;
const dependencies=protocol.dataDependencies,signature={id:node.id,incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?element.geometry:undefined,transform:t?{...t}:undefined,position:{...node.position},width:node.width,height:node.height,rotation:node.data.rotation,data:dependencies===undefined?element?.data:dependencies.map(key=>[key,element?.data[key]])};
const result=old&&equal(old.signature,signature)?old.result:projectedPaintOverflow({...projection,nodes:[node]},registry);
if(result!==old?.result)computed++;entries.set(node.id,{signature,result});regions.push(...result.regions);unknown.push(...result.unknown);
let entry=old;
if(!entry||!sameSignature(entry.signature,node,element,t,protocol)){
const signature=captureSignature(node,element,t,protocol),result=projectedPaintOverflow({...projection,nodes:[node]},registry);captured++;computed++;entry={signature,result};entries.set(node.id,entry);
}
regions.push(...entry.result.regions);unknown.push(...entry.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}),
stats:()=>Object.freeze({computed,captured,entries:entries.size}),
clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;},
});
}
@@ -15,6 +15,7 @@ export function createProjectionCache(){
for(let index=0;index<values.length;index++){
const value=values[index],record=document.elements.get(value.id);if(!record)throw fault('projection_identity_missing',[value.id]);
const old=previous.get(value.id),sameLifetime=old&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId;
if(sameLifetime&&old.value===value){next.set(value.id,old);continue;}
let shared=value;
if(sameLifetime)for(const key of ['data','style','position'] as const){const incoming=(shared as Record<string,unknown>)[key],before=(old.value as Record<string,unknown>)[key];if(Object.hasOwn(shared,key)&&Object.hasOwn(old.value,key)&&!Object.is(incoming,before)&&equal(incoming,before))shared={...shared,[key]:before};}
const retained=sameLifetime&&equal(old.value,shared)?old.value:shared;
+13
View File
@@ -9,3 +9,16 @@ test('projected paint uses paint role independently of logical bounds and conser
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();});
test('paint invalidation follows declared data dependencies instead of ACK record identities or unrelated titles',()=>{let calls=0;const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:['radius'],bounds({element,geometry:g}){calls++;return{x:g.x-element.data.radius,y:g.y,width:g.width+2*element.data.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),nodes=Array.from({length:40},(_,i)=>({id:'n'+i,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:i*200,y:0,width:100,height:100,rotation:0},data:{title:'n'+i,text:'',radius:10}})),cache=createProjectedPaintIndex(),sync=records=>cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);sync(nodes);assert.equal(calls,40);sync(structuredClone(nodes));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,title:'only title'}}:n));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,radius:25}}:n));assert.equal(calls,41);sync(nodes.map((n,i)=>i===1?{...n,incarnation:2}:n));assert.equal(calls,43);});
test('unchanged paint rows retain their captured dependencies across metadata and ACK copies',()=>{
const registry=createRegistry(builtinComponents()),records=Array.from({length:1000},(_,i)=>({id:'stable-'+i,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:i*200,y:0,width:100,height:100,rotation:0},data:{title:'stable',text:''}})),document=createDocument('p',[{id:'p',parent:null}],records),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();projection.transforms.set('p',{...projection.transforms.get('p')});cache.sync(projection,registry);assert.equal(cache.stats().captured,1000);for(let i=0;i<10;i++)cache.sync({...projection},registry);assert.equal(cache.stats().captured,1000);cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);assert.equal(cache.stats().captured,1000);
projection.nodes[0].position.x+=500;cache.sync(projection,registry);assert.equal(cache.stats().captured,1001);projection.transforms.get('p').x+=100;cache.sync(projection,registry);assert.equal(cache.stats().captured,2001);
});
test('mutable nested paint data and geometry are compared against detached dependency snapshots',()=>{
for(const declared of [undefined,['paint']]){
const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:declared,bounds({element,geometry:g}){return{x:g.x-element.data.paint.radius,y:g.y,width:g.width+2*element.data.paint.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),record={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:'',paint:{radius:10}}},document=createDocument('p',[{id:'p',parent:null}],[record]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),mutable={...projection,document:{...document,elements:new Map([['a',record]])}},cache=createProjectedPaintIndex();assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),false);record.data.paint.radius=30;assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),true);assert.equal(cache.stats().captured,2);record.geometry.rotation=45;cache.sync(mutable,registry);assert.equal(cache.stats().captured,3);assert.equal(Object.isFrozen(record.data.paint),false);
}
});
test('renderer fallback switches paint protocols even when data and projected geometry stay equal',()=>{
const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,bounds({geometry:g}){return{x:g.x-100,y:g.y,width:g.width+200,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),document=createDocument('p',[{id:'p',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:''}}]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a'));projection.nodes[0].data.__headlessFallback={reason:'renderer-unavailable',componentId:'text',dataVersion:1,handles:[]};assert.equal(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).size,0);assert.equal(cache.stats().captured,2);delete projection.nodes[0].data.__headlessFallback;assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a'));
});