开源 3D 建筑编辑器 Pascal Editor 的测量体系拆解

2026-08-05

本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。

让 Agent 报出一个敢让人签字的尺寸,比让它把模型建出来难一个量级。 建模错了肉眼能看见——墙歪了、房间穿模了、屋顶飘在半空;尺寸错了什么都看不见,它只是一个格式完全正确的浮点数,静静躺在工具返回值里,等着被写进报价单。

Pascal Editor 是一个跑在浏览器里的开源 3D 建筑编辑器(这里说的是这个三维建筑建模项目,跟 Pascal 编程语言、跟压强单位帕斯卡都没有关系),仓库 README 把自己定位成「一个用 React Three Fiber 和 WebGPU 构建的 3D 建筑编辑器」,许可证 MIT(Copyright 2026 Pascal Group Inc.)。它自带一套 MCP 服务器,让 Agent 能直接下场建模。而它的测量部分,恰好是一个把「数值可信度」这件事拆得很细的样本:几何算子放哪、标注怎么存、人和模型输入的尺寸字符串在哪一层被翻译成米,三块界限画得很清楚。

先说清本篇跟站内几篇相邻文章的分工:结构化输出为什么不稳 讲的是让模型稳定吐出合法 JSON,输出约束与终审对手验证 讲的是让模型别把话说满、别自说自话。本篇接在这三篇后面:假设格式已经合法、话也没说满,那个数字本身对不对,误差是在哪一层被放大或被拦住的。

一、这套测量体系被切成了哪几层

先给全景。整个仓库有 2508 个受版本控制的文件、apps/ 下 2 个应用、packages/ 下 9 个包(各包体量大致是 nodes 733、editor 445、core 233、mcp 152、viewer 107 个文件)。测量相关的代码不集中在一个「measurement 包」里,而是按职责摊在四个位置。

组成部分它负责什么仓库位置你什么时候会碰到它
纯几何算子距离、夹角、周长、面积向量、质心、棱柱体积、最近特征绑定packages/core/src/lib/measurement-geometry.ts任何要复算一个尺寸的地方
测量节点 schema五种测量载荷的判别联合与合法性校验packages/core/src/schema/nodes/measurement.ts写入标注被拒、排查校验报错
节点侧语义特征扩展点features / resolve / match / quickMeasure 四个钩子packages/core/src/registry/types.tspackages/nodes/src/wall/measurement.ts给自定义节点类型加可测锚点
显示层格式化米、毫米、英尺英寸的写法,面积体积的单位换算packages/editor/src/lib/measurements.ts界面标注跟你算的对不上时
人输入的尺寸解析属性字段把 180cm1m80 解析成存储单位packages/editor/src/lib/measurement-parser.ts调属性面板输入行为
工具入参的尺寸解析Agent 传数字或字符串都收,歧义数字直接报错packages/mcp/src/tools/measurement.ts设计 MCP 工具入参 schema
measure 工具两个节点之间的中心距离、多边形的净面积packages/mcp/src/tools/measure.tsAgent 问「这两堵墙隔多远」
区域量清单证据不足时返回不可用而非估算packages/core/src/lib/zone-quantities.tsAgent 要一个房间的面积和体积

这张表里有一条隐含的架构判断值得单独拎出来:几何算子只在 core 里存在一份wiki/architecture/measurements.md 里的原话是,这些计算要留在 @pascal-app/core,渲染器和面板不得重新实现一遍。对 Agent 系统来说这条规矩很实在——如果三维渲染层自己算一遍面积、二维平面图层再算一遍、MCP 工具第三次算,那三个数字迟早会分叉,而 Agent 会挑到哪一个完全看运气。

二、几何这一层:面积是向量,体积是点积

measurement-geometry.ts 是纯函数文件,不 import 三维引擎、不碰状态。里面有几个设计选择直接决定了误差行为。

