开源 3D 建筑编辑器 Pascal Editor 的空间查询机制拆解

2026-08-05

本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。

这套空间查询真正省下的不是碰撞计算本身,而是「哪些东西可能相关」这一步的搜索范围;而读代码时最容易踩的坑是——索引建好了,但相当一部分查询并没有走它。 这里说的 Pascal Editor 是一个跑在浏览器里的开源 3D 建筑编辑器(仓库 README 把自己定位为 “A 3D building editor built with React Three Fiber and WebGPU”),和 Pascal 编程语言、和压强单位帕斯卡都没有关系。它同时自带一套 MCP 服务器,让 AI Agent 能直接建模。许可证是 MIT(Copyright 2026 Pascal Group Inc.)。

一、问题是什么:每次指针移动都要回答一遍「这里放得下吗」

先把三个建筑侧的名词说清楚,后面才好读。

  • 楼板(slab):你脚下那块水平的板,既是这一层的地面,也是下一层的顶。在这个项目里它是一个带多边形轮廓、带标高、可以挖洞的节点。
  • 吊顶(ceiling):房间顶上那层吊装面,射灯、风口这类构件挂在它上面,同样用多边形描述。
  • 净空 / 净距(clearance、keep-out):门前、通道里必须留出的一块空地,家具不许占。它不是几何碰撞——家具和门扇根本没接触,但门推不开,一样算问题。

编辑器里的交互是这样的:你从素材面板拖一个沙发进场景,指针每移动一次,屏幕上那个半透明的「幽灵」就要变红或变绿。这意味着每帧都要回答一次「这个位置和已有东西撞不撞」。朴素做法是遍历整个场景的所有节点两两比一遍,场景一大,指针一动,主线程就卡在这上面。

空间索引解决的就是这个:把平面切成格子,先按格子把候选缩小到十几个,再对这十几个做精确判断。这属于**广相 / 窄相(broad phase / narrow phase)**这个经典分工——先粗筛,再精算。

这篇讲的是几何索引与增量同步这条链路。它和站内另外几篇的分工是:RAG 混合检索讲的是文本与向量侧的召回怎么合并,AI 缓存策略讲的是结果复用与失效判定,Agent 工具设计讲的是工具契约本身怎么写——本篇不碰这三件事,只沿着一个真实开源仓库的代码,把「空间上的东西怎么被索引、怎么被查」讲透。

二、网格本体:格子筛候选,包围盒下结论

核心那个类叫 SpatialGrid,全文不到两百行,在 packages/core/src/hooks/spatial-grid/spatial-grid.ts。它内部只有三张 Map:

  • cells:格子键 → 这个格子里有哪些构件 id。键的类型就是字符串模板 `${number},${number}`,也就是「列号,行号」。
  • itemCells:构件 id → 它占了哪些格子(反向查,删除时用)。
  • itemBounds:构件 id → 它真实的轴对齐包围盒(AABB),也就是一个用 minX/maxX/minZ/maxZ 描述的正立矩形。

格子大小由构造参数 cellSize 决定,SpatialGridManager 传的默认值是 0.5,源码注释把它类比成模拟人生那种半格。

旋转怎么处理?它不做真正的有向包围盒,而是取一个保守的外接矩形

const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot))
const rotatedW = w * cos + d * sin
const rotatedD = w * sin + d * cos

一张 2×1 的桌子转 45 度,算出来的外接矩形边长约 2.12,比它实际占的地方大。这是刻意的取舍:宁可框大一点、宁可多筛几个候选,也不引入多边形求交的复杂度。

canPlace 就是两段式:先把 AABB 覆盖到的格子里的 id 全收进候选集(跳过 ignoreIds),再对候选逐个做真正的矩形重叠判断。窄相这段有个细节值得记住:

const EPSILON = 1e-4 // tolerance to allow touching
// ...
if (
  bounds.minX < other.maxX - EPSILON &&
  bounds.maxX > other.minX + EPSILON &&
  bounds.minZ < other.maxZ - EPSILON &&
  bounds.maxZ > other.minZ + EPSILON
) {
  conflicts.push(id)
}

减掉这个容差,意思是紧贴不算冲突。两个柜子边对边并排放是合法的,浮点误差导致的零点零零几毫米交叠也不会误报。做家装类编辑器,这条几乎是必需品——不留容差,用户永远拼不上两个柜子。

