开源三维建筑编辑器 Pascal Editor:让智能体自查碰撞与门净空
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
这套设计里最值得抄走的,不是它怎么建模,而是它把「生成时的避让」和「验收时的判据」压成了同一份实现。 furnish_room 摆家具时用来挑位置的那个重叠判断函数,就是 check_collisions 事后复检时调的同一个函数;门前要留多深的空地,也只在一处定义。你写任何一个会改动外部世界的智能体工具,这一条都能直接搬。
先做个消歧:这里说的 Pascal Editor 是一个开源的三维建筑编辑器,仓库 README 用一句话这样定位自己——「一个用 React Three Fiber 与 WebGPU 构建的 3D 建筑编辑器」,也就是画墙、开门、摆家具、盖屋顶那种编辑器,跑在浏览器的图形栈上。它跟 Pascal 编程语言没有关系,也跟压强单位帕斯卡没有关系。仓库地址是 https://github.com/pascalorg/editor,许可证 MIT(Copyright 2026 Pascal Group Inc.)。
一、这个仓库长什么样,以及几个你必须先认识的名词
在仓库根目录数一下受版本控制的文件,全仓 2508 个,apps/ 下 2 个应用,packages/ 下 9 个包。按包体量排,packages/nodes 733 个文件最重,packages/editor 445 个,packages/core 233 个,packages/mcp 152 个,packages/viewer 107 个。本文要拆的四个工具全在 packages/mcp 里。
packages/nodes/src/ 下有 46 个子目录,其中 shared/ 装的是公共代码不是节点类型,所以节点类型是 45 种。所谓「节点」,就是这个编辑器里场景图的一个元素——一面墙是一个节点,一扇门是一个节点,一件家具也是一个节点,它们靠 parentId 串成父子关系。
几个建筑侧的名词,先一句话交代清楚,后面不再解释:
- 楼层(level):一层楼,是所有其它节点的顶层归属容器。判断两件家具会不会打架,第一步是看它们在不在同一层。
- 区域 / 房间(zone):平面上的一个多边形,圈出「这里是卧室」。它只是范围,没有厚度。
- 楼板(slab):一层楼的地面结构板,你站的那块。房间有了范围还得有楼板,不然人是踩空的。
- 净空(clearance):某个位置周围必须留出来的空地。门前的净空就是「开了门人能走进来」所需要的那块地面,家具不许占。
- AABB(轴对齐包围盒):把一个物体套进一个边跟坐标轴平行的方盒子,只记最小最大坐标。判两个物体撞没撞,就退化成判两个盒子的区间有没有交叠——非常快,但也非常粗。
坐标约定这个项目自己写在智能体指南里:X/Z 是平面轴,Y 是竖直轴,单位是米。所以下文说的「平面重叠」,指的是在 X/Z 两个轴上比。
二、Agent 建模真正会翻车的地方
让模型输出一栋房子的场景图,它极少输出语法不合法的东西。Zod 那一层几乎总是通过:坐标是数字,尺寸是数字,枚举值也对。翻车全在下一层——数值合法,但空间语义是错的。沙发中心点落在餐桌中心点旁边 0.3 米,两者尺寸都是 1 米出头,于是它们互相穿进去;床摆在门后 0.2 米处,门推不开;二楼的柜子和一楼的门在平面上重合,你要是不区分楼层,还会误报一堆根本不存在的冲突。
这类错误的共同点是:看单个节点永远看不出来,必须看节点之间的关系。而模型自己是看不见的——它没有渲染画面,只有一堆坐标。所以要么让人去看图,要么给它一组能自己调的检查工具。这个项目选了后者,并且分了四层。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
validate_scene | 对场景里每个节点跑 Zod 校验,返回 { valid, errors },每条错误带 { nodeId, path, message } | packages/mcp/src/tools/validate-scene.ts | 批量改完图之后的第一道关 |
check_collisions | 旋转感知的平面 AABB 重叠检测,可用 levelId 收窄到单层 | packages/mcp/src/tools/check-collisions.ts | 怀疑家具穿模时 |
| 门净空 keep-out | 围绕门洞造一个矩形禁放区,找出堵门的家具 | packages/mcp/src/tools/door-clearance.ts | 门被柜子挡住时 |
| 布局净空与候选位搜索 | 家具之间的最小间距、放置失败原因分类、备选位姿(位置加朝向的组合)生成 | packages/mcp/src/tools/layout-clearance.ts | furnish_room 摆不下东西时 |
verify_scene | 高层体检,返回 hasIssues 与一组 issues 描述 | packages/mcp/src/tools/scene-query.ts | 交付前最后一步 |
| 回归清单 | 把踩过的 8 个坑(L1–L8)与预合并 checklist 固化成文档 | packages/mcp/docs/layout-clearance-error-log.md | 你要动这套逻辑之前 |
顺带一个能自己看出来的结构性事实:packages/mcp/src/tools/ 顶层几乎每个工具文件旁边都躺着一个同名的 .test.ts,本文要拆的这四个文件一个不落。判据是靠单元测试钉住的,不是靠人记得住。
三、四层各自判什么
第一层 validate_scene 只管形状。 它的实现短到一屏都不用:调 bridge.validateScene(),把结果原样吐出去。输出约定是 valid 加一个错误数组,每条错误告诉你哪个 nodeId 的哪个 path 出了什么 message。它管不到任何空间关系——两张床完全重合,这一层照样返回 valid: true。
第二层 check_collisions 管真重叠。 它注册时给自己的描述是「通过旋转感知的平面 AABB 测试检出重叠的家具占地」。「旋转感知」这四个字对应 door-clearance.ts 里的这段:
const cos = Math.abs(Math.cos(rotationYRad))
const sin = Math.abs(Math.sin(rotationYRad))
const halfW = (w * cos + d * sin) / 2
const halfD = (w * sin + d * cos) / 2
物体转了角度之后,包围盒按投影重新算宽深,而不是拿原始宽深糊弄。取尺寸时走 getScaledDimensions,也就是缩放后的实际尺寸——错误日志里的 L4 就是当年直接用了未缩放的原始尺寸留下的教训。
这个工具最值得看的其实是它的一段注释。重叠判断函数 findItemItemCollisions 默认间距是 DEFAULT_ITEM_GAP(0.08 米),但 check_collisions 调用时显式传了 gap: 0,并在旁边写明理由:0.08 是 furnish_room 摆新家具时想要的呼吸空间,用在这里会把「只是靠得近」的两件家具报成碰撞,破坏本工具「只报真实重叠」的契约。同一段几何代码,两个调用点的语义诉求不同,差异被写进注释而不是靠人记——这是个很小但很硬的工程习惯。
gap 这个参数的语义本身也有讲究,它表示要求的最小自由间距,判定是把两个盒子各向外扩再看相不相交:
return a.maxX + g > b.minX && a.minX - g < b.maxX && a.maxZ + g > b.minZ && a.minZ - g < b.maxZ
错误日志 L3 记着反过来写的那版 bug:符号写成减号之后,gap 越大反而要求穿得越深才报警,行为整个倒转。这种符号级错误肉眼极难复查,所以它被留成了一条带复现步骤的回归项:两件相距 0.05 米的家具、gap 取 0.08,必须报冲突。
第三层门净空管通行。 doorKeepoutFromWall 拿一面墙和一扇门,造出一个矩形禁放区:沿墙方向取门宽一半再加 DEFAULT_DOOR_SIDE_PAD(0.05 米)的余量,垂直墙面方向(沿墙的法线,也就是跟墙面垂直的那个方向)朝两侧各推 DEFAULT_DOOR_CLEAR_DEPTH(0.65 米)。注意是两侧都推——代码注释写得很直白:这样无论门往哪边开都被保护到。门宽拿不到时按 0.9 米兜底。
它还有个细节挺聪明:keepoutForPolygonEdge 允许在门还不存在的时候,就为房间多边形的某条边造一个「计划中的门」的禁放区,id 形如 planned-door-<i>。furnish_room 摆家具时用得上——房间刚圈出来还没加门,也得先把入口那面墙前面留出来。与之配套的 keepoutCoversPlanned 负责判断「这条边上是不是已经有真门覆盖了」,判据是计划禁放区的中心点必须落在既有禁放区内部,且相交面积占计划区面积不低于一半。这条严格判据是 L6 那个 bug 换来的:早先只要 AABB 沾边就算已覆盖,结果隔壁走廊的门会把本房间的入口保护给顶掉。
第四层 verify_scene 管常识。 它不比几何,比的是「一层楼该有的东西齐不齐」:有空楼层要报,有墙没有房间要报,有房间没有楼板要报,有房间没有天花要报,有墙一扇门都没有也要报;屋顶支撑层(专门放屋顶几何的那一层)里混进了住人内容也要报,提示是把房间、墙、楼梯、楼板、天花和家具挪回居住层去,这一层只留屋顶几何。反过来,一个被标成屋顶支撑层却没有任何屋顶几何的层同样要报,提示是补上或挪来屋顶几何,而不是把这层删掉去凑楼层数。输出里的 hasIssues 就是给智能体看的总闸——项目的智能体指南里明写着这个字段应当为 false,否则要向用户解释剩下的问题。
四、把这套东西搬到你自己的 Agent 里
站内已经写过几篇相邻的:让另一个 Agent 当对手来验证 讲的是用第二个模型去挑第一个的毛病,完成前验证 讲的是收尾时的证据纪律,输出约束怎么写才被真正遵守 讲的是提示词层面的约束落地。这篇不重复它们——本篇看的是把判据做成可调用的确定性工具这一路,也就是不靠另一个模型、不靠提示词自觉,靠一段跑得出布尔值的代码。
值得抄的有四处:
其一,避让与验收共用实现。 furnish_room 摆家具时调 findValidPlacement 提前躲开门和别的家具,verify_scene 与 check_collisions 事后再查一遍,两条路径最终都落到同一个 aabbsOverlap。你的工具如果生成时用一套规则、验收时用另一套,迟早会出现「生成器认为合规、校验器认为违规」的死循环,模型会在里面空转到耗尽预算。
其二,失败原因是枚举不是自由文本。 放置失败的类型只有四种:outside_bounds、blocks_door_clearance、overlaps_item、ok。furnish_room 把它们翻成 skipped 数组里的短句,形如 <资产 id>: blocks door clearance。区别在于,模型看到「越界」会往里挪,看到「堵门」会换墙,看到「压到别的家具」会缩尺寸——三种修法完全不同。给一句「放置失败」,它只会原样重试。关于返回值该怎么设计才让模型接得住,可以对着 工具返回值的设计 一起看。
其三,报错要指向根因而不是最后一次尝试。 findValidPlacement 会围绕主位姿生成一批候选:沿墙横移 7 档(0、±0.4、±0.8、±1.2 米),朝房间内侧退 4 档(0、0.25、0.5、0.75 米)。全都失败时,它刻意上报主位姿的失败原因,而不是最后一个候选的。原因写在注释里:大幅挪动之后的候选通常是「出界」,而真正卡住你的是门或者旁边那个柜子。这正是 L7 修过的 bug。任何做「重试 N 次再报错」的工具都会遇到同一个陷阱——最后一次的错误往往最没有信息量。
其四,坑要落成文件。 packages/mcp/docs/layout-clearance-error-log.md 从 L1 到 L8 逐条记着「当时的 bug 是什么、正确规则是什么、回归测试怎么复现」,末尾还有一份预合并 checklist。八条里有两条其实是编辑器渲染预览侧的问题,跟这套几何判据无关,被顺手记在了同一份文件里——这也说明它是当作团队的公共教训清单在用,而不是某个模块的私有笔记。顺带一提,这个仓库根目录同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份智能体约定文件,wiki/architecture/ 下有 20 份 md(1 份 README 索引加 19 篇分主题)——它是把「给模型看的上下文」当正式产物在维护的。
五、边界与代价:它明确不管什么
先说方法本身的粗糙度。它做的是平面包围盒近似,不是真几何求交。 所谓旋转感知,只是按投影把盒子放大,一张 L 形沙发、一张圆桌,一律按外接矩形算。后果是双向的:凹形物体的空腔里其实塞得下东西,它会误报;两个物体角对角擦过去,包围盒交了但实体没交,它也会误报。反过来,被外接矩形包住的细长杆件之间的真实穿插,它未必分得清。
它只比 X/Z 两维,不比高度。 挂在墙上的吊柜和地上的地柜在平面上必然重叠。项目用的是过滤而不是三维求交:收集占地时看 attachTo 是不是 wall、wall-side、ceiling,是就跳过;父节点是墙的家具在地面排布时也跳过。这是启发式,资产没标 attachTo 就会漏。
门净空是矩形不是扇形。 真实开门扫出来的是一个四分之一圆,这里用的是墙两侧各 0.65 米的对称矩形,不区分内开外开,也不管门是平开还是推拉。它保的是「门口有块空地」,不是「门扇能扫过去」。
楼层隔离靠父链,挂错就失效。 resolveNodeLevelId 沿 parentId 一路往上走到 level 节点,走不到就返回 null。而比较时的规则是两边都有楼层 id 且不同才跳过——一个没归属的节点,会跟其它楼层的东西照常比。这是 L1 那条多层误报的另一面。
结构化程度不一致。 check_collisions 给的是带 kind: item-aabb 的结构化条目,程序好消费;verify_scene 的 issues 是一组人话字符串,你要在代码里按类型分流,只能做字符串匹配。这在写自动修复回路时是个实打实的摩擦点。
再说彻底不在射程内的:结构受力、疏散与防火之类的规范合规、管线综合、采光日照、造价。这套工具查的是「这屋子里的东西摆得开吗」,不是「这栋楼能不能建」。把它的绿灯当成设计通过,是误用。
最后是运行代价,这部分必须讲清楚,因为它跑在你自己机器上:
- 数据落在本地。 场景存储走运行时内置的 SQLite 驱动,默认写到
~/.pascal/data/pascal.db。想换位置,PASCAL_DB_PATH指定具体文件,PASCAL_DATA_DIR指定放pascal.db的目录。你的所有项目图纸都在这一个文件里,备份和清理都得你自己管。 - HTTP 传输默认只绑回环。 代码里有一道硬拦截:绑到非回环网卡时,必须提供
PASCAL_MCP_HTTP_TOKEN或显式传authToken,否则直接抛错。另外还有PASCAL_MCP_HTTP_ORIGINS控制允许的跨域来源,以及一个按客户端计的每分钟请求上限。把端口开出去意味着任何摸到这个端口并拿到令牌的人都能改你的场景。 - 智能体的权限不小。 这套 MCP 工具里有创建节点、删除节点、批量打补丁、撤销重做、保存场景这些能力,它能改的东西就是你的模型文件本身。权限边界怎么划,最小权限设计 那篇讲得更系统。
六、上手与避坑清单
把 check_collisions 当「够不够宽敞」用。 会踩是因为名字听起来像通用体检,而它的契约是 gap: 0,只报真实重叠——两件家具贴着放,它一声不吭。避法:要判断「留没留出缝」,走的是家具间默认 0.08 米那条路(furnish_room 摆放时用的就是它),别指望 check_collisions 替你把关。
调完 validate_scene 就收工。 会踩是因为 valid: true 给的安全感太强,而它只做了 Zod 结构校验。避法:按项目智能体指南给的顺序走完 validate_scene → verify_scene → get_project_status,把 hasIssues 当真正的出口条件。
多层项目不传 levelId。 会踩是因为 check_collisions 的 levelId 是可选参数,省略就是全场景扫。虽然内部会按楼层过滤同层比较,但排查时你拿到的是一锅混合结果,很难定位。避法:多层就逐层传 levelId 分别跑。
把地面家具挂到墙节点下面。 会踩是因为 place_item 之类的操作可以自由指定父节点,看起来挂哪都行。但楼层解析和地面排布过滤都吃 parentId:父节点是墙的家具在收集地面占地时会被跳过,于是它既不参与避让,也不容易被查出来。避法:地面家具一律挂到楼层节点上——furnish_room 自己就是这么做的,它下补丁时把 parentId 设成房间所在的楼层 id。
无视 furnish_room 返回的 skipped。 会踩是因为返回里有个 placed 数字,看着挺像成功计数,容易只看它。skipped 才是那些没放下去的东西。项目自己在错误日志里写明:智能体应当把跳过原因和体检问题当作可行动项,而不是忽略。避法:把 skipped 非空当作任务未完成,逐条按原因改坐标、换墙面或者缩尺寸。
改这套几何逻辑不看错误日志。 会踩是因为这些代码看起来都是几行数学,谁都觉得自己能改对,而 gap 符号方向、缩放尺寸、楼层作用域这三类 bug 的复发概率极高。避法:动 door-clearance.ts / layout-clearance.ts / furnish_room / verify_scene / check_collisions 之前,先把 packages/mcp/docs/layout-clearance-error-log.md 的 L1–L8 和预合并 checklist 过一遍。
随手把 HTTP 传输绑到公网口。 会踩是因为要跟远端的智能体联调时,改个 host 是最省事的办法。代码确实会拦你要令牌,但你也确实可能顺手编一个弱令牌塞进去。避法:默认走 stdio 或者回环地址;真要开出去,令牌当密钥管,同时把允许的来源收窄。
七、收尾
如果只带走一句话:把校验做成智能体能自己调的确定性工具,并且让它跟生成逻辑共用同一份判据。 这比在提示词里写「请注意不要让家具穿墙」有效得多,因为前者返回布尔值和枚举原因,后者返回的是模型的自我感觉。
给你自己的项目留一份对照自检:
- 生成路径和校验路径调的是不是同一个判定函数?
- 失败原因是可枚举的类型,还是一句自由文本?
- 重试多次失败时,上报的是首次尝试的根因还是最后一次的表象?
- 每类判据的默认阈值是常量还是散落在各处的魔数?不同调用点的语义差异有没有写下来?
- 踩过的坑有没有变成一条带复现步骤的回归项?
接着往下读的话,按这个顺序:packages/mcp/src/tools/door-clearance.ts(几何判据的源头,aabbsOverlap 和 itemPlanAabb 都在这),然后 packages/mcp/src/tools/layout-clearance.ts(看 classifyPlacement 和 findValidPlacement 怎么把判据接成决策),再是 packages/mcp/src/tools/room-tools.ts 里 furnish_room 的注册体(看这套判据在真实生成流程里怎么被用),最后是 packages/mcp/docs/layout-clearance-error-log.md(看这些设计各自是被哪个 bug 逼出来的)。四个文件读完,这套自校验的来龙去脉就完整了。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor:照片转场景的边界 和 浏览器里的 3D 建筑编辑器 Pascal Editor:Agent 建完模型后的两条导出线。