面积不是标量,是向量。 measurementAreaVector 对多边形顶点做一圈叉积累加,返回一个三维向量;真正的面积 measurementArea 是这个向量的模长。这么做的好处是它天然处理任意朝向的平面——一块斜屋面上的多边形不需要先投影到水平面再算。同时也意味着面积跟顶点绕向无关(顺时针逆时针出来的模长一样),文档里写作「winding-independent」。

体积是面积向量和挤出向量的点积绝对值。 挤出(extrusion)就是把一个平面多边形沿某个方向拉出厚度,像挤牙膏一样得到一个柱体。代码只有一行:

export function measurementPrismVolume(
  base: readonly MeasurementPoint[],
  extrusion: MeasurementPoint,
): number {
  return Math.abs(dot(measurementAreaVector(base), extrusion))
}

这一行的含义是:斜着拉出去的棱柱,只有挤出向量中垂直于底面的那个分量参与体积计算。这是正确的(斜棱柱体积等于底面积乘以垂直高度),但它也解释了为什么 schema 会拦掉某些输入——如果挤出方向完全躺在底面里,点积为零,体积为零,这个测量就没意义。

角度做了钳位。 measurementAngle 在算 Math.acos 之前把余弦值夹到 [-1, 1],并且当两条边长的乘积非有限、或不大于文件里那个 GEOMETRY_EPSILON(值为 1e-9)时直接返回 0。浮点误差让余弦值变成 1.0000000001 从而让 acos 返回 NaN,是这类代码最经典的翻车方式,这里显式堵住了。

质心不是顶点平均。 质心就是一块均匀薄板的平衡点,标注的引线和数值默认往这个点上挂。measurementCentroid 用三角扇分解,每个三角形的权重是叉积在法向上的投影,按权重加权平均。这跟「把所有顶点坐标加起来除以个数」是两回事——后者在顶点疏密不均的凹多边形上会明显偏移。这个区别待会儿在讲 MCP 的 measure 工具时会再出现一次,那里恰恰用了顶点平均。

最近特征绑定带优先级。 closestMeasurementFeatureBinding 遍历一组候选特征,对点类型直接算距离,对线段、路径、多边形则逐段求最近点,并把参数 t 归一化成沿整条几何的累计位置。距离相同时,priority 高的候选胜出。这一句是把「墙角优先于墙面」这种语义排序做进了通用算法,而不是散落在各个节点类型里。

共面判定也在这个文件:areMeasurementPointsCoplanar 默认容差 1e-6,但 schema 校验时传入的是文件里导出的 MEASUREMENT_PLANAR_TOLERANCE,值为 0.01。也就是说,内部数学判定严格,用户绘制的多边形宽容到一厘米。这两个数字不同不是疏忽,是两个不同用途。

三、标注这一层:五种类型,两种锚点

测量在这个项目里不是一个临时的 HUD 数值,而是场景图里的正式节点——跟墙、楼板(水平的承重板,也就是你脚下那层地面)一样,是楼层节点的子节点,能被选中、删除、复制。

packages/core/src/schema/nodes/measurement.ts 用 zod 定义了一个按 kind 判别的联合类型,五个分支:

  • distance:恰好两个锚点。
  • angle:恰好三个锚点,中间那个是顶点。
  • area:至少三个锚点的平面多边形。
  • perimeter:同样的平面多边形,取闭合周长。
  • volume:平面底 + 一个挤出向量。

校验规则写在 zod 的 superRefine 里。平面底的校验会取每个锚点的回退点做共面检测,不过就报错文案 Measurement base must be planar and enclose an area;体积分支额外检查挤出向量在底面法向上的分量是否大于 1e-9,不满足就报 Measurement extrusion must have a non-zero normal component这是把几何合法性挡在数据写入之前,而不是等渲染时才崩。 对 Agent 尤其重要:一个能被 schema 拒绝的畸形测量,比一个能存进去但显示为 NaN 的测量友好得多,因为前者会把可读的错误信息回传给模型。

真正有意思的是锚点的双形态。一个 MeasurementAnchor 要么是裸的三元组坐标,要么是这样一个对象:

export const MeasurementFeatureAnchor = z.object({
  kind: z.literal('feature'),
  reference: MeasurementFeatureReference,
  fallback: MeasurementPoint,
})