墙面是另一套结构。WallSpatialGrid(同目录 wall-spatial-grid.ts)根本不是网格,而是按墙 id 分桶的区间表:每个挂件记一段沿墙的参数区间 tStart/tEnd(0 到 1,表示从墙起点走了多少比例)和一段高度区间 yStart/yEnd,查询就是在这面墙的桶里做一维区间相交。它的容差是 EPSILON = 0.001,比地面那套松一个量级。它还多做一件事:如果挂件顶出墙高或沉到地面以下,autoAdjustYPosition 会把它按 AUTO_SNAP_MARGIN(0.05)吸回边界内,并把调整后的高度回传。

三、同步层:索引凭什么和场景保持一致

索引最怕的是和真相脱节。这个项目的做法是一个显式的订阅函数 initSpatialGridSync(),在 spatial-grid-sync.ts 里,编辑器挂载时调用一次(packages/editor/src/components/editor/index.tsx),返回一个取消订阅的函数,卸载时调用,避免单例攥着旧场景不放。

它做两件事:启动时把当前所有节点灌进管理器;然后订阅场景 store,每次状态变化做三轮差分——新增的、消失的、以及判定为「实质变了」的

第三轮的判定条件比想象中挑剔。构件只有位置、旋转、缩放三个数组之一不相等,或者父节点、所贴墙面侧(side)变了,才重新入索引;缩放变了还会额外标脏一次,因为脚印尺寸跟着变,楼板标高得重算。楼板则是这样比的:

const supportChanged =
  node.polygon !== prev.polygon ||
  node.elevation !== prev.elevation ||
  node.holes !== prev.holes

注意是 !==,比的是对象身份而不是内容。这依赖「store 按约定不可变」这个前提:任何一次真实修改都会产出新数组。文件里另一处注释把这个理由写得很直白——一次地形雕刻必然产生新的对象,而一次不相干的编辑会保留旧的。代价是:谁要是原地 mutate 了那个数组,同步层就当无事发生。

墙的改动(起终点、弧度偏移、厚度)也要送进管理器,理由不是墙自己进了索引,而是渲染用的楼板轮廓会吃掉墙体那条带,墙一动,那个缓存就得作废。

同步层还负责另一半工作:把受影响的节点 markDirty,让下一帧的系统重算它们的高度。楼板挪了,压在上面的柱子、栏杆要跟着升降;地形被雕刻了,那一层所有落地构件都要重新贴地。这部分逻辑在同一个文件里,占了大半篇幅。

组成部分它负责什么仓库位置你什么时候会碰到它
SpatialGrid平面格子索引 + 包围盒记录,提供插入/删除/更新packages/core/src/hooks/spatial-grid/spatial-grid.ts想搞清楚候选剪枝到底怎么筛的时候
WallSpatialGrid按墙分桶的区间表,管门窗与墙面挂件、含高度吸附packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts排查「这堵墙上放不下」为什么误判
SpatialGridManager单例,按层/按吊顶分桶持有多张索引,暴露三个校验方法与楼板标高查询packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts绝大多数调试的落点
initSpatialGridSync订阅场景 store 做增量同步,并标脏受影响节点packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts索引和画面对不上时第一个查这里
useSpatialQueryReact 侧的薄封装钩子,转发给单例packages/core/src/hooks/spatial-grid/use-spatial-query.ts写放置工具时的实际入口
MCP 侧净空实现Agent 用的门前净空与构件重叠检查,另一套代码packages/mcp/src/tools/layout-clearance.tsdoor-clearance.ts发现 Agent 的判定和编辑器不一致时
架构说明三个校验方法的签名与调用约定wiki/architecture/spatial-queries.md动手前先看这份

顺带一个可以自己数的结构性事实:这个仓库归版本控制的文件共 2508 个,apps/ 下 2 个应用,packages/ 下 9 个包,其中 nodes 733 个文件、editor 445、core 233、mcp 152、viewer 107;packages/nodes/src/ 有 46 个子目录,除去不是节点类型的 shared/,节点类型是 45 种;wiki/architecture/ 有 20 份 md(1 份 README 索引加 19 篇分主题)。这些都是 ls 一遍就能复现的数字。仓库根目录还同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件。

四、查询入口,以及 Agent 走的其实是另一条路

React 侧的入口只有一个钩子:

const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()

三个方法都返回 { valid, conflictIds }canPlaceOnWall 额外返回一个 adjustedY(吸附后的高度)。钩子本身没有任何逻辑,三个 useCallback 原样转发给单例。架构文档给的约定是:每次指针移动都调用以驱动幽灵变色,只在抬手时才真正写节点。

