浏览器里的开源 3D 建筑编辑器 Pascal Editor:MCP 模板层怎样把一句需求变成户型

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 建筑编辑器。它在 packages/mcp 里自带一套 MCP 服务器,让外部 Agent 能直接往场景里塞墙、门、窗。这篇只盯住其中最小的一块——模板(template)——因为它是所有”一句话生成户型”演示背后的真实机关,也是最容易被误读成”它已经懂建筑了”的地方。

站内已有三篇讲通用做法的文章:任务分解粒度 讲一个请求该切成几步,结构化输出不稳 讲怎么逼模型稳定吐出可解析的字段,工具返回设计 讲返回体里该塞什么。它们讲的是原则;这一篇是同一批原则在一个你能当场 clone 下来核对的真实仓库里长成了什么样,包括长歪的地方。

一、模板层挡掉的是”冷启动”,不是”设计”

Agent 建模最难受的一步不是画墙,是空场景。一个空场景意味着模型要连续做对十几个决定——建 Site(场地)、建 Building、建 Level(楼层)、定墙厚、定层高、把门挂在正确的墙上——中间任何一步父子关系挂错,后面全崩,而且报错信息离病根很远。

模板层的做法很直接:预先手写好几套完整的场景图,Agent 一次调用就得到一个已经自洽的起点。场景图(scene graph)在这里就是一个节点字典加一组根节点 id,节点之间靠 parentIdchildren 串成树。

仓库里注册了三个模板,唯一真源在 packages/mcp/src/templates/index.tsTEMPLATES 常量里:

  • empty-studio(Empty studio),描述写的是 40 m² 单间公寓:4 面墙、1 个起居/厨房区域、1 扇窗、1 扇入户门。
  • two-bedroom(Two-bedroom apartment),80 m² 两居:9 面墙、4 个区域(起居/厨房、两间卧室、卫生间)、4 扇门、5 扇窗。
  • garden-house(Garden house),12 × 8 m 单层住宅带围栏后花园区域:4 面墙、2 扇门、4 扇窗、3 段隐私围栏。

list_templates 工具(packages/mcp/src/tools/templates/list-templates.ts)把这三条枚举出来,返回每个模板的 id、显示名、一行描述和节点数。它是无状态的,注释里说明用途是给 from_brief 提示词和 UI 的模板选择器用。

真正干活的是两个工具。create_from_template 是低层入口:你给 id,它给场景。create_house_from_brief 是高层入口:你给一段需求描述,它替你选模板、建项目、存草稿、把编辑器 URL 还回来。它的工具描述里明说了自己的定位是给外部 Agent 用的高层托管流程,选一个起步用的房子,然后建/存/发布,最后返回编辑器 URL,精确定制要靠后面的语义工具。

二、“从一句需求到一套户型”这条路,实际是怎么走的

这是本篇最该说清楚的一件事:create_house_from_brief不读你那句需求

它的入参声明在 createHouseFromBriefInput 里,包含 brief(必填,至少一个字符)、projectIdprojectNamebedroomCount(整数,0 到 12)、rooms(字符串数组)、stylelandscaping(布尔)、constraints。而内部的 chooseTemplate 函数只接收三个字段:bedroomCountroomslandscaping。判断顺序是:

  • landscaping 为真,或者 rooms 里(转小写后)出现 gardenpatioyard 任一个,选 garden-house
  • 否则 bedroomCount ≤ 1 选 empty-studio
  • ≤ 2 选 two-bedroom
  • 再多,还是 garden-house

brief 那段自然语言从头到尾没进过分支判断,它只出现在返回体的 summary 文本里被原样拼进去。styleconstraints 也一样不进几何,只会触发一条 limitation:风格与约束被记进摘要,但还没有被完整合成为定制几何。

