开源 3D 建筑编辑器 Pascal Editor:照片转场景的边界

2026-08-05

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

Pascal Editor 是一个跑在浏览器里的开源三维建筑编辑器(不是 Pascal 编程语言,也和压强单位帕斯卡无关),它的 MCP 服务器带一组把户型图和房间照片变成场景的视觉工具。这条链路真正被工程化的部分不是「看图」,而是看完之后的那两道闸:视觉模型吐回来的 JSON 先过一遍 zod schema,成功之后每个生成的节点再单独过一遍 AnyNode.safeParse,过不了的墙和房间直接丢掉,只在返回值的 notes 字段里留一句话。 识图准不准,仓库没打算兜底;它兜的是「识错了也不会把场景图搞脏」。你如果按「上传照片就能出模型」的期待去接这套东西,会在第一个真实户型图上翻车;按「它是一个带强校验的降级管道」去接,才用得住。

先做个消歧:这里说的 Pascal Editor 是 pascalorg 开源的三维建筑编辑器(许可证 MIT,Copyright 2026 Pascal Group Inc.),和 Pascal 编程语言、和压强单位帕斯卡都没有关系。仓库根 README 对自己的定位原话是 “A 3D building editor built with React Three Fiber and WebGPU.”,这是项目自己的说法。它的 MCP 服务器包 @pascal-app/mcp 的 README 则这样描述服务端形态:headless 跑在 Bun 里,不需要浏览器、WebGPU、React 或外部数据库服务。

站内已经有几篇讲相邻问题的文章:browser-use 怎么把页面变成结构化输出 讲的是另一个项目的抽取管道,大模型幻觉 讲的是幻觉这件事本身,Agent 输出约束与忠实度 讲的是通用的约束方法论。本篇不重复这三件事,只做一件事:把一个真实开源仓库里「视觉 → 结构化 → 三维几何」的落地实现拆到文件和字段这一层,看它的可靠边界具体卡在哪几行。

一、它想解决什么,以及几个你需要先知道的概念

场景很直白:用户手里有一张手绘户型图或者一张房间照片,希望 Agent 直接把它变成可以在编辑器里转着看的三维场景,而不是自己去点墙、拉尺寸。

要看懂后面的机制,有几个词得先讲清楚,它们都是建筑或三维图形侧的说法,跟 AI 工程日常用语不是一回事。

场景图(scene graph),在这个项目里不是一棵嵌套的树对象,而是一个扁平结构:一个 nodes 字典(节点 id 映射到节点),加一个 rootNodeIds 数组标记哪些是根。父子关系靠节点自己的 parentIdchildren 字段串起来。这种扁平形态对 Agent 友好,因为改一个节点不需要在嵌套里定位路径。

节点类型,就是场景里能出现的东西的种类。packages/nodes/src/ 下有 46 个子目录(你自己 ls 一下就能数出来),其中 shared/ 是共享代码不算类型,所以是 45 种节点类型,从墙、门、窗到楼梯、屋顶、太阳能板、风管件都有。这个数字待会儿有用。

墙(wall) 在这里由平面上的起点和终点两个坐标定义,加上厚度和高度。坐标写成 [x, z] 而不是 [x, y],因为三维里 y 轴留给了竖直方向,所以平面就是 x-z 平面。

楼层(level) 是「建筑的某一层」这个容器,墙、区域这些都挂在它下面。它自己有个 height 字段,packages/core/src/schema/nodes/level.ts 里的注释写明这是楼层的 floor-to-floor 高度,也就是从本层地面到上层地面的高度,不等于房间的净高(净高是地面到天花板的可用空间,会被楼板和吊顶吃掉一截;楼板就是隔开上下两层的那块水平结构板,本身有厚度)。

区域(zone) 是一块用平面多边形圈出来的地面范围,用来表达「这是客厅」「这是厨房」这种房间概念。要注意它本身不是墙围出来的实体,就是一圈点。

二、三个工具,各管一段

这条链路在 MCP 侧一共有三个工具,加上一段提示词和一个网络抓取工具。它们的分工是分开的,不是一条流水线的三个阶段。

