Pascal Editor 三维建筑编辑器:图层与隔离怎么让你只看一层楼
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
**「只看一层楼」在 Pascal Editor 里不是一个开关,而是两套互不相干的机制凑出来的效果:一套是 Three.js 的图层位掩码,决定某个物体会不会进入某一遍渲染;另一套是楼层显示模式与隔离过滤,决定哪些楼层被搬开、被藏起来、被单独留下。**把这两套当成一件事,你调出来的视图就会莫名其妙——比如明明「藏」了楼层,阴影却还在;比如隔离了一扇门,门却跟着宿主墙一起消失了。
先做一次消歧:Pascal Editor 是一个跑在浏览器里的开源三维建筑编辑器——仓库 README 开头就这样定位自己,原话是「一个用 React Three Fiber 与 WebGPU 构建的 3D 建筑编辑器」;仓库 https://github.com/pascalorg/editor ,MIT 许可证(Copyright 2026 Pascal Group Inc.)。它跟 Pascal 这门编程语言没有任何关系,也跟压强单位帕斯卡无关。它的特别之处在于随包带了一个 MCP 服务器,让 AI Agent 可以直接下工具调用去建墙、开洞、加楼层。
站内已有的 Agent 工作区隔离 讲的是文件系统层面的隔离,Pi 容器隔离 讲的是进程与容器层面的隔离,Agent 上下文管理 讲的是喂给模型的信息怎么裁剪;这篇讲的是渲染层面的隔离——同一份场景数据不动,只改「这一遍渲染谁参与」,以及这件事怎么反过来影响 Agent 能不能核对自己的成果。
一、多层建筑的可见性问题到底是什么
先把几个建筑侧的名词摊开,读者是做 AI 工程的,不必默认懂这些。
- 层(level):一栋楼的一个楼层,是场景树里的一个容器节点,底下挂着这层的墙、门、家具。
- 楼板 / 板(slab):楼层之间那块水平的混凝土板,也就是你脚下踩的、楼下抬头看到的天花。
- 屋顶(roof):盖在最上面的那层斜面或平面几何体。
- 场景图(scene graph):三维引擎里的一棵树,父节点移动,子节点跟着一起移动。
问题就出在这棵树上。三层楼叠在一起,你从外面看,永远只看得到最外面那层皮:屋顶挡住三楼,三楼的楼板挡住二楼。要看清二楼的隔墙有没有对齐,你得有办法把上面的东西挪开或者藏掉——但又不能真的改数据,因为改了就得改回来,改回来就会出错。
所以这类编辑器的通行解法都是「渲染时过滤」,Pascal Editor 也是。它给用户提供了三种看法,在界面的楼层按钮上循环切换(packages/editor/src/components/viewer/viewer-controls-bar.tsx 里 levelModeLabels 定义了这三个标签):
- Stacked(叠放):所有层按真实标高摞在一起,就是建成后的样子。
- Exploded(爆炸):每层沿竖直方向拉开一段固定间隔,像拆开的模型,能一眼看到每层的平面布局。
- Solo(独显):只留当前选中的那一层,其余全部隐去。
状态存在观察器自己的 store 里,packages/viewer/src/store/use-viewer.ts 中 levelMode 的类型是 'stacked' | 'exploded' | 'solo' | 'manual',默认值 'stacked'。第四个值 manual 是给手动摆放楼层留的位置,工具栏上碰到它时按一下会直接退回 stacked;楼层系统本身没有对 manual 做特判,落位与 stacked 相同。
配套还有一个 wallMode,管的是墙要不要按镜头方向剖掉。它的类型 WallMode 同样定义在 use-viewer.ts 里,取值有四个:up、cutaway、down、translucent,默认 up。packages/viewer/src/systems/wall/wall-cutout.tsx 里导出的 getWallHideState 是这块的纯函数:up 一律不隐藏,down 一律隐藏,其余取值才走朝向判断——拿墙面的世界朝向跟镜头方向点乘,据此把背对镜头的那一侧外墙藏掉;函数入口还有一条兜底,两面都标为室内的墙默认就是隐藏的。楼层模式管纵向,墙模式管横向,两者独立。
二、第一套机制:图层是渲染分桶,不是可见性开关
Three.js 的 Layers 本质是一个 32 位的位掩码。每个物体有一份掩码,每个相机也有一份掩码,两者按位与不为零,这个物体才会被这台相机渲染。Pascal Editor 把这套位掩码当成渲染分桶用,常量集中在 packages/viewer/src/lib/layers.ts:
| 常量 | 值 | 它负责什么 | 你什么时候会碰到它 |
|---|---|---|---|
SCENE_LAYER | 0 | Three.js 默认层,承载全部常规场景几何 | 写任何普通网格时,不用管,默认就在这层 |
OVERLAY_LAYER | 1 | 编辑器叠加物:操作手柄、移动把手、工具预览、光标网格、吸附辅助线 | 加一个「必须始终清晰、不能被描边或环境光遮蔽算法弄脏」的 UI 网格时 |
ZONE_LAYER | 2 | 区域的地面填充与墙体描边 | 画半透明的功能分区色块时 |
GRID_LAYER | 3 | 编辑器地面网格 | 需要被墙体正确遮挡的平面辅助物 |
SHADOW_ONLY_LAYER | 4 | 只投影不显形的几何:剖切/独显时被藏起来的屋顶与楼层 | 藏掉某层但希望它的影子还在时 |
配套代码位置:常量在 packages/viewer/src/lib/layers.ts,只投影不显形的施加与还原在 packages/viewer/src/lib/shadow-only.ts(applyShadowOnly / clearShadowOnly),文字说明在 wiki/architecture/layers.md。
这里有一个反直觉的设计选择,也是全篇最值得记住的一条。三维引擎里通常隐藏物体用 visible = false,但这个项目在隐藏楼层与隔离时刻意不用它,理由写在 packages/viewer/src/lib/isolation.ts 的注释里:visible 会级联,父节点隐藏,全部后代跟着消失;而图层掩码是逐物体判定的,渲染器在投影阶段跳过掩码不匹配的物体,但仍然会往下递归它的子节点。
这个差别直接决定了功能能不能实现。门是挂在宿主墙下面的子节点,如果你想「藏掉墙但留下门」,用 visible 做不到;用图层掩码就可以——把墙的 SCENE_LAYER 位关掉,门自己那份掩码不动,门照常渲染,位置也由矩阵链自动保持在原处。
同样的道理换个方向用,就得到了 SHADOW_ONLY_LAYER。applyShadowOnly 做两件事:关掉 SCENE_LAYER、打开 SHADOW_ONLY_LAYER。主相机从不启用第 4 层,所以物体在画面上消失;但投射阴影的光源的阴影相机启用了它,所以影子还在。效果就是你独显二楼时,三楼的楼板依然把阳光挡成窗户形状的光斑落在二楼地面上,而不是让二楼被均匀的日光冲白。要是用 visible = false,物体会被整个从阴影渲染里剪掉,这个效果就没了。
两处还原逻辑都用了同一个手法:把物体原来的 layers.mask 存进一个用 Symbol 作键的私有字段,清除时按原值写回。用 Symbol 是为了不跟任何节点类型自己的 userData 字段撞名。
三、第二套机制:楼层模式与隔离过滤
楼层模式的实现在 packages/viewer/src/systems/level/level-system.tsx,是一个每帧跑的系统,注册时给了较低的优先级,注释写明是为了让它排在其它系统的变换算完之后。
它每帧做的事很直白:遍历场景注册表里所有 level 类型的节点,算出每层的目标高度。爆炸模式下目标高度等于基础标高加上 index * EXPLODED_GAP,文件里 EXPLODED_GAP 是 5。然后用 lerp 把当前高度朝目标插值过去,所以你切换模式时看到的是楼层平滑滑开,而不是瞬移。
独显模式的处理有个细节值得抄:被藏的楼层不是一刀切。代码里判断,只有位于被独显楼层上方的楼层才走「只投影不显形」,下方的楼层直接 visible = false。理由写在注释里——楼下的楼层挡不住太阳,留着它进阴影渲染纯属浪费。
隔离过滤是另一件事,实现在 packages/viewer/src/lib/isolation.ts,对外暴露四个函数:applyIsolation、clearIsolation、collectIsolationSubtree、isIsolationActive,都从 packages/viewer/src/index.ts 导出。流程是:
collectIsolationSubtree拿一组节点 id,从场景注册表取出对应的对象,逐个traverse,把全部后代收进一个Set。注释特别说明这里走的是活的 Three.js 图而不是数据模型里的children数组——有些系统会挂出数据模型不记录的合成子网格,两者会对不上。applyIsolation遍历注册表里的每个根对象,凡是不在保留集合里的,递归关掉它的SCENE_LAYER位,但递归过程中遇到保留集合里的节点就停手——这正是「隔离一扇门,宿主墙藏掉、门留下」的实现。clearIsolation不记录改过谁,而是重新走一遍全部注册对象,见到存了原掩码的就写回、删掉标记。注释里说这么做是因为traverse便宜且幂等。
对外的接口有两个:<Viewer> 组件的 isolate 属性(AnyNodeId[] | null),以及通过 ref 暴露的命令式 setIsolated。属性走 effect,卸载时会清理;命令式那条是给临时场景准备的,比如按住不放的悬停隔离。packages/viewer/src/components/viewer/index.tsx 里还专门用一个 ref 记住最近一次施加的隔离,避免父组件卸载和外部调用者抢清理。
这两套机制的边界在 wiki/architecture/viewer-isolation.md 里划得很清楚:观察器包只放表现状态,levelMode、wallMode、cameraMode、主题、若干显示开关都在 useViewer;只在编辑器里有意义的状态(当前工具、阶段、编辑模式)必须留在 useEditor。观察器不许反向引用编辑器应用,编辑器要往画布里塞东西只能作为 children 注入。
四、这对 Agent 建模意味着什么
关键判断先说:MCP 服务器不控制这些看法。我在 packages/mcp/src 下搜过,levelMode 一次都没出现。也就是说,Agent 通过工具调用建完三层楼,它没有一个「切到独显看看二楼」的动作可用。楼层模式和隔离是给坐在屏幕前的人准备的。
Agent 的「看」是另一条路:packages/mcp/src/tools/ 下的 get-scene.ts、find-nodes.ts、describe-node.ts、scene-query.ts 这些结构化查询,加上 measure.ts、check-collisions.ts、validate-scene.ts 这类校验工具。它拿到的是节点树和数值,不是画面。换句话说,Agent 核对自己的工作只能靠能被程序读的结构化产物,不能靠眼睛。
有意思的是,这两条路在仓库里被显式接了起来。packages/mcp/src/tools/construction-tools.ts 里 create_roof 工具的描述原文是:默认在参考楼层之上创建一个专用的屋顶层,好让爆炸/独显楼层视图能把屋顶隔离出来。同样的意思还出现在 packages/mcp/src/prompts/scene-guidance.ts 的引导文案里,以及 packages/mcp/src/tools/scene-query.ts 的校验里——当某一层同时有屋顶几何和墙体/区域/楼梯时,校验会报一条问题,提示把屋顶放到专用屋顶层上,理由同样是为了独显与爆炸视图。
把这条链串起来看:Agent 自己看不见渲染结果,但它建模时的结构选择决定了人能不能看清。屋顶混在居住层里,模型数据完全正确,几何也不重叠,唯独人切到爆炸视图时会发现屋顶跟三楼的墙粘在一起拉不开。项目的处理办法不是事后修视图,而是把这个约束写进工具描述和场景校验里——工具描述本身就是喂给模型的上下文,这跟 工具描述怎么写 讲的是同一件事:描述里那句「好让独显视图能隔离屋顶」,是在替模型解释为什么要多建一层。
导出链路也依赖这套机制。packages/viewer/src/systems/level/level-utils.ts 里的 snapLevelsToTruePositions 会把所有楼层瞬间归位到真实叠放标高、全部置为可见,并返回一个还原函数。packages/editor/src/lib/glb-export.ts 和 packages/editor/src/components/editor/thumbnail-generator.tsx 都调它——缩略图那边还会先把 levelMode 临时改成 stacked、拍完再改回去,还原动作放在 finally 里,防止渲染失败把辅助物永久藏住。这条约束很实际:你当下的看法不能污染导出物。
五、边界与代价
这个设计放弃了一些东西,用之前得知道。
图层数量有上限,且是全局资源。 32 位掩码,已经占掉 5 个编号,wiki/architecture/layers.md 明确写了「不要硬编码层号」「不要在不同步更新这一页和后处理管线的情况下新增图层」。你想加一个自己的渲染分桶,要动的是全局约定,不是局部代码。
图层不级联,是双刃剑。 好处上面说了,代价是施加与还原都得逐物体走。applyShadowOnly 用 traverse 施加,applyIsolation 用自己写的递归以便中途保留子树,clearIsolation 更是每次都重走全部注册对象。场景大了这是实打实的遍历开销,而且楼层系统每帧都在跑重新施加,注释里说明是为了让隐藏期间被重建的网格(主题或贴图变化触发)能被重新藏住。
隔离状态没有被下游消费。 isIsolationActive 的注释写得很清楚,它是给那些「不能在局部视图下动作」的消费方准备的,举的例子是缩略图自动保存不该在隔离时截图。但我在仓库里只搜到它的定义和导出,没搜到调用方。也就是说这个防护点目前是留白的,你自己集成时别假设它已经生效。
文档和代码有一处不同步。 wiki/architecture/layers.md 说 apps/editor 暴露 EDITOR_LAYER,实际上这个常量定义在 packages/editor/src/lib/constants.ts,值是 OVERLAY_LAYER 的别名。结论没错,路径过时了。读这类高频迭代项目的架构文档时,路径一律回代码核。
它明确不管的事。 楼层模式和隔离只影响渲染,不改数据;不参与权限控制,隔离掉的节点仍然能被程序读到和改到;不做任何服务端过滤。别把它当成「给某个协作者只显示某层」的权限方案。
还有一类代价跟视图无关,但装之前必须知道:这个 MCP 服务器跑在你自己机器上,会读写一个本地 SQLite 数据库文件。路径解析逻辑在 packages/mcp/src/storage/sqlite-scene-store.ts 的 resolveDefaultDatabasePath 里,优先级是 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 通过 apply-patch、delete-node、save-scene 这些工具有权改它,undo / redo 是有的,但它是场景操作层的撤销,不是文件级备份。传输层在 packages/mcp/src/transports/ 下同时有 stdio.ts 和 http.ts,走 HTTP 就意味着开端口,谁能连上端口谁就能调这些工具,这条边界要按 MCP 安全边界 那套来管。另外 packages/mcp/src/lib/safe-fetch.ts 存在意味着它会按配置发起出网请求,白名单由环境变量 PASCAL_ALLOWED_ASSET_ORIGINS 控制,别默认它不联网。
六、上手与避坑清单
别用 visible = false 藏楼层。 为什么会踩:这是三维开发的肌肉记忆,一句话就写出来了。踩了会怎样:可见性级联,宿主墙一藏,挂在它下面的门窗全没;而且物体会被一并剪出阴影渲染,室内直接被均匀日光冲白。怎么避:藏东西走 applyIsolation 或 applyShadowOnly,它们改的是 layers.mask,不改 visible。
别硬编码层号。 为什么会踩:0、1 写起来比 import 快。踩了会怎样:后处理管线里各遍渲染的图层掩码是按常量配的,编号一旦被别处改动,你的网格会静默消失或者被描边算法弄脏,且不报错。怎么避:从 @pascal-app/viewer 引常量,编辑器侧引 EDITOR_LAYER。
别在观察器包里塞编辑器状态。 为什么会踩:写着写着发现某个特性要读当前工具,顺手 import 一下最快。踩了会怎样:只读观察路由和任何嵌入场景都会跟着崩,wiki/architecture/viewer-isolation.md 把这条列为禁止项。怎么避:照它的检查清单自问三句——这个特性在只读观察器里说得通吗?它引用了编辑器状态吗?能不能改成传属性或塞 children?
别让当前看法漏进导出物。 为什么会踩:你正在爆炸视图下检查布局,顺手点了导出或者触发了自动缩略图。踩了会怎样:导出的模型里楼层是拉开的,或者项目卡片上是一张半张脸的截图。怎么避:导出前调 snapLevelsToTruePositions 并在 finally 里执行它返回的还原函数,缩略图那条链路里还额外把 levelMode 临时压回 stacked,可以照抄。
让 Agent 把屋顶放到专用屋顶层。 为什么会踩:模型觉得屋顶属于顶楼,顺手挂上去,几何毫无问题。踩了会怎样:人切爆炸或独显视图时屋顶拉不开,等于丢了一个核对手段。怎么避:用 create_roof 的默认行为(它会自己建专用屋顶层),并让 validate_scene 那条「屋顶混在居住层」的提示进你的验收流程。
别指望 Agent 自己发现视图不对。 为什么会踩:把「建完自己检查」写进提示词,感觉就该没问题了。踩了会怎样:Agent 手里只有结构化查询和数值校验,它看不到画面,视图层的问题它天然发现不了。怎么避:把这类判断转成可查询的结构约束(哪些类型不该同层、哪些层该单独存在),交给 validate_scene 与人工抽查分工。
收个尾
一句话记住这篇:图层决定「这一遍渲染谁参与」,楼层模式与隔离决定「谁该被搬开或藏起来」,前者是机制,后者是策略。
给你一份可以当场跑的自检:切到爆炸视图,屋顶能不能跟顶楼拉开?切到独显二楼,二楼地面上有没有窗户形状的光斑(有,说明只投影不显形生效了)?隔离一扇门,宿主墙藏了而门还在吗?导出一次 GLB,模型里的楼层是不是叠放的?四条全过,这套机制在你的集成里就是活的。
接着读哪个文件,按这个顺序:packages/viewer/src/lib/layers.ts 看五个常量和各自的注释,packages/viewer/src/lib/isolation.ts 看不用 visible 的完整理由,packages/viewer/src/systems/level/level-system.tsx 看每帧循环,最后回 wiki/architecture/layers.md 和 wiki/architecture/viewer-isolation.md 对一遍约定——这两页在 wiki/architecture/ 的二十份 md 里(一份 README 索引加十九篇分主题),是跟这个话题最相关的两篇。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor 的地形系统与接缝处理 和 开源 3D 建筑编辑器 Pascal Editor 的材质与主题机制。