From ce05ab653d114cce0938f2225aed94a933d941ff Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 15:32:19 +0800 Subject: [PATCH] docs(bindings): explain canvas projection and connection paint bounds --- src/lib/canvas-bindings/compat-types.ts | 1 + src/lib/canvas-bindings/connection-paint.ts | 24 ++++++++++++----- src/lib/canvas-bindings/index.ts | 1 + src/lib/canvas-bindings/projection.ts | 29 +++++++++++++++------ 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/lib/canvas-bindings/compat-types.ts b/src/lib/canvas-bindings/compat-types.ts index 3c0e9ca..c52b3e0 100644 --- a/src/lib/canvas-bindings/compat-types.ts +++ b/src/lib/canvas-bindings/compat-types.ts @@ -1,6 +1,7 @@ /** * 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。 * 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。 + * 本文件仅定义 TypeScript 形状,不运行默认值/迁移/编辑行为;投影器负责从标准 element 生成这些显示字段。 */ /** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */ type Point={x:number;y:number}; diff --git a/src/lib/canvas-bindings/connection-paint.ts b/src/lib/canvas-bindings/connection-paint.ts index 1b38d35..643e56d 100644 --- a/src/lib/canvas-bindings/connection-paint.ts +++ b/src/lib/canvas-bindings/connection-paint.ts @@ -11,7 +11,9 @@ 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. */ /** 可选缓存的共享实现,限制本次边数和总曲线段数。 - * 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */ + * 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 + * maxEdges 限访问的边数,maxSegments 同时作单边与总段数限;存在待查端点时仍遍历所有 projected nodes。 + * 几何回调同步执行且没有时间抢占,回调产出数组前的成本不由预算控制;输入须属于同一有效显示帧。 */ 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(); @@ -29,21 +31,25 @@ function connectionPaint(projection:ReturnType,registry:Re 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; - // 同时捕获显示边、真实记录、端点位置/大小、锚点及变换;预览/移动都可能改变曲线范围。 + // 签名复制锚点/端点数值/transform,但 record 与 edge 借用原不可变引用,缓存不深捕获它们。 + // 外部几何服务/协议配置不在签名内,须通过下方 geometryRevision 或 registry 对象变化失效。 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); - // 即使本边范围是空,也可缓存“已证明没有额外范围”,避免重复调用几何服务。 + // 锚点与基础输入每轮仍重新求取;签名相等才复用曲线范围/段数,跳过 segments/stroke 计算。 + // 空 region 可表示笔画未超原生范围或空 segments,不能将其误当“整条边没有显示内容”。 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。 + // 非空段数超过本次剩余预算则追加全局 paint_budget 并终止后续边;空数组仍要求有效 stroke。 + // 空数组缓存0段/无region,属本几何协议声明无笔画,不误标 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(); - // 原生范围由两端节点矩形并集包围;只有实际笔画包围盒超出它,才添加补充空间行。 + // 原生范围取两端节点未旋转矩形的联合 AABB;曲线控制点凸包+半线宽 AABB 超出时才添整块补充范围。 + // 这是保守估计而非曲线精确极值,允许额外保留可见候选;不计算命中距离或直接控制 renderer 挂载。 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,不影响其它已知范围。 @@ -56,11 +62,15 @@ function connectionPaint(projection:ReturnType,registry:Re 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, - * 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */ + * 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 + * 每视图独立实例;缓存按 registry 对象/project/canvas/revision 隔离,不另核 editor owner。 + * cache 保留边几何结果,index 存本轮额外区域;超过预算的其它边可暂留缓存但不进入本轮 regions。 */ 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 是累计重新计算数,不把缓存命中记成一次曲线计算。 */ + * unknown 中元素 ID 表示该边无法解释,字符串 paint_budget 表示余下查询不完整,不能据空 regions 判不可见。 + * query 是可变 index 的方法,旧返回对象也查最新网格,unknown 则是本轮冻结列表。 + * computed 计通过锚点/变换检查后未命中的几何尝试(可随后失败),不等于成功区域数;clear 不重置累计值。 */ 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/index.ts b/src/lib/canvas-bindings/index.ts index 97a0c95..417d3df 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -1,6 +1,7 @@ /** * 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。 * 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。 + * 此入口只重导出,不创建 view、订阅或缓存实例;scene coordinator 与分桶源的内部接线不在此集中公开。 */ /** Portable scene projection and hit queries: no React or React Flow imports. */ // 模型到场景投影、几何缓存和受预算控制的命中查询。 diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts index fe09a05..bd5004f 100644 --- a/src/lib/canvas-bindings/projection.ts +++ b/src/lib/canvas-bindings/projection.ts @@ -16,7 +16,8 @@ import { childCanvasTransform, toWorld, transformPath, type Transform } from '.. export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts'; /** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */ const origin:Transform={x:0,y:0,scale:1,rotation:0}; -/** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */ +/** 降级原因/类型/版本及从已知结构派生的把手;节点显示还可保留字符串标题和通用身份/几何。 + * 不透传整份未知 data 给旧 renderer,不等于删除原 payload 或保证显示完全不读取任何通用字段。 */ 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}}; @@ -28,7 +29,10 @@ const object = (value: Json | undefined) => value && typeof value === 'object' & /** Construct one node. Scene updates and full traversal share exactly the same projection rules. */ /** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。 - * renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */ + * renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 + * 已知 data 字段浅展开后由通用显示字段覆盖,record-v1 另借用原 element/transform,不深复制模型记录。 + * draggable 只按 known 与 geometry.move 能力判定,缺 renderer 的 fallback 不必自动只读;策略仍由 editor 检查。 + * 返回可变派生对象供后续补把手/结构共享,只有 __headlessTarget 单独冻结,不是完整输出深冻结。 */ 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'; @@ -47,7 +51,9 @@ export function projectNode(element:Extract,ca /** Endpoints are already resolved to their displayed node IDs by the caller. */ /** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。 - * 预览只替代本条边的路径;不把 data 内可能残留的 path 混入已验证的结构路径。 */ + * compat-v1 只对 target.id 匹配的边采用预览路径,变到场景后通过 connectionPathValid 才输出。 + * record-v1 接原记录/transform,不在此嵌入预览或转换结构路径;原生 renderer 须从自己的受控显示上下文读取预览。 + * data.path 从兼容 payload 中剔除,避免残留字段覆盖结构 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 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。 @@ -59,7 +65,10 @@ 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 隔离。 @@ -72,7 +81,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist /** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */ const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[]; const nodes: ProjectionNode[] = [], visible = new Set(); - /** 递归展开只改变投影:先输出入口,再按其当前几何/单位比例变换子分区。 */ + /** 按成员稳定顺序深度优先:先投影入口,再用同一份预览几何/单位构造内部 canvas 变换。 + * 隐藏分支用栈遍历后代建立 hiddenBy,不生成内部节点/边变换;无 queries 时反复从 canvas 表找子项。 */ const visit = (id: Id, depth: number) => { const transform = transforms.get(id)!; for (const element of getMembers(id)) if (element.structure === 'node') { @@ -91,10 +101,12 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist } }; visit(canvasId, 0); - // 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。 + // 节点/hiddenBy 全部就绪后再投影边,避免端点代理依赖节点是否已遍历。 + // queries 路径汇集展开 owner 的边再按文档层序排,无 queries 则扫描 element 表;模型归属始终不变。 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;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */ + /** 端点节点可见则保留 ID,否则按其 owner 的 hiddenBy 找折叠代理。 + * 缺显示端点或两端同代理时仅省略本帧边,不从模型删除,也不改为自由端点。 */ 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; @@ -121,7 +133,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist 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 修改。 + // 成功遍历结束才 end 清未访问几何项;不是 finally,异常时由下次完整轮次或视图 clear 清理缓存。 + // document 是原不可变引用,nodes/edges/transforms 是本次派生容器,不发布 editor 修改。 cache?.end(); return { document,canvasId,nodes, edges, transforms }; }