From 7297985829790c4bebe3a265527ab0600ff1fa1e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 15:18:42 +0800 Subject: [PATCH] docs(bindings): explain projection geometry and hit testing budgets --- src/lib/canvas-bindings/connectors.ts | 8 ++++-- src/lib/canvas-bindings/geometry-cache.ts | 13 ++++++--- src/lib/canvas-bindings/hit-query.ts | 32 ++++++++++++++++------- src/lib/canvas-bindings/node-internals.ts | 9 ++++--- src/lib/canvas-bindings/visibility.ts | 3 ++- 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/lib/canvas-bindings/connectors.ts b/src/lib/canvas-bindings/connectors.ts index 2c98e40..5a13586 100644 --- a/src/lib/canvas-bindings/connectors.ts +++ b/src/lib/canvas-bindings/connectors.ts @@ -9,7 +9,9 @@ import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometr 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 使用同一布局。 */ + * 找到具名 port 时,同 direction 的 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。 + * 即使 fallback hint 选择了另一侧,已识别 port 仍按高度做 y 偏移;这是旧布局协议,不适用于原生 Handle。 + * nodeConnector 先负责节点边界/旋转,下面直接叠加 y 偏移,不再把这段偏移当局部向量旋转。 */ 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仅属于旧卡片布局约定。 @@ -21,7 +23,9 @@ export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:numbe 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,不编造中心点。 */ + * source 仅查 source 列表;target 按 target 再 source 顺序查,匹配 loose 模式。 + * 非空 handleId 精确查找,空字符串/null/undefined 取首项;无匹配或未测量返回 undefined。 + * 假定 positionAbsolute 与 Handle 尺寸采用同一场景单位;直接叠加,不在这里验证有限值或再次处理节点旋转。 */ 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/geometry-cache.ts b/src/lib/canvas-bindings/geometry-cache.ts index ec48f8b..14f72b3 100644 --- a/src/lib/canvas-bindings/geometry-cache.ts +++ b/src/lib/canvas-bindings/geometry-cache.ts @@ -12,21 +12,26 @@ const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.wi /** 子画布变换只依赖 presentation 中 contentScale/unitScale,忽略无关样式字段。 */ const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[]; /** View-local numeric/path projection only. No editable records, history, visibility or task facts. */ -/** 缓存实例应归一个显示上下文;计数器累计实际计算次数供性能诊断,不是模型版本。 */ +/** 一个显示上下文使用一份缓存:三个 Map 分别缓存节点几何、子 canvas 变换和连线路径。 + * 调用方须按 begin→若干 node/child/edge→end 使用;这里不校验调用配对,也不提供失败回滚/并发帧隔离。 + * 累计计数只在实际重算后增加,不是文档版本或当前命中次数;返回投影值冻结,键只保留所需输入。 */ export function createGeometryProjectionCache(){ let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0; type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number}; const nodes=new Map(),children=new Map(),edges=new Map();let seenNodes=new Set(),seenChildren=new Set(),seenEdges=new Set(); return Object.freeze({ - /** 开始一轮投影并重置 seen;owner 身份隔离不同 renderer/editor,即使 projectId 相同也不能混用。 */ + /** scope 按对象身份、project/canvas 按 ID 隔离;任一变化清三张表,累计计数不清。 + * 每轮重建 seen,随后命中缓存也记一次访问;begin 不自行遍历文档或判断元素仍可见。 */ begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();}, /** 将节点中心转到场景,按变换比例缩放宽高并合成旋转;缓存复制输入数值防止外部修改污染键。 */ node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;}, - /** 计算分区内部坐标到场景的变换,单位比例变化也会使缓存失效。 */ + /** 分区入口几何+父变换+contentScale/unitScale 生成内部 canvas→场景变换,单位变化也使缓存失效。 + * 不含 collapsed 等显示决策,是否调用 child 由投影器决定;使用核心 childCanvasTransform 解释单位。 */ child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;}, /** JSON 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */ edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;}, - /** 删除本轮未访问的缓存项,避免浏览多个分区后无限保留旧节点和路径。 */ + /** 扫描三张缓存表,删除本轮未访问项;保留规则按调用访问,不等于元素已经从文档删除。 + * 调用方若提前抛错且不执行 end,未访问旧项可暂留至下次完整清理,缓存不是业务恢复事实。 */ end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);}, /** 返回当前缓存大小和累计计算量的冻结快照;begin 不重置累计计数。 */ stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}), diff --git a/src/lib/canvas-bindings/hit-query.ts b/src/lib/canvas-bindings/hit-query.ts index 506a133..68cb4b2 100644 --- a/src/lib/canvas-bindings/hit-query.ts +++ b/src/lib/canvas-bindings/hit-query.ts @@ -29,7 +29,10 @@ const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFin function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};} /** Query the captured display frame. It neither edits nor replaces the native input router. */ /** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断; - * 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 */ + * 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 + * 同步消费借入的 projection/query/services,不捕获另一份文档;几何服务须按此显示帧返回同坐标系的结果。 + * maxCandidates 约束节点精测+边访问,maxCurveSteps 由全部边共享,maxSegments 则是每边上限; + * 前置 Map/层序/空间候选收集和用户几何回调不被这些计数抢占,不能据此承诺总耗时硬上限。 */ export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){ // 强制快照/registry 身份一致;旧投影搭配新索引可能产生看得见却点不中的结果,直接拒绝。 if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096; @@ -38,34 +41,43 @@ export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries // 屏幕位置减容器 origin 与 viewport 平移后除 zoom;容差同样换算,不随缩放改变视觉点击范围。 const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps; if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query'); - // 分别把场景点与容差变回各 canvas,查询本地节点索引;previewIds 可补上索引尚未覆盖的预览位置。 + // 先收集 previewIds,再逐 canvas 反变换查局部索引,以 Set 合并;真正精测前会排除未出现在投影中的 ID。 + // 索引几何未随瞬态 preview 改动时,renderer 必须补 previewIds,不能只靠原位置的空间候选。 for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);} - /** 由真实 owner 向当前显示根回溯,生成 canvas/入口路径;检测循环和无效父关系。 */ + /** 从元素真实 owner 沿 parent 回到当前显示根,反转成根→owner 路径;不是修改元素归属。 + * 每条命中都生成显示路径,循环/父关系断裂作为该目标诊断;viewSessionId 只沿用调用值,不查询 session 注册表。 */ function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};} /** 自定义层级必须有限;否则记入对应目标诊断,而不是用无效数值打乱排序。 */ const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;}; - // 节点精测只处理投影中实际存在的候选;未知/降级组件使用矩形协议,单元素协议失败隔离为诊断。 + // 节点精测只处理投影中实际存在的候选;unknown/fallback/非 node 几何协议统一用矩形显示外壳。 + // confirmed 仅证明采用的显示几何命中,不宣称已经理解未知组件原始轮廓。目标回调须按 Error 风格抛错。 for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale; - // 组件协议返回的几何边界也要校验;精测依据局部旋转几何和按比例换算的容差。 + // bounds 在此用于合法性检查,最终命中由 hit 返回布尔值;不能把通过 bounds 检查当成已经命中。 + // geometry 使用 node 的显示尺寸/位置(含预览)反算,element 仍是同帧文档记录,容差换算为 owner 局部单位。 const bounds=protocol.bounds({element:record,geometry:g,role:'hit'});if(![g.x,g.y,g.width,g.height,g.rotation,bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||g.width<0||g.height<0||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds'); const result=protocol.hit({element:record,geometry:g,point:p,tolerance:localTolerance});if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push({target:{id,incarnation:record.incarnation},kind:'node',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),layer:layer(id,'node',node.zIndex??0),certainty:'confirmed'}); }catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}} // 优先采用 renderer 锚点。原生 headless 记录不能用旧卡片的兼容锚点猜位置,缺少服务就给出诊断。 const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left'); - // 连线与节点共用候选预算;逐边解析真实显示端点和画笔,失败不影响其它边的查询。 + // 节点精测在先,连线随后遍历 projection.edges(不再做空间粗筛),两者共用 work 预算。 + // 单边解析显示端点/画笔失败记诊断,继续其它边;某些边因节点预算已耗尽可能尚未访问。 for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable'); const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}; - // renderer 曲线优先,随后才用组件协议;验证段数及每段四个有限点,避免异常几何耗尽查询。 + // 自定义 edgeGeometry 非 null/undefined 的结果优先(包括空数组),否则调用已知 connection 协议。 + // 先检查每段4个有限点及每边段数,再求距;回调本身仍同步执行,没有超时或抢占。 const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response'); - // 有效点击半径 = 场景容差 + 实际线宽/2;不把只点中心线误当成整条可见线的命中范围。 + // strokeWidth 同样优先服务回调,再用组件协议,要求是场景单位的有限非负数。 + // 有效点击半径=场景容差+线宽/2;不能把模型局部宽度或屏幕像素宽度未经换算直接送进来。 const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType|undefined,confirmed:ReturnType|undefined; // 曲线细分步数由所有边共享剩余预算;无法继续证明时保守返回 possible,不伪造 confirmed。 for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}} // 同一边任一段确定命中即可 confirmed;只有不确定命中则同时说明精度或预算诊断。 const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});} }catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}} - // 尚未完整加载的显示分区可能漏掉未收到元素,命中列表存在也不代表整场景查询完整。 + // 只核当前 projection.transforms 覆盖的显示 canvas 是否完整;不推断折叠内部的未显示元素。 + // 命中列表非空仍可能不完整,diagnostics 汇总几何解释/预算/精度与模型覆盖问题。 if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'}); - // 排序按显示层级从高到低,同层节点优先于边,再按文档层序倒序;只返回结果,不直接改变选择。 + // 排序:layer 降序→同层 node 优先→文档层序倒序,不以 confirmed/possible 抢改显示层级顺序。 + // complete 仅等于本次 diagnostics 为空;消费者仍需理解命中 certainty,函数不改变 selection 或手势。 hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics}); } diff --git a/src/lib/canvas-bindings/node-internals.ts b/src/lib/canvas-bindings/node-internals.ts index 358679b..eaee4cc 100644 --- a/src/lib/canvas-bindings/node-internals.ts +++ b/src/lib/canvas-bindings/node-internals.ts @@ -3,10 +3,12 @@ * owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。 */ /** One renderer store's transient measurement requests. Not model or history state. */ -/** 每个 renderer store 独立创建;默认微任务调度,也可注入测试或宿主 defer。 */ +/** 每个 renderer store 独立创建:owners 记录挂载 token→nodeId,dirty 只记录待测 token。 + * defer 默认为微任务;update 接收执行时仍有效的 ID,不接触文档。工厂没有全局 dispose/flush 公开入口。 */ export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){ const owners=new Map(),dirty=new Set();let queued=false; - /** 执行时才解析仍有效的 owner,按节点 ID 去重;先清 dirty 再回调,让重入失效进入下一轮。 */ + /** 执行时解析仍有效 owner 并按 ID 去重,清 queued/dirty 后 update;重入 invalidate 可安排下一轮。 + * update 异常直接传播,本批不自动回队重试;卸载 token 的请求被排除,新 token 须自行 invalidate 才入本批。 */ function flush(){ queued=false;const ids=new Set();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear(); if(ids.size)update([...ids]); @@ -16,7 +18,8 @@ export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flus acquire(id:string){ const owner=Symbol(id);owners.set(owner,id); return Object.freeze({ - /** 仅活跃租约可以变脏,同一批最多安排一次延后 flush。 */ + /** 活跃 token 去重变脏,queued 防重复 defer;多个 token 若同 nodeId,flush 时再合成一个 ID。 + * 不捕获 defer 异常:若宿主抛错但未排任务,queued 不自动复位,宿主应使用可靠调度端口。 */ invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}}, /** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */ dispose(){owners.delete(owner);dirty.delete(owner);}, diff --git a/src/lib/canvas-bindings/visibility.ts b/src/lib/canvas-bindings/visibility.ts index 44ac7e2..516d3fa 100644 --- a/src/lib/canvas-bindings/visibility.ts +++ b/src/lib/canvas-bindings/visibility.ts @@ -4,5 +4,6 @@ import type {Rect} from '../canvas-core/geometry.ts'; /** Positive-area native node visibility. Degenerate nodes are retained for measurement. */ /** 正面积矩形要求与视口有正面积交叠,只有边界接触不算可见。 - * 宽/高非正的退化节点保留,让 renderer 有机会测量它,而不是提前裁掉。 */ + * 宽/高非正的节点直接保留,即使远离视口,让 renderer 有机会测量;负数也走同一分支,并不在此修正。 + * 调用方须提供同一坐标系中的轴对齐矩形;此函数不处理旋转、zoom 或 NaN/Infinity 合法性。 */ export function nativeRectVisible(node:Rect,viewport:Rect){if(node.width<=0||node.height<=0)return true;return Math.min(node.x+node.width,viewport.x+viewport.width)>Math.max(node.x,viewport.x)&&Math.min(node.y+node.height,viewport.y+viewport.height)>Math.max(node.y,viewport.y);}