Pascal Editor 上手记:浏览器里的开源 3D 建筑编辑器,装起来要过多包仓库和显卡两道关

2026-08-05

本文基于 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.jsonworkspaces 字段声明了三组工作区:apps/*packages/*tooling/*。在这个 commit 上,apps/ 下有 2 个应用,packages/ 下有 9 个包,tooling/ 下有 releasetypescript 两项。全仓受版本控制的文件是 2508 个,你自己 git ls-files | wc -l 就能复现。

体量分布很不均匀。按各包目录下的受控文件数排,nodes 733、editor 445、core 233、mcp 152、viewer 107。也就是说,重量压在节点定义那一侧,而不是渲染那一侧。packages/nodes/src/ 下有 46 个节点类型目录,从 wallslabdoorwindow 这类基础构件,一直到 duct-segmentpipe-trapsolar-panelturbine-vent 这种暖通与屋面构件。

有几个建筑侧和三维图形侧的词先说清楚,免得后面读着卡壳:

  • level(楼层):一栋楼里的一层,是大部分构件的归属容器。
  • slab(楼板):一层楼的那块地面板,用一个多边形加一个标高定义。
  • zone(区域):一块被圈出来的房间范围,本身不是实体,用来标注”这是卧室”。
  • CSG 实体布尔运算:用一个形体去减另一个形体。在这里的用途是在墙上挖出门窗洞口。
  • mitering(斜接):两面墙相交时,把端头切成斜角好让它们干净地拼上,而不是互相插进去。
  • 三角剖分:把一个任意形状的多边形拆成一堆三角形。显卡只会画三角形,楼板、天花板这种自由多边形必须先剖分才渲染得出来。
  • IFC:建筑行业用来在不同软件之间交换建筑模型的一种开放数据格式,一个 .ifc 文件里装着墙、门窗、楼板这些构件的定义和它们的相互关系。
  • SSGI(屏幕空间全局光照):在后处理阶段近似算一遍光线在物体之间的反弹,让室内暗部不至于死黑;它只用屏幕上已有的像素信息估算,所以叫屏幕空间。
组成部分它负责什么对应仓库位置你什么时候会碰到它
@pascal-app/core节点 schema、场景状态(Zustand)、注册表契约、空间查询、事件总线;根 AGENTS.md 明确它不许引入 Three.jspackages/core改数据结构、查节点关系时
@pascal-app/viewerReact Three Fiber 渲染运行时、默认相机与控制、后处理packages/viewer画面出不来、后处理黑屏时
@pascal-app/editor编辑工具、面板、选择、直接操作类 UIpackages/editor改交互行为时
@pascal-app/nodes内置注册表插件:节点定义、渲染器、几何、系统packages/nodes加一种新构件时
@pascal-app/mcpMCP 服务器与场景存储适配层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.mdCLAUDE.mdGEMINI.md 三份 Agent 约定文件。AGENTS.md 里写明后两者是它的符号链接,内容同源。可执行的工作流放在 .agents/skills/ 下(open-prreview-architecture 两个),并通过 .claude/.cursor/.codex/ 三处链接过去。这套做法本身值得抄:一份事实、多个宿主入口,不用在三个文件里维护三份会漂移的说明。

二、装:为什么很多命令必须从根目录跑

SETUP.md 给的前置条件是 Bun 1.3+ 与 Node.js 20.9+,根 package.jsonengines 也把 Node 下限钉在 20.9.0。启动步骤只有两行:bun install,然后 bun dev,编辑器在 http://localhost:3002

问题出在第二行的语义。根 package.jsondev 展开是 dotenv -e ./.env -e ./.env.defaults -- turbo run dev --env-mode=loose,而 turbo.jsondev 任务写着 "dependsOn": ["^build"]^build 的意思是先把这个包依赖的所有上游工作区包 build 一遍。testcheck-types 同样挂了 ^build

这条依赖不是装饰。CONTRIBUTING.md 里点破了原因:好几个包引用工作区里的兄弟包时,走的是那个兄弟包的 dist/ 目录而不是它的源码;不先 build 上游,下游导入的就是一个还没被生成出来的目录。所以根 README 才反复强调”永远从根目录跑 bun dev”——只有走根目录这条路,包的 watch 进程才会一起起来,你改 packages/core/src/ 才会热更新。你要是进了 apps/editor 直接跑 Next 的 dev,它能起,但上游包不会重建,你会以为改的代码没生效。

第二个坑更隐蔽,CONTRIBUTING.md 专门为它写了一整段:跑测试要用 bun run test,不能用 bun testtest 是 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 -9clean:cache 用的是 rm -rfrestart 是这两者的串联。这些在 Git Bash 之外的 Windows 终端里不成立,端口占用得自己另想办法清。

optionalDependencies 那一段也值得瞄一眼:根 package.json 显式钉了 8 个平台原生二进制,覆盖 @tailwindcss/oxidelightningcss 的 darwin-arm64、darwin-x64、linux-arm64-gnu、linux-x64-gnu 四种组合。这份清单里没有 Windows 条目,也没有 musl(Alpine)条目。它的作用范围就这么大,别指望它替你兜住别的平台。

最后是文档漂移,这是多包仓库的常见病,这个仓库也有。SETUP.md 里的结构图只列了 coreviewerui 三个包;apps/editor/README.md 里写的是”三个主要包”,而且上手命令给的是 pnpm install / pnpm dev;根 README.md 写的是”四个主要运行时包”,命令是 bunCONTRIBUTING.md 提到复制 .env.example 后可以填 Google Maps API key 做地址搜索,但 .env.example 里实际只有 PORTMINT_PASCAL_HOST_ORIGIN 两项。结论很直接:以根 package.jsonworkspacesturbo.json 为准,文档里的结构描述只当参考。

三、画面出不出来,取决于显卡

装完之后第二道关跟包管理毫无关系。根 README 对这个项目的自我定位是”用 React Three Fiber 和 WebGPU 构建的 3D 建筑编辑器”。WebGPU 是浏览器直接调用现代显卡的那套图形接口,比老的 WebGL 更贴近底层,但对驱动、浏览器版本和硬件加速开关都更挑。

packages/viewer/src/lib/renderer-capability.ts 里的 detectRendererCapability 把探测过程写得很清楚,是一条三段式的降级链:

  1. navigator.gpu 就先要一块 WebGPU 设备:requestAdapter({ featureLevel: 'compatibility', ... }),拿到 adapter 后再 requestDevice。整个过程被 withTimeout 包住,超时常量 WEBGPU_INITIALIZATION_TIMEOUT_MS 是 4000 毫秒。
  2. 这一步失败或超时,退到用 canvas.getContext('webgl2') 探 WebGL2。
  3. 两条都不成,返回 { 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_scenefind_nodesget_level_summaryget_wallsget_zonesmeasure;建造侧有 create_roomcreate_story_shellcreate_stair_between_levelscreate_roofadd_dooradd_windowfurnish_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.tsDEFAULT_HOST127.0.0.1,命令行 --host 的默认值同样是 127.0.0.1。绑定到非 loopback 地址时,代码会直接抛错,要求提供 PASCAL_MCP_HTTP_TOKENauthToken——请求侧接受 Authorization: Bearerx-pascal-mcp-token 头。这条硬约束的意思是:只要你改了 --host,就是在把一个能改你本地数据库的写接口放到网络上,token 是唯一那道门。关于这类边界怎么划,可以对照 MCP 的安全边界最小权限的工具设计 一起看。

出网请求:视觉类工具 analyze_floorplan_imageanalyze_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_nodecascade 参数可以级联删子树,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.mddocker-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 是个庞杂且各家实现松散的标准,真实导出文件差异极大,会出现元素错位或丢失、几何读不出来时墙退回固定高度、部分物件被整个跳过、还有没映射的元素类型。它的定位是预览和迭代,不是生产。

六、上手清单:每条都写清为什么会踩

  1. 别在子目录里跑 dev。会踩是因为 apps/editor 自己也能起 Next,起得来还不报错;但 turbo.jsondev^build 依赖和包的 watch 进程都被绕过了,你改上游包不会生效。怎么避:任何时候都在仓库根跑 bun dev
  2. 别敲 bun test。会踩是因为它是 Bun 的内置子命令,压根到不了 package 脚本,会去扫 dist/ 里的编译副本报出虚高数字。怎么避:跑 bun run test;只想跑单个包时用 bun --cwd packages/core run test
  3. 别照着 apps/editor/README.md。会踩是因为那份文档还停在 pnpm install / pnpm dev 和三个包的旧结构上。怎么避:以 SETUP.md、根 README.md 和根 package.json 为准,包管理器用 Bun。
  4. 别按 CONTRIBUTING.md.env.example 里找 Google Maps key。会踩是因为文档提到了它,但那个文件里实际只有 PORTMINT_PASCAL_HOST_ORIGIN。怎么避:需要什么变量直接读 .env.example.env.defaults 原文。
  5. 画面不出来先查显卡再查依赖。会踩是因为渲染失败的表现是一块静态卡片或者黑屏,很像构建挂了。怎么避:看控制台有没有 [viewer] WebGPURenderer init failed,确认浏览器硬件加速是开的;远程桌面和虚拟机里先降低预期。
  6. MCP 配置别直接指向源码。会踩是因为 README 给的本地调试配置指的是 packages/mcp/dist/bin/pascal-mcp.js,那是构建产物。怎么避:先 bun run --cwd packages/mcp build,再把宿主指过去;宿主找不到 bunx 时把 command 换成 bun 的绝对路径。
  7. 接 MCP 之前先决定数据目录。会踩是因为默认落在 ~/.pascal/data/pascal.db,你可能过很久才意识到 Agent 一直在写你的家目录。怎么避:显式设 PASCAL_DATA_DIR,并且编辑器和 MCP 两侧设成同一个值,否则实时联动不会发生。
  8. --host 不是随手改的。会踩是因为改成 0.0.0.0 之后,这个能改本地数据库的接口就上网了。怎么避:保持默认 loopback;确实要开放时,代码会强制你提供 PASCAL_MCP_HTTP_TOKEN,别用弱 token 敷衍过去。
  9. Docker 端口别改。会踩是因为 NEXT_PUBLIC_APP_URLnext build 时被内联,改了映射端口,/scenes 页面就 500。怎么避:容器端口保持 3000,换域名时通过 MINT_PASCAL_HOST_ORIGIN 传。
  10. 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 服务器:无头运行与两种传输方式怎么选

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