diff --git a/src/lib/canvas-bindings/connection-paint.ts b/src/lib/canvas-bindings/connection-paint.ts index cca12e4..1b38d35 100644 --- a/src/lib/canvas-bindings/connection-paint.ts +++ b/src/lib/canvas-bindings/connection-paint.ts @@ -1,3 +1,7 @@ +/** + * 连线超出原生端点包围范围的额外绘制索引。用曲线控制点凸包加半线宽保守包住笔画, + * 不要求求出三次曲线的精确极值。无法获取锚点/协议或耗尽预算时返回 unknown,宿主应保守保留显示。 + */ import type {Registry} from '../canvas-core/types.ts'; import type {projectCanvas} from './projection.ts'; import type {HitServices} from './hit-query.ts'; @@ -6,39 +10,57 @@ 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. */ +/** 可选缓存的共享实现,限制本次边数和总曲线段数。 + * 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */ function connectionPaint(projection:ReturnType,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map){ if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget'); const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map(),endpoints=new Set(); // Only inspected edges need node entries. Preserve last-ID-wins behavior without // allocating an entry and pair array for every unrelated projected node. + // 仅为边预算内真正要检查的端点建节点表;仍遍历节点,但不为所有无关节点分配 Map 条目。 for(let i=0;i=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(); + /** 优先用 renderer 锚点;原生 record-v1 不能猜旧卡片把手,缺测量即归 unknown。 */ 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||![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++; + // renderer 服务优先,随后用组件几何协议;stroke 也必须来自同一显示语义。 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); + // 校验每边段数、线宽,再扣全局剩余段数;空段列表是已知无笔画,不应误标 unknown。 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);}} + // 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 unknown。 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();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}); + // 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。 }catch{cache?.delete(edge.id);unknown.push(edge.id);} } 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. */ +/** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision, + * 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */ export function createConnectionPaintIndex(){ const cache=new Map(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0; + /** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。 + * query 与 unknown 配套使用;computed 是累计重新计算数,不把缓存命中记成一次曲线计算。 */ 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)}); diff --git a/src/lib/canvas-bindings/connectors.ts b/src/lib/canvas-bindings/connectors.ts index f717263..2c98e40 100644 --- a/src/lib/canvas-bindings/connectors.ts +++ b/src/lib/canvas-bindings/connectors.ts @@ -1,15 +1,27 @@ +/** + * 提供旧卡片兼容布局与原生测量布局的连线锚点。 + * 两种协议不能混猜:compat-v1 依卡片边框/ports 排布,record-v1 应使用 renderer 实测 Handle。 + */ import type {CanvasGeometryNode} from './compat-types.ts'; import type {ProjectionNode} from './projection.ts'; import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometry.ts'; +/** 兼容几何节点加投影 data,保留 measured 尺寸和旧卡片端口声明。 */ type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']}; /** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */ +/** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。 + * 同方向多个 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。 */ export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{ const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side); + // 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。 if(port){const peers=node.data.ports!.filter(item=>item.direction===port.direction);result.point.y+=((peers.indexOf(port)+1)/(peers.length+1)-.5)*(node.measured?.height||node.height||400);}return result; } +/** renderer 测量端口:绝对位置与 source/target handle 边界,避免在这里依赖整个 UI store。 */ export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:number;y:number};readonly handleBounds?:{readonly source?:readonly MeasuredHandle[]|null;readonly target?:readonly MeasuredHandle[]|null}}}; +/** 单个 Handle 在节点中的相对矩形及所在边。 */ type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number}; /** Native Handle outer edge, matching the installed React Flow loose connection mode. */ +/** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式, + * 指定 handleId 未找到或尚无测量时返回 undefined,不编造中心点。 */ export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{ const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return; const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]}; diff --git a/src/lib/canvas-bindings/paint-index.ts b/src/lib/canvas-bindings/paint-index.ts index 1ce82b4..74eb9ef 100644 --- a/src/lib/canvas-bindings/paint-index.ts +++ b/src/lib/canvas-bindings/paint-index.ts @@ -1,18 +1,31 @@ +/** + * 显示空间的可重建均匀网格,用于额外绘制范围的可见性查询。 + * 大区域、大查询或超出安全格坐标时用保守回退,避免为了空间索引速度漏掉可见元素。 + */ import type {Id} from '../canvas-core/types.ts'; import type {Rect} from '../canvas-core/geometry.ts'; import {fault} from '../canvas-core/values.ts'; +/** 区域 ID 与场景轴对齐矩形;不保存节点内容、选择或交互状态。 */ export type PaintRegion={readonly id:Id;readonly bounds:Rect}; +/** 同时校验端点,防止有限 x/width 相加后溢出;允许零面积边界。 */ const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].every(Number.isFinite)&&r.width>=0&&r.height>=0; +/** 包含边界接触的保守交集,与 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。 */ 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 走保守路径。 */ 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,每次查询都检查。 */ 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,大查询扫描全部行,最后统一按矩形相交过滤。 */ 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 65abe69..2dec437 100644 --- a/src/lib/canvas-bindings/projected-paint.ts +++ b/src/lib/canvas-bindings/projected-paint.ts @@ -1,3 +1,7 @@ +/** + * 节点额外绘制范围的保守索引。原生 renderer 的未旋转矩形不一定包含旋转/自定义组件的全部笔画, + * 因此用 geometry.paint bounds 补充超出的范围;无法解释的节点标 unknown,不能当作已证明不可见。 + */ import {clone,equal} from '../canvas-core/values.ts'; import {rectangleGeometry} from '../canvas-core/geometry.ts'; import {toLocal,toWorld,type Transform} from '../canvas-core/coordinates.ts'; @@ -5,13 +9,17 @@ import type {ElementRecord,Geometry,GeometryProtocol,Registry} from '../canvas-c 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,其余节点继续处理。 */ export function projectedPaintOverflow(projection:ReturnType,registry:Registry){ const regions:PaintRegion[]=[],unknown:string[]=[]; for(const node of projection.nodes)try{ const element=projection.document.elements.get(node.id),transform=element&&projection.transforms.get(element.ownerCanvasId);if(!element||element.structure!=='node'||!transform)throw Error('missing_geometry'); const width=node.width!/transform.scale,height=node.height!/transform.scale,center=toLocal(transform,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2}),geometry={x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-transform.rotation+360)%360}; + // 未知/降级节点用标准矩形边界;有效组件可声明更宽的笔画或旋转后的范围。 const definition=!node.data.__headlessFallback&®istry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=definition?.kind==='node'?definition:rectangleGeometry; const local=protocol.bounds({element,geometry,role:'paint'});if(![local.x,local.y,local.width,local.height].every(Number.isFinite)||local.width<0||local.height<0)throw Error('invalid_paint_bounds'); + // 局部边界再经分区变换,必须考虑父级旋转/缩放,不能只给原矩形加一个固定像素边距。 const points=[{x:local.x,y:local.y},{x:local.x+local.width,y:local.y},{x:local.x,y:local.y+local.height},{x:local.x+local.width,y:local.y+local.height}].map(p=>toWorld(transform,p)),x=Math.min(...points.map(p=>p.x)),y=Math.min(...points.map(p=>p.y)),right=Math.max(...points.map(p=>p.x)),bottom=Math.max(...points.map(p=>p.y)); if(![x,y,right,bottom,right-x,bottom-y].every(Number.isFinite))throw Error('invalid_paint_bounds'); if(xnode.position.x+node.width!||bottom>node.position.y+node.height!)regions.push({id:node.id,bounds:{x,y,width:right-x,height:bottom-y}}); @@ -19,10 +27,16 @@ export function projectedPaintOverflow(projection:ReturnType; +/** 缓存签名覆盖生命周期、模型几何、显示预览、变换、协议身份和其声明的数据依赖。 */ 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; +/** 变换值比较避免无关根发布反复计算 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, + * 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */ 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; @@ -31,30 +45,37 @@ function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRec for(let i=0;iclone(element?.data[key]))}; } /** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */ +/** 视图级增量 paint 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */ 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,再同步网格。 */ 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),old=entries.get(node.id),declared=element&&!node.data.__headlessFallback&®istry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=declared?.kind==='node'?declared:rectangleGeometry; 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); } + // 删除已不在场景中的缓存,返回可查询索引与冻结的未知列表;调用方须保守处理 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)}); }, + /** 累计实际计算/捕获次数和当前项数,仅供诊断,不是帧耗时或文档 revision。 */ stats:()=>Object.freeze({computed,captured,entries:entries.size}), + /** 解除场景与 registry 身份引用并清空网格;累计诊断计数仍保留。 */ clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;}, }); } diff --git a/src/lib/canvas-bindings/projection-cache.ts b/src/lib/canvas-bindings/projection-cache.ts index d13f698..8d13d61 100644 --- a/src/lib/canvas-bindings/projection-cache.ts +++ b/src/lib/canvas-bindings/projection-cache.ts @@ -1,15 +1,25 @@ +/** + * React Flow 输入对象的结构共享缓存,与计算场景的 scene-projection 分工不同。 + * 按元素代次与 owner 判定同一生命周期,复用值相等的 data/style/position 和整个数组,减少显示刷新。 + * 缓存属于一个 owner/project/视图 canvas,既不是业务文档,也不是跨项目身份仓库。 + */ import type {Document,Id} from '../canvas-core/types.ts'; import {equal,fault} from '../canvas-core/values.ts'; import type {projectCanvas} from './projection.ts'; +/** 只处理已经生成的投影,不在这里解释元素组件或计算坐标。 */ 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 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */ 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 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */ function retain(document:Document,values:T[],previous:Map>){ const next=new Map>(); for(let index=0;index)[key],before=(old.value as Record)[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; values[index]=retained;next.set(value.id,{incarnation:record.incarnation,ownerCanvasId:record.ownerCanvasId,value:retained}); @@ -24,17 +35,21 @@ export function createProjectionCache(){ return next; } return Object.freeze({ + /** 范围改变先整体清空;保存规范化后的对象表及稳定数组,返回包装后的投影。 */ reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{ if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;} const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges); nodes=nextNodes;edges=nextEdges;nodeArray=stable(nodeArray,projection.nodes);edgeArray=stable(edgeArray,projection.edges); return{...projection,nodes:nodeArray,edges:edgeArray}; }, + /** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */ getNode(id:Id){return nodes.get(id)?.value;}, + /** 只有上下文、生命周期、renderer type、尺寸和 data 均兼容才可沿用旧测量,不能只比较 ID。 */ canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){ const old=nodes.get(value.id),record=document.elements.get(value.id); return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data); }, + /** 清空对象/数组和范围身份,调用方无需也不应据此清除业务文档。 */ clear(){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=undefined;projectId=undefined;canvasId=undefined;}, }); } diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts index c3bcaf7..fe09a05 100644 --- a/src/lib/canvas-bindings/projection.ts +++ b/src/lib/canvas-bindings/projection.ts @@ -1,3 +1,8 @@ +/** + * 把编辑器文档与瞬态手势投影成 renderer 可消费的节点、边与坐标变换。 + * 模型 ID/代次/owner 保留原意;折叠分区只改变显示端点,不迁移连线归属或修改真实引用。 + * 同一套 projectNode/projectEdge 被完整遍历与增量补丁共用,避免两条路径产生不同显示规则。 + */ import type {DocumentQueries} from '../canvas-core/queries.ts'; import type {GeometryProjectionCache} from './geometry-cache.ts'; import type { Document, ElementRecord, Geometry, Id, Json, Point, Registry } from '../canvas-core/types.ts'; @@ -9,21 +14,30 @@ import type {CanvasNodeData,EdgeData} from './compat-types.ts'; import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts'; export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts'; +/** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */ const origin:Transform={x:0,y:0,scale:1,rotation:0}; +/** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */ export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]}; +/** renderer 节点形状。__headlessTarget 绑定真实身份,record-v1 另带模型记录与所属 canvas 变换。 */ export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}}; +/** 显示端点可以指向折叠入口;record-v1 把路径解释留给 renderer,compat-v1 接已转换路径。 */ export type ProjectionEdge={id:Id;type?:string;source:Id;target:Id;sourceHandle?:string|null;targetHandle?:string|null;selected?:boolean;zIndex?:number;data:EdgeData&{__headlessRecord?:ElementRecord;__headlessTransform?:Transform}}; +/** 仅将 JSON 对象当作 presentation 读取,空值/数组不推测为有效展示配置。 */ const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; /** Construct one node. Scene updates and full traversal share exactly the same projection rules. */ +/** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。 + * renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */ export function projectNode(element:Extract,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{ const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 }); const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known'; const renderer=renderers.resolve(element); + // 组件未知、版本不支持、renderer 缺失是不同失败边界;降级时不展开任意组件数据给旧 renderer。 const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined; const type = fallback ? 'unsupported' : renderer!.rendererId; const data = element.data; + // 几何统一在场景单位;frame 层序按深度调整,兼容 frame 的内容区不截住其内部元素点击。 const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2, style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) }, data: { ...(fallback?{}:data), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData, @@ -32,8 +46,11 @@ export function projectNode(element:Extract,ca } /** Endpoints are already resolved to their displayed node IDs by the caller. */ +/** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。 + * 预览只替代本条边的路径;不把 data 内可能残留的 path 混入已验证的结构路径。 */ export function projectEdge(element:Extract,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{ const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer; + // 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。 const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : (cache?cache.edge(element.id,element.incarnation,edgePreview?.target.id===element.id?edgePreview.path:element.path,transform):transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transform)); const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined; const { path: unusedPath, ...edgeData } = element.data; void unusedPath; @@ -41,15 +58,21 @@ export function projectEdge(element:Extract | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) { if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]); + // 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。 const queries=services.queries,cache=services.geometry;if(queries&&queries.document!==document)throw fault('stale_query_snapshot');if(queries&&queries.registry!==registry)throw fault('query_registry_changed');cache?.begin(services.owner??cache,document.projectId,canvasId); + // transforms 只包含展开范围;hiddenBy 把所有折叠后代 canvas 指向当前可见的入口节点。 const transforms = new Map([[canvasId, origin]]), hiddenBy = new Map(), childByEntry = new Map(); if(!queries)for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id); const members = new Map(); if(!queries)for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); } + /** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */ const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[]; const nodes: ProjectionNode[] = [], visible = new Set(); + /** 递归展开只改变投影:先输出入口,再按其当前几何/单位比例变换子分区。 */ const visit = (id: Id, depth: number) => { const transform = transforms.get(id)!; for (const element of getMembers(id)) if (element.structure === 'node') { @@ -58,6 +81,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist nodes.push(projected); visible.add(element.id); if (child) { const presentation = object(data.presentation); + // 未知入口无法安全解释子分区展示规则,也按折叠处理;后代都映射到这一可见入口。 if (!!fallback || presentation.collapsed === true) { const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); if(queries)stack.push(...queries.childCanvases(hidden));else for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); } } else { @@ -67,14 +91,17 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist } }; visit(canvasId, 0); + // 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。 const edges: ProjectionEdge[] = []; for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) { + /** 可见端点保留原 ID;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */ const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? ''); const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId); if (!source || !target || source === target) continue; edges.push(projectEdge(element,source,target,registry,selected,transforms.get(element.ownerCanvasId)!,edgePreview,renderers,cache)); } // Handles come from known envelope endpoints, never from opaque component payloads. + // 降级把手只取标准方向与边结构的端口引用,不从未知组件的任意 payload 推测端口声明。 const nodesById=new Map(nodes.map(node=>[node.id,node])); const fallbackNodes=new Map(nodes.filter(node=>node.data.__headlessFallback).map(node=>[node.id,node.data.__headlessFallback!])); const handleIds=new Map>(); @@ -82,6 +109,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist for(const edge of edges)for(const end of ['source','target'] as const){ const fallback=fallbackNodes.get(edge[end]); if(!fallback){ + // 未知边接兼容节点时只保留实际已知 handle,防止 renderer 寻找不存在的 DOM 端口。 if(edge.type==='unsupported'&&!nodesById.get(edge[end])?.data.__headlessRecord){ const node=nodesById.get(edge[end]),field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]; if(!handleIds.has(edge[end])){const ids=new Set(['top','right','bottom','left']);if(Array.isArray(node?.data.ports))for(const port of node.data.ports)if(port&&typeof port.id==='string')ids.add(`port:${port.id}`);handleIds.set(edge[end],ids);} @@ -89,9 +117,11 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist } continue; } + // 降级节点可补出边明确引用的把手,分配一个可显示的方向;这是显示外壳,不更改模型 portId。 const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle; const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});} } + // 本轮遍历结束后删除未访问的几何缓存项,只返回派生场景,不发布 editor 修改。 cache?.end(); return { document,canvasId,nodes, edges, transforms }; } diff --git a/src/lib/canvas-bindings/scene-changes.ts b/src/lib/canvas-bindings/scene-changes.ts index 1ba80d6..c1ade0d 100644 --- a/src/lib/canvas-bindings/scene-changes.ts +++ b/src/lib/canvas-bindings/scene-changes.ts @@ -1,3 +1,7 @@ +/** + * 场景的受检增量修补。只在能够证明拓扑、身份和坐标关系仍兼容时替换少量显示对象; + * 不确定就返回 undefined,让调用方完整投影。缓存命中率不能高于模型与显示一致性的优先级。 + */ import type {DocumentQueries} from '../canvas-core/queries.ts'; import type {Document,Id,Registry} from '../canvas-core/types.ts'; import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts'; @@ -5,10 +9,15 @@ import {equal} from '../canvas-core/values.ts'; import {projectNode,projectEdge,type projectCanvas} from './projection.ts'; import type {RendererCatalog} from './renderers.ts'; +/** 与完整 projectCanvas 保持同一结果结构,避免增量路径自行定义另一套显示规则。 */ type Projection=ReturnType; +/** 连续发布的可信写集合;from/to 必须恰好连接旧投影与新文档,topology 表示禁止局部修补。 */ 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→数组位置、分区入口及深度索引;发现降级节点/边后禁用快速修补。 */ 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); @@ -16,12 +25,15 @@ export function indexScene(projection:Projection){ return{nodes,edges,entries,depths,unsupported:projection.nodes.some(node=>!!node.data.__headlessFallback)||projection.edges.some(edge=>edge.type==='unsupported')}; } /** Reuse existing projections for checked field edits and ordinary node insertions. */ +/** 纯候选投影修补:合并文档/选择/预览脏项,先核对全体脏项再构造新数组。 + * 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。 */ 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;拓扑提示则立刻放弃。无提示时需要逐项核对表顺序及内容。 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. @@ -33,16 +45,21 @@ export function patchScene(base:Projection,layout:ReturnType, for(const[id,record]of document.elements){if(id!==elementOrder.next().value)return;if(record!==base.document.elements.get(id)&&!equal(record,base.document.elements.get(id)))dirty.add(id);} } } + // 选择变化取对称差;预览变化同时检查新旧集合,取消预览也必须重画旧目标。 if(selection!==oldSelection){for(const id of oldSelection)if(!selection.has(id))dirty.add(id);for(const id of selection)if(!oldSelection.has(id))dirty.add(id);} if(preview!==oldPreview){for(const[id,g]of oldPreview??[])if(!equal(g,preview?.get(id)))dirty.add(id);for(const[id,g]of preview??[])if(!equal(g,oldPreview?.get(id)))dirty.add(id);} + // 连线预览换目标或结束时,把旧边和新边都标脏,以去掉上一条线的预览。 if(connection?.target.id!==oldEdgeId||connection?.path!==oldPath){if(oldEdgeId)dirty.add(oldEdgeId);if(connection)dirty.add(connection.target.id);} // Validate the whole dirty footprint before constructing or publishing any replacement. for(const id of dirty){const before=base.document.elements.get(id),after=document.elements.get(id); + // 新节点暂记 created;未改分区拓扑且所属 canvas 已有变换时才可能插入当前场景。 if(!before&&after?.structure==='node'){created.add(id);continue;} if(!before||!after||before.incarnation!==after.incarnation||before.structure!==after.structure||before.componentId!==after.componentId||before.dataVersion!==after.dataVersion||before.ownerCanvasId!==after.ownerCanvasId)return; if(before.structure==='connection'&&after.structure==='connection'&&(!equal(before.source,after.source)||!equal(before.target,after.target)))return; + // 入口几何/单位展示或其预览变化会影响整棵子树变换,不能只修补入口本身。 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; } + // 懒复制:未受影响的数组与节点/边对象沿用原引用,新对象仍调用完整路径共用的投影函数。 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'){ @@ -58,13 +75,16 @@ export function patchScene(base:Projection,layout:ReturnType, if(edges===base.edges)edges=[...edges];edges[index]=edge; } } + // 插入后按显示根的深度优先成员序重排引用,不能直接 append 而破坏嵌套绘制层序。 if(inserted.size){ // Membership order is depth-first, not global insertion order. Reorder references // 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; + /** 只重排节点引用,不重算未变化节点;任一所需投影缺失就放弃这份候选。 */ 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; } + // 文档及数组均未变化时返回原场景,否则只替换必要字段,不发布任何业务状态。 return nodes===base.nodes&&edges===base.edges&&document===base.document?base:{...base,document,nodes,edges}; } diff --git a/src/lib/canvas-bindings/scene-projection.ts b/src/lib/canvas-bindings/scene-projection.ts index 27b0470..a7b0bea 100644 --- a/src/lib/canvas-bindings/scene-projection.ts +++ b/src/lib/canvas-bindings/scene-projection.ts @@ -1,3 +1,7 @@ +/** + * 一个视图拥有的派生场景协调器。组合几何缓存、可信 changes 提示和完整/增量投影, + * 读取固定的 session 快照;不把 viewport、工具状态或同步任务状态当成场景重算条件。 + */ import {fault} from '../canvas-core/values.ts'; import type {Change,Document,Registry} from '../canvas-core/types.ts'; import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts'; @@ -8,20 +12,26 @@ 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 释放全部派生缓存。 */ export function createSceneProjection(){ let geometry=createGeometryProjectionCache(); let previous:SceneInputs|undefined,value:ReturnType|undefined,layout:ReturnType|undefined,pending:SceneChangeHint|undefined; return Object.freeze({ + /** 累积从上次投影文档到当前文档的连续写集合;丢发布、无 changes 的替换或大范围改动退回全量。 */ advance(before:Document,after:Document,changes:readonly Change[]){ if(before===after)return; if(!previous||previous.document===after){pending=undefined;return;} + // 只有链尾接上 before 才能继续累计;不连贯时仅从恰好匹配的旧场景重新起链。 if(pending?.to!==before){pending=previous.document===before?{from:before,to:after,ids:new Set(),topology:false}:undefined;} if(!pending)return;pending.to=after; if(!changes.length){pending=undefined;return;} + // canvas 结构改动或超过16384个脏 ID 时不再扩充集合,标拓扑失效并清空大集合。 for(const change of changes){if(change.kind==='canvas'){pending.topology=true;break;}pending.ids.add(change.kind==='element'?change.id:change.target.id);if(pending.ids.size>16384){pending.topology=true;break;}} if(pending.topology)pending.ids.clear(); }, + /** 固定 document/registry/catalog/显示 canvas 和捕获的 session 输入;相同引用直接复用场景。 + * 可兼容才尝试 patch,失败正常回退 projectCanvas;查询必须与同一份文档/registry 绑定。 */ 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'); @@ -29,12 +39,15 @@ export function createSceneProjection(){ const next:SceneInputs={owner,document,registry,catalog,canvasId:view.canvasId,selection:view.selection,preview:preview?.size?preview:undefined,edgeId:connection?.target.id,path:connection?.path}; if(previous&&Object.keys(next).every(key=>Object.is(next[key as keyof SceneInputs],previous![key as keyof SceneInputs])))return value!; // Read the captured session, not a newer live gesture during an older React render. + // owner/registry/renderer catalog/根 canvas 改变意味着解释上下文变了,不能沿用局部补丁。 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 提示。 if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result); previous=next;value=result;pending=undefined;return result; }, + /** 撤销上一帧、写集合和几何缓存,解除对旧文档/视图的引用。 */ clear(){previous=undefined;value=undefined;layout=undefined;pending=undefined;geometry=createGeometryProjectionCache();}, }); }