reference 里存的是节点 ID、特征 ID 和可选参数。fallback 那个字段名容易被误读成缓存值,架构文档特意澄清了:它不是缓存,它是引用解析不出来时用于「脱链」展示的那个点。

特征 ID 是节点类型自己定义的语义角色。以墙为例,packages/nodes/src/wall/measurement.ts 里出现的有 wall:startwall:endwall:face:leftwall:face:rightwall:height,曲线墙还额外发布 wall:curve:center。这些 ID 是稳定标识,而人看的标签不能当标识用——这条约束写在 registry/types.ts 的注释里。

绑定语义锚点的收益是关联性:你把尺寸线钉在墙的左面上,之后拖动这堵墙,尺寸值跟着变,而测量节点本身一个字节都没改写,也不产生新的历史记录。反过来,如果引用的节点被删了,测量不会静默消失也不会冻结成旧值,而是走 fallback 点、标红、打上 Unlinked 标记,检查器里给一个显式的「脱链」动作把它转成自由点。

节点类型接入这套体系的扩展点是 MeasurementContribution,一个必填钩子加三个可选钩子:必填的 features 枚举可捕捉的语义几何,可选的 resolve 处理无法穷举的连续特征,可选的 match 让节点类型用自己的知识做更聪明的匹配(比如挑离表面击中点最近的那一侧墙面),可选的 quickMeasure 返回一份即时报告。必填的那个是这套设计的底线:任何想被测量的节点类型,至少得说得出自己身上有哪些点、哪些边可以被钉住。特征几何本身有四种形态:点、线段、路径、多边形;捕捉类型枚举了七种:endpointmidpointedgecenterfaceridgeheight

quickMeasure 产出的 QuickMeasurementReport 结构是标题、类型标签、锚点、若干指标行、可选备注。每条指标带 quantity 字段,取值 length / area / volume,注释明确说值一律是规范单位——米、平方米、立方米。这个约定很关键:报告结构里没有单位字段供调用方猜,单位由 quantity 决定,展示层再换算。

四、解析这一层:把人打出来的字符串变成米

场景数据永远是米。架构文档写得很直白:节点 JSON 始终以米存储,切换显示单位不得改动场景数据、不得产生历史记录。那么「英尺英寸」这套东西活在哪?活在两个方向的边界上。

出的方向packages/editor/src/lib/measurements.tsformatLinearMeasurement 接收米、单位制、以及公制记法偏好(metersmillimeters)。英制分支把米乘以 1 / 0.3048 得到英尺,取整数部分,小数部分乘 12 四舍五入成英寸——然后有一句容易被忽略的补丁:如果英寸四舍五入成了 12,整数英尺加一、英寸归零。没有这三行,2.9999 m 会被打印成 9'12"。公制毫米分支是四舍五入到整毫米,米分支是先 toFixed(2)parseFloat 掉尾随零。非有限数一律返回 --

进的方向packages/editor/src/lib/measurement-parser.ts,背后是外部依赖包 @pascal-app/lingo。这个文件干的事是:让用户在任意测量字段里打 6ft180cm1m805'11"45°1.57rad,都被规范化成该字段存储的那个单位。lingoUnitSpec 把字段的 unit 属性映射成解析目标——m/cm/mm/in/ft 归为长度,°/deg/degrees 归为角度并以度为目标,rad/radians 以弧度为目标,其余一律返回 null

那个 null 是有意的。注释说得很清楚:纯数字、百分比、角速率、计数这些字段故意不做自然语言解析,保持 Number.parseFloat 的精确行为。这是一条我认为值得抄走的边界——万能解析器一旦铺到所有字段,就会开始在你不希望它聪明的地方自作主张。

还有一个提示函数 measurementHint,在你打字过程中显示一个淡淡的 = 1.83 m 预览。它有个前置判断:先用正则 ^[+-]?\d*\.?\d*$ 判断输入是不是纯十进制数,是的话直接返回 null 不提示。只有当你打了单位、复合写法或数字词,预览才出现。