组成部分它负责什么对应仓库位置你什么时候会碰到它
analyze_floorplan_image把一张户型图解析成 walls / rooms / approximateDimensions / confidence 的 JSON,不改场景packages/mcp/src/tools/vision/analyze-floorplan-image.ts你只想拿数据、自己决定怎么落地时
analyze_room_photo把一张房间照片解析成 approximateDimensions / identifiedFixtures / identifiedWindows,不改场景packages/mcp/src/tools/vision/analyze-room-photo.ts你要从实拍照片里捞家具和窗户的大致信息时
photo_to_scene编排器:采样识图 → 建场景图 → 可选存库 → 切换当前场景packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts你要一次调用直接拿到可打开的场景时
safeFetch图片传的是 http(s) URL 时,替三个工具做防 SSRF 的抓取packages/mcp/src/lib/safe-fetch.ts你把图片放在自己的图床或内网时
renovation_from_photos 提示词把现状照片、参考照片、改造目标拼成一组消息,指挥 Agent 按顺序调上面的工具packages/mcp/src/prompts/renovation-from-photos.ts你走的是「改造既有房子」这条剧本时
工具注册入口决定 photo_to_scene 这个工具到底出不出现在工具列表里packages/mcp/src/tools/index.ts工具「凭空消失」时你得回这里看
两份走查文档完整的调用剧本与返回样例packages/mcp/examples/photo-to-scene.mdpackages/mcp/examples/renovate-from-photos.md想快速看全流程时,但字段名以 .ts 为准

最关键的一点:这个包里没有视觉模型。packages/mcp/src/tools/vision/index.ts 的注释原话是「No vision model is bundled in this package」。三个工具都是走 MCP 的 sampling 能力,调 server.server.createMessage(...) 把图片和一段只许输出 JSON 的 system prompt 丢回给宿主,由宿主那边的模型来看图。宿主没有 advertise sampling 能力,三个工具一律先查 getClientCapabilities(),然后抛 sampling_unavailable

图片入参的解析规则三个工具是一致的:http(s):// 开头的走 safeFetch 抓下来转 base64;data:image/...;base64, 前缀的剥掉前缀、mime 从 URI 里取;剩下的一律当裸 base64,mime 默认按 image/jpeg 处理。最后一条是兜底猜测,你传的是 PNG 的话,mime 就被标错了。

采样参数是写死的:temperature: 0maxTokens: 2000。前者让输出尽量稳定,后者是硬编码在这三个文件里的常量。

三、JSON 落成场景图这一步,到底做了什么

photo_to_scene 是这条链路里唯一会动场景的工具,值得逐段拆。

第一步是采样。它自己内联了一份和 analyze_floorplan_image 几乎一样的 system prompt,唯一多出来的一句是要求「只有当墙高在图上被可见地标注或量出时才输出 height」。对应地,它内部的 VisionResponseSchemaanalyze_floorplan_image 的输出 schema 多了一个可选的 height 字段。这两处差异是有意的,因为它要拿这个 JSON 去建三维几何,而不只是回给 Agent 看。

第二步是校验。JSON.parse 失败抛 sampling_response_unparseablesafeParse 不过抛 sampling_response_invalid,两个错误都会把原始文本放进错误数据里,方便你回看模型到底吐了什么。这两个错误码在 photo-to-scene.test.ts 里都有对应的用例覆盖。

第三步是建图。它用核心 schema 的工厂函数造一副骨架:SiteNode(场地)→ BuildingNode(建筑)→ LevelNode(第 0 层),然后每一堵视觉墙造一个 WallNode,每一个视觉房间造一个 ZoneNode,全部挂到这一层下面。这里有个数字对比很说明问题:仓库里有 45 种节点类型,这条链路只用到其中 5 种。

第四步是逐节点复核。每个造出来的节点在写进字典前,还要单独再过一次 AnyNode.safeParse。不过的墙会被 continue 跳过,同时往 warnings 里追加一条 wall[i] dropped: ...;房间同理。WallNode.parse 本身抛异常也是同样的处理。所有 warnings 最后 join 成一个字符串放进返回值的 notes

这里藏着一个你必须知道的语义:返回的 wallsrooms成功进入场景图的数量,不是模型认出来的数量。中间被丢掉的那些,只在 notes 这个可选字符串里留痕。你在 Agent 侧做校验时,不能只看 walls 有没有大于零,得把 notes 一起读了。这跟工具返回值该怎么设计里说的那类问题是同一类:一个计数字段同时承担了「产出量」和「成功率」两种语义,调用方极容易只读一半。

第五步是切换和落库。bridge.setScene(...) 把当前场景整个换成新造的这一副,后续的 find_nodesmeasureapply_patch 就都在新场景上操作了。scene-bridge.ts 里这个方法的注释标了它是可撤销的(走 Zundo 的时间态历史)。save 默认为 true,此时会调 bridge.saveScene(...) 落库,把返回的元信息设成活动场景,再 appendLiveSceneEvent 推一条实时事件给浏览器侧的订阅者,返回 sceneIdurl: /scene/<id>save: false 时不落库,直接把整个 graph 内联在返回里,并调 clearActiveScene()