到这里为止都很顺。但如果你按名字推断「三个方法都吃网格」,就会推错。翻开 spatial-grid-manager.ts 会看到:

  • 地面校验 canPlaceOnFloor 转给 canPlaceOnFloorFootprints,它遍历整个场景节点表,按节点注册表里的 capabilities.floorPlaced 能力位挑出会碰撞的种类(构件、搁板、柱子、柜体、楼梯都算),逐个比包围盒。好处是加一种新的落地类型不用改这段代码,代价是它压根没查格子。
  • 吊顶校验 canPlaceOnCeiling 同理:先判四个角是否都在吊顶多边形内、中心是否落在洞里,再遍历节点表找同一个吊顶下的构件。
  • 只有墙面校验真正吃了分桶结构,而且吃完之后还会再遍历一次节点表做同墙复核。

再往下查一层:SpatialGrid 自己的三个查询方法 canPlacequeryRadiusgetItemCount,在整个仓库里 grep 不到任何调用方,只有定义。也就是说,索引的写入路径(插入、更新、删除)一直在维护,读取路径目前基本闲置。这不是什么丑闻,是活项目里很常见的状态——结构先立住,查询侧还没全部切过来。但你要是照着「已经有网格了所以查询是常数级」去做性能假设,就会判断错。

Agent 那条路更是完全独立的一套。MCP 服务器是个 Node 进程,不在浏览器里,拿不到 React 的单例:

  • check_collisions 工具调的是 findItemItemCollisions,双重循环两两比包围盒,是彻底的平方级。源码里那段注释还专门解释了阈值选择:这个工具传 gap: 0,因为它的契约是报告真实重叠;而默认值 DEFAULT_ITEM_GAP(0.08 米)是 furnish_room 布置家具时想要的呼吸间距,用在这里会把只是靠得近的家具误报成碰撞。
  • 门前净空是 MCP 独有的:door-clearance.ts 里按 DEFAULT_DOOR_CLEAR_DEPTH(0.65 米)向门洞两侧各推出一块矩形禁区,沿墙方向再各留 DEFAULT_DOOR_SIDE_PAD(0.05 米),家具压上就报「门被挡住」。编辑器那套空间索引里没有这个概念。
  • MCP 从核心包借的只是纯几何函数:find-nodes.tspointInPolygon 做区域过滤,scene-query.tscomputeWallSlabSupport 算墙的支撑标高,都是从 @pascal-app/core/spatial-grid 这个子路径导出的。这个子路径本身是为 Node 环境专门加的——主入口会连带拉起一堆图形依赖,在没有浏览器的环境里直接崩。

仓库里的 packages/mcp/PR_DESCRIPTION.md 把这件事写在后续项里:surface real spatial-grid collision detection(currently a simple AABB pass in check_collisions)。所以现状是维护者自己知道并记录在案的,不是外部读者「发现」的问题。

既然要在自己机器上跑这个 MCP 服务器,有三件事得摆在明面上。一是数据落在哪:场景存进 SQLite,路径按优先级解析——PASCAL_DB_PATHPASCAL_DATA_DIR/pascal.db、Windows 下 %APPDATA%/Pascal/data/pascal.db$XDG_DATA_HOME/pascal/data/pascal.db,兜底是 $HOME/.pascal/data/pascal.db。共用机器上不显式指定,方案就静静躺在个人目录里。二是端口:除了标准输入输出,它还有 HTTP 传输,测试里客户端直接连 http://127.0.0.1:<port>/mcp 就能列出全部工具,没有出现任何凭据参数——绑到 0.0.0.0 意味着同网段的人也能改你的模型。三是权限面:Agent 手里的工具包含 create_roomadd_dooradd_windowfurnish_roomplace_itemdelete_nodeapply_patch 以及 undo/redo,也就是说它能建、能删、能整体改写场景。素材抓取那条路上有个 safe-fetch,支持用 PASCAL_ALLOWED_ASSET_ORIGINS 配置来源允许清单,默认之外的出网请求这一层是有闸的。这类边界怎么划,MCP 安全边界那篇讲得更系统。

五、边界与代价:它明确不管什么

它是平面的,不是立体的。 SpatialGrid 存的包围盒只有 X 和 Z,Y 被整个丢掉了。桌上放灯、地毯上放沙发这类竖向关系不靠它——地毯这种低矮面被专门豁免掉,不当障碍物;竖向靠的是另一套「支撑选举」逻辑:按脚印找出所有压住的楼板,取标高最高的那块当宿主,还要考虑指针实际指向哪个面作为上限。这是完全独立的一条代码路径。

