From f1c5853fea9839a69e7eac891d8d82074feb5e57 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 12:32:01 +0800 Subject: [PATCH] docs(core): clarify feature schemas and bounded geometry queries --- src/lib/canvas-core/curve-hit.ts | 11 +++++++++-- src/lib/canvas-core/features.ts | 26 ++++++++++++++++++++------ src/lib/canvas-core/geometry.ts | 10 +++++++--- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/lib/canvas-core/curve-hit.ts b/src/lib/canvas-core/curve-hit.ts index 2b95e1b..df64a1b 100644 --- a/src/lib/canvas-core/curve-hit.ts +++ b/src/lib/canvas-core/curve-hit.ts @@ -1,6 +1,7 @@ /** * 有限预算的三次曲线命中精判。先用控制点包围盒排除,再用弦线距离与平坦度误差判断; - * 不能确定时用 de Casteljau 二分。预算/数值精度不足明确返回 possible,不能伪称已证明 miss。 + * 不能确定时用 de Casteljau 二分。预算/数值精度不足明确保留可能命中,不能伪称已证明 miss。 + * 上层须提供合法曲线/点、非负半径和有效预算,本函数没有独立参数校验或 registry 检查。 */ import type {Point} from './types.ts'; import type {Cubic} from './geometry.ts'; @@ -16,12 +17,18 @@ function split([a,b,c,d]:Cubic):[Cubic,Cubic]{const ab=mid(a,b),bc=mid(b,c),cd=m * maxSteps 限总工作,maxDepth 限细分深度,error 控制可接受平坦度;stack 用显式 DFS。 * 每轮:包围盒排除→near±flat 判定→足够平/深度已满则记 possible→否则入栈左右子曲线。 * near 是到端点弦线段距离,flat 包括两控制点到该弦的偏差及 roundoff。 - * 剩余 stack 非空表示总预算耗尽,仍可能有未检查的命中区域。 */ + * 剩余 stack 非空表示总预算耗尽,仍可能有未检查的命中区域。 + * certainty 只在 hit=true 时区分 confirmed/possible;hit=false 的正常返回也固定带 possible,不是“可能没命中”的三态枚举。 */ export function hitCubic(curve:Cubic,point:Point,radius:number,options:{error:number;maxDepth:number;maxSteps:number}){ + // 栈中保存尚未证明可排除的子曲线,steps 按实际取出的子曲线计数;possible 记住已停止细分的模糊区域。 const stack:{curve:Cubic;depth:number}[]=[{curve,depth:0}];let steps=0,possible=false,estimate=Infinity,error=0; while(stack.length&&stepsp.x),ys=item.curve.map(p=>p.y),dx=Math.max(0,Math.min(...xs)-point.x,point.x-Math.max(...xs)),dy=Math.max(0,Math.min(...ys)-point.y,point.y-Math.max(...ys));if(Math.hypot(dx,dy)>radius)continue; + // 弦距离 near 与 flat 给出保守区间:near-flat 超半径可排除,near+flat 在半径内可确认。 + // 数值非有限时直接返回 possible 且 budget=true;该标志此时不只是“栈还有工作”的含义。 const[a,b,c,d]=item.curve,roundoff=Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),...xs.map(Math.abs),...ys.map(Math.abs)),flat=Math.max(distance(b,a,d),distance(c,a,d))+roundoff,near=distance(point,a,d);if(!Number.isFinite(flat)||!Number.isFinite(near))return{hit:true,certainty:'possible' as const,steps,distance:Infinity,error:Infinity,budget:true};if(near-flat>radius)continue;if(near+flat<=radius)return{hit:true,certainty:'confirmed' as const,steps,distance:near,error:flat,budget:false}; + // 达到平坦误差或深度阈值只保留 possible,budget 可仍为 false;继续二分时先压右再压左,按左优先 DFS。 if(flat<=options.error||item.depth>=options.maxDepth){possible=true;if(near0,certainty:'possible' as const,steps,distance:estimate,error,budget:stack.length>0}; } diff --git a/src/lib/canvas-core/features.ts b/src/lib/canvas-core/features.ts index 382030e..a6cd3ba 100644 --- a/src/lib/canvas-core/features.ts +++ b/src/lib/canvas-core/features.ts @@ -1,6 +1,7 @@ /** * 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。 * 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 handler 来写文档。 + * capability 表示安装了哪类行为,schema 与各 handler 的实际检查仍须分别阅读。 */ import {builtinDataValidator} from './element-data.ts'; import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts'; @@ -15,12 +16,14 @@ const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geom if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]); const patch = input.patch as Partial; if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]); + // constrain 在字段形状检查后执行;此处不归一 rotation 或单独证明最终宽高为正,交给文档几何校验。 const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch }; return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }]; }; /** 移动仅允许 x/y,不能顺带修改大小、旋转或 owner。 */ export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) }); -/** 捕获 min/max/aspect 配置,构造只修改 width/height 的缩放行为。 */ +/** 浅捕获 min/max/aspect 选项,检查显式 min/max,再构造只修改 width/height 的缩放行为。 + * 缺省 min=1、max=100000;aspect 默认不启用,内置图片也复用这份默认缩放 feature。 */ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => { const options = Object.freeze({ ...inputOptions }); if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint'); @@ -32,6 +35,7 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b return geometryHandler(['width', 'height'], geometry => { // owner 变换后 unitScale 也会改变,因此 min/max 要乘它才能维持同一视觉尺寸约束。 const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units; + // 先逐维 clamp,再按原 width/height 比例重算另一维;同时给宽高且 aspect=true 时以宽度为主。 let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height)); // 宽度优先;只给高度时反算宽度。保持原比例后越界则拒绝,不分别截断而破坏比例。 if (options.aspect) { @@ -44,13 +48,16 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b }, }); }; -/** 旋转只产生 rotation 修改;[0,360) 等最终范围约束由几何校验处理。 */ +/** 旋转 handler 只接受 rotation patch,但输出的是整份 geometry 字段变化。 + * 不把 360/-1 自动归一到 [0,360),最终几何校验负责拒绝越界结果。 */ export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) }); /** 内容编辑按顶层 data 键生成独立条件变化,保存存在性,区分缺失字段与 null。 */ export const editable = () => defineFeature('content.edit', { 'content.edit': ({ element, input }) => { const patch = input.patch as Data; if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]); + // 签名要求 Data 对象;上面的运行时 guard 检查 JSON 值域并排除数组,不是任意未知输入的完整对象 schema。 + // patch 中出现的键都表示写入,null 是显式值;此 handler 没有以 undefined 表示删除的约定。 // 每个字段携带 before,ACK/重放时才能检查冲突,而不是用整份节点覆盖后来的编辑。 return Object.entries(patch).flatMap(([key, after]): FieldChange[] => { const before = element.data[key] ?? null; @@ -58,7 +65,8 @@ export const editable = () => defineFeature('content.edit', { }); }, }); -/** 内置节点的基础 data 合同:title/text 为字符串,允许其它合法 JSON 扩展字段。 */ +/** 内置节点共用的基础 data 合同:title/text 为字符串,检查已识别 unitScale,允许其它合法 JSON 扩展。 + * 图片 URL/资源身份、图标名或任务字段并不由此函数逐项验证;它也不代表所有表现字段都有专门 schema。 */ const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string'); /** 安装文本、图片、脚本、图标、frame 和 connection 的默认定义。 * 节点共用基础行为;frame 不安装旋转行为。复制声明与资源字段也是定义的一部分。 */ @@ -67,18 +75,23 @@ export function builtinComponents(): readonly ComponentDefinition[] { id, dataVersion: 1, structure: 'node', validate: textData, features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()], resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [], - // frame 的 ports 引用必须随复制映射;任务/run/progress 不复制成新的执行任务。 + // frame ports[].nodeId 是显式 element 引用,复制范围外引用按 reject;局部 port id/handle 不是这里的映射目标。 + // 只 omit 下列四个顶层任务状态字段,不递归猜测任意插件任务结构;资源字段按声明另供 runtime 扫描。 geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] }, + // 后续 connection 使用单独定义:strokeWidth 取 appearance.width 的真值或 1.5,并不在这里验证其类型/正数范围。 + // segments 要求 displayPath 已提供且可解释,再调用共享 Bezier 生成器;输出有效性由几何查询边界继续检查。 })).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data:Data) => validJson(data)), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]); } -/** null 表示默认连线;否则仅支持最多 64 个 knot 的 bezier-chain,并校验控制点/唯一 ID。 */ +/** null 表示默认连线;否则仅支持最多 64 个 knot 的 bezier-chain,并校验控制点/唯一 ID。 + * 这里只检查所需路径字段,不拒绝所有扩展键;corner/smooth/symmetric 是模式标签,不在此验证控制柄相切或镜像关系。 */ export function connectionPathValid(path: Json): boolean { if (path === null) return true; if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false; // 控制点只能是有限 x/y;缺少坐标、数组或非对象值均不接受。 const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y); if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false; + // 每个 knot 的 ID 非空且本路径内唯一;位置和 in/out 都必须是有限点,空 knots 数组也合法。 const ids = new Set(); for (const knot of path.knots) { if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false; @@ -86,7 +99,8 @@ export function connectionPathValid(path: Json): boolean { } return true; } -/** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。 */ +/** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。 + * feature ID 为 connection.path,但注册 capability 键是 connection.edit;editor 按后者查 handler。 */ export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => { const path = (input.patch as { path?: Json }).path; if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]); diff --git a/src/lib/canvas-core/geometry.ts b/src/lib/canvas-core/geometry.ts index 65c92de..d89d547 100644 --- a/src/lib/canvas-core/geometry.ts +++ b/src/lib/canvas-core/geometry.ts @@ -1,6 +1,7 @@ /** * 与渲染框架无关的基本几何协议。所有计算输入必须在同一坐标空间; * bounds 负责粗筛,hit 负责矩形精判,曲线 segments 由模型命中和 SVG 绘制共享。 + * 函数不主动校验输入几何/容差/控制点,也不换算 viewport 或 presentation.unitScale,调用方先统一空间。 */ import type {Geometry,Point} from './types.ts'; /** 轴对齐包围盒,用于空间索引粗筛;旋转节点的盒会大于其实际轮廓。 */ @@ -14,12 +15,15 @@ export type BezierChain={readonly knots:readonly{readonly position:Point;readonl /** 绕节点中心旋转后的 AABB:宽=|cos|·w+|sin|·h,高=|sin|·w+|cos|·h。 */ export function rectangleBounds(g:Geometry):Rect{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),width=g.width*c+g.height*s,height=g.width*s+g.height*c;return{x:g.x+g.width/2-width/2,y:g.y+g.height/2-height/2,width,height};} /** 将点反向旋转到矩形自身坐标,计算到矩形的距离,再与容差比较。 - * 角外区域使用欧氏距离而非扩展 AABB;浮点误差余量随坐标尺度调整。 */ + * 角外区域使用欧氏距离而非扩展 AABB;浮点误差余量随坐标尺度调整。 + * 点在矩形内部时距离为 0;tolerance 由调用方保证非负,正常边界含在命中内。 */ export function hitRectangle(g:Geometry,point:Point,tolerance=0):boolean{const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);return Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height);} /** 默认矩形节点协议;空 dataDependencies 表示形状仅依赖 geometry,不因 title 等数据变化重建。 */ export const rectangleGeometry=Object.freeze({kind:'node' as const,version:1 as const,dataDependencies:Object.freeze([] as string[]),bounds:({geometry}:{geometry:Geometry})=>rectangleBounds(geometry),hit:({geometry,point,tolerance}:{geometry:Geometry;point:Point;tolerance:number})=>hitRectangle(geometry,point,tolerance)}); /** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */ /** 由锚点链生成每段三次曲线:上一 knot.out 和下一 knot.in 分别作为控制偏移。 - * 无显式首尾控制柄时,按端点距离的 0.4 倍选择长度并限制在 40–240。 - * add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段。 */ + * 无显式首尾控制柄时,reach 按两端直线距离的 0.4 倍限制在 40–240,再乘相应法向量。 + * 本函数不归一 normal;只有单位法向量才使 reach 等于控制柄的实际长度。 + * add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段,共 knots.length+1 段。 + * 无 knot 时直接生成端点间一段;这里不根据 smooth/symmetric 标签自动修正控制柄。 */ export function bezierChainSegments(from:Connector,to:Connector,path?:BezierChain|null):Cubic[]{const reach=Math.min(240,Math.max(40,Math.hypot(to.point.x-from.point.x,to.point.y-from.point.y)*.4)),knots=path?.knots??[],anchors=[from.point,...knots.map(knot=>knot.position),to.point],add=(a:Point,b:Point)=>({x:a.x+b.x,y:a.y+b.y});return anchors.slice(0,-1).map((point,index)=>[point,add(point,index?knots[index-1].out:path?.startControl??{x:from.normal.x*reach,y:from.normal.y*reach}),add(anchors[index+1],index