顺带一个用起来很舒服的小设计:defaultWallThicknessdefaultWallHeight 这两个入参不是裸 number,而是包了一层 measurement('length', 'm', ...)packages/mcp/src/tools/measurement.ts 的注释说明它接受 0.15"6 in""180cm""2 ft 3 in" 这类写法,由 @pascal-app/lingo 归一到米,模型用哪个单位思考就用哪个单位回答。同一份注释还写明了一个刻意的取舍:"1,234" 这种分隔符真正有歧义的输入会被直接拒绝,而不是猜一个值吞下去,免得欧洲写法的小数变成千倍值。

四、边界与代价:它明确不管的部分

这一节比上面都重要,因为大部分翻车都发生在这里。

只产墙和区域。 门、窗、楼板、屋顶、家具,photo_to_scene 一个都不建。packages/mcp/examples/photo-to-scene.md 结尾自己写着现阶段只覆盖 walls 和 zones,门窗和物件是后续工具的事。你想要门洞,得在之后自己调 cut_opening

视觉这一层是纯 2D 的。 墙只有 [x, z] 的起终点,整个 schema 里没有任何东西描述竖直方向的形态。高度靠那个可选的 per-wall height,而 system prompt 又明确要求只在图上可见标注时才给。也就是说,绝大多数户型图跑下来,墙是没有高度的——WallNodeheight 是可选且没有默认值。

区域不等于房间实体。 ZoneNode 的 schema 里其实有 autoFromWallsboundaryWallIdsenclosureStatus 这些字段,用来表达「这个区域是由一圈闭合的墙证明出来的」。但 photo_to_scene 只写 namepolygon,其余全走默认。结果就是:墙和区域是两套各自独立的几何,谁也不保证谁。墙认歪了、区域多边形却是对的,这种不一致场景图不会替你报错。

没有比例尺推断。 一张照片本身不包含真实尺寸信息,工具也没做任何标定。唯一的入口是 scaleHint 这个可选文本,示例里是 "1 cm = 1 m, approx 20 m²" 这种。你不给,模型就是在凭图上的相对比例猜绝对米数。

confidence 只是透传。 它是模型自报的 0 到 1 的数,工具不设阈值、不拦截、不降级。示例文档的原话是把置信度暴露出来,好让 Agent 去提醒用户。换句话说,判断权在你的 Agent 编排层,不在这个工具里。

数据会落在你的机器上,也可能出网。 save: true(默认值)时,场景写进本地 SQLite。@pascal-app/mcp 的 README 说默认路径是 ~/.pascal/data/pascal.db,可以用 PASCAL_DATA_DIR 指目录、PASCAL_DB_PATH 指具体文件。你丢进去的户型图会变成一份持久化的本地场景数据,得自己想清楚这份数据算不算敏感。图片传的是 http(s) URL 时会真发起出网请求,走 safeFetch:环回、私网、link-local(含云元数据地址 169.254.169.254)、非 http(s) 协议全部拒绝,每一跳重定向重新校验,最多 3 跳,默认限 20 MB、超时 10 秒,还可以用 PASCAL_ALLOWED_ASSET_ORIGINS 收成 origin 白名单。这个文件的注释坦白记了背景:早先这三个工具都是裸 fetch(url),等于给了一个直捅云元数据的口子。这类「Agent 有权发起哪些网络请求」的问题,MCP 的安全边界怎么划里有更系统的讨论。

HTTP 模式要自己上锁。 README 写明服务器可以 pascal-mcp --http --port 8787 暴露在环回地址上;绑非环回地址必须配 PASCAL_MCP_HTTP_TOKEN 这个 bearer token,另有 PASCAL_MCP_HTTP_ORIGINS 管跨域来源。暴露端口意味着任何能打到这个端口的进程都能换掉你正在编辑的场景、往你的库里写场景,这个权限范围要自己评估。

五、上手与避坑清单

宿主没有 sampling 能力时,三个工具全废。 会踩是因为工具列表里明明有它们,但真正干活的模型不在这个包里。避法是接之前先确认宿主 advertise 了 sampling;两份示例文档给的降级路径都是退回纯文本的 from_brief 提示词。

photo_to_scene 可能压根不出现在工具列表里。 会踩是因为 packages/mcp/src/tools/index.ts 里它被放在 if (operations.hasStore) 分支内,和场景生命周期工具、变体工具一起注册;而两个 analyze_* 工具走的是另一条路径 registerVisionTools,不受这个条件约束。所以「有 analyze 没有 photo_to_scene」是完全正常的现象,不是版本问题。避法是先确认这个 MCP 服务实例带了存储。