文件开头还有一处工程细节:import './structured-clone-fallback' 必须排在 lingo 的 import 之前,因为按注释所述,lingo 在自己的模块体求值期间会用 structuredClone 克隆它的类型表。这种顺序敏感的副作用 import,是那种在打包器换了之后会莫名其妙炸掉的东西。

五、工具边界这一层:模型可以用任何单位回答

上面那套是给人用的。给模型用的那套在 packages/mcp/src/tools/measurement.ts,导出一个叫 measurement 的 zod 字段工厂。

它的类型是 z.union([z.number(), z.string()]) 加一个 transform。注释解释了动机:发出去的 JSON Schema 是 number | string模型可以用它正在思考的那个单位来回答,转换在工具边界完成,handler 拿到的永远是规范单位的数字,所以不需要改任何 handler。

用起来是这样,取自 packages/mcp/src/tools/create-wall.ts

export const createWallInput = {
  levelId: NodeIdSchema,
  start: Vec2Schema,
  end: Vec2Schema,
  thickness: measurement('length', 'm', {
    positive: true,
    description: 'Wall thickness.',
  }).optional(),
  height: measurement('length', 'm', { positive: true, description: 'Wall height.' }).optional(),
}

这里有三层保护叠在一起,每一层都值得单独说:

第一层是描述自动补全measurement 会把「接受数字或自然语言字符串」这句话和示例(长度类给 0.9, "6 ft", "180cm", "2 ft 3 in")拼到你写的语义描述后面,边界信息也自动拼——positive 拼成必须大于 0,有上下界就拼成区间。工具描述是模型唯一能看到的说明书,这种自动拼接保证了说明书不会跟校验逻辑走散。

第二层是歧义数字直接失败。角度字段有个细节:弧度制字段的示例故意把本单位的例子放在最前面,因为「一个裸的 45 进弧度字段就是 45 弧度」,示例顺序在引导模型别搞错。而更硬的一条是这个:

const result = parseQuantity(val, {
  kind,
  unit,
  strictness: 'forgiving',
  // A genuinely ambiguous separator ("1,234") must not silently become
  // a 1000× value at a tool boundary — fail so the model self-corrects.
  escalate: { AMBIGUOUS_NUMBER: 'error' },
})

1,234 在英语世界是一千二百三十四,在欧洲是一点二三四。这两个数差 1000 倍。编辑器里的解析器用的是宽容模式,工具边界这里把这一类升级成硬错误——宁可让模型重试一次,也不接受一个可能差三个数量级的墙厚。这是我在这个仓库里看到的最值钱的一行配置。

第三层是校验失败给模型可读的话。超范围返回的不是 zod 默认文案,而是「必须至少 X 单位(收到 Y)」这种带实际值的句子。这类返回值设计的一般原则在 工具返回值怎么设计 里展开过,这里是一个具体到单位换算的落地样本。

顺带一提,这个文件的注释自己承认它被刻意复制了两份,另一份服务于 AI 聊天工具栈,因为两套工具栈跨了包边界共享不了模块,注释要求两份保持一致。这是个诚实的技术债标注,不是设计范例。

六、边界与代价:它明确不管的那些事

这一节是本篇的重点,因为上面所有精巧的设计都有明确的作用域,越界就失效。

MCP 的 measure 工具跟编辑器里的测量不是一回事。 读一下 packages/mcp/src/tools/measure.ts 里那个 getCentre:墙和栅栏取起终点中点并压到 Y=0,物品、门、窗、建筑、楼梯、屋顶取 position,楼板、吊顶、区域(zone,就是用一圈多边形圈出来的一块空间,通常对应一个房间)取多边形顶点的算术平均并压到 Y=0。也就是说,measure 报的 distanceMeters两个节点代表中心之间的直线距离,不是面到面的净空(净空指两个构件表面之间那段空的距离,比如门洞两侧墙面之间可通行的宽度)。Agent 问「这两堵墙隔多远」拿到的是中心距,若墙有厚度,净空要自己减。工具描述里写的是 measure distance between two nodes,没有撒谎,但模型很容易把它当成净空来用。