所以这条路径真实的形状是:自然语言到结构化字段的转换,发生在调用方(也就是模型自己)那一侧,工具只负责把结构化字段映射到三选一。这正是 结构化输出不稳 那篇讲的问题在一个具体仓库里的落点——模型如果没把”要带院子”抽成 landscaping: truerooms: ['garden'],那句”我要带院子的三居”里的院子,工具是看不见的。

选定模板后的动作是一串固定流程:用 cloneSceneGraph 克隆模板(重生所有 id)、bridge.setScene 应用到桥、bridge.validateScene() 校验、若 bridge.canCreateProject 且没传 projectId 就先 createProject、再 saveScenesaveMode 写死 'draft'publish 写死 false,最后 appendLiveSceneEvent 追加一条实时事件让浏览器端订阅者看到变化。

返回体的字段是刻意做厚的:除了 projectIdeditorUrlurlversion,还有 templateIdnodeCountroomCountlevelIdsdefaultLevelIdvalidationvalid 加一个错误字符串数组,格式是 节点id:路径: 消息)、summarylimitationsnextStepnextStep 是一句给模型看的下一步指令,比如提示去调 verify_sceneget_project_status,需求不够具体就用语义工具细化后再 save_scene。这种把”下一步该干嘛”写进返回体的做法,跟 工具返回设计 里讲的思路是一致的。

一个容易看漏的细节:roomCount 的算法是遍历所有节点,数 node.type === 'zone' 的个数。zone(区域)在这个项目里是一圈 [x, z] 多边形加个名字和颜色,用来标注一块地面范围,它不是承重的楼板。所以 roomCount 是几何计数,不是”这套房子有几个房间”的语义判断。

三、模板本体拆开看:two-bedroom 到底给了你什么

packages/mcp/src/templates/two-bedroom.ts 是三个模板里信息量最大的一个,也是你想自己写模板时最该照抄的一个。

它顶部的注释写清了坐标约定:[x, z] 在 XZ 平面上,x 跑东西,z 跑南北,正 z 指向南。这是很多三维项目的通用约定——地面用两个水平轴,竖直方向单独留给 Y。提示词里的设计指引也重申了这一条:X/Z 是平面图的水平轴,Y 是竖直轴。

几何常量写死在文件顶部:X_MIN = -5X_MAX = 5Z_MIN = -4Z_MAX = 4,也就是 10 m × 8 m 的外轮廓;CORRIDOR_Z = 0 是把北半部(两卧一卫)和南半部(起居)隔开的横墙;BED_X = -1BATH_X = 2 是北半部里的两道竖向隔断;WALL_THICKNESS = 0.1WALL_HEIGHT = 2.5

节点树是 site_2brbuilding_2brlevel_0site_2br 是场地,多边形给到 ±15,也就是一块 30 × 30 m 的地。level_0children 里挂了 13 个节点:9 面墙加 4 个 zone。

有个设计选择值得单独拎出来:东西向的走廊墙被切成了 wall_corr_1wall_corr_2wall_corr_3 三段。文件注释直说了原因——这样每段都能有自己的一扇内门。因为在这个项目的约定里,门和窗必须挂在墙下面(parentId 就是墙的 id),一面墙如果要托三扇门,模板作者选择了拆墙而不是在一面墙上排三个开洞。这是个很实在的取舍:拆墙让父子关系简单到不可能出错,代价是墙的数量翻倍、后期想挪门就得连墙一起改。

门和窗的字段是逐个写全的字面量。门默认 width: 0.8height: 2.1frameThickness: 0.05hingesSide: 'left'swingDirection: 'inward'、带门槛、把手高 1.05,还有两段 panel 分格。窗是 height: 1.2、带窗台、sillDepth: 0.08,宽度按位置传 1.5 / 1.2 / 0.6。

再看一个能自己数出来的事实:wall_n 这一面墙上挂了 window_bed1window_bathwindow_bed2 三扇窗,而 makeWindow 给每扇窗写的 position 都是同一个值 [0, 1.2, 0];四扇门的 position 也全是 [0, 1.05, 0]。也就是说模板给的是拓扑正确的骨架,不是排布好的成品——具体落到墙上哪个位置,得靠后续工具处理。提示词里的设计指引正好补上了这一段:add_door / add_window 用一个 t = 0..1 表示沿墙的位置,0 是起点,0.5 是中点,1 是终点。