别指望 defaultWallHeight 落到墙上。 packages/mcp/examples/photo-to-scene.md 里写它会应用到每一堵生成的墙,但代码里 WallNode.parse...(w.height !== undefined ? { height: w.height } : {}),只有模型给了高度才写;defaultWallHeight 实际是传给了 LevelNode.parse({ level: 0, height: defaultWallHeight }),也就是楼层高度。会踩是因为文档和实现在这一点上漂移了。避法是把统一墙高当成一个独立步骤,建完场景之后自己批量补。

别照着示例文档的 JSON 字段名去拼参数。 packages/mcp/examples/renovate-from-photos.md 里的返回样例写的是 labelwidthMeterskindwallHintapproximateWidth,而 .ts 里的 zod schema 实际是 nametypewallLabelapproximateWidthM;尺寸字段还得再分一层:户型图那个工具用的是 widthM/depthM,房间照片那个工具用的是 widthM/lengthM,两处不是同一个词。会踩是因为示例文档读起来更顺,Agent 也更容易被它带偏。避法只有一条:字段名一律以三个 .ts 文件里的 schema 为准,文档当剧本看不当契约看。同一份 photo-to-scene.ts 里编排步骤的注释编号也是跳着的(1、2、5、4),一并说明这类旁注不能当规范。

提示词路径不会替你抓 http 图片。 renovation-from-photos.tstoImageContenthttp(s) 开头的字符串只产出一条 URL: ...文本块,模型根本看不到图;只有 data URL 和长度、字符集都通过检测的裸 base64 才会变成 image 块。会踩是因为工具路径(analyze_*photo_to_scene)是会 safeFetch 抓取的,两条路径行为不一致。避法是走提示词时一律传 data URL。

内网图床会被 safeFetch 直接挡掉。 会踩是因为把图片放在 192.168.x.x*.local 上是很自然的做法,而这些恰好在拒绝名单里,报 url_host_blocked。避法是要么用 data URL 绕开抓取,要么把图源放到可公网解析的地址并配好 PASCAL_ALLOWED_ASSET_ORIGINS

默认参数会直接改你手上的场景。 save 默认 true,而且不管存不存,bridge.setScene(...) 都会先把当前场景整个换掉。会踩是因为大家习惯把「分析类」工具当成只读的,但这个是编排器不是分析器。避法是探索阶段一律显式传 save: false 先拿 graph 看一眼;真要回退,setScene 按注释是走时间态历史可撤销的,但别把它当成唯一保险。

大户型可能撞上截断。 采样的 maxTokens 在源码里写死成 2000,墙一多,JSON 还没闭合就到头了,表现出来是 sampling_response_unparseable。会踩是因为报错文案指向「解析失败」,很容易被误判成模型不听话。避法是先看错误数据里的 raw 字段是不是被截断在半路,是的话就拆图分次处理,或改走 analyze_floorplan_image 拿数据后自己分批 apply_patch。这类「怎么区分模型没听懂和物理限制」的判断,和 Agent 失败分类是同一套思路。

六、收尾:一份可以照着走的自检

如果你要把这条链路接进自己的 Agent,建议按下面五条过一遍再上:

一、确认宿主 advertise 了 sampling,并准备好 sampling_unavailable 的降级分支。二、探索期一律 save: false,把 graph 拿出来自己看,确认无误再重跑一次存。三、每次调用都读 notes,把它和 walls/rooms 的计数对起来看,别只看计数。四、把 confidence 的阈值判断写在你自己的编排层里,工具不会替你拦。五、图片走 data URL,把出网和内网访问这两件事从链路里摘出去。

接下来该读哪个文件,取决于你卡在哪一段:想弄清校验为什么会失败,读 packages/mcp/src/tools/photo-to-scene/photo-to-scene.test.ts,四个用例正好覆盖成功、无 sampling、非 JSON、不落库四条路径;想弄清节点还能带哪些信息,读 packages/core/src/schema/nodes/wall.tszone.ts,你会发现墙和区域实际能承载的字段远比这条链路写进去的多;想看整体架构,wiki/architecture/ 下有 20 份 markdown(1 份 README 索引加 19 篇分主题)。另外仓库根目录同时放着 AGENTS.mdCLAUDE.mdGEMINI.md 三份 Agent 约定文件,你要拿 Agent 改这个仓库的话,那是先该看的地方。

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 浏览器里的开源 3D 建筑编辑器 Pascal Editor:MCP 模板层怎样把一句需求变成户型开源三维建筑编辑器 Pascal Editor:让智能体自查碰撞与门净空

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