旋转判定是保守的,会误报。 前面那个外接矩形,在 45 度时膨胀最多。两件斜放的家具明明擦身而过,判定可能说撞了。想要精确,得换有向包围盒或多边形求交,这个项目没走那条路。

它不是通用碰撞引擎。 没有凸包、没有实体布尔运算(把两个三维实体做加减交的运算),也不做连续碰撞。它只回答「平面上这块地被占了没」。

它是单例加全局订阅,不是纯函数服务。 一个进程一份状态,靠订阅场景 store 活着。写单元测试要显式起同步、用完取消。想在服务端并发跑多个场景,这个结构直接用不了——MCP 那边另起炉灶,一部分原因就在这。

缓存有明确的绕行条件。 楼板的渲染轮廓按 slab id 缓存,但只要这一层有构件正处在拖拽预览中(存在实时覆盖或实时变换记录),查询就绕过缓存重算。理由写在注释里:预览期间场景 store 还没提交,用缓存会拿旧脚印去选支撑,画面上会看到东西整体掉到地面。

它明确不管合规。 防火分区、疏散宽度、采光比这些规范层面的东西,一概不在这套机制里。门前净空那 0.65 米是 MCP 侧写死的默认值,是个工程上的经验参数,不是引用了哪本规范。

六、上手与避坑清单

忘了把自己放进 ignoreIds 拖动一个已经在场景里的构件时,索引里还有它自己那份记录,于是它和自己重叠,valid 恒为 false,幽灵永远是红的。做法是把 [item.id] 传进 ignoreIds;管理器内部会顺着 children 把子节点一起加进忽略集,所以拖一整组家具也不用手动展开。

拿光标原始高度而不是 adjustedY 墙面校验会把顶出墙高或沉进地面的挂件吸回来,并把结果放在 adjustedY 里返回。你写回节点时如果用的是原始的 Y,画面上就是画一个位置、存另一个位置,下次打开场景才发现歪了。约定是:写回一律用返回值里的那个。

用素材原始尺寸而不是缩放后尺寸。 一个柜子被放大到 1.5 倍,你还按素材声明的宽深去校验,判定会说放得下,渲染出来却和邻居穿模。架构文档专门点名要用 getScaledDimensions(item)

原地改数组以为能触发重算。 同步层比的是对象身份,不是内容。你把楼板多边形数组 push 一个点,引用没变,这次改动对索引不存在。所有修改都得产出新对象。

假设地面校验是常数级开销。 它现在扫全量节点表,而且是每次指针移动都扫。小场景无感,大场景要自己量。别因为目录名带 grid 就相信复杂度,先测一遍再决定要不要优化。

把 MCP 的检查当成编辑器同款。 两套代码、两个阈值:check_collisions 用 0,furnish_room 用 0.08,门前净空只有 MCP 有。Agent 说「没冲突」不等于在编辑器里拖得进去;反过来也一样。工具返回值要怎么写才不会让模型误判,可以对着Agent 工具参数校验那套思路过一遍。

长期跑 MCP 却不知道数据库在哪。 默认落到用户主目录下的 .pascal/data/。团队共用机器、或者你希望方案跟着项目仓库走,就显式设 PASCAL_DB_PATH,别等到要迁移时满盘找。


按顺序读的话,这条链路是:先看 wiki/architecture/spatial-queries.md 拿到三个方法的签名与调用约定,再看 use-spatial-query.ts 确认它确实只是转发,然后直奔 spatial-grid-manager.ts 里的 canPlaceOnFloorFootprints —— 那是地面校验的真实实现,也是整套机制里认知落差最大的一段;接着回到 spatial-grid-sync.ts 弄明白索引靠什么保持新鲜;最后如果你关心 Agent,跳到 packages/mcp/src/tools/layout-clearance.ts,那是另一套完全独立的判定。

给自己留三个可验证的问题:这次查询走的是索引还是全量扫描(在管理器里跟一遍调用链就知道)?两个贴边的构件在这套容差下算不算冲突(1e-4 与 1e-3 分属两套结构)?Agent 报的「没问题」和编辑器算的「没问题」,用的是不是同一段代码(不是)?把这三个答案落到具体行号上,这套机制你就算读明白了。

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor 的 systems 机制:渲染器只占位,几何靠脏节点批量重建开源 3D 建筑编辑器 Pascal Editor 的插件机制:内置节点自己也是一个插件

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