还有一处:模板文件里每个节点都以 as unknown as AnyNode 断言写成,绕过了 schema 的解析。这说明模板是手写字面量,不是用构造器生成的。你要照着加自己的模板,得自己保证字段齐全,类型系统在这里帮不了你。

四、组成部分速查

组成部分它负责什么仓库位置你什么时候会碰到它
create_house_from_brief按结构化字段三选一,实例化、建项目、存草稿、返回编辑器 URLpackages/mcp/src/tools/templates/create-house-from-brief.tsAgent 接到”给我来套两居”这类整活请求的第一跳
create_from_template按模板 id 实例化,save 决定是否落库packages/mcp/src/tools/templates/create-from-template.ts你已经知道要哪个模板,不想让模型再猜一次
list_templates枚举 id、名称、描述、节点数packages/mcp/src/tools/templates/list-templates.ts不想在提示词里硬编码模板 id 时
TEMPLATES 注册表与 isTemplateId三个模板的唯一真源与类型守卫packages/mcp/src/templates/index.ts想加自己的模板时改这里
two-bedroom 模板本体手写的 80 m² 场景图字面量packages/mcp/src/templates/two-bedroom.ts想照着写模板,或想确认模板到底给了什么
from_brief 提示词把 brief 包进带不变式和分阶段工作流的系统提示packages/mcp/src/prompts/from-brief.ts想让模型一步步自己搭,而不是套模板
SCENE_DESIGN_GUIDANCE尺寸默认值与工具调用顺序的成文约定packages/mcp/src/prompts/scene-guidance.ts排查模型为什么用了这个尺寸、这个顺序
cloneSceneGraph重生全部 id,并重映射 parentId / children / wallIdpackages/core/src/utils/clone-scene-graph.ts同一模板连开两次却不撞 id 时

五、另一条路:from_brief 提示词做的事更重

如果说模板工具是”三选一”,那 from_brief 才是真正试图把自然语言变成场景的那条路。它注册的是一个 MCP prompt(不是 tool),参数只有 brief 和可选的 constraints,核心是 buildFromBriefPrompt 这个纯函数——注释里写明拆成纯函数就是为了可测。

它拼出来的提示里,有几块信息是这个项目的真正积累:

父子关系不变式。 Level 必须在 Building 下;墙、围栏、区域、楼板、天花、屋顶、楼梯必须在 Level 下;门窗必须在 Wall 下(parentId 等于墙 id);地面家具挂 Level,墙挂件与顶挂件挂对应的 Wall 或 Ceiling;不要把物件直接挂到 Site 节点上。这几条就是模板层用”预先写死”绕过去的那些坑,在这条路上被摊开成了硬约束。

多层的红线。 多层建筑的外墙必须是逐层的 story wall,绝不允许把下层的墙拉高来冒充上层的墙。这条单独写出来,说明模型确实会这么干。

尺寸口径。 用米;墙厚 0.1 到 0.3 m;层高 2.4 到 3.0 m,除非需求另有要求。设计指引里另给了门默认 0.9 × 2.1 m、窗默认 1.5 × 1.5 m 带 0.9 m 窗台高。

顺手对一下会发现个有意思的错位:two-bedroom 模板里的门写死的是 0.8 m 宽,而设计指引说的默认是 0.9 m 宽。两条路各有各的常量,模板并不是指引的实例化产物。你要是打算把两条路混着用,这种口径差异得自己拉平。

