Pascal Editor 选择机制:开源 3D 建筑编辑器的两层拆分
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
在这个项目里,选择不是渲染层的附属状态,而是一层可以被整个替换掉的策略层——想读懂它,你得先接受一件事:选中一堵墙和选中一整层,走的根本不是同一条代码路径,也不由同一个组件决定。
先做个消歧:Pascal Editor 是一个开源的浏览器端 3D 建筑编辑器,跟 Pascal 编程语言无关,也跟压强单位帕斯卡无关。仓库在 https://github.com/pascalorg/editor ,MIT 许可(Copyright 2026 Pascal Group Inc.)。仓库 README 这样定位自己:一个用 React Three Fiber 和 WebGPU 构建的 3D 建筑编辑器。它还自带一套 MCP 服务器,让 Agent 直接在场景里建模——这件事对选择机制的影响相当反直觉,第五节会讲。
一、选择这件事到底难在哪
交互软件里选择难做,根子在于:你点到的那个东西,和你想选的那个东西,经常不是一个。
三维场景里更糟。射线拾取(raycast,从鼠标位置往场景里打一条射线,看它先撞上谁)撞到的是最近的一片三角面。那片面可能属于一堵墙的外表皮,但用户心里想的是”这个房间”;也可能属于屋顶的某一个分片,而用户想选的是整个屋顶。再叠上一层:同一个模型,在只读浏览场景里和在编辑器里,该选中什么完全不同。
这个仓库的处理是把问题拆成两个独立的问题,分别放在两层:
- 此刻谁有资格被选中——这是选择管理器(selection manager)的事。
- 点中一个之后会带出哪些别的——这是选择分组(selection group)的事。
两层不共享代码,也不在同一个包里。下面这张表是全景,路径都是仓库里实际存在的文件:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
视图层 SelectionManager | 按建筑层级逐级收权,只认视图状态 | packages/viewer/src/components/viewer/selection-manager.tsx | 只嵌只读查看器、不上编辑器时 |
编辑器 SelectionManager | 按阶段(phase)收权,认工具与模式状态 | packages/editor/src/components/editor/selection-manager.tsx | 改编辑器里任何点击行为时 |
| 选择路由纯函数 | 修饰键解析、选择代理解析、节点到阶段的映射 | packages/editor/src/lib/selection-routing.ts | 新增一种点击语义时 |
| 会话分组纯函数 | 建组、解散、扩展、活成员过滤 | packages/editor/src/lib/session-groups.ts | 调整 Ctrl+G 语义时 |
| 会话分组 store | 状态存储,以及跟视图、场景两个 store 的桥接 | packages/editor/src/store/use-session-groups.ts | 想让别的入口也能建组时 |
| 二维平面图背景命中 | 平面图空白处点击时的选择解析 | packages/editor/src/components/editor/floorplan-background-selection.ts | 动二维视图选择时 |
| 交互作用域 | selectionEnabled(scope) 决定此刻能不能选 | packages/editor/src/lib/interaction/scope.ts | 加新的交互模式时 |
架构文档里有几条写死的规则,先记住,后面每一节都在兑现它们:渲染器里不许有选择逻辑;useViewer 是选择状态的唯一真源,所有写入都得走 setSelection / resetSelection;悬停是一个独立标量,不属于选中集合。
二、视图层:一条从楼栋往下走的层级路径
视图层这个管理器只做一件事:把可选范围限制在当前层级的下一级。路径是 Building → Level → Zone → Elements。
几个建筑侧的名词先讲清楚。Building 是一栋楼;Level 是其中一个楼层;Zone 是在平面上用一圈多边形顶点围出来的一块区域,你可以理解成房间或功能分区,它在数据里就是一串二维坐标;Elements 才是具体构件——墙(wall)、门(door)、窗(window)、柱(column)、楼板(slab,人踩的那层水平结构)、吊顶(ceiling,房间顶上那层装饰性水平面,位置在楼板下方、抬头能看见的那一层)、屋顶(roof,以及它的分片 roof-segment——一面斜屋顶会被拆成若干片单独建模的坡面)、围栏(fence),以及家具(item)。
选择状态就是这条路径本身(下面这段是架构文档给的简化写法,实际源码里三个 id 用的是各自节点类型上的 id 类型,形状一致):
type SelectionPath = {
buildingId: string | null
levelId: string | null
zoneId: string | null
selectedIds: string[] // walls, items, slabs, etc.
}
getStrategy() 读这三个 id 谁为空,返回四种策略之一。每个策略都是同一副形状:能响应哪些类型、点中怎么办、点空怎么办、以及一个 isValid 判定。没有 buildingId 时只有楼栋可选;有楼栋没楼层时只有楼层可选;到楼层选中、区域未选中时只有区域可选;区域选中之后才开放构件级别。
真正有含量的是最后一档的 isValid。它不只判类型,还要调 isNodeInZone:这个节点得既属于当前楼层,又在几何上落在当前区域的多边形里。归属判定要绕一道——门窗和家具的父节点往往是墙,所以代码要先看父节点是不是墙、这堵墙在不在这一层;挂在吊顶、楼板、屋顶上的家具同理。几何判定优先从场景注册表里取该节点的三维对象、算出世界坐标再做点在多边形内的判定,取不到对象才回退到节点自身的数据字段。
不同构件的”算在区域内”标准也不一样:墙和围栏是两个端点任意一个落在区域内就算;楼板和吊顶是两个多边形互相有顶点落进对方就算;屋顶只要在同一层就直接算数。
还有一个容差常量 EDGE_TOLERANCE,值是 0.5 米。判定先做精确的点在多边形内检查,不中的话把区域多边形从质心向外整体放大 0.5 米再试一次。目的很实在:贴着墙边摆的东西,坐标可能刚好在区域边界外侧,没有这层容差就永远选不中。
点空处怎么处理也值得看。有一个专门的组件监听画布的 click 事件,用 requestAnimationFrame 等 R3F 的事件处理器先跑完,如果这次点击没被任何三维对象认领,才调当前策略的 handleDeselect。注意每一档的 handleDeselect 是往上退一级,不是清空——区域里选着几个构件时点空处,先清构件;再点一次才退回楼层。
两个特例读源码才知道。其一,进入、离开、点击三个回调开头都有一句对吊顶类型的直接 return,注释解释说吊顶只能通过平面图辅助和边界编辑的顶点手柄来选,直接点三维多边形必须让点击穿透到下面的家具或墙——因为吊顶几乎总是挡在最上面。其二,楼板在悬停时不写入悬停 id,描边同步时也被跳过,它不参与高亮。(这里的”描边”是三维里那种沿物体轮廓画一圈亮边的后期效果,用来表示”这个被选中了/正被指着”;同步的意思是把当前选中和悬停的那些三维对象塞进两个数组,交给后期处理去画。)
三、编辑器层:用阶段替换掉层级
编辑器不挂视图层那个管理器。它把自己的那个作为查看器的子组件注入进去,顶掉默认行为。这两个组件同名,但不是同一个组件配置出的两副样子,是两个文件。体量上也不在一个量级:视图层那个 480 行,编辑器那个 2318 行(wc -l 数的)。
编辑器的收权维度不是层级,是阶段:site(场地)、structure(结构)、furnish(软装)。structure 下面还有一个子层 structureLayer,取值 zones 或 elements——在 zones 时只有区域可选,在 elements 时结构类构件全开。
节点类型到阶段的映射写在 resolveNodeSelectionTarget 里,逻辑是分段的:楼栋归 site;区域归 structure 的 zones 子层;家具要看它的资产分类,分类是门或窗的归 structure 的 elements 子层,其余归 furnish;墙、围栏、柱、电梯、楼板、吊顶、屋顶、屋顶分片、楼梯、楼梯分片、出生点、门、窗这一串直接归 structure 的 elements;都不匹配的就去节点注册表查定义:注册表里压根没有这个种类的定义就返回空,等于这次点击没有对应阶段;查到了则看定义上的分类,是 furnish 归软装,否则兜底到结构。
点中一个不属于当前阶段的东西时会自动切阶段,双击则往下钻一级上下文。这一层不是 UI 上的分组标签,它实打实地决定了你此刻点得中什么。
还有两个开关容易被忽略。一个是交互作用域:选择和悬停只在作用域为 idle 时才有意义,判定函数是 selectionEnabled(scope)。正在放置、正在移动的时候,指针属于那个交互的主体,不该被选择逻辑抢走。另一个是选择代理:resolveSelectionProxyId 会把你点到的节点换成它的代理对象,屋顶分片换成整个屋顶就是这个机制。编辑器侧的 resolveCanvasSelectionNode 又给了一个反向出口——某个节点种类可以在注册表里声明 selectionProxy.bypassDirectPick,意思是”我有代理,但直接点我的本体时请选我自己”。
四、会话分组:一个从不裁剪成员表的设计
这是我觉得整套机制里最值得单独学的一块。
Ctrl/Cmd+G 从两个以上的选中项建一个会话分组,自动起名 Group N;Ctrl/Cmd+Shift+G 解散与当前选择相交的分组,选择本身保留;Alt+click 表示只选这一个成员、不要扩展。这些组不是场景图节点,也不写进项目 JSON,刷新即失。
最反直觉的一条:成员表从不因为删除而重写。删掉一个成员,存下来的成员 id 列表原封不动,每次读的时候拿当前场景的活节点 id 集合过滤一遍;活成员少于两个的组变成惰性的,而不是被移除。
理由在源码注释里写得很明白:会话分组不在撤销历史里。如果删除时顺手裁剪成员,那么删除再撤销之后,节点回来了但组关系没了;更糟的是组一旦跌破两人下限就被整个丢掉,没有任何路径能把它找回来。读时过滤还有一个很实在的工程副作用——删除节点的函数有多个调用方,采用读时过滤之后,没有任何一条删除路径需要挂钩子。
存储只在两种情况下整体清空:加载场景,以及进入版本预览。这两种情况下节点 id 全都换了,留着没有意义。
扩展逻辑被三条点击路径共用同一个入口参数 expandIdsForNode:三维点击走 resolveSelectedIdsForNodeClick,二维注册表层走 applyEntrySelection,二维背景命中走 resolveFloorplanBackgroundSelection。编辑器的选择管理器把 expandSessionSelectionForNode 传进去。优先级在这段代码里一目了然:
if (isSelectionModifierActive(modifierKeys)) {
const selectedIds = baseSelectedIds ?? currentSelectedIds
if (selectedIds.includes(nodeId)) {
return selectedIds.filter((id) => id !== nodeId)
}
return [...selectedIds, nodeId]
}
if (modifierKeys.alt) {
return [nodeId]
}
const expanded = expandIdsForNode?.(nodeId)
if (expanded && expanded.length > 1) return expanded
return [nodeId]
Ctrl/Meta/Shift 任意一个按下就是切换成员身份,不走扩展;只按 Alt 就是硬取单个;都没有才尝试扩展,而且扩展结果必须多于一个才采纳。扩展函数还有个细节:返回的数组会把你实际点的那个 id 顶到第一位(除非它本来就在第一位),这对下游那些以第一个选中项为锚点的操作有意义。
分组还有个近亲要分清。packages/core 里另有 collections:具名、带颜色、写进项目 JSON 的节点 id 集合,从检查器里管理。两者的分工是:会话分组用于”先把这六把椅子拴在一起,我把这个房间摆完”这种一次性场景,collections 用于你之后还会回来找的那种标记集合。文档里给的不合并理由很直白——给 Ctrl+G 加上持久化,等于一次误按就往所有人保存的项目里塞一个无名的 Group 4。
五、边界与代价:它明确不管什么
层级路径是硬编码的顺序,不能跳级。 没选楼栋就点不中楼层。对建筑场景这很合理,但”我就想一次框住全场所有插座”这类跨层诉求,视图层这套路径不为你服务。
可选类型目前是硬编码列表加运行时合并。 视图层维护一个写死的类型联合,另外在运行时把注册表里声明了可选中能力的种类合并进来。源码注释把这标注为过渡状态。那是维护者写在注释里的计划,不是对外承诺,读代码时按当前行为理解就好。
会话分组不进撤销历史,不进文件。 这是明确的取舍。代价是存储里会累积已删除节点的死 id,惰性组会一直躺着直到整体清空。数量级上无所谓,但如果哪天要给分组加持久化,读时过滤这套假设得从头重来。
MCP 那一层完全没有选择概念。 架构文档的层规则表里,MCP 包这一栏就是”否”;在 packages/mcp/src 下按 select 做不区分大小写的搜索,只命中 SQLite 场景存储那一处(实现文件与它的测试文件),而那是 SQL 的 SELECT 语句。这意味着 Agent 通过 MCP 工具改场景时,走的是按 id 操作的路径——建墙、放家具、打补丁、删节点,全程不经过任何”当前选中了什么”的状态。对你的直接影响是:别指望”我在界面上选好这几个,然后让 Agent 处理选中项”,这条链路在架构上就不存在,你得把 id 显式喂给它。
跑这套 MCP 服务器的代价要算清楚,它在你自己机器上落盘、并且可能开端口。
- 数据落在一个本机 SQLite 文件里,不是内存。源码注释写明的路径解析顺序是:
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。你的场景数据留在哪,取决于这条链上第一个命中的。 - 传输有 stdio 和 HTTP 两种实现。HTTP 默认监听
127.0.0.1;把它绑到非环回地址时,代码强制要求提供PASCAL_MCP_HTTP_TOKEN或者显式传入 authToken,否则直接抛错。跨源访问要用PASCAL_MCP_HTTP_ORIGINS逐条列白名单。这层强制是好事,但也说明另一半风险:一旦你自己配了 token 并把它开到局域网上,你开出去的是一个能建墙、能删节点、能导出场景文件的接口。 - Agent 的写权限是真的写。实时同步里那个发布快照的函数会把当前图存成草稿版本、再往场景事件流里追加一条给浏览器订阅者,还专门处理版本冲突。也就是说 Agent 改的东西会实时出现在你正开着的编辑器窗口里。
顺带说清本篇跟站内几篇的分工:这里拆的是一个交互软件内部的选择状态怎么分层,属于被操作的那一侧;Agent 自己的运行状态该收成状态机还是放开让模型自由决策,看状态机与自由裁量的取舍;Agent 会话里的上下文怎么裁怎么留,看上下文管理的工程做法;多个会话同时改一份东西怎么防冲突,看多会话并发的冲突处理。那三篇讲 Agent 这一侧,本篇讲它要操作的应用那一侧,而 Pascal Editor 恰好把两侧摆在同一个仓库里,对照着读正合适。上面那段端口与写权限的账,可以配合 MCP 安全边界一起算。
六、上手与避坑清单
架构文档里的编辑器管理器路径跟实际不符。 那份文档开头标注的适用文件写的是 apps/editor/components/editor/selection-manager.tsx,但在仓库里按文件名搜 selection-manager,只搜得到视图层和编辑器包两处,apps/editor 下没有这个文件。为什么会踩:你照文档路径打开发现不存在,会怀疑自己 clone 错了分支。怎么避:这个仓库迭代很快,架构文档的路径信息滞后于代码是常态,动手前先搜一遍文件名再打开。
给新节点类型加可选中,只改类型联合不够。 文档给的完整步骤是四步:类型加进可选中类型联合;渲染器调 useNodeEvents(node, type) 并把返回的处理器展开到网格上;在对应策略里加分支(视图层是层级档位,编辑器是阶段映射);渲染器里调 useRegistry,描边才找得到这个对象。为什么会踩:只做第一步,点击毫无反应,因为压根没有事件源;只做前两步,能选中但不高亮,你会以为是描边渲染坏了。
别把选择逻辑写进渲染器。 渲染器展开事件处理器就该收手。为什么会踩:某个类型的选择行为特殊,改渲染器看着最快。代价是同一种节点在二维和三维、在只读查看器和编辑器里会长出两套不一致的行为,而且二维注册表层和背景命中那两条路径根本不经过你改的那个渲染器。
别直接改选择状态字段。 全部写入走 setSelection / resetSelection。为什么会踩:直接 set 一个字段最省事。但 setSelection 带层级守卫——设了楼层 id 却没有楼栋 id 时会重置子级,绕过它就绕过了这道保护,你会得到一个楼栋为空、楼层不为空的非法路径。清空一律用 resetSelection()。
描边数组要就地改,不要换新数组。 那两个数组是把长度置零再往里 push 的,这是性能考虑。为什么会踩:写 React 的手会习惯性地赋一个新数组上去。那样引用就变了,握着旧引用的另一侧再也收不到更新,表现是”选中了但描边不动”。
悬停不是选中集合的一部分。 它是独立的字符串标量,用专门的 setter 更新。为什么会踩:想加”悬停也高亮”,顺手往选中数组里塞一个 id。塞进去之后所有以选中集合为输入的下游逻辑——多选面板、批量移动、分组判定——全会把它当成真选中。
改了选择手势,记得同步浮动助手和快捷键弹窗。 文档明确点名了那个浮动助手组件,它按当前选择状态和按住的修饰键镜像这套规则。为什么会踩:手势改完自测通过,但提示还是旧的,用户照着屏幕上的提示按,按了没反应。
新增点击入口时记得接上分组扩展。 三条既有路径共用同一个扩展参数。为什么会踩:新入口只写了”点谁选谁”,于是同一个分组在三维视口里点会整组亮起、在二维平面图里点只亮一个,这种不一致极难从现象倒推到原因。
收尾:按这个顺序读,一天够了
想自己把这套机制吃透,按这个顺序读最省力:先读架构文档里选择管理器与选择分组那两篇建立心智;再读视图层的 selection-manager.tsx,480 行一次能读完,getStrategy 是全篇核心;接着读 selection-routing.ts,194 行纯函数、不含 React,是最容易写测试验证的一块;再读 session-groups.ts,201 行同样是纯函数;最后才碰那个 2318 行的编辑器选择管理器,那时候你已经知道该往哪看了。
排查选择类 bug 时可以固定问三句:这次点击此刻谁有权认领(作用域是不是 idle、当前档位或阶段放不放行)?认领之后要不要换成代理(是不是分片该换成整体、有没有声明本体直选)?换完之后要不要扩展成一组(有没有会话分组、按没按修饰键)?三句问完,你基本能定位到是哪一层的问题,剩下的就是打开对应那个文件的事。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 开源 3D 建筑编辑器:新增构件只写一份节点定义 和 Pascal Editor 工具层拆解:开源 3D 建筑编辑器一次画墙要管住多少状态。