diff --git a/src/lib/canvas-bindings/paint-index.ts b/src/lib/canvas-bindings/paint-index.ts index 74eb9ef..4060c1b 100644 --- a/src/lib/canvas-bindings/paint-index.ts +++ b/src/lib/canvas-bindings/paint-index.ts @@ -12,18 +12,23 @@ const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].eve /** 包含边界接触的保守交集,与 nativeRectVisible 的正面积判定用途不同。 */ const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height; /** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */ -/** cellSize 决定网格尺度,maxCells 限制单区域/查询展开的格数,默认512和256。 */ +/** cellSize 决定网格尺度,maxCells 限单区域/查询展开格数,默认512和256。 + * rows 存矩形,cells 存格→ID桶,members 存 ID→格键,overflow 存无法安全展开的 ID;全部可从输入重建。 + * maxCells 不限制总区域数/总表大小;大区域或大查询保守回退扫描,可能线性耗时。 */ export function createPaintIndex(cellSize=512,maxCells=256){ if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index'); const rows=new Map(),cells=new Map>(),members=new Map(),overflow=new Set(); - /** 算出覆盖格键;负坐标用 floor,过大或不安全的格范围返回 undefined 走保守路径。 */ + /** 用 floor 算包含两端边界的格区间,负坐标也一致;恰好压格线的矩形可占相邻两格。 + * 不安全整数或格数超 maxCells 返回 undefined,避免巨大循环,随后通过真实矩形交集排除假阳性。 */ function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;} /** 同时清除反向成员表、格桶与溢出集合,空桶立即释放。 */ function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);} return Object.freeze({ - /** 先验证完整输入的 ID 唯一性和矩形,再更新差异;超大区域归入 overflow,每次查询都检查。 */ + /** 先复制全部输入矩形并验证非空 ID/唯一性/合法矩形,再移除消失项并更新变化项。 + * 不变行保留现有桶;大区域归 overflow 每次查。格式校验失败不开始改表,但不承诺任意运行时异常都回滚。 */ sync(regions:readonly PaintRegion[]){const incoming=new Map();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}}, - /** 普通查询查覆盖格加 overflow,大查询扫描全部行,最后统一按矩形相交过滤。 */ + /** 覆盖格+overflow 合并候选,无法安全展开的大查询改扫全部 rows;最后含边界相交精筛。 + * 返回新 Set(只读仅是类型),外部改它不影响索引;不排序,不能用迭代次序当绘制层序。 */ query(region:Rect):ReadonlySet{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));}, /** 释放所有可重建空间表,不保留跨场景的区域引用。 */ clear(){rows.clear();cells.clear();members.clear();overflow.clear();}, diff --git a/src/lib/canvas-bindings/projected-paint.ts b/src/lib/canvas-bindings/projected-paint.ts index 2dec437..0729d3a 100644 --- a/src/lib/canvas-bindings/projected-paint.ts +++ b/src/lib/canvas-bindings/projected-paint.ts @@ -10,7 +10,9 @@ 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. */ /** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。 - * 只记录超出原生矩形的节点;单节点协议失败记 unknown,其余节点继续处理。 */ + * 只输出超出原生矩形的节点完整 paint AABB,不是只输出外溢条带;每帧遍历所有显示 node。 + * 单节点协议失败记 unknown 后继续,不包含边/selection/手势状态,也不决定 renderer 最终是否保留元素。 + * 不检查 registry 与 projection 的来源身份,调用方须保持同解释上下文;fallback 按矩形外壳解释。 */ export function projectedPaintOverflow(projection:ReturnType,registry:Registry){ const regions:PaintRegion[]=[],unknown:string[]=[]; for(const node of projection.nodes)try{ @@ -36,7 +38,8 @@ const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b= /** 变换值比较避免无关根发布反复计算 paint 边界。 */ 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; /** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data, - * 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */ + * 显式声明后只比较列出的 element.data 顶层字段(不是点分路径),空数组表示声明不依赖 data。 + * 依赖列表须随同一 protocol 对象保持不变;遗漏字段/原地修改声明不会被缓存自动推导为新协议。 */ 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; @@ -51,14 +54,18 @@ function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t: 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. */ -/** 视图级增量 paint 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */ +/** 视图级增量 paint 网格:entries 缓存每个节点的签名及 regions/unknown 结果,index 只存额外范围。 + * 不增量维护输入成员表,每次 sync 仍遍历全部 node 并 index.sync 全部 regions;只节省未变几何计算。 + * 由 registry 对象/project/canvas 隔离,不额外核 editor/view owner;宿主应给每个视图独立实例。 */ export function createProjectedPaintIndex(){ let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0; type Projection=ReturnType; type Entry={signature:PaintSignature;result:ReturnType}; const entries=new Map(),index=createPaintIndex(); return Object.freeze({ - /** 切 registry/project/canvas 清空缓存;逐节点收集额外范围与 unknown,再同步网格。 */ + /** 切 registry/project/canvas 先清表,逐节点比较签名,变化才重新捕获依赖并调用完整单节点算法。 + * unknown 结果同样可缓存至签名变化;数据捕获/比较异常可能直接抛出,整轮并非事务回滚。 + * query 是同一可变 index 的方法,旧返回对象的 query 会查询后续 sync 的网格;unknown 则是本轮冻结列表。 */ 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[]=[]; diff --git a/src/lib/canvas-bindings/projection-cache.ts b/src/lib/canvas-bindings/projection-cache.ts index 8d13d61..24a4135 100644 --- a/src/lib/canvas-bindings/projection-cache.ts +++ b/src/lib/canvas-bindings/projection-cache.ts @@ -12,14 +12,17 @@ type Projection=ReturnType; /** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */ type Retained={incarnation:number;ownerCanvasId:Id;value:T}; /** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */ -/** 创建视图专属结构共享器;reuse 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */ +/** 创建视图专属结构共享器;reuse 会就地改传入 nodes/edges 数组,调用方应提供自有可变派生数组。 + * 不深克隆/冻结显示对象;缓存的对象须按不可变约定使用,不能传入业务记录数组或原地修改旧投影。 */ export function createProjectionCache(){ let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined; let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined; /** 数组长度与逐项引用完全一致时沿用旧数组,顺序改变也会产生新数组身份。 */ const stable=(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next; let nodes=new Map>(),edges=new Map>(); - /** 对每个显示 ID 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */ + /** 每个显示 ID 须有文档记录;生命周期键只核 incarnation+owner,不另含 component/dataVersion,显示差异由值比较处理。 + * 仅 data/style/position 三个嵌套字段单独等价共享,再比较整个显示对象;新表只含本帧项。 + * 不查投影 ID 重复、不检查 node/edge 与记录的全部对应语义;这依赖上游合法投影。 */ function retain(document:Document,values:T[],previous:Map>){ const next=new Map>(); for(let index=0;index; -/** 连续发布的可信写集合;from/to 必须恰好连接旧投影与新文档,topology 表示禁止局部修补。 */ +/** from/to 按 Document 对象身份连接连续发布,ids 是宿主提供的完整写集合;这里不重算其覆盖证明。 + * topology=true 禁用局部修补,Set 可由 scene coordinator 累加,不是持久 Change 日志或业务事件队列。 */ export type SceneChangeHint={from:Document;to:Document;ids:Set;topology:boolean}; /** 几何手势的瞬态预览,不将 pointermove 变成文档修改。 */ type Preview=NonNullable['preview']|undefined; /** 真正影响场景内容的输入;viewport/tool/mode 等状态由别的显示通道处理。 */ export type SceneInputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:Id;selection:ViewState['selection'];preview:Preview;edgeId:Id|undefined;path:NonNullable['path']|undefined}; -/** 为上一帧建立 ID→数组位置、分区入口及深度索引;发现降级节点/边后禁用快速修补。 */ +/** 建上一帧 ID→数组下标、入口 elementId→子 canvasId、显示 canvas 深度索引。 + * 深度沿 transforms 迭代顺序累计,依赖完整投影按父先子后产出;不在这里重新排序/校验整棵树。 + * unsupported 标记当前已有 fallback node/unsupported edge,供 patchScene 直接回退。 */ export function indexScene(projection:Projection){ const nodes=new Map(projection.nodes.map((node,index)=>[node.id,index])),edges=new Map(projection.edges.map((edge,index)=>[edge.id,index])),entries=new Map(),depths=new Map([[projection.canvasId,0]]); for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id); @@ -26,14 +29,17 @@ export function indexScene(projection:Projection){ } /** Reuse existing projections for checked field edits and ordinary node insertions. */ /** 纯候选投影修补:合并文档/选择/预览脏项,先核对全体脏项再构造新数组。 - * 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。 */ + * 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。 + * 调用者须先核 owner/registry/catalog/canvas 兼容,layout 对应 base,queries 对应 next.document;本函数不重复全部护栏。 + * undefined 表示本次不适合快路径,不是业务拒绝;投影器/外部服务抛错则直接传播,没有错误吞并或业务发布。 */ export function patchScene(base:Projection,layout:ReturnType,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):Projection|undefined{ const {document,selection,preview,registry,catalog,canvasId}=next; const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous; if(layout.unsupported||document.projectId!==base.document.projectId)return; const dirty=new Set(),created=new Set(); if(document!==base.document){ - // 可信连续 changes 可直接路由 ID;拓扑提示则立刻放弃。无提示时需要逐项核对表顺序及内容。 + // 匹配 from/to 的 hint 按完整写集合合同直接取 ids,不额外全表证明“没有遗漏其它修改”。 + // 缺可信 hint 才比较 canvas 表及 element 表大小/顺序/内容;这条扫描路径本身不支持插入或删除。 if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);} else{ // Canonical replacement and missed publications carry no trustworthy write set. @@ -59,7 +65,8 @@ export function patchScene(base:Projection,layout:ReturnType, // 入口几何/单位展示或其预览变化会影响整棵子树变换,不能只修补入口本身。 if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return; } - // 懒复制:未受影响的数组与节点/边对象沿用原引用,新对象仍调用完整路径共用的投影函数。 + // 先通过脏项形状检查再懒复制受影响数组,未变对象仍共享;所有新节点/边调用完整路径共用投影函数。 + // base.transforms 只在拓扑兼容时复用;未显示 canvas 的普通变更可以只更新返回 document 而不增加显示节点。 let nodes=base.nodes,edges=base.edges;const inserted=new Map(); for(const id of dirty){const element=document.elements.get(id)!; if(element.structure==='node'){ @@ -81,7 +88,8 @@ export function patchScene(base:Projection,layout:ReturnType, // without reconstructing unaffected nodes, transforms or edge projections. const members=new Map();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);} const ordered:Projection['nodes']=[];let complete=true; - /** 只重排节点引用,不重算未变化节点;任一所需投影缺失就放弃这份候选。 */ + /** 按 owner 成员顺序深度优先重排节点引用,只进已在 transforms 显示的子 canvas。 + * 缺任一所需投影就令 complete=false 回退;依赖已验证树,无独立循环/递归深度预算。 */ const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}}; visit(canvasId);if(!complete)return;nodes=ordered; } diff --git a/src/lib/canvas-bindings/scene-projection.ts b/src/lib/canvas-bindings/scene-projection.ts index a7b0bea..21f17f1 100644 --- a/src/lib/canvas-bindings/scene-projection.ts +++ b/src/lib/canvas-bindings/scene-projection.ts @@ -12,13 +12,17 @@ import {indexScene,patchScene,type SceneChangeHint,type SceneInputs} from './sce import type {RendererCatalog} from './renderers.ts'; /** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */ -/** 每个视图独立创建;advance 接 editor 发布,project 接当前显示输入,clear 释放全部派生缓存。 */ +/** 每个视图一份 previous/value/layout/pending 与几何缓存;advance 只积写集合,project 才按需算场景。 + * 缓存借用不可变文档/选择/预览/registry/catalog 身份,不能在原对象中改字段后期待自动发现失效。 + * 实例不订阅 editor、不请求帧,宿主负责接线 advance/project 与视图 clear 生命周期。 */ export function createSceneProjection(){ let geometry=createGeometryProjectionCache(); let previous:SceneInputs|undefined,value:ReturnType|undefined,layout:ReturnType|undefined,pending:SceneChangeHint|undefined; return Object.freeze({ - /** 累积从上次投影文档到当前文档的连续写集合;丢发布、无 changes 的替换或大范围改动退回全量。 */ + /** 累积上次投影文档→当前文档的连续发布链;同一文档对象直接忽略,未有场景时无需积累。 + * 断链只在 previous.document===before 时能重起;空 changes 丢提示,canvas 改动/脏 ID>16384 只标 topology。 + * 这里只影响快路径选择,不重放 Change、不写 doc,也不保证输入 changes 本身语义完整。 */ advance(before:Document,after:Document,changes:readonly Change[]){ if(before===after)return; if(!previous||previous.document===after){pending=undefined;return;} @@ -31,7 +35,9 @@ export function createSceneProjection(){ if(pending.topology)pending.ids.clear(); }, /** 固定 document/registry/catalog/显示 canvas 和捕获的 session 输入;相同引用直接复用场景。 - * 可兼容才尝试 patch,失败正常回退 projectCanvas;查询必须与同一份文档/registry 绑定。 */ + * 可兼容才尝试 patch,undefined 正常回退 projectCanvas;异常不自动吞成另一份成功结果。 + * queries 先查 document/registry 身份;其对象身份不在场景键中,单独更换 queries 不触发重算。 + * 空 preview 规范为 undefined,viewport/tool/mode 不在键中;传入的 sessions 是捕获帧,不读取更新的 live 会话。 */ project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){ if(queries&&queries.document!==document)throw fault('stale_query_snapshot'); if(queries&&queries.registry!==registry)throw fault('query_registry_changed'); @@ -43,7 +49,8 @@ export function createSceneProjection(){ const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId; const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):undefined; const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner}); - // 全量重建或节点数量改变才重建布局索引;成功采用结果后消费并清掉 changes 提示。 + // 全量结果或节点数变化才重建 layout,其余兼容 patch 沿用数组下标/拓扑索引。 + // 成功后一次推进 previous/value 并清 pending;异常不写这三项,但本轮几何缓存可能已有计算,不承诺整缓存回滚。 if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result); previous=next;value=result;pending=undefined;return result; },