分阶段工作流。 编辑既有场景先查(list_levels / get_level_summary / get_walls / get_zones);从空场景开始先出体量(create_level 加每层一次 create_story_shell),体量指的是先把楼有几层、每层多大这个”盒子”立起来,细部留到后面,create_story_shell 就是一次性生成某一层的外壳;有了体量再做房间(create_roomadd_door / add_windowfurnish_room),楼梯走 create_stair_between_levels,屋顶走 create_roof;放具体家具前先 search_assetsplace_item;语义工具表达不了的批量精确编辑才用 apply_patch;每个大阶段后拉一次摘要让进度可见;多房间或整层做完要 validate_sceneverify_scene

还有一条特别务实:verify_scene 同时报 levelCountoccupiedStoryCount,判断”一层还是两层”的需求有没有满足,要看后者——因为专门的屋顶层和辅助层是允许存在的,不该为了对齐层数把它们删掉。这种”计数口径歧义会导致模型做出破坏性动作”的经验,只有踩过才写得出来。

系统提示序言的收尾一句是:只用工具调用回应,不要输出啰嗦的叙述,解释就压在工具参数里。这个约束的取舍值得你自己权衡——它换来了确定性,也意味着模型没有地方写它的推理。

至于 Agent 该在”三选一模板”和”十几步语义工具”之间怎么切,就是 任务分解粒度 那篇讲的判断;而工具描述里那些”用这个不要用那个”的措辞怎么写才能真的被模型听进去,可以对照 工具描述写法 一起看。

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

模板只给墙和区域。 level_0 的 13 个子节点是 9 面墙加 4 个 zone,没有楼板、没有天花、没有屋顶、没有一件家具。楼板(slab)是脚下那块承重的水平面,schema 里有独立的节点类型,带厚度、标高、开洞;zone 只是一圈标注范围的多边形。模板给的是”有墙有分区”,不是”能住人”。

三个模板全是单层。 level_0level 字段就是 0,只此一层。任何多层需求都得落到 create_story_shell 那条路上,模板层一步也帮不上。

风格与约束不进几何。 传了 styleconstraints 只会换来一条 limitation 说明,几何不变。

三居以上是静默降级。 bedroomCount 大于 2 会落到 garden-house,不报错,只在 limitations 里加一句:MVP 版本对 3 间以上卧室的请求使用最接近的内置模板,要精确房间数请用 create_room / add_door / add_window 细化。如果你的编排代码只看 validation.valid,这次降级你是完全无感的。

整场景替换,不是追加。 两个工具都调 bridge.setScene(nodes, rootNodeIds),把桥上的场景整个换掉。create_from_templatesave: false 时还会额外调 bridge.clearActiveScene()。也就是说,Agent 一次”帮我起个模板看看”,可以把你正在编辑的东西直接覆盖掉。

数据落在你的机器上。 createSceneStore 的注释说得很清楚:默认写到 ~/.pascal/data/pascal.db,可以用 PASCAL_DB_PATH 指定确切文件路径,或用 PASCAL_DATA_DIR 指定放 pascal.db 的目录。这是一个本地 SQLite 文件,Agent 建的所有场景都进这里,它既不在你的 git 里,也不在你的备份策略里,除非你主动安排。

HTTP 模式等于把写权限开出去。 pascal-mcp 默认走 stdio;加 --http 时默认端口 3917、默认绑 127.0.0.1,另有 --auth-token 和可重复的 --cors-origin,环境变量侧对应 PASCAL_MCP_HTTP_TOKENPASCAL_MCP_HTTP_ORIGINS。默认值是安全的,但只要你把 --host 改宽,任何能连到这个端口的人都能改你本地的场景库。这类边界的通用思路可以参考 MCP 安全边界

出网是有的,且被专门收口过。 packages/mcp/src/lib/safe-fetch.ts 给视觉类工具抓取用户提供的图片 URL 用,它拦掉回环地址、链路本地地址(含云元数据的 169.254.169.254)、私有网段、非 http(s) 协议,重定向逐跳套同一份规则,默认 20 MB 体积上限和 10 秒超时,可选用 PASCAL_ALLOWED_ASSET_ORIGINS 放行特定来源。文件注释里自陈了这块是补上来的:此前 photo_to_sceneanalyze_floorplan_imageanalyze_room_photo 都是裸调 fetch(url),没有任何防护。你要评估这个服务器能不能进生产环境,这段注释比 README 有用。

