浏览器里的 3D 建筑编辑器 Pascal Editor:Agent 建完模型后的两条导出线
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
Pascal Editor 的两条导出线不是同一份数据的两种封装,而是两份不同的东西:JSON 那条给出的是可以再编辑的参数化场景图,GLB 那条给出的是已经算完、不可逆的三角形与关键帧。 先说清楚名字:这里说的 Pascal Editor 是一个跑在浏览器里的开源 3D 建筑编辑器(仓库 README 把自己定位为 a 3D building editor built with React Three Fiber and WebGPU),和 Pascal 编程语言、和压强单位帕斯卡都没有关系。它自带一套 MCP 服务器,让 Agent 直接调工具建墙、开洞、摆家具。
Agent 把模型建完之后,最常见的一个问题就是「现在怎么把它拿出来」。仓库里这件事的答案有点反直觉:MCP 服务器这边只有一条路是通的。
一、两条线的分工:一条能在无头进程里跑,一条不能
先看 MCP 工具层。packages/mcp/src/tools/export-glb.ts 这个文件只有三十几行,注册了一个叫 export_glb 的工具,它的输入 schema 是空对象,输出 schema 是 { status: 'not_implemented', reason: string },工具描述里写得很直白:GLB 导出在 headless 模式下不可用,它需要 Three.js 渲染器,而渲染器只存在于浏览器。(headless 指没有图形界面、没有浏览器窗口的纯进程运行方式,MCP 服务器就是这么跑的。)
注意它的返回姿态:isError: false。这个工具不是抛异常,而是把「我不做这件事」当成一个正常返回值结构化地告诉调用方,同配的测试文件 export-glb.test.ts 里第一条断言就是 expect(result.isError).toBeFalsy()。对写 Agent 的人来说这是个值得抄的细节——能力缺口用结构化字段表达,模型可以读到 reason 然后换路线,而不是收到一个红色异常后开始瞎重试。这一层的设计取舍我在 Agent 工具返回值该怎么设计 里单独展开过,这里不重复。
另一条线是 packages/mcp/src/tools/export-json.ts。它同样短,入参只有一个可选的 pretty 布尔值,出参是 { json: string }。实现体是三行:从 bridge 拿场景快照,JSON.stringify(scene, null, pretty ? 2 : 0),包成 payload 返回。pretty 为真时缩进 2 空格,为假时压成一行。
所以在 MCP 会话里,Agent 唯一能真正端出来的成品是 JSON。GLB 得换个地方拿。
二、JSON 那头到底装了什么
export_json 调的是 bridge.exportJSON(),实现在 packages/mcp/src/bridge/scene-bridge.ts。这个方法从 core 的 Zustand store 里取出四样东西:nodes(一个扁平的节点字典,key 是节点 id)、rootNodeIds(根节点 id 数组)、collections,以及一个条件字段 installedPlugins——只有当场景显式设置过插件安装状态、或者已安装列表非空时才会出现在结果里。取完之后走了一次 JSON.parse(JSON.stringify(...)) 做深拷贝,注释写明了目的:不让调用方拿到能直接改 store 状态的引用。
这里的「节点」是这套编辑器的核心抽象。场景不是一堆网格(网格即三角形面片的集合,是显卡真正画的东西),而是一棵参数化的树:墙是墙、楼板是楼板,各自带自己的字段。翻 packages/core/src/schema/nodes/wall.ts 可以看到 WallNode 的形状——thickness(厚度)、height(高度)、curveOffset(弧墙的偏移量)、slots(按插槽记录的材质引用)、supportSlabId(这面墙落在哪块楼板上)等等。楼板在这套模型里叫 slab,就是一层楼的水平承重板,可以简单理解为「地面/楼面那块板」。
节点类型有多少种可以自己数:packages/nodes/src/ 下有 46 个子目录,其中 shared/ 装的是公共代码不是节点类型,所以节点类型是 45 种,从 wall、door、window、slab、roof、stair 这些常规构件,一路到 duct-segment(风管段)、pipe-trap(存水弯)、solar-panel、scan(三维扫描点云)这类专项对象。
这份 JSON 的价值在于它是可逆的。同一个文件里紧跟着 loadJSON(),接受字符串或已解析对象,校验 nodes 必须是非数组对象、rootNodeIds 必须是数组,并且显式拒绝 __proto__、constructor、prototype 这三个键作为 nodes 的顶层 key(防原型污染)。导出去的东西能原样导回来,继续被 Agent 编辑。
顺带一个容易踩的对比:packages/mcp/src/tools/get-scene.ts 里的 get_scene 工具调的是同一个 bridge.exportJSON(),但它的 structuredContent 是拆开的 { nodes, rootNodeIds, collections },而 export_json 的 structuredContent 是 { json: "……" }——一整坨字符串。同样的数据,两种返回形状,消费方式完全不同。你想让模型顺着字段读就用前者,想直接落盘成文件就用后者。这类「同源数据被包成不同结构」的坑,和 接口返回结构变了 Agent 就崩 里讲的是同一类问题。
三、GLB 那头:在浏览器里把参数烘成三角形
GLB 是 glTF 这套三维交付格式的二进制单文件形态,几何、材质、动画打包在一起,Blender、Unity、各家网页播放器都能直接吃。它的生产逻辑在 packages/editor/src/lib/glb-export.ts,入口是 exportSceneToGlb(sceneGroup, nodes, options),核心中间步骤是 prepareSceneForExport()。
这两层的分工要先分清:外层的 exportSceneToGlb 负责「把编辑器摆到一个适合烘焙的状态」,内层的 prepareSceneForExport 才按注释所说「从活的场景图构建一棵与引擎无关的导出树」。
外层先发一次 thumbnail:before-capture 事件,让选择手柄、吊顶与场地托架这些编辑器专用的视觉辅助自己藏起来;再调 snapLevelsToTruePositions() 把各楼层吸附回真实的堆叠位置——编辑器里楼层可能正处在爆炸视图(把各楼层沿竖直方向拉开、方便单独看的一种显示状态)或单层独显状态,不这么做就会把某一层烘在跑偏的偏移上。内层跑完后,外层在 finally 里恢复楼层位置并发出 thumbnail:after-capture,不管中间是否抛错都会复位。
内层第一步是克隆整棵场景图,保证活的对象不被改动。然后是删减。凡是节点类型在注册表里声明 bake === 'strip' 的(代码注释举的例子是扫描点云/LiDAR、参考底图这类外部重资产),整个从产物里摘掉。位于非场景图层的编辑器覆盖物——变换手柄、选择框、地面网格、区域填充色——一律剪掉。没有材质的可渲染对象会被中和成一个空变换节点,因为 GLTFExporter 会无条件读 material.isShaderMaterial 然后崩。用 colorWrite: false 隐藏的射线拾取碰撞盒也算不可渲染——glTF 没有这个概念,直接导出去会变成一个不透明白盒子。
再是材质转换。viewer 用的是 WebGPU 的 NodeMaterial,而 GLTFExporter 只认 isMeshStandardMaterial 和 isMeshBasicMaterial,不转换的话每个面都会导成一个空白默认材质。转换时还处理了几个具体坑:glTF 没有只画背面的模式,所以 BackSide 材质会被翻成 FrontSide 并把三角形绕序反过来(绕序指一个三角形三个顶点的书写顺序,渲染器靠它判断哪一面是正面;翻面不改绕序,从房间里看吊顶就是反的);transparent: true 但 opacity 等于 1 的材质会被判定为不透明,避免整块面在别的引擎里变成半透明还不写深度缓冲(深度缓冲记录每个像素当前最近的物体距离,不写它的面遮不住后面的东西)。贴图这一环由外层收尾:涂装表面用的是 KTX2 这类显卡直读的压缩贴图,GLTFExporter 读不了,于是外层调 exporter.setTextureUtils(WebGPUTextureUtils),在一个独立的离屏渲染器上把它们转成普通 RGBA 再嵌进文件——注释特意说明了为什么不复用编辑器那台渲染器:那会把编辑器画布重设尺寸并画花。
最后是动画烘焙与身份标记。门窗的开启动作被采样成 glTF 关键帧轨道(关键帧即在若干时间点记录位置/旋转/缩放,播放时插值):平开门读叶片上的 pascalSwingLeaf 标记,从关门角度到全开角度给每片叶子生成一条四元数轨道(四元数是记录三维旋转的一种四个分量的表示,比欧拉角更适合插值,不会出现万向锁);代码里被归为 operation door 的那一类——推拉门、暗藏推拉门、谷仓门、折叠门、车库提升门——运动轨迹不是线性的(注释举的例子是分节提升门的头顶弧线),于是用常量 OPERATION_DOOR_SAMPLES = 16 沿 0→1 均匀采样,逐个运动部件比对位置/旋转/缩放是否真的变了(阈值 POSE_EPSILON = 1e-5),只给动了的部件出轨道。采样完克隆体被摆回关闭姿态,所以 GLB 的静止状态是门关着。片段命名规则是 <id>: open,用节点 id 而不是显示名,注释里解释得很清楚:播放端按片段名映射动作,两个都叫 Window 1 的窗户会塌成同一个动作,点一个动另一个。
身份这块的处理是这条线最值得看的部分:先把克隆体上所有 userData 全部清空,再只给注册表跟踪的节点盖上 extras——pascalId、kind、label,有相机书签的加 camera,真的烘出了开启片段的才加 openable 和 clips(没有活动叶片的门洞、固定窗不会被标成可开启),zone 节点带上 polygon 和 color,spawn 节点带上 rotation。一句话:这个文件用一组精确的字段自我描述,而不是把编辑器的运行时垃圾一起泄进 glTF 的 extras 里。
四、组成部分速查
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
export_json 工具 | 把场景图序列化成 JSON 字符串,可选 2 空格缩进 | packages/mcp/src/tools/export-json.ts | Agent 要把成果落盘、交给下游程序处理时 |
export_glb 工具 | 结构化返回 not_implemented,说明渲染器只在浏览器 | packages/mcp/src/tools/export-glb.ts | Agent 误以为无头进程能出 GLB 时 |
SceneBridge.exportJSON | 从 core store 取节点字典/根 id/集合,深拷贝后返回 | packages/mcp/src/bridge/scene-bridge.ts | 想知道 JSON 里究竟有哪几个顶层字段时 |
get_scene 工具 | 同源数据,但结构化字段是拆开的 | packages/mcp/src/tools/get-scene.ts | 想让模型按字段读场景而不是读一坨字符串时 |
exportSceneToGlb / prepareSceneForExport | 克隆、剪枝、材质转换、动画烘焙、身份盖章 | packages/editor/src/lib/glb-export.ts | 需要一个能给三维管线的产物时 |
| 导出管理组件 | 接住导出请求,另外还支持 STL 与 OBJ | packages/editor/src/components/editor/export-manager.tsx | 想知道浏览器侧一共给了哪几种格式时 |
| 烘焙导出组件 | 用 textures: 'reference' 模式出 GLB | packages/editor/src/components/editor/bake-exporter.tsx | 做发布用的轻量产物、贴图另走存储时 |
| 实时同步 | 把 headless 改动存库并追加场景事件给浏览器订阅方 | packages/mcp/src/tools/live-sync.ts | 想把 Agent 的成果推到浏览器里再导 GLB 时 |
顺带记一下:浏览器侧的导出并不只有 GLB。export-manager.tsx 里的导出函数签名接受 'glb' | 'stl' | 'obj',后两种走的是 prepareSceneForExport 出来的那棵树,再交给 STLExporter / OBJExporter,文件名形如 model_<日期>.glb。STL 与 OBJ 不带动画也不带完整材质,属于更朴素的几何交付。另外这个文件里有个专门的补丁函数,把被中和过、没有 position 属性的网格换成一个零顶点几何体——因为 STL/OBJ 两个导出器会无条件读 position.count。
五、边界与代价:各自明确不管什么
JSON 那条线不管几何。 它给的是参数,不是面片。墙的厚度、高度、弧度都在,但墙被门洞切开之后的实际形状不在;屋顶各段的关系在,但屋面的三角形不在。谁想看到实体,谁就得自己有一套能把这些参数解释成几何的实现——也就是这个仓库本身。换句话说,JSON 是这套编辑器的内部方言,不是通用交换格式。你不能把它丢给 Blender。
JSON 也不带材质像素。 材质在节点里是引用(形如 library:<id> / scene:<id> 这样的槽位值),不是贴图本体。拿到 JSON 的人拿不到那张图。
GLB 那条线不可逆。 一旦烘完,墙就不再是「一面厚 200 的墙」,而是一堆带 extras.pascalId 的三角形。你可以顺着 pascalId 找回它对应哪个节点,但你没法从 GLB 反推出参数再编辑。extras 里保留的那几个字段是刻意选的最小集,够播放端做选中、悬停、飞到相机书签、重建房间多边形,不够重建编辑能力。
GLB 明确丢掉一部分对象。 声明为 strip 的类型(扫描点云、参考底图)整个不在产物里,注释说明的理由是这些重资产存在别处、并且受项目的公开可见性开关管控,不该悄悄溜进一个静态公开文件。这既是体积考虑也是隐私考虑。
GLB 的循环控制不是标准能力。 代码注释直说了 glTF 核心规范里没有循环标志位,所以门窗片段是通过 clip.userData 把 loop: false 写进 extras——认这个字段的播放端播一次并停在开启姿态,不认的普通 glTF 播放器会一直循环开关门。这就是「用 extras 表达语义」的固有代价:约定只在懂约定的人之间成立。
GLB 必须有浏览器。 它依赖 Three.js 渲染器,依赖 React Three Fiber 已经提交、名为 scene-renderer 的那个场景组,甚至依赖等待两帧(nextFrames())让实例化的植被把不可渲染的代理换成真几何。这些前提在 CI 容器里、在纯 Node 进程里都不成立。想让 Agent 拿到 GLB,路径只能是把场景推进浏览器再导——live-sync.ts 里的 publishLiveSceneSnapshot() 做的正是把当前图存库并追加一条场景事件供浏览器订阅方消费。
跑这套 MCP 服务器本身有代价,要如实记住三条。 一,它会写本地数据库:README 写明场景存在 ~/.pascal/data/pascal.db,可用 PASCAL_DB_PATH 指定确切文件、PASCAL_DATA_DIR 指定目录,用的是 WAL 模式加事务版本校验,好让编辑器和 MCP 服务器共享同一份库。二,它可以开 HTTP 端口:pascal-mcp --http --port 8787 是回环地址,一旦绑到非回环主机,packages/mcp/src/transports/http.ts 会强制要求 PASCAL_MCP_HTTP_TOKEN(没有就直接报错),另有 PASCAL_MCP_HTTP_ORIGINS 管跨域来源——这个强制不是摆设,暴露出去等于把改你本地场景库的能力挂到网上。三,它可能出网取资源:packages/mcp/src/lib/safe-fetch.ts 支持用 PASCAL_ALLOWED_ASSET_ORIGINS 配置允许的来源白名单。这三件事的权衡属于 MCP 的安全边界 的范畴,接进生产前值得单独过一遍。
六、上手与避坑清单
别让 Agent 在无头会话里等 GLB。 为什么会踩:工具列表里 export_glb 明晃晃地在那儿,模型看到名字就会调,而且它返回 isError: false,粗糙的编排代码会把它当成功。怎么避:在提示词或工具白名单层面直接说明这条线在当前运行方式下不可用,或者在编排里把 status === 'not_implemented' 显式当成失败分支处理,别只看 isError。
要 GLB 就先把场景推到浏览器。 为什么会踩:headless 侧改完不落库不发事件,浏览器那边根本不知道场景变了,你在编辑器里点导出拿到的是旧模型。怎么避:走 publishLiveSceneSnapshot() 那条路——它先存草稿再追加场景事件,并且会处理版本冲突(撞版本会抛出带 live_sync_version_conflict 的错误),别自己绕开它直接写库。
pretty 别默认开。 为什么会踩:pretty: true 用 2 空格缩进,一个多层建筑的节点字典缩进后体积翻着涨,这坨字符串会原样进模型的上下文。怎么避:给人看的时候才开,给程序落盘一律关;如果只是想让模型读某几个节点,用按字段返回的工具而不是整份导出。
别把 JSON 当交换格式发给外部。 为什么会踩:它长得很像一个通用场景描述,字段名也友好,容易被误当成可以给第三方消费的东西。怎么避:记住它是内部方言,只有这套编辑器能解释成几何;跨工具交付走 GLB,或者用浏览器侧的 STL / OBJ。
导出前确认楼层与可见性状态。 为什么会踩:编辑器里正开着单层独显或爆炸视图,直觉上以为导出会照搬所见。怎么避:其实代码已经替你兜了——snapLevelsToTruePositions() 会把楼层吸附回真实位置,身份盖章阶段还会把 level、zone、spawn 三类节点强制置为可见,因为 GLTFExporter 的 onlyVisible 会把隐藏节点丢掉。知道这个兜底存在,就不会因为导出结果和视口不一致而误以为出了 bug。
下游认不认 extras,先验一遍。 为什么会踩:openable、clips、polygon、loop 这些语义全挂在 glTF 的 extras 上,很多通用查看器会直接忽略。怎么避:拿到第一个 GLB 就先在目标播放器里过一遍,确认它读不读 extras;读不到就得在下游自己写一层解析,而不是指望格式帮你传达。
别指望结构化返回天生稳定。 为什么会踩:export_json 的输出 schema 只声明了一个 json: string,里面那坨内容的形状由 store 决定,installedPlugins 还是条件出现的字段——模型如果按固定形状解析就会间歇性失败。怎么避:解析前先判字段存在性,把「字段可能不在」写进提示词。这类问题的系统性处理,结构化输出不稳该怎么办 里有更完整的招式。
收尾:三个问题决定你走哪条线
给自己过一遍这份自检:产物还需要被继续编辑吗?需要就只能是 JSON。产物要交给别的三维软件或网页播放器吗?要就只能是 GLB,而且得有浏览器在跑。产物里的门窗要能动、房间要能被点选吗?要,那你不只需要 GLB,还需要下游认 extras 里那套约定。
想继续往下挖,建议按这个顺序读:先 packages/mcp/src/bridge/scene-bridge.ts 看清 JSON 的边界,再 packages/editor/src/lib/glb-export.ts 从 prepareSceneForExport() 往下读那四个阶段(剪枝、材质、动画、身份),最后翻 wiki/architecture/ 下的 20 份架构文档(1 份 README 索引加 19 篇分主题)里的 scene-registry.md 和 node-definitions.md,把节点注册表这个总枢纽补上。顺带一提,这个仓库根目录同时放了 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件——你派 Agent 去读源码之前,先读这三份,省一半来回。仓库以 MIT 许可证开放(Copyright 2026 Pascal Group Inc.)。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源三维建筑编辑器 Pascal Editor:让智能体自查碰撞与门净空 和 Pascal Editor 三维建筑编辑器的 Agent 实时同步链路。