Pascal Editor 上手记:浏览器里的开源 3D 建筑编辑器,装起来要过多包仓库和显卡两道关
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
装这个仓库,真正会卡住你的从来不是依赖下不下来,而是两件跟单包项目完全不同的事:一是 Turborepo 的任务依赖图决定了大量命令必须从仓库根目录跑,跑错位置不会报错、只会给你一个行为怪异的开发服务器;二是它的画面能不能出来,最终由你这台机器的显卡和浏览器的图形能力说了算,跟 npm 装没装成毫无关系。
先做个消歧,因为这个名字很容易读岔。这里说的 Pascal Editor,是开源仓库 pascalorg/editor 里那个跑在浏览器里的三维建筑编辑器——你在画布上画墙、拉楼板、开门窗、盖屋顶的那种工具。它跟 Pascal 编程语言没有关系,跟压强单位帕斯卡也没有关系。许可证是 MIT(Copyright 2026 Pascal Group Inc.)。
站内几篇相邻的文章分工是这样的:Claude Code 装不上怎么排查 处理的是单个 CLI 工具的安装失败链路,AI 工具选型流程 处理的是要不要引入一个工具的判断,MCP 服务器本地怎么测 处理的是 MCP 通道本身的调试手法;本篇只管一件事——把这个具体的多包仓库在你自己机器上跑起来,并且在跑不起来时知道该看哪一层。
一、你 clone 下来的到底是什么
这是个 Turborepo 组织的多包仓库。根 package.json 的 workspaces 字段声明了三组工作区:apps/*、packages/*、tooling/*。在这个 commit 上,apps/ 下有 2 个应用,packages/ 下有 9 个包,tooling/ 下有 release 和 typescript 两项。全仓受版本控制的文件是 2508 个,你自己 git ls-files | wc -l 就能复现。
体量分布很不均匀。按各包目录下的受控文件数排,nodes 733、editor 445、core 233、mcp 152、viewer 107。也就是说,重量压在节点定义那一侧,而不是渲染那一侧。packages/nodes/src/ 下有 46 个节点类型目录,从 wall、slab、door、window 这类基础构件,一直到 duct-segment、pipe-trap、solar-panel、turbine-vent 这种暖通与屋面构件。
有几个建筑侧和三维图形侧的词先说清楚,免得后面读着卡壳:
- level(楼层):一栋楼里的一层,是大部分构件的归属容器。
- slab(楼板):一层楼的那块地面板,用一个多边形加一个标高定义。
- zone(区域):一块被圈出来的房间范围,本身不是实体,用来标注”这是卧室”。
- CSG 实体布尔运算:用一个形体去减另一个形体。在这里的用途是在墙上挖出门窗洞口。
- mitering(斜接):两面墙相交时,把端头切成斜角好让它们干净地拼上,而不是互相插进去。
- 三角剖分:把一个任意形状的多边形拆成一堆三角形。显卡只会画三角形,楼板、天花板这种自由多边形必须先剖分才渲染得出来。
- IFC:建筑行业用来在不同软件之间交换建筑模型的一种开放数据格式,一个
.ifc文件里装着墙、门窗、楼板这些构件的定义和它们的相互关系。 - SSGI(屏幕空间全局光照):在后处理阶段近似算一遍光线在物体之间的反弹,让室内暗部不至于死黑;它只用屏幕上已有的像素信息估算,所以叫屏幕空间。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
@pascal-app/core | 节点 schema、场景状态(Zustand)、注册表契约、空间查询、事件总线;根 AGENTS.md 明确它不许引入 Three.js | packages/core | 改数据结构、查节点关系时 |
@pascal-app/viewer | React Three Fiber 渲染运行时、默认相机与控制、后处理 | packages/viewer | 画面出不来、后处理黑屏时 |
@pascal-app/editor | 编辑工具、面板、选择、直接操作类 UI | packages/editor | 改交互行为时 |
@pascal-app/nodes | 内置注册表插件:节点定义、渲染器、几何、系统 | packages/nodes | 加一种新构件时 |
@pascal-app/mcp | MCP 服务器与场景存储适配层 | packages/mcp | 想让 Agent 直接建模时 |
apps/editor | 承载上面这些包的独立 Next.js 应用 | apps/editor | 日常 bun dev 打开的就是它 |
apps/ifc-converter | 把 IFC 建筑模型转成 Pascal 场景图 JSON 的网页应用 | apps/ifc-converter | 需要导入外部建筑模型时 |
| 架构约定文档 | 分层红线、节点 schema、系统、渲染器等分主题说明 | wiki/architecture/(20 份) | 改动跨层之前 |
另外,仓库根目录同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件。AGENTS.md 里写明后两者是它的符号链接,内容同源。可执行的工作流放在 .agents/skills/ 下(open-pr 与 review-architecture 两个),并通过 .claude/、.cursor/、.codex/ 三处链接过去。这套做法本身值得抄:一份事实、多个宿主入口,不用在三个文件里维护三份会漂移的说明。
二、装:为什么很多命令必须从根目录跑
SETUP.md 给的前置条件是 Bun 1.3+ 与 Node.js 20.9+,根 package.json 的 engines 也把 Node 下限钉在 20.9.0。启动步骤只有两行:bun install,然后 bun dev,编辑器在 http://localhost:3002。
问题出在第二行的语义。根 package.json 里 dev 展开是 dotenv -e ./.env -e ./.env.defaults -- turbo run dev --env-mode=loose,而 turbo.json 里 dev 任务写着 "dependsOn": ["^build"]。^build 的意思是先把这个包依赖的所有上游工作区包 build 一遍。test 与 check-types 同样挂了 ^build。
这条依赖不是装饰。CONTRIBUTING.md 里点破了原因:好几个包引用工作区里的兄弟包时,走的是那个兄弟包的 dist/ 目录而不是它的源码;不先 build 上游,下游导入的就是一个还没被生成出来的目录。所以根 README 才反复强调”永远从根目录跑 bun dev”——只有走根目录这条路,包的 watch 进程才会一起起来,你改 packages/core/src/ 才会热更新。你要是进了 apps/editor 直接跑 Next 的 dev,它能起,但上游包不会重建,你会以为改的代码没生效。
第二个坑更隐蔽,CONTRIBUTING.md 专门为它写了一整段:跑测试要用 bun run test,不能用 bun test。test 是 Bun 自己的子命令,敲 bun test 根本到不了 package 脚本,Bun 会用自己的收集器扫遍它能找到的所有文件——包括 dist/ 下那些编译产物的副本——然后报出一个虚高的数字。bun run test 才会走 Turborepo,先建依赖再跑各包自己的测试脚本。这种”命令名撞上包管理器内置子命令”的事在单包项目里几乎不会遇到,因为单包项目没有 dist/ 副本这层放大器。
端口那块也有故事。.env.defaults 是提交进仓库的默认值文件,里面就一行 PORT=3002,但注释解释了为什么要专门开一个文件:.env 和 .env.local 都在 gitignore 里,默认值没地方放;而在 package 脚本里写 ${PORT:-3002} 也不行——Bun 自带的 shell(在 Windows 上 bun run 用的就是它)没实现默认值参数展开,会把这串字面量原样丢给 Next。优先级是:shell 的 PORT > .env.local > 这个文件。
Windows 用户还得留意根 package.json 里的几个脚本。kill 用的是 lsof -ti:3002 | xargs kill -9,clean:cache 用的是 rm -rf,restart 是这两者的串联。这些在 Git Bash 之外的 Windows 终端里不成立,端口占用得自己另想办法清。
optionalDependencies 那一段也值得瞄一眼:根 package.json 显式钉了 8 个平台原生二进制,覆盖 @tailwindcss/oxide 和 lightningcss 的 darwin-arm64、darwin-x64、linux-arm64-gnu、linux-x64-gnu 四种组合。这份清单里没有 Windows 条目,也没有 musl(Alpine)条目。它的作用范围就这么大,别指望它替你兜住别的平台。
最后是文档漂移,这是多包仓库的常见病,这个仓库也有。SETUP.md 里的结构图只列了 core、viewer、ui 三个包;apps/editor/README.md 里写的是”三个主要包”,而且上手命令给的是 pnpm install / pnpm dev;根 README.md 写的是”四个主要运行时包”,命令是 bun。CONTRIBUTING.md 提到复制 .env.example 后可以填 Google Maps API key 做地址搜索,但 .env.example 里实际只有 PORT 和 MINT_PASCAL_HOST_ORIGIN 两项。结论很直接:以根 package.json 的 workspaces 和 turbo.json 为准,文档里的结构描述只当参考。
三、画面出不出来,取决于显卡
装完之后第二道关跟包管理毫无关系。根 README 对这个项目的自我定位是”用 React Three Fiber 和 WebGPU 构建的 3D 建筑编辑器”。WebGPU 是浏览器直接调用现代显卡的那套图形接口,比老的 WebGL 更贴近底层,但对驱动、浏览器版本和硬件加速开关都更挑。
packages/viewer/src/lib/renderer-capability.ts 里的 detectRendererCapability 把探测过程写得很清楚,是一条三段式的降级链:
- 有
navigator.gpu就先要一块 WebGPU 设备:requestAdapter({ featureLevel: 'compatibility', ... }),拿到 adapter 后再requestDevice。整个过程被withTimeout包住,超时常量WEBGPU_INITIALIZATION_TIMEOUT_MS是 4000 毫秒。 - 这一步失败或超时,退到用
canvas.getContext('webgl2')探 WebGL2。 - 两条都不成,返回
{ status: 'unsupported' }。
同文件里的 initializeGpuRenderer 还多加了一层兜底:即使 WebGPU 设备拿到了,渲染器 init() 仍可能失败;这时它会 releaseDevice 释放设备,然后用 createRenderer({ forceWebGL: true }) 重建一次。这里有条注释解释了为什么必须显式释放——因为设备是调用方自己请求并交给渲染器的,three 会把它当作 caller-owned,永远不销毁;请求了又丢掉的设备会一直占着浏览器的并发设备额度,直到这个页面结束。
全部失败之后,packages/viewer/src/components/viewer/index.tsx 会渲染 UnsupportedGpuViewerFallback。这个组件的文案是”3D viewer unavailable”,正文告诉用户这个浏览器或环境初始化不了 WebGPU 或 WebGL,建议换一个开了硬件加速的浏览器。同一处还有一段注释值得单独读:初始化失败时它返回的是一个永远不 resolve 的 Promise,而不是 reject——因为 R3F 在自己的 configure() 里 await 这个 Promise 且没有 catch,reject 会变成 unhandled rejection;resolve 更糟,R3F 会拿一个没有上下文的渲染器去调 render()。真正结束这个挂起状态的,是上面那次 state 更新触发的 Canvas 卸载。
后处理那一层是独立判断的。post-processing.tsx 里直接检查 'gpu' in navigator,没有就把 renderPipelineRef 置空并短路整条管线,让 useFrame 只走 renderer.render(scene, camera) 这条直接渲染路径。注释写明了原因:SSGI、降噪和 RenderPipeline 都是 WebGPU 专属 API,在 WebGL2 上硬建管线要么静默抛错,要么给你一个”画面先正常几帧然后变黑”的结果,因为重试循环会跟直接渲染路径打架。
这对你意味着什么,说白了就三句:远程桌面、没开硬件加速的浏览器、以及不少虚拟机和无 GPU 的 CI 环境里,你大概率只能拿到降级画面,甚至只能看到那块”3D viewer unavailable”的卡片。这不是你装错了,是能力探测的结果。排查顺序应该是先看浏览器控制台里有没有 [viewer] WebGPURenderer init failed,再去确认硬件加速开关,最后才回头怀疑依赖。
四、MCP 服务器:它在你机器上跑,改的是你的本地库
packages/mcp 是这个项目区别于普通三维编辑器的地方。它是一个 MCP 服务器——把编辑器 UI 用的那套场景变更操作,包装成 AI 宿主能调用的工具、资源和提示词。
按 packages/mcp/README.md 的说法,这个服务器在 Bun 里无头运行,不需要浏览器、不需要 WebGPU、不需要 React、也不需要外部数据库服务。启动方式是 bunx pascal-mcp,走 stdio;也可以 pascal-mcp --stdio --scene ./my-scene.json 从磁盘加载初始场景,或者 pascal-mcp --http --port 8787 开在本地 HTTP 上。
我按 README 里的表格数了一下:工具 34 个、资源 5 个、提示词 3 个。工具这一层分得比较细,读取侧有 get_scene、find_nodes、get_level_summary、get_walls、get_zones、measure;建造侧有 create_room、create_story_shell、create_stair_between_levels、create_roof、add_door、add_window、furnish_room;批量改动走 apply_patch(README 说它会先 dry-run 校验再提交);还有 undo / redo / validate_scene / verify_scene / check_collisions 这类兜底与自检工具。所有工具的输入输出都用 Zod 校验,变更类工具被 Zundo 的时间旅行中间件记成一个可撤销步骤。
现在说代价,这部分必须看清楚。
数据落在哪:通过 MCP 保存的场景写进一个本地 SQLite 数据库,默认路径是 ~/.pascal/data/pascal.db。想换目录设 PASCAL_DATA_DIR,想指定具体文件设 PASCAL_DB_PATH。这个库用 WAL 模式和事务性版本检查,所以编辑器进程和 MCP 进程可以同时开着同一个库。反过来说,只要你配了这个 MCP,Agent 就有了一个持久化的本地写入面。
联动到什么程度:当编辑器和 MCP 服务器共享同一个 PASCAL_DATA_DIR 时,MCP 的变更会写进 SQLite 并记入本地 scene_events 流,编辑器页面通过 /api/scenes/:id/events 用 server-sent events 订阅它。也就是说,你浏览器里开着的那个标签页,会随着 Agent 的操作实时变。如果浏览器或另一个 MCP 进程先保存了更新的版本,MCP 工具会返回 live_sync_version_conflict,要求你先 load_scene 重新加载。
暴露端口意味着什么:packages/mcp/src/transports/http.ts 里 DEFAULT_HOST 是 127.0.0.1,命令行 --host 的默认值同样是 127.0.0.1。绑定到非 loopback 地址时,代码会直接抛错,要求提供 PASCAL_MCP_HTTP_TOKEN 或 authToken——请求侧接受 Authorization: Bearer 或 x-pascal-mcp-token 头。这条硬约束的意思是:只要你改了 --host,就是在把一个能改你本地数据库的写接口放到网络上,token 是唯一那道门。关于这类边界怎么划,可以对照 MCP 的安全边界 和 最小权限的工具设计 一起看。
出网请求:视觉类工具 analyze_floorplan_image 和 analyze_room_photo 会去取图。packages/mcp/src/lib/safe-fetch.ts 做了 SSRF 防护,屏蔽 loopback(127.0.0.0/8、::1)、私网、link-local 与云元数据地址。这两个工具还依赖宿主支持 sampling 能力(createMessage),不支持的宿主会拿到结构化的 sampling_unavailable 错误。
Agent 有权改动的范围:apply_patch 支持 create / update / delete / move 四类操作,delete_node 带 cascade 参数可以级联删子树,duplicate_level 能整层克隆。撤销靠 Zundo 的时间历史:packages/mcp/README.md 只说变更类工具会被记成一个可撤销步骤,没给深度;深度写在根 README.md 的 Stores 一节里,是 50 步。也就是说撤销是有底的,Agent 连着改上几十次之后,最早那几步就退不回去了。这个盘子不小,配之前先想清楚你是不是接受一个 Agent 拥有这些权限。
五、边界与代价:它明确不管的事
packages/mcp/README.md 有一节 Limitations,写得相当坦白,值得原样理解:
- 无头模式不生成派生几何。墙的斜接、楼板的三角剖分、门窗洞口的 CSG 布尔、屋顶与楼梯的生成,这些系统都跑在编辑器的 React hooks 里。无头跑 MCP 时,节点数据完全可改,但几何不会重算。需要真几何的消费方,得在浏览器宿主里跑
@pascal-app/viewer。 export_glb直接返回not_implemented。GLB 导出依赖 Three.js 渲染器,无头环境够不着。dirtyNodes会在无头模式下堆积,因为没有渲染器去消费它。README 建议在意可观测性时调bridge.flushDirty()。loadAssetUrl/saveAsset是浏览器专属的,引用asset://<id>的物件在 Node 里解析不了;要在浏览器外用,得换成绝对 URL 或data:URL。- 内置目录是故意做小的。README 说宿主应用可以通过额外的工具和资源暴露自己更丰富的目录,而不必让 MCP 包去依赖编辑器 UI 那一大坨。
坐标这块也有实打实的代价。Pascal 是右手系,X 和 Z 构成地面,Y 朝上,长度单位米、旋转单位弧度。README 用一整节警告:它的视口会在世界坐标之上叠自己的旋转——二维平面板会按用户的视图旋转角转内容,三维的”俯视”吸附会保留相机当前的方位角(方位角就是相机绕竖直轴转过的那个角度),所以从默认的等轴测机位(斜着往下看的那种标准三维视角)触发时,世界坐标轴和屏幕坐标轴会差大约 45 度,直到你把相机转到轴对齐。结果就是:一份按”Y 是北、俯视看”这种常见测绘习惯算出来的布局,导进 Pascal 之后会是转过的。编辑器自己的二三维工具跟存储坐标是自洽的,这个坑只坑程序化生成的几何。另外一条独立的坑:贴在墙上的坐标是墙局部坐标,门窗存的 position[0] 以及 place_item 打到墙上时的 position[0],都是沿墙的米数,不是平面坐标。
架构层面的红线也是代价的一部分。AGENTS.md 写明:packages/core 不许 import Three.js,也不许知道渲染、工具、模式这类概念;packages/viewer 不许知道 useEditor、编辑器工具、平面图状态这些编辑器词汇;CONTRIBUTING.md 把”packages/viewer 绝不能 import apps/editor”列为关键规则,编辑器特性一律通过 props 和 children 注入。好处是 viewer 可以被别的宿主独立复用,代价是你想在渲染层顺手读一个编辑器状态时,得绕一大圈。
部署侧还有一条不能动的约束。SETUP.md 和 docker-compose.yml 都写了:容器端口必须保持 3000,因为 /scenes 页面通过一个只有 NEXT_PUBLIC_APP_URL 能覆盖的 base URL 去请求自己的 API,而 Next 在构建时就把这个值内联了——把宿主端口映射到别的号上,这个页面会返回 500。同时,保存的场景放在 pascal-data 这个 volume 里;docker-compose.yml 的注释直说了,没有这个 volume,容器一重建所有项目就没了。
最后,apps/ifc-converter 的 README 自己标了 early alpha,明说 IFC 是个庞杂且各家实现松散的标准,真实导出文件差异极大,会出现元素错位或丢失、几何读不出来时墙退回固定高度、部分物件被整个跳过、还有没映射的元素类型。它的定位是预览和迭代,不是生产。
六、上手清单:每条都写清为什么会踩
- 别在子目录里跑 dev。会踩是因为
apps/editor自己也能起 Next,起得来还不报错;但turbo.json里dev的^build依赖和包的 watch 进程都被绕过了,你改上游包不会生效。怎么避:任何时候都在仓库根跑bun dev。 - 别敲
bun test。会踩是因为它是 Bun 的内置子命令,压根到不了 package 脚本,会去扫dist/里的编译副本报出虚高数字。怎么避:跑bun run test;只想跑单个包时用bun --cwd packages/core run test。 - 别照着
apps/editor/README.md装。会踩是因为那份文档还停在pnpm install/pnpm dev和三个包的旧结构上。怎么避:以SETUP.md、根README.md和根package.json为准,包管理器用 Bun。 - 别按
CONTRIBUTING.md去.env.example里找 Google Maps key。会踩是因为文档提到了它,但那个文件里实际只有PORT和MINT_PASCAL_HOST_ORIGIN。怎么避:需要什么变量直接读.env.example和.env.defaults原文。 - 画面不出来先查显卡再查依赖。会踩是因为渲染失败的表现是一块静态卡片或者黑屏,很像构建挂了。怎么避:看控制台有没有
[viewer] WebGPURenderer init failed,确认浏览器硬件加速是开的;远程桌面和虚拟机里先降低预期。 - MCP 配置别直接指向源码。会踩是因为 README 给的本地调试配置指的是
packages/mcp/dist/bin/pascal-mcp.js,那是构建产物。怎么避:先bun run --cwd packages/mcp build,再把宿主指过去;宿主找不到bunx时把command换成bun的绝对路径。 - 接 MCP 之前先决定数据目录。会踩是因为默认落在
~/.pascal/data/pascal.db,你可能过很久才意识到 Agent 一直在写你的家目录。怎么避:显式设PASCAL_DATA_DIR,并且编辑器和 MCP 两侧设成同一个值,否则实时联动不会发生。 --host不是随手改的。会踩是因为改成0.0.0.0之后,这个能改本地数据库的接口就上网了。怎么避:保持默认 loopback;确实要开放时,代码会强制你提供PASCAL_MCP_HTTP_TOKEN,别用弱 token 敷衍过去。- Docker 端口别改。会踩是因为
NEXT_PUBLIC_APP_URL在next build时被内联,改了映射端口,/scenes页面就 500。怎么避:容器端口保持 3000,换域名时通过MINT_PASCAL_HOST_ORIGIN传。 - Docker 镜像里那句
apk add nodejs别删。会踩是因为看起来冗余——基础镜像已经是 Bun 的了。但 Dockerfile 注释说清了:镜像自带的node是个重新执行 bun 的 shim,Next 的构建会把它打崩,而 CI 上不会复现,因为 GitHub runner 有真的 node。怎么避:改 Dockerfile 前先读注释;另外基础镜像的 Bun 版本要和packageManager对齐,版本漂移会让--frozen-lockfile只在镜像里挂。
收束
把这个仓库跑起来,判断顺序其实很固定:先确认 Bun 和 Node 满足 SETUP.md 的前置条件,再确认你是在仓库根目录敲命令,然后确认浏览器给不给你 WebGPU 或至少 WebGL2,最后才轮到 MCP 那一侧的数据目录与端口。前三步任何一步没过,后面的排查都是白费力气。
装完之后接着读哪个文件,我的建议是这个顺序:wiki/architecture/README.md 拿到那 20 份架构文档的索引;AGENTS.md 拿到分层红线,改任何跨层代码之前它是最短的止损;如果你要接 MCP,packages/mcp/README.md 的 Coordinate conventions 一节必须读完,配套的 examples/coordinate-conventions-demo.md 和同名 JSON 可以直接用 pascal-mcp --stdio --scene 加载,把坐标转向这件事在自己机器上验一遍——比读三遍文字管用。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 3D 建筑编辑器排障:显卡回退与报错分类 和 开源 3D 建筑编辑器 Pascal Editor 的 MCP 服务器:无头运行与两种传输方式怎么选。