七、上手与避坑清单

别把 brief 当参数用。 会踩是因为工具名叫 create_house_from_brief,看名字任谁都以为它读那句话。实际 brief 只进 summary 文本。避法:在 Agent 侧显式要求模型先抽出 bedroomCount / rooms / landscaping 三个字段再调,抽不出来就别调这个工具。

rooms 是小写全等匹配,不是语义匹配。 会踩是因为参数叫 rooms,直觉上是”我想要的房间列表”。实际它转小写后只跟 gardenpatioyard 三个词做集合命中。传 back gardenGarden Room 或中文词都命不中。避法:调用前归一化到这三个词,或者干脆用 landscaping: true 表达意图。

静默降级要靠读 limitations 发现。 会踩是因为返回体里有 validation.valid,看起来它就是成败判据。实际降级不会让 valid 变假。避法:把 limitations 当必读字段解析,数组非空就往上报,别只看一个布尔。

save 为真不等于存下来了。 会踩是因为 create_from_template 在没接 store 时不抛错,而是返回 saveSkipped: true——代码注释说这是有意的优雅降级,好让无 store 的 headless 部署(测试、冒烟脚本)也能跑。避法:判 saveSkipped,不要用”没抛异常”当作保存成功。

在有活跃场景时慎调这两个工具。 会踩是因为它们的名字听起来像”新建”,实际是对当前桥做整场景替换。避法:调之前先 save_scene 打一个 checkpoint;在既有项目里做增量,改走 create_room 这类语义工具。

别在提示词里硬编码模板 id。 会踩是因为 create_from_template 的入参描述里直接列了三个 id,抄进提示词很顺手;模板集合一变,你的提示词就开始换回 unknown_templateInvalidParams 错误。避法:先调 list_templates 拿当前集合,把结果喂给模型。

先想清楚数据库文件放哪。 会踩是因为默认路径在 home 目录下,装完就能跑,没人会去看。共用机器上,不同项目的场景会堆进同一个文件。避法:按项目设 PASCAL_DB_PATH 做隔离,并把这个文件纳入你的备份和清理流程。

HTTP 模式的三个开关一起配。 会踩是因为 --http --port 两个参数就能跑起来,--auth-token--cors-origin 是可选的,赶时间就跳过了。避法:只要 --host 不是 127.0.0.1,令牌和来源白名单就当作必填;只在同机使用,就老老实实留在 stdio。

结尾:接下来该读哪个文件

这套模板层的完整判断可以压成三句:它把冷启动这一步做成了确定性动作,它把选择逻辑收缩到三个结构化字段,它把落位、层数、风格、楼板、家具全部推给了下游。够不够用,取决于你的场景是”演示一个能跑通的闭环”还是”交付一份能用的图”。

想继续往下挖,按这个顺序读效率最高:packages/mcp/src/templates/two-bedroom.ts 看模板的完整形状,packages/mcp/src/prompts/scene-guidance.ts 看这个项目沉淀下来的建模约定,packages/core/src/utils/clone-scene-graph.ts 看 id 重生与引用重映射是怎么保证多次实例化不打架的,最后回到 packages/mcp/src/storage/index.tspackages/mcp/src/lib/safe-fetch.ts 把数据落盘与出网这两件事看明白再决定要不要接进你的工作流。

接进去之前,给自己过一遍这四问:模型能不能稳定抽出那三个字段;limitations 有没有被你的编排代码读到;当前活跃场景被整个替换掉你能不能恢复;数据库文件的位置和这台机器上还有谁能连到那个端口,你是不是都清楚。

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源浏览器端 3D 建筑编辑器 Pascal Editor:Agent 建的模型存在哪,本地 SQLite 与版本校验约定开源 3D 建筑编辑器 Pascal Editor:照片转场景的边界

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