Files
Endoleap/src/lib/canvas-bindings/scene-changes.ts
T

99 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 场景的受检增量修补。只在能够证明拓扑、身份和坐标关系仍兼容时替换少量显示对象;
* 不确定就返回 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';
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<typeof projectCanvas>;
/** from/to 按 Document 对象身份连接连续发布,ids 是宿主提供的完整写集合;这里不重算其覆盖证明。
* topology=true 禁用局部修补,Set 可由 scene coordinator 累加,不是持久 Change 日志或业务事件队列。 */
export type SceneChangeHint={from:Document;to:Document;ids:Set<Id>;topology:boolean};
/** 几何手势的瞬态预览,不将 pointermove 变成文档修改。 */
type Preview=NonNullable<SessionSnapshot['gesture']>['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<SessionSnapshot['connectionGesture']>['path']|undefined};
/** 建上一帧 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<Id,Id>(),depths=new Map<Id,number>([[projection.canvasId,0]]);
for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id);
for(const id of projection.transforms.keys())if(id!==projection.canvasId){const parent=projection.document.canvases.get(id)?.parent;if(parent&&depths.has(parent.canvasId))depths.set(id,depths.get(parent.canvasId)!+1);}
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/连线端点变更交给完整路径。
* 调用者须先核 owner/registry/catalog/canvas 兼容,layout 对应 base,queries 对应 next.document;本函数不重复全部护栏。
* undefined 表示本次不适合快路径,不是业务拒绝;投影器/外部服务抛错则直接传播,没有错误吞并或业务发布。 */
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,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<Id>(),created=new Set<Id>();
if(document!==base.document){
// 匹配 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.
if(document.canvases.size!==base.document.canvases.size)return;
const canvasOrder=base.document.canvases.keys();
for(const[id,canvas]of document.canvases)if(id!==canvasOrder.next().value||!equal(canvas,base.document.canvases.get(id)))return;
if(document.elements.size!==base.document.elements.size)return;
const elementOrder=base.document.elements.keys();
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;
}
// 先通过脏项形状检查再懒复制受影响数组,未变对象仍共享;所有新节点/边调用完整路径共用投影函数。
// base.transforms 只在拓扑兼容时复用;未显示 canvas 的普通变更可以只更新返回 document 而不增加显示节点。
let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>();
for(const id of dirty){const element=document.elements.get(id)!;
if(element.structure==='node'){
const index=layout.nodes.get(id);if(index===undefined&&!created.has(id))continue;
if(!base.transforms.has(element.ownerCanvasId))continue;
const transform=base.transforms.get(element.ownerCanvasId)!,depth=layout.depths.get(element.ownerCanvasId)!;
const node=projectNode(element,canvasId,registry,selection,preview?.get(id),transform,depth,layout.entries.get(id),catalog);
if(created.has(id)){if(node.data.__headlessFallback)return;inserted.set(id,node);}
else{if(nodes===base.nodes)nodes=[...nodes];nodes[index!]=node;}
}else{
const index=layout.edges.get(id);if(index===undefined)continue;const prior=base.edges[index];
const edge=projectEdge(element,prior.source,prior.target,registry,selection,base.transforms.get(element.ownerCanvasId)!,connection,catalog);
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<Id,Id[]>();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;
}
// 文档及数组均未变化时返回原场景,否则只替换必要字段,不发布任何业务状态。
return nodes===base.nodes&&edges===base.edges&&document===base.document?base:{...base,document,nodes,edges};
}