Pascal Editor 仓库导读:3D 建筑编辑器怎样切开渲染层与编辑层
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
这个仓库最值得拆的地方不是它的三维渲染,而是它把边界写成了「谁不许知道谁」——每个包的职责不是靠一句「我负责渲染」定义的,而是靠一张明确的禁止清单定义的,这份清单直接躺在给 AI Agent 看的 AGENTS.md 里。 对天天让 Agent 改代码的人来说,这比任何架构图都实用:一个包能不能被 Agent 安全地改,取决于它的边界能不能被一句话说清楚。
先做个消歧,这一步不能省。Pascal Editor 是开源项目 pascalorg/editor 的项目名,指的是一个基于 React Three Fiber 和 WebGPU、跑在浏览器里的 3D 建筑编辑器(画墙、画楼板、摆家具那种),跟 Pascal 编程语言没有任何关系,也跟压强单位帕斯卡没关系。仓库许可证是 MIT(Copyright 2026 Pascal Group Inc.)。
站内已经写过几篇同类的仓库结构导读:opencode 的仓库结构、Hermes 的仓库结构、Pi 的仓库结构。那三篇拆的都是「Agent 本体」——会话、工具注册、模型接入怎么分层;这篇拆的是反过来的一侧:一个被 Agent 操作的应用,自己该怎么长,才经得起 Agent 来改。两类仓库的关注点不一样,可以对照着看。
一、先把能数出来的部分数清楚
判断一个陌生仓库,第一步不是读代码,是数目录。这些数字你自己 clone 下来当场就能复现。
全仓受版本控制的文件 2508 个。顶层是一个 Turborepo monorepo,apps/ 下 2 个应用,packages/ 下 9 个包。README 里给的架构树是这样的(截取自仓库 README):
editor/
├── apps/
│ └── editor/ # Next.js application
├── packages/
│ ├── core/ # Schemas, scene state, and registry contracts
│ ├── viewer/ # 3D rendering runtime and shared systems
│ ├── editor/ # Editing tools and UI components
│ ├── nodes/ # Built-in node definitions, renderers, and systems
│ └── ui/ # Shared UI components
这棵树是简化过的。实际 packages/ 下还有 mcp、ifc-converter,以及 eslint-config、typescript-config 两个纯配置包;apps/ 下除了 editor 还有一个 ifc-converter。这里的 IFC 是建筑信息模型行业通用的开放数据交换格式,作用是把建筑模型从一款建模软件搬到另一款里,不丢构件的类型和属性;仓库里给它单独开了一个包加一个应用,说明格式转换被当成一条独立管线在养,没有塞进编辑器主干。README 把自己描述成「four main runtime packages」(四个主要运行时包),而 AGENTS.md 那张表列的是 core / viewer / editor / mcp 加上 apps/editor——两份文档对「哪四个是主角」的取舍不完全一致,读的时候心里有数就行。
各包的体量(同样按受版本控制的文件数):nodes 733、editor 445、core 233、mcp 152、viewer 107。这个分布本身就是信息量:最大的包是节点定义,渲染运行时反而是最小的之一。后面会讲这是为什么。
架构文档在 wiki/architecture/ 下,20 份 md,其中 1 份是 README 索引,另外 19 篇分主题(layers、systems、renderers、tools、node-definitions、viewer-isolation、scene-registry、events 等)。根目录同时存在 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件,AGENTS.md 里写明后两份和 .github/copilot-instructions.md 都是指向它的符号链接——一份内容,四个入口,不同厂商的 Agent 各读各的门牌。
二、四个运行时包各自守什么
先建立术语。这个项目里几个建筑名词的含义:楼板(slab) 是一层楼的水平承重面,也就是你脚下踩的那块板;吊顶(ceiling) 是这一层的天花板;区域(zone) 是用一个平面多边形圈出来的房间范围,用来算面积和归类家具;层(level) 是一整层楼,楼层之间靠累加高度堆叠。这些在仓库里都是节点类型的名字,不理解词义就看不懂目录。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
@pascal-app/core | 节点 schema、场景状态 store、注册表契约、空间查询、事件总线 | packages/core/src/(schema/、store/use-scene.ts、registry/、hooks/scene-registry/、systems/) | 改数据形状、加字段、动撤销重做时 |
@pascal-app/viewer | 独立的 3D 画布:渲染器、viewer 侧 system、展示态 | packages/viewer/src/(components/renderers/、components/viewer/、systems/、store/) | 改画面、改相机、改后处理时 |
@pascal-app/editor | 可被独立应用和嵌入方复用的编辑器 UI 组件 | packages/editor/src/(components/、hooks/、store/、lib/) | 复用面板、复用编辑器控件时 |
@pascal-app/nodes | 每一种节点自带的 schema、几何、渲染器、system、工具、面板 | packages/nodes/src/<kind>/(如 wall/、slab/、zone/) | 加新构件、改某类构件的行为时 |
@pascal-app/mcp | MCP 服务器与场景存储适配器,无头驱动场景图 | packages/mcp/src/(tools/、resources/、prompts/、storage/、transports/) | 让 Agent 直接建模时 |
apps/editor | 独立编辑器应用,把 viewer + editor + 工具组装起来 | apps/editor/(app/、components/、lib/) | 改整体编辑体验、加快捷键和命令面板时 |
这张表里 apps/editor 那一行值得单说一句:README 的「Key Files」表把编辑器工具和编辑器 store 指到 apps/editor/components/tools/ 和 apps/editor/store/,但当前这个提交里这两个目录都不存在,apps/editor/components/ 下只剩下寥寥几个壳组件,工具和状态早已下沉到 packages/editor 和 packages/nodes 里去了。这个细节比它看上去重要:宿主应用越薄,说明分包越成功——独立应用退化成一个装配壳,正是「编辑器能被别人嵌」这句话的可验证证据。也提醒你,这个仓库的 README 是有滞后的,路径类信息以实际目录为准。
真正有价值的是 AGENTS.md 里那份负向清单,它不写「我负责什么」,写的是「我不许知道什么」:
packages/core拥有领域数据和纯逻辑,不许引入 Three.js,不许 importpackages/viewer和apps/editor,不许出现渲染/UI 概念、工具、模式、阶段,也不许出现平面图或涂装预览这类只属于某个视图的概念。packages/viewer拥有独立 3D 画布、渲染器、viewer 侧 system 和真正属于展示的状态,不许知道useEditor、编辑器工具、阶段、模式、涂装模式、平面图状态,以及任何只属于编辑器的表达。apps/editor拥有编辑体验本身:工具、useEditor、面板、平面图辅助、涂装模式、快捷键、命令面板、动作菜单、光标徽标、编辑器专属浮层。编辑器的能力通过 props 和 children 注入到<Viewer>里。
wiki/architecture/viewer-isolation.md 把这条规则写成了一句话:viewer 从外部被控制,它暴露控制点(props、回调、children),它永远不去够 apps/editor。这条约束的收益很具体——同一个 viewer 既能给编辑器用,也能给只读的查看路由用,还能给未来的嵌入方用;一旦 viewer 里出现一个 if (isEditorMode),这三种用法就开始互相拖累。
对做 Agent 工程的人来说,这份负向清单的价值在于它是可被机械检查的。「core 负责数据」没法验证,「core 里不许出现 tool、mode、phase 这些词」可以直接 grep。你给 Agent 写项目约定文件时,值得照这个思路改写:把「应该」换成「不许」,把形容词换成可搜索的名词。相关的写法讨论可以参考 Agent 改动边界怎么约定。
三、横着切三刀,竖着切一刀
前面三个包是横切:按技术层次分(数据层、渲染层、编辑层)。packages/nodes 是纵切:按节点种类分,每一种构件的所有层次的代码都塞在自己的目录里。
packages/nodes/src/ 下有 46 个子目录,其中 shared/ 不是节点类型,所以内建节点类型是 45 种。名单里既有你能猜到的 wall、slab、ceiling、roof、door、window、stair、zone、level、building、site,也有一眼看不出来的:ridge-vent(屋脊通风器)、dormer(老虎窗)、cupola(屋顶小塔)、downspout(雨水落水管)、duct-segment(风管段)、pipe-trap(存水弯)、structural-grid(结构轴网)、solar-panel。这份名单本身就说明了这个编辑器的野心范围——它不只想画房间轮廓,还想覆盖屋面构造和机电管线。
打开其中一个目录看它的构成,以 packages/nodes/src/wall/ 为例,里面同时存在 schema.ts、definition.ts、renderer.tsx、system.tsx、tool.tsx、panel.tsx、parametrics.ts、preview.tsx、floorplan.ts、measurement.ts、treatments.tsx,以及成对的 .test.ts。数据形状、几何生成、每帧逻辑、绘制工具、属性面板,全在一个目录里。
wiki/architecture/node-definitions.md 把这套模型叫「三勾选模型」:一个节点种类由注册到 nodeRegistry 的 NodeDefinition 描述,三个可选字段决定它在运行时怎么出现——geometry(纯构建函数,返回这个节点的网格)、renderer(可选的自定义 React 组件,需要 JSX 专属能力时才用)、system(可选的每帧组件,需要动画、材质逐帧改动、跨类型脏标记级联时才用)。文档里那句话说得很干脆:三个字段互相独立,没有判别标签,出现即参与(presence is participation)。
仓库里的例子(截取自该文档):
// shelf — pure geometry, no React, no per-frame work
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
// ...
geometry: buildShelfGeometry, // pure function in geometry.ts
}
配套的运行时装配在 packages/viewer/src/components/viewer/ 里:<NodeRenderer> 决定 React 挂什么,<GeometrySystem> 每帧读脏节点集合、对声明了 geometry 的种类重建网格。所以 viewer 只有 107 个文件却撑得住 45 种构件——它提供的是框架和调度,不是每种构件的实现。nodes 有 733 个文件也就顺理成章了。
这套结构对 AI 工程读者的启发在于:它把「加一种新东西」的改动面收敛到了一个目录。加一种构件不需要在渲染分发处加 switch 分支,不需要动 viewer,只需要新建一个目录并注册。README 里项目自己也是这样定位插件机制的——它说新的节点种类和侧边栏面板以插件形式发布,而不是去改内建代码,插件用的是和内建完全一样的 Plugin 清单,没有单独的内部 API。这个「内建和第三方走同一条注册路径」的取向,和 MCP 工具设计 里讲的注册表模式是同一套思路:只要内建实现享受不到特权,第三方扩展就不会沦为二等公民。
顺带解释两个会在墙体代码里撞见的图形学词。斜接(mitering) 指两面墙在拐角处按角平分线切齿咬合,不然墙角会露出缺口或互相穿模。实体布尔运算(CSG) 指用一个立方体去「减掉」墙上的一块,从而挖出门窗洞口;README 的技术栈里列的 three-bvh-csg 就是干这个的。
四、MCP 服务器这一侧:Agent 能改什么,数据落在哪
packages/mcp 是这个仓库里跟 AI 工程最直接相关的部分。它的自我定位(见该包 README)是:在 Bun 里无头运行,不需要浏览器、WebGPU、React,也不需要外部数据库服务,把编辑器 UI 用的同一批场景改动暴露成 MCP 的 tools、resources 和 prompts。
工具面覆盖了从读到写的完整链路,都是仓库文档里列出的真名:读取侧有 get_scene、get_node、describe_node、find_nodes、list_levels、get_level_summary、get_walls、get_zones、measure、search_assets;写入侧有 create_room、create_story_shell、create_wall、create_level、create_roof、create_stair_between_levels、add_door、add_window、place_item、cut_opening、set_zone、furnish_room、duplicate_level、delete_node、apply_patch;校验侧有 validate_scene、verify_scene、check_collisions;历史侧有 undo、redo。资源用 pascal:// 这个 scheme,包括 pascal://scene/current、pascal://scene/current/summary、pascal://agent/guide、pascal://catalog/items、pascal://constraints/{levelId}。prompts 有三个:from_brief、iterate_on_feedback、renovation_from_photos。
有几个设计细节值得单拎出来。apply_patch 接受一批 create/update/delete/move 操作,文档说明它会先校验并 dry-run 再提交——批量改动要么整批通过要么不落地。所有工具的输入输出都用 Zod 校验,写入类工具被 Zundo 的 temporal 中间件记成一个可撤销步骤,也就是说 Agent 建完一个房间,人类按一次撤销就能整体回退。这两点合起来,是把「Agent 的一次意图」和「人类的一次撤销」对齐了,比逐条记录历史友好得多。相关的取舍在 Agent 检查点与撤销 里有更一般化的讨论。
现在说代价,这部分必须写清楚。
数据落在你自己的盘上。 通过 MCP 保存的场景进本地 SQLite 数据库,默认路径是 ~/.pascal/data/pascal.db;PASCAL_DATA_DIR 换目录,PASCAL_DB_PATH 指定确切文件路径。这个库用 WAL 模式和事务性版本检查,所以编辑器进程和 MCP 进程能同时开同一个库。
编辑器和 Agent 之间有一条实时通道。 当两边共享同一个 PASCAL_DATA_DIR 时,MCP 的改动写进 SQLite 并记进本地 scene_events 流,编辑器页面通过 /api/scenes/:id/events 以 SSE 订阅这条流,于是你开着的浏览器标签页会跟着 Agent 的动作变。换句话说,MCP 服务器一起来,你屏幕上的模型就归 Agent 管了,它有 delete_node(cascade: true 时级联删)这种权限。
暴露端口意味着什么。 默认走 stdio。pascal-mcp --http --port 8787 是回环 HTTP。绑非回环地址时文档要求带 bearer token:
PASCAL_MCP_HTTP_TOKEN="$(openssl rand -hex 32)" \
pascal-mcp --http --host 0.0.0.0 --port 8787 --cors-origin https://editor.example
它会出网。 视觉类工具 analyze_floorplan_image、analyze_room_photo 要拉取你给的图片 URL。仓库里 packages/mcp/src/lib/safe-fetch.ts 的注释说明了这条路径的历史:这几个工具早先直接 fetch(url) 没有任何防护,等于给出了一个指向云元数据地址 169.254.169.254 的外带原语。现在的 safe-fetch 会拦回环、链路本地、私网段和非 http(s) scheme,重定向逐跳复检,并有体积上限和超时;需要放行特定来源时用 PASCAL_ALLOWED_ASSET_ORIGINS 环境变量按逗号分隔配置。这是个很好的实证:只要你的工具接受一个用户可控的 URL,它就是一个 SSRF 面,跟这个工具「本来是做什么的」无关。这类边界的通用做法见 MCP 安全边界 和 最小权限设计。
另外,视觉类工具依赖 MCP 主机支持 sampling 能力(createMessage),不支持的主机会拿到结构化的 sampling_unavailable 错误。
五、边界与代价:这套切法明确不管什么
几何不在无头侧生成。 该包 README 的 Limitations 写得很直白:墙体斜接、楼板三角化、CSG 开洞、屋顶与楼梯生成这些 system 都跑在编辑器的 React hook 里,无头模式不会重算派生几何——节点数据完全可改,但你拿不到成品网格。想要渲染结果就得在浏览器宿主里跑 @pascal-app/viewer。连带的后果是 export_glb 直接返回 not_implemented,因为 GLB 导出依赖 Three.js 渲染器。所以「让 Agent 生成模型然后直接导出交付」这条路,在当前这个仓库状态下是断的。
脏节点集合在无头模式会堆积,因为没有渲染器去消费它;文档给的办法是需要可观测性时自己调 bridge.flushDirty()。
资产 URL 有浏览器绑定。 core 的 loadAssetUrl / saveAsset 是浏览器专属,引用 asset://<id> 的物件在 Node 里解析不了,要在浏览器外用就得给绝对 URL 或 data: URL。
并发写会撞车。 每次改动前会对已保存场景做版本检查,如果浏览器或另一个 MCP 进程先存了更新的版本,工具返回 live_sync_version_conflict,得先 load_scene 重载再继续。这是明确的乐观锁语义,不是自动合并——不要指望它替你解决冲突。
坐标系会咬人。 packages/mcp/README.md 的坐标约定一节说明 Pascal 是右手系,X 和 Z 构成地平面,Y 向上;长度单位米,旋转用弧度存成欧拉 [x, y, z]。你传的每个二维点都是层/建筑本地的地平面坐标 [x, z],第二个分量是世界 Z(进深)不是「上」。文档还专门提醒:如果你在编辑器外按「Y 是北、俯视看」的习惯算坐标(地形测绘、北向上的场地平面图、二维绘图库都是这个习惯),送进来会是旋转过的,可能还带镜像;建议先放一张已知锚点的参考底图去对齐验证。编辑器自带的二维三维工具彼此一致,这个坑只影响程序化生成的几何。
最关键的一条:这些包边界是文档约定,不是构建期强制。 AGENTS.md 说 packages/core 不许引入 Three.js,但实际上 packages/core/src 的 230 个受控文件里有 5 个 import 了 three——包括 hooks/scene-registry/scene-registry.ts,因为场景注册表本身就是「节点 id → Object3D」的映射,这一点 README 也是这么描述的;packages/core/package.json 的 peerDependencies 里同样声明了 three 和 @react-three/fiber。约束靠的是人和 Agent 的评审:仓库在 .agents/skills/review-architecture/SKILL.md 里放了一个专门的架构评审 skill,它会先加载 wiki/architecture/ 下的规则页,再取 diff、按层给新文件分类、按严重度汇报。这套做法本身是可借鉴的——当边界没法用编译器表达时,就把它写成一份 Agent 每次评审都必须先读的规则集,而不是指望人记得住。
六、上手与避坑清单
开发服务必须从仓库根目录起。 README 用加粗强调了这点:bun dev 要在根目录跑,因为它先构建 core 和 viewer 再启动两个包的 watcher,最后才拉起 Next.js 开发服务。会踩是因为习惯了进到 apps/editor 里再 dev——那样包的 watcher 不在,你改 packages/core/src/ 下的文件页面不会热更新,然后会花半小时怀疑自己的改动没生效。避法就一条:只在根目录起服务。端口是 3002,根 package.json 里那个 kill 脚本干的就是 lsof -ti:3002 | xargs kill -9,端口占用时直接用它。
别按 README 的 system 表去找代码。 README 的「Core Systems」表里列了 WallSystem、SlabSystem 这些名字并挂在 core 名下,但仓库当前的组织方式是每种节点自带 system.tsx,墙的那份在 packages/nodes/src/wall/system.tsx。会踩是因为 README 面向的是概念理解,wiki/architecture/node-definitions.md 描述的才是当前的注册表模型。避法:改代码前以 wiki/architecture/ 下的文档和实际目录为准,README 当导览图看。
MCP 和编辑器要共享数据目录。 两边不共享 PASCAL_DATA_DIR 时,Agent 老老实实写了半天,你的浏览器里什么都不动。会踩是因为默认路径在两个进程里看起来「应该一样」,但只要有一边被别的配置改过就分叉了。避法:起编辑器和起 MCP 时都显式带上同一个 PASCAL_DATA_DIR,包 README 里给的就是这个双终端写法。
绑非回环地址前先想清楚。 --host 0.0.0.0 意味着同网段的任何人都能调 delete_node 和 apply_patch 改你的模型。会踩是因为在容器或远程开发机里调试时,随手就把 host 放开了。避法:能用 stdio 就用 stdio;确实要 HTTP 就留在回环;必须对外就按文档带 PASCAL_MCP_HTTP_TOKEN,并用 --cors-origin 收窄来源。
给视觉工具喂 URL 前想想它会去访问哪。 safe-fetch 已经拦了私网和元数据地址,但你如果为了方便把 PASCAL_ALLOWED_ASSET_ORIGINS 放得很宽,等于自己把闸门拆了。避法:白名单里只放真正需要的来源,且优先用 data: URL 或本地已下载的图片走宿主侧。
外部算好的坐标先验一遍再批量灌。 会踩是因为你的生成脚本在自己的坐标习惯里是自洽的,灌进来整个平面旋转了,而且旋转量取决于你在哪个视口、相机在什么方位角——三维俯视吸附会保留当前方位角,从默认等轴测位置调用时世界轴和屏幕轴差约 45 度。避法:先灌一个小的轴对齐样本,用带已知锚点的参考底图核对朝向,确认无误再跑批。
在 core 里加渲染或工具概念,PR 大概率过不去。 会踩是因为「就加一个字段而已」——比如给节点 schema 塞一个 isPaintPreview。这个词属于编辑器视图概念,AGENTS.md 的禁止清单里点名了。避法:动手前对照那份清单判断这个概念归谁;不确定就先读 wiki/architecture/layers.md 和 viewer-isolation.md。
版本冲突不要重试硬怼。 拿到 live_sync_version_conflict 就 load_scene 重载再改,盲目重试只会一直撞。这类失败该怎么分类处理,可以参考 Agent 失败分类。
七、接下来读哪个文件
如果你只想搞清楚这个仓库怎么组织,按这个顺序读三份文件就够:先 AGENTS.md(一页看完层边界和禁止清单),再 wiki/architecture/README.md(那 19 篇分主题文档的索引,按你要动的东西挑),最后 wiki/architecture/node-definitions.md(理解 geometry / renderer / system 三勾选模型,这是整个 nodes 包的组织原理)。要接 Agent 就再加一份 packages/mcp/README.md,工具表、坐标约定和 Limitations 都在里面。
临走前留一份自检清单,供你把这套做法搬到自己的项目上:你的项目约定文件里,边界是用「负责什么」写的还是用「不许知道什么」写的?后者能 grep,前者不能。加一种新类型时改动落在几个目录里?如果超过一个,说明分发处还有硬编码的分支。你的内建实现和第三方扩展走的是不是同一条注册路径?如果内建有特权,扩展迟早跑不通。你的 Agent 一次意图对应人类几次撤销?如果不是一次,回退成本会劝退所有想试的人。最后一条最容易被跳过:你让 Agent 跑起来的那个服务,数据落在哪、开了什么端口、会往哪出网——这三个问题答不上来,别急着开工。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor:为什么要给 Agent 单独写一份使用指南 和 3D 建筑编辑器 Pascal Editor 的节点模型为何是扁平字典。