同一个工具里的面积用的是另一套算法。fromId === toId 且节点是 zone / slab / ceiling 时,它用鞋带公式(shoelace,用多边形顶点坐标交叉相乘求平面面积的经典算法)算 XZ 平面投影面积,再减去洞口面积(zone 没有洞口,按空数组处理)。这跟 core 里那个三维面积向量算法不是同一条路径:投影面积对水平楼板是对的,对倾斜面就是投影值而非真实面积。

区域量清单宁可交白卷。 zone-quantities.ts 里的 deriveZoneQuantityReport 是保守的:表面覆盖率(楼板一套、吊顶一套)和空间包含率的门槛都是 0.95,也就是说必须有九成五以上的面积被真实构件盖住,才承认这个区域被「证实」了。证据不足或互相矛盾时返回的是 { status: 'unavailable', reason },理由文案是给人看的英文句子,意思分别是「没有楼板覆盖能证实这个区域」「覆盖该区域的多块楼板标高不一致」「有表面开洞跨越了区域边界」。除了覆盖率,它还查标高是否一致(多块楼板高低不齐就不给算体积)、开洞是否整块吃掉或跨越了区域边界——三类否决理由各自对应一种「几何上说不清楚」的现实情况。架构文档明说:缺失或冲突的证据返回一个面向用户的不可用理由,而不是一个看起来合理的估算值。对 Agent 管线来说这条极其重要——一个明确的「不可用 + 原因」,模型能处理;一个凭空补出来的合理数字,模型只会照单全收。

关联性有代价。 语义锚点让尺寸跟着宿主走,代价是每次渲染都要解析引用、订阅被引用节点及其父节点。文档里提到平面图缓存靠节点定义声明的依赖来失效。这套机制换来的是「拖墙时尺寸实时变且不写历史」,但它也意味着一份场景 JSON 单独拿出来看,测量值不是自解释的——你必须有整个场景才能复算出那个数。

分析标注不进模型产物。 测量节点在烘焙时被标为 strip,也就是说导出的模型里没有这些标注。你不能指望把测量当成模型的一部分交付出去。

它不打算做的事。 架构文档在区域量那一节直接列了未完成的拓扑工作:墙面开洞的净面积扣减、规范化的持久跨度参数、倾斜的上表面。别把这几件事当成已有能力去承诺。

跑起来它会在你机器上留下东西。 这套 MCP 服务器是本地进程:packages/mcp/src/bin/pascal-mcp.ts 支持 --stdio(默认)和 --http --port <n>,HTTP 端口默认 3917,另有 --scene <path>。场景数据落在一个本地 SQLite 文件里,packages/mcp/src/storage/sqlite-scene-store.ts 的路径解析顺序注释写着:PASCAL_DATA_DIR/pascal.db,Windows 上是 %APPDATA%/Pascal/data/pascal.db,否则 $XDG_DATA_HOME/pascal/data/pascal.db$HOME/.pascal/data/pascal.db。用 HTTP 传输就是在本机开了一个端口,任何能访问该端口的进程都能调用这些工具——而工具集里包含 create_wallcut_openingdelete_nodeapply_patch 这类会改场景的操作,也包含 undo / redo

它按配置会出网。 视觉类工具接受用户提供的图片 URL,packages/mcp/src/lib/safe-fetch.ts 为此做了 SSRF 防护:拒绝回环地址、链路本地地址(含云元数据地址 169.254.169.254)、私有网段和非 http(s) 协议,重定向每一跳都重新过一遍白名单,默认 20 MB 体积上限和 10 秒超时,可通过环境变量 PASCAL_ALLOWED_ASSET_ORIGINS 放行指定来源。这个文件的注释还坦白记录了它的由来:某轮排查发现三个视觉工具此前直接裸调 fetch(url),毫无防护。装之前该知道这段历史,也该知道现在这层防护的具体边界。

七、上手与避坑清单

