Pascal Editor 3D 建筑编辑器排障:显卡回退与报错分类
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
**Pascal Editor 排查最容易走错的一步,是把三层故障混成一层看。**这个开源 3D 建筑编辑器(与 Pascal 编程语言、与压强单位帕斯卡都没有关系)实际上是三段独立的东西串起来的:浏览器里的三维渲染、一个跑在你本机的 MCP 服务器进程、以及一套关于门前留多少空、家具能不能挨着放的场景语义规则。这三层的失败长得很像 —— 都是「Agent 说做完了,我这边什么都没有」—— 但排查入口完全不同。先判断故障落在哪一层,比对着任何一层瞎调都省时间。
站内已经有几篇讲通用方法的:Agent 失败该怎么分类 讲的是失败类型学,MCP Server 启动失败排查 讲的是任意 MCP 服务器的通用启动链路,Agent 工具调错了怎么办 讲的是工具选择与调用层面的纠偏。这一篇不重复那些,它只做一件事:把 Pascal Editor 这一个具体仓库里的报错通道逐个指到文件,让你能拿仓库对照着读。
一、先把三层边界划清楚
按这个顺序问自己三个问题,能省掉大半盲调。
第一,浏览器里画面出来了吗。如果连三维视口都没有,问题在渲染层,跟 MCP、跟 Agent 一点关系都没有 —— 这时候去看 MCP 日志纯属浪费时间。
第二,MCP 进程起来了吗、Agent 连上了吗。packages/mcp/src/bin/pascal-mcp.ts 是这个进程的入口,它把所有致命错误都打到 stderr,前缀是 [pascal-mcp]。进程活着但工具调不动,和进程压根没起来,是两码事。
第三,工具调用返回的是协议级错误,还是场景语义上的「我没放下」。这两件事在 Pascal 的设计里走的是完全不同的通道,混着看会得出错误结论。第四节展开。
顺带说一句仓库结构,方便你定位。git ls-files 数出来全仓 2508 个受版本控制的文件,apps/ 下 2 个应用,packages/ 下 9 个包,其中 nodes 最大(733 个源码文件),其次是 editor(445)、core(233)、mcp(152)、viewer(107)。排障时你实际要打开的只有后两个。
二、显卡不支持时,它退到什么状态
先说清一个前提:这是个跑在浏览器里的三维编辑器,画面靠 GPU 出。浏览器给到 GPU 的通道有两条 —— 较新的 WebGPU 和较老的 WebGL。Pascal 的策略是「先试新的,不行退老的,都不行显示一张说明卡片」。
这段逻辑集中在 packages/viewer/src/lib/renderer-capability.ts。它导出的能力探测结果是个三选一的联合类型,读一眼就知道有哪几种结局:
export type RendererCapability =
| { backend: 'webgpu'; device: unknown; status: 'supported' }
| { backend: 'webgl'; status: 'supported' }
| { error?: unknown; status: 'unsupported' }
detectRendererCapability 的执行顺序是:有 navigator.gpu 就先去 requestAdapter 拿设备,这一步套了超时保护(常量 WEBGPU_INITIALIZATION_TIMEOUT_MS,4 秒);拿不到或超时,就退去建一个 canvas 试 getContext('webgl2');再不行才返回 status: 'unsupported'。
比探测更关键的是 initializeGpuRenderer 的二次回退:探测说 WebGPU 可用、但真正 renderer.init() 时炸了,它不会直接放弃,而是先把已拿到的 WebGPU 设备 destroy() 掉,再用 { forceWebGL: true } 重建一次渲染器。releaseDevice 上方的注释解释了为什么必须显式释放:设备是调用方自己请求的,three 把它当 caller-owned 不会替你销毁,而 init() 失败时 dispose() 又是空操作 —— 泄漏的设备会一直占着浏览器的并发设备配额直到页面关掉。
都失败之后你会看到什么。 packages/viewer/src/components/viewer/index.tsx 里有个 rendererInitFailed 状态,置真时直接返回 UnsupportedGpuViewerFallback。这个组件在 unsupported-gpu-fallback.tsx,内容是一张卡片,标题 3D viewer unavailable,正文说明这个浏览器或环境无法初始化 WebGPU 或 WebGL,建议换一个开启了硬件加速的浏览器打开。所以:看到这张英文卡片,就是渲染层判定失败,不用再往下查 MCP。
这里有两处工程细节值得单独记住,因为它们直接影响你怎么读现象。
一是 gl 工厂在初始化失败后返回的是一个永远不 settle 的 Promise,源码注释写得很直白:拒绝会被 R3F 在自己的 configure() 里 await 且无 catch,变成未捕获的 promise rejection(对应它记的 MONOREPO-EDITOR-59);解决则更糟 —— R3F 会对一个没有上下文的渲染器调 render()。真正让这个挂起 Promise 被释放的,是同时触发的状态更新把 Canvas 卸载掉。这意味着你在控制台不会看到一个漂亮的抛错堆栈,你能看到的是 console.error('[viewer] WebGPURenderer init failed', result.error) 这一行。搜 [viewer] 前缀,别指望搜到异常。
二是回退时会显式把场景就绪状态置真。注释解释了原因:GPU 画布挂不上,SceneReadyTracker 就永远不会挂载,宿主编辑器会一直卡在自己的场景就绪超时上。所以回退路径主动调 onSceneReadyChange?.(true),让外层加载态立刻消失。加载圈消失但看到英文卡片,是设计如此,不是加载中断。
画面先出来、用着用着才黑又是另一回事。GPUDeviceWatcher 订阅了 WebGPU 设备的 lost promise 和 uncapturederror 事件,前者触发时的日志明确写了「必须重新加载页面才能恢复 GPU 上下文」。它还处理了另一种情况:拿不到 device 时打一条 warn,说明你其实跑在回退渲染器上,性能表现和 WebGPU 不是一回事。
三、MCP 服务器这一层,故障点就那么几个
packages/mcp/src/bin/pascal-mcp.ts 不到一百行,把启动链路摆得很清楚,可以照着逐段排。
入口第一行是 shim。 它 import ../bridge/node-shims,且注释强调必须最先加载。原因在 node-shims.ts 里写着:核心状态库在 updateNodesAction 和 undo/redo 的订阅回调里用了 requestAnimationFrame,而后者在模块导入时就注册,所以在 Node 环境下如果没有这个垫片,@pascal-app/core/store 会在 import 阶段直接抛。如果你自己写脚本复用它的桥接层,导入顺序错了会得到一个看起来毫无道理的启动崩溃。
传输方式选择。 CLI 的帮助文本列了这些选项:--stdio、--http、--port、--host、--auth-token、--cors-origin、--scene、--version、--help。不传传输标志时默认走 stdio。HTTP 的默认端口是 3917,默认绑定 127.0.0.1。端口值会被校验范围,越界直接抛 invalid --port value。
场景库初始化。 createSceneStore() 在建服务器之前先跑。它落在 SQLite 上,驱动在 packages/mcp/src/storage/sqlite-driver.ts:globalThis 里有 Bun 就用 bun:sqlite,否则 import('node:sqlite');两条路都不通时,抛的错误消息是 SQLite requires Bun or a Node runtime with node:sqlite support。看到这句话,问题在你的运行时,不在 Pascal。
数据落在哪。 resolveDefaultDatabasePath(在 sqlite-scene-store.ts)的优先级链条是:PASCAL_DB_PATH → PASCAL_DATA_DIR/pascal.db → Windows 上 %APPDATA%/Pascal/data/pascal.db → $XDG_DATA_HOME/pascal/data/pascal.db → $HOME/.pascal/data/pascal.db。这条链值得记住,因为它是「昨天 Agent 建的场景今天找不到了」的标准解释 —— 换了个 shell、环境变量没带上,数据库就换了个文件。
四、工具报错分两条通道,别混着读
这是 Pascal 这套 MCP 服务器里最值得单独讲的设计,也是最容易读错的地方。
packages/mcp/src/tools/errors.ts 整个文件只有三十多行,导出两个函数,对应两条完全不同的通道。
第一条是 throwMcpError(code, message, data),直接 throw new McpError(...),由 SDK 翻译成 JSON-RPC 错误响应。这是协议级失败:调用就没成立。在非测试代码里数一遍 ErrorCode. 的出现,只用到三种:InvalidParams(54 处)、InternalError(21 处)、InvalidRequest(15 处)。分工也很规整 —— 找不到节点、类型不对、参数越界走 InvalidParams;版本冲突、状态不允许走 InvalidRequest(例如 live-sync.ts 里的 live_sync_version_conflict);落库真的炸了走 InternalError(例如同文件的 live_sync_failed)。
第二条是 toolError(message, data),注释写得很明确:
/**
* Return a non-throwing tool error payload — used for structured failures that
* we want the client to see inline in `content` rather than as a protocol
* error. Sets `isError: true` so SDK clients treat it as a failure.
*/
它返回一个带 isError: true 的正常响应,错误信息以 JSON 塞在 content 的文本里。**留一个实际观察:在当前这份代码里,toolError 只有定义,非测试源码中我没搜到调用点。**它更像一个预留的通道 —— 想说明它的意图可以,但别在文档里写「Pascal 用它返回业务错误」,那不符合当下的代码事实。
**真正承载「我做了但没做成」的,是第三条通道:结构化输出字段。**这条通道没有走任何 error 封装。看 furnish_room(packages/mcp/src/tools/room-tools.ts):它的输出 schema 里有一个 skipped: z.array(z.string()),每次放不下家具就往里 push 一条字符串。放不下的原因被压成三选一:
const reason =
resolved.reason === 'blocks_door_clearance'
? 'blocks door clearance'
: resolved.reason === 'outside_bounds'
? 'outside room bounds'
: 'overlaps another item'
skipped.push(`${asset.id}: ${reason}`)
对排障的含义很直接:**Agent 说「已完成,放了 3 件家具」,工具调用本身是成功的,但 skipped 数组里可能躺着七条跳过原因。**如果你的 Agent 只看有没有抛错、不读 structuredContent,就会把一屋子没摆上的家具当成功交付。仓库自带的 Agent 指南(packages/mcp/src/resources/agent-guide.ts,通过 pascal://agent-guide 这个资源暴露)也是这么要求的:verify_scene.hasIssues 应为 false,否则要向用户解释剩余问题。这类「返回值里的软失败」怎么设计约束,可以对照 Agent 参数与返回值的校验 一起看。
补一句名词:check_collisions 的输出里有个 kind 字段,值形如 item-aabb。AABB 是 axis-aligned bounding box,轴对齐包围盒 —— 把一个物体近似成一个不旋转的方盒子来做快速相交判断。这里用的是「平面 AABB」,即只看俯视平面上的 X/Z 两轴,不管高度。
五、那份净空错误日志,正好当排查教材
先解释名词。**净空(clearance)**在建筑语境里是指必须留空、不许放东西的区域;门前那块地要是被柜子占了,门就打不开。Pascal 把它实现成 keep-out:以门洞为中心在平面上圈一个矩形,家具的平面包围盒撞进来就算违规。默认参数在 packages/mcp/src/tools/door-clearance.ts:DEFAULT_DOOR_CLEAR_DEPTH = 0.65(门洞两侧各清出的进深,米)、DEFAULT_DOOR_SIDE_PAD = 0.05(沿墙方向比门扇多留的半宽)。物件之间的最小间距在 layout-clearance.ts,DEFAULT_ITEM_GAP = 0.08。
再解释「层(level)」:多层建筑里的一层楼。这个概念在下面第一条坑里是命门。
packages/mcp/docs/layout-clearance-error-log.md 这份文档,我认为对 AI 工程读者的价值超过它的建筑价值。它是一份永久性的回归清单,开头就写明用途:改动 door-clearance.ts、layout-clearance.ts、furnish_room、verify_scene、check_collisions 时对照使用。它由两部分组成:一张「报错码 / 消息 → 含义」对照表,和八条编号为 L1–L8 的历史坑,每条都是「Bug(错在哪)— Rule(正确规则)— Test(用什么测例守住)」三段式。
挑几条讲,因为它们的失效模式在任何 Agent 工具里都会重现。
L1,多楼层假阳性。 原来的判定只比对平面 X/Z 坐标,结果楼上的家具把楼下的门给「挡」了。修法是每次门与物件的比较都必须落在同一个层 id 上,用 resolveNodeLevelId 沿 parentId 往上走到层节点。这条的普遍教训是:几何判定漏掉了一个维度的作用域,报出来的错全是假的,而假阳性比漏报更消耗 Agent —— 它会去修一个不存在的问题。
L3,间距符号写反。 原实现是 a.maxX - gap > b.minX,效果是 gap 越大反而要求两个盒子插得越深才算碰撞,语义整个反了。正确规则是把两个盒子各自按 gap 撑开再判相交:a.maxX + gap > b.minX && a.minX - gap < b.maxX(Z 轴同理)。守它的测例也写得很具体:两个物件相距 0.05 米、gap 设 0.08,必须报碰撞。一个符号错误不会让程序崩,只会让 Agent 拿到一份安静的错误答案 —— 这类 bug 只能靠这种「反向数值」的测例钉死。
L4,忽略缩放。 用了资产的原始 dimensions 而不是 getScaledDimensions,被缩放过的家具按原尺寸判碰撞。规则区分得很细:场景里已有的物件走 itemNodePlanAabb / getScaledDimensions,目录里尚未缩放的候选放置才用资产原始尺寸。
L7,跳过原因具误导性。 findValidPlacement 会围绕主位姿试若干候选点,原来它报的是最后一个候选的失败原因,通常是「超出边界」。可实际情况往往是:主位姿撞了门的净空,往旁边挪的那些尝试才越界。规则改成失败时优先报主位姿的原因,且门/重叠的优先级高于边界,代码里现在有一个显式的 reasonPriority 数组做这件事。这条对做 Agent 工具的人最有借鉴价值:错误信息报错了对象比不报还糟 —— 你的 Agent 会照着那条误导去改房间尺寸,而真正的问题是门口摆了东西。
L5 和 L8 是另一类,都发生在 apps/editor,都跟客户端导航时 useEffect 的依赖写法有关。它们和布局判定无关却记在同一份日志里 —— 这份文档收的是「这次改动踩到的所有坑」,不按模块归档。
文档最后是一张合并前检查清单(层作用域测例、兄弟房间门的规划 keep-out、间距语义单测、缩放尺寸单测,以及跑 bun test),外加两条关联 PR 编号。这个格式可以直接抄去做你自己 Agent 工具的回归防线。
六、各部分速查表
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 渲染能力探测 | 依次尝试 WebGPU 与 WebGL2,失败返回 unsupported;init 失败时二次强制回退 WebGL | packages/viewer/src/lib/renderer-capability.ts | 视口空白、画面出不来 |
| 不支持时的兜底面板 | 渲染一张 3D viewer unavailable 说明卡片 | packages/viewer/src/components/viewer/unsupported-gpu-fallback.tsx | 看到那张英文卡片就是它 |
| 设备状态监听 | 把 WebGPU 设备丢失与 uncapturederror 打到控制台 | packages/viewer/src/components/viewer/index.tsx | 画面先正常、用着用着挂了 |
| MCP 进程入口 | 加载 RAF 垫片、解析 CLI 参数、选传输、建场景库 | packages/mcp/src/bin/pascal-mcp.ts | Agent 连不上 MCP 服务器 |
| 错误封装 | throwMcpError 走协议错误;toolError 造 isError 载荷 | packages/mcp/src/tools/errors.ts | 读 Agent 收到的报错时 |
| 本地场景库 | SQLite 落盘、解析数据库路径优先级 | packages/mcp/src/storage/sqlite-scene-store.ts、sqlite-driver.ts | 场景「不见了」、换机器/换 shell |
| HTTP 传输守卫 | Origin 校验、Bearer 校验、按来源计数的频次桶 | packages/mcp/src/transports/http.ts | 用 --http 把服务暴露出去时 |
| 净空判定与错误日志 | 门前 keep-out、物件最小间距、八条历史坑 | packages/mcp/src/tools/door-clearance.ts、layout-clearance.ts、packages/mcp/docs/layout-clearance-error-log.md | Agent 摆家具大量被跳过 |
| 外链抓取护栏 | 用户提供的图片 URL 做 SSRF 校验 | packages/mcp/src/lib/safe-fetch.ts | 用到跟图片有关的工具时 |
七、边界与代价:这套设计放弃了什么
它把「摆不下」定义成正常结果,不是错误。 这是个有意的取舍:几何约束冲突在建模里太常见,每次都抛协议错会让 Agent 的循环极其难写。代价是失败变得安静 —— 客户端不读 structuredContent 就完全感知不到。如果你在它上面搭自动化流水线,必须自己加一道「skipped 非空即视为未完成」的判定,服务器不会替你做。
它会在你机器上写文件。 场景落在本地 SQLite 库里,默认位置是家目录下的 .pascal/data/pascal.db(完整优先级见第三节)。好处是离线可用、数据在你手里;代价是它不在项目目录里、不受 git 管理、也不跟着仓库走,备份和迁移是你自己的事。
HTTP 传输是有代价的暴露。 默认绑 127.0.0.1 已是保守选择,connectHttp 还有一条硬约束:绑非回环地址而没有 auth token 时直接抛错,要求提供 PASCAL_MCP_HTTP_TOKEN 或显式 authToken。守卫层做三件事 —— Origin 白名单(回环放行,其余走 PASCAL_MCP_HTTP_ORIGINS 或 --cors-origin)、Bearer/x-pascal-mcp-token 常量时间比对、按来源地址计数的频次桶;路径也收得很紧,只有 /mcp 有响应,其余一律 404。但这些是本机防护,不是把服务放公网的授权。只要开了 HTTP,任何能到达这个端口并持有 token 的进程就能改你的场景数据。
Agent 的权限边界要看清楚。 从工具目录直接能读出它能做什么:建层、建墙、开洞、放物件、装修房间、删节点、撤销重做、导出 GLB/JSON、保存与删除场景。接上这台 MCP 服务器的 Agent,对你本地场景数据是有写权限和删除权限的。这类权限怎么收,MCP 的安全边界怎么划 讲得更系统。
按配置它会出网。 涉及图片的工具会拉取用户给的 URL。packages/mcp/src/lib/safe-fetch.ts 的注释直说了缘由:此前 photo_to_scene、analyze_floorplan_image、analyze_room_photo 都是裸 fetch(url),等于在任意主机上开了一个指向云元数据地址的外发原语。现在这条路径封了回环、链路本地(含云元数据段)、私网段与非 http(s) 协议,手动跟随重定向并对每一跳重新校验,另有体积上限和超时;可选的 PASCAL_ALLOWED_ASSET_ORIGINS 能进一步收成白名单。值得读的原因不是「它安全了」,而是它把一个真实发生过的 SSRF 缺口和修法完整留在了注释里。
它明确不管的事:不替你判断建筑规范是否合规(净空常量是工程默认值,不是规范条文);不做三维实体布尔运算级别的精细求交(实体布尔运算是把两个三维实体真的做交、并、差计算,得到精确的相交体,代价是算得慢得多)—— 碰撞判定是平面 AABB,斜放的沙发按旋转后的包围盒算但不精确到轮廓;check_collisions 的 gap 显式设为 0、只报真实重叠,而 furnish_room 放置时用 0.08 米的呼吸间距,check-collisions.ts 的注释专门说明了这个口径差 —— 别拿这两个工具的结果互相印证,它们本来就不该一致。
八、上手与避坑清单
一、别照着 SETUP.md 的目录树找包。 文档里的 Monorepo Structure 只列了 core、viewer、ui 三个包,而实际 packages/ 下有 9 个(含 mcp、nodes、editor、ifc-converter 和两个配置包)。顺带解释一下最后那个名字:IFC 是建筑行业用来在不同设计软件之间交换模型数据的开放文件格式,ifc-converter 就是干这个转换的,跟排障没关系,但你搜代码时会撞见它。为什么会踩:你按文档找 MCP 相关代码会找不到,进而怀疑自己 clone 错了分支。怎么避:目录结构以 ls packages/ 为准,SETUP.md 当快速上手用、不当目录索引用。
二、Docker 部署别改端口映射。 SETUP.md 明确警告:容器端口必须保持 3000,因为 /scenes 页面通过一个只有 NEXT_PUBLIC_APP_URL 能覆盖的基址请求自己的 API,而 Next 在构建时就把这个值内联了,改了映射那个页面会返回 500。为什么会踩:改端口太顺手,而且改完首页还是好的。怎么避:**端口保持 3000,换域名时改 MINT_PASCAL_HOST_ORIGIN。**顺带记住三个数字别串:本地 bun dev 是 3002、Docker 是 3000、MCP 的 HTTP 传输是 3917。
三、别用「没抛错」判断建模成功。 理由见第四节。怎么避:把 furnish_room 的 skipped、verify_scene 的 hasIssues、check_collisions 的 collisions 一起纳入验收,任何一个非空就算未完成 —— 仓库的 Agent 指南自己就把这几步写成了必做的收尾检查。
四、白屏先看控制台前缀,别先怀疑 MCP。 为什么会踩:Agent 报告「场景已保存」而屏幕空白,第一反应总是数据没同步。怎么避:搜 [viewer]。看到 WebGPURenderer init failed 是渲染层判定失败;看到 No WebGPU device on backend 说明在回退渲染器上跑;看到 WebGPU device lost 只能刷新页面。再记住第二节那个反直觉点:初始化失败不会产生异常堆栈,别在控制台里找 stack。
五、跨 shell 跑 MCP 时把数据库路径固定下来。 为什么会踩:路径解析链上有四个环境变量参与,IDE 起的进程和终端起的进程环境不一样,结果就是两个库文件、两套场景。怎么避:显式设 PASCAL_DB_PATH,再确认那个文件真的在变大。
六、复用它的桥接层时,第一行 import 必须是 shim。 为什么会踩:模块导入顺序是 lint 工具最爱重排的东西,自动整理一下 import 就炸在 @pascal-app/core/store 的导入阶段。怎么避:照抄 bin/pascal-mcp.ts 的写法,import '../bridge/node-shims' 放最上面并在旁边留注释说明不能动 —— 源码里就是这么做的。
七、改净空相关代码前先读那份错误日志。 为什么会踩:八条坑里至少有三条(层作用域、间距符号、缩放尺寸)改动时看起来完全合理,测试也未必覆盖。怎么避:只要你动 door-clearance.ts、layout-clearance.ts、furnish_room、verify_scene、check_collisions 其中之一,先把 L1–L8 过一遍,再对着底部的合并前清单勾一遍。
收尾:一份两分钟自检
按顺序问四句话,基本能定位到层:
- 视口里有没有那张
3D viewer unavailable卡片?有就是渲染层,看renderer-capability.ts,别碰 MCP。 - 控制台搜
[viewer],是 init failed、no device 还是 device lost?三种现象对应三条不同处置。 - 控制台或终端搜
[pascal-mcp],进程活着吗?活着但工具没反应,去看是不是选错了传输。 - 工具返回的
structuredContent里,skipped/hasIssues/collisions三个字段是什么?非空就是没做完,哪怕调用「成功」。
接下来该读哪个文件:想弄清 Agent 该怎么用这套工具,读 packages/mcp/src/resources/agent-guide.ts,那是项目自己写给 Agent 的操作规范;想弄清判定为什么这么写,读 packages/mcp/docs/layout-clearance-error-log.md;想弄清画面为什么没出来,读 packages/viewer/src/lib/renderer-capability.ts。仓库 wiki/architecture/ 下还有 20 份 markdown(1 份 README 索引加 19 篇分主题),比 SETUP.md 详细得多。
最后一个可能有用的观察:这个仓库根目录同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件。你用哪个工具读这份代码,就先看对应那份 —— 维护者已经把希望 Agent 遵守的规矩写在那儿了。许可证是 MIT(Copyright 2026 Pascal Group Inc.),拿来读、拿来改都没有障碍。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 开源 3D 建筑编辑器:MCP 服务器暴露出去前的风险面梳理 和 Pascal Editor 上手记:浏览器里的开源 3D 建筑编辑器,装起来要过多包仓库和显卡两道关。