69 lines
8.7 KiB
TypeScript
69 lines
8.7 KiB
TypeScript
/**
|
||
* 连线超出原生端点包围范围的额外绘制索引。用曲线控制点凸包加半线宽保守包住笔画,
|
||
* 不要求求出三次曲线的精确极值。无法获取锚点/协议或耗尽预算时返回 unknown,宿主应保守保留显示。
|
||
*/
|
||
import type {Registry} from '../canvas-core/types.ts';
|
||
import type {projectCanvas} from './projection.ts';
|
||
import type {HitServices} from './hit-query.ts';
|
||
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
|
||
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<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){
|
||
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<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
|
||
// 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<Math.min(maxEdges,projection.edges.length);i++){const edge=projection.edges[i];endpoints.add(edge.source);endpoints.add(edge.target);}
|
||
if(endpoints.size)for(const node of projection.nodes)if(endpoints.has(node.id))nodes.set(node.id,node);
|
||
let remainingSegments=maxSegments,computed=0;
|
||
for(let index=0;index<projection.edges.length;index++){
|
||
// 超出边预算给全局 paint_budget,而非声称未检查的边没有额外范围。
|
||
if(index>=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=x<native.left||y<native.top||x+width>native.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<typeof projectCanvas>,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<string,{signature:unknown;segments:number;region?:PaintRegion}>(),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<typeof projectCanvas>,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)});
|
||
},stats:()=>Object.freeze({computed,entries:cache.size}),clear(){cache.clear();index.clear();owner=undefined;project=undefined;canvas=undefined;revision=undefined;}});
|
||
}
|