别拿 measure 工具的返回值当净空。 为什么会踩:它返回的字段叫 distanceMeters,单位字段写着 meters,看起来毫无歧义,模型会直接拿去回答「够不够放下一个 1.2 米的柜子」。怎么避:在你的系统提示或工具包装层里把它明确成「中心距」,需要净空时要么改用编辑器那套语义锚点测量,要么在包装层里显式减去两侧构件的厚度并把口径写进返回值。

别在角度字段上放裸数字给模型自由发挥。 为什么会踩:弧度制字段收到裸的 45,按规则就是 45 弧度,而模型十有八九想说 45 度。怎么避:仓库的做法是把本单位示例放在描述的最前面引导模型;你还可以更进一步,角度字段一律要求带单位后缀的字符串。

别把宽容解析铺到所有数值字段。 为什么会踩:解析器很好用,顺手就想全局启用。怎么避:照 lingoUnitSpec 那个思路,显式列出要参与自然语言解析的单位,其余返回 null 走精确的数字解析。百分比、计数、比率这些字段被「聪明」地换算一次,排查起来极其痛苦。

别信一个跨了单位又跨了工具的数字链条。 为什么会踩:模型说「6 英尺」,工具边界转成 1.8288 米存进去,界面按毫米记法显示成 1829mm,模型下一轮读到 1829 又当成米。怎么避:所有跨边界的数值一律带单位一起传,或者干脆约定全链路只用规范单位(这个仓库选的是后者:JSON 永远是米,单位只活在最外的两层)。

别用显示字符串反推数值。 为什么会踩:formatLinearMeasurement 的米记法会 toFixed(2),英制记法会四舍五入到整英寸。这些字符串是给人看的,回读一次就掉精度。怎么避:Agent 管线里传结构化数值,展示字符串只往人的方向单向流动。

别忽略 unavailable 的返回。 为什么会踩:区域量报告返回的是带状态的对象,代码里一个 ?? 0 就能把「不可用」变成 0 平方米。怎么避:把状态字段当成必须分支处理的枚举,unavailable 时把 reason 原样透传给模型,让它去补齐证据或者向人求助——这正是 人在环路 该介入的时刻。

别在开 HTTP 传输的机器上假设只有自己能连。 为什么会踩:默认端口固定、本地开发时图方便就开着。怎么避:明确知道这个端口上暴露的是一组能改场景、能删节点、能撤销重做的工具,按需绑定回环地址并做好访问控制,数据库路径也提前用 PASCAL_DATA_DIR 指到你可控的位置。

收尾:一份自检清单

如果你要把类似的测量能力接进自己的 Agent 系统,可以拿这几条对一遍:几何算子是不是只有一份实现;数值的规范单位是不是只有一个、且在 schema 层就固定住;工具入参能不能接受模型习惯的单位表达,歧义写法是硬失败还是被静默吞掉;证据不足时返回的是显式的不可用理由还是一个看起来合理的估算;返回的距离到底是中心距还是净空,这个口径有没有写在描述里让模型看得见。

想继续往下读的话,顺序建议是:先看 wiki/architecture/measurements.md 建立全局图,再读 packages/core/src/lib/measurement-geometry.ts 把算子过一遍,然后是 packages/core/src/schema/nodes/measurement.ts 看约束怎么落到数据上,最后读 packages/mcp/src/tools/measurement.tspackages/mcp/src/tools/measure.ts —— 这两个文件放在一起看,会很直观地暴露出「工具边界上多花的那点力气」和「工具边界上省掉的那点力气」,各自的代价分别是什么。顺带说一句,这个仓库的 wiki/architecture/ 下有 20 份 md(1 份 README 索引加 19 篇分主题),根目录同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件,packages/nodes/src/ 下有 46 个子目录(其中 shared/ 不是节点类型,所以节点类型是 45 种)——这些数字你自己 ls 一遍就能核对,也顺便能看出这个项目对「让 Agent 读得懂自己」这件事投入了多少。

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor 的材质与主题机制三维建筑编辑器 Pascal Editor 的平面图模式与图纸导出链路

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。