开源 3D 建筑编辑器 Pascal Editor 的墙体开洞与墙角斜接
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
在墙上开一个门洞,难的不是那次布尔减法,而是减法之前必须把墙自己的形状算对。 Pascal Editor 是一个跑在浏览器里的开源 3D 建筑编辑器(MIT 许可,Copyright 2026 Pascal Group Inc.;它跟 Pascal 编程语言、跟压强单位帕斯卡都没有关系),仓库 README 把自己定位成「用 React Three Fiber 和 WebGPU 做的 3D 建筑编辑器」。它把这两件事拆成了两层:一层在 packages/core 里纯算平面几何,把墙角相接的交点解出来;另一层在 packages/viewer 里做三维挤出和实体布尔运算。你要是只盯着”布尔减法”这四个字看,会觉得这不过是调一次库函数;真正吃掉工程量的,是它前后那一圈防御。
一、先说清楚这段代码在跟什么较劲
三个名词先摆平,后面不再解释。
挤出(extrude):给一个平面多边形一个厚度,让它沿垂直方向长成一个立体。墙就是这么来的——先在俯视平面上画出墙的占地轮廓,再往上挤出高度。
实体布尔运算(CSG):把两个立体当集合做加减交。开洞用的是减法,即用一个”洞形状的实体”去减墙这个实体,剩下的就是带洞的墙。它不是在贴图上抠个窟窿,是真的改三角网格。
斜接(miter):两面墙在墙角碰头时,各自把端头斜切一刀拼上,接缝呈对角线,就像相框四角那样。对立面是平口(butt joint),一面墙直接顶在另一面墙的侧面上。
Pascal Editor 的墙体几何入口是 generateExtrudedWall,在 packages/viewer/src/systems/wall/wall-system.tsx。它一次性把这些事做完:解算墙角轮廓、坐标系变换、挤出、收集所有要挖掉的东西、跑布尔减法、重算法线和材质分组。整个流程的分层是这样的:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 墙角斜接解算 | 找出墙与墙的交汇点,解出每面墙端头左右两侧的实际顶点 | packages/core/src/systems/wall/wall-mitering.ts | 墙角出现尖刺、缝隙或错位时 |
| 斜接结果缓存 | 按楼层缓存斜接解,避免每帧重算 | packages/viewer/src/systems/wall/level-miter-cache.ts | 大模型导入卡顿、或改了墙但接头没更新时 |
| 洞口轮廓生成 | 把门窗节点变成矩形/圆角/拱形的二维截面并挤出成实体 | packages/viewer/src/systems/wall/opening-cutout-geometry.ts | 洞口形状不对、圆角穿帮时 |
| 布尔运算基础件 | 封装 Brush、Evaluator、包围层次结构的构建与属性补齐 | packages/viewer/src/lib/csg-utils.ts | 减完之后网格发黑、UV 丢失时 |
| 墙体几何总装 | 上面几件按顺序串起来,产出最终的墙网格 | packages/viewer/src/systems/wall/wall-system.tsx | 几乎所有墙相关问题的落脚点 |
| Agent 开洞入口 | MCP 工具 cut_opening,让 Agent 直接往墙上挖洞 | packages/mcp/src/tools/cut-opening.ts | 你让模型改模型的时候 |
顺带提一个容易踩的命名坑:packages/viewer/src/systems/wall/wall-cutout.tsx 这个文件名里也有 cutout,但它跟开洞几何毫无关系。里面的 WallCutout 组件干的是另一件事——按相机朝向和 wallMode 切换墙的材质,让你从上往下看房间时前面那道墙变透明或消失,判定逻辑是导出的 getWallHideState。找开洞代码找到这个文件会白花时间。
二、墙角先算对,洞才有地方挖
generateExtrudedWall 拿到墙节点后,第一件事不是挤出,是调 getWallMiterBoundaryPoints 取端头四个角点,再交给 getWallPlanFootprint(在 packages/core/src/systems/wall/wall-footprint.ts)拼成俯视轮廓。这些角点从哪来?从 calculateLevelMiters 对整层墙做的一次全局解算里来。
解算过程值得看。它先找交汇点:把每面墙按包围盒扔进一张 2 米一格的均匀网格(JUNCTION_GRID_CELL = 2.0)做预筛,跨格超过 JUNCTION_GRID_MAX_CELLS_PER_WALL(64 格)的超长斜墙单独进一个兜底列表,跟每个交汇点逐一比对。判断两个点算不算同一个位置,靠的是 pointToKey 把坐标按 TOLERANCE = 0.001 量化成字符串——一米之内的一千分之一,够住宅尺度用。
到了每个交汇点上,代码把汇入的墙按出射角排序,然后对角度上相邻的两面墙,拿第一面的左边线和第二面的右边线求直线交点。行列式接近零(Math.abs(det) < 1e-9)说明两条边线平行,直接跳过,让墙退回默认的平口端头。
真正见功力的是后面那道限制。仓库里的注释写得很直白:斜接交点到交汇点的距离正比于 1/sin θ,θ 是两墙夹角;夹角一小,交点就跑到天边去,墙渲染出来是一根无限长的尖刺。触发这个场景不需要多刁钻,房间预设拖到已有墙上、或者随手画一面几乎平行的墙就够了。它的处理是设一个 MITER_LIMIT = 10,交点离交汇点超过 10 倍半墙厚就作废,退回平口。注释顺手算了笔账:0.1 米厚的墙靠这个上限仍能斜接到约 11 度。
T 形接头是另一条分支。一面墙的端点落在另一面墙中段时,被穿过的那面墙标记成 passthrough:它把自己正反两个方向都注册进交汇点,供别人对它斜接,但自己不接收交点数据——它的几何不变。这个决定很干脆,代价是 T 形接头两边不对称,被撞的那面墙保持直通。
斜接解算按楼层缓存在 level-miter-cache.ts 里,命中判断用的是逐字段精确比较(id、起点两个分量、终点两个分量、thickness、curveOffset),不做哈希。文件顶部给了理由:一次陈旧命中会静默地画错接头,而几个数值比较乘以墙数只是微秒级,比冒这个险便宜得多。同一份注释还记着一个真实场景——1081 面墙的导入,按每帧 8 面的节奏排下来要约 136 帧,这段时间里斜接解并不会变,naive 写法却每帧重算一遍。缓存是模块级的,所以 WallSystem 卸载时会调 clearLevelMiterCache,否则同一个标签页里重新挂载会一直握着上一批墙。
轮廓拿到后,代码把世界坐标转成墙局部坐标:x 沿墙长方向,z 是墙厚方向,用 THREE.ExtrudeGeometry 沿 Z 挤出高度,再 rotateX(-Math.PI / 2) 把挤出方向掰成竖直的 Y。源码注释特意标了一句:shape 的 y 要取局部 z 的负值,因为旋转之后 shape.y 对应的是 -z。这种坐标系的正负号是最容易在重构里被改错的地方。
三、洞的轮廓:三种形状,一套代码
洞口的二维截面由 buildOpeningCutoutShape 生成,输入是门或窗节点,加一个 left/right/bottom/top 的矩形框。函数文档明确说它是纯函数,矩形框用调用方自己的坐标系——墙系统传墙局部坐标,屋顶那条线传以原点为中心的坐标,同一套圆角和拱形数学服务两个宿主。
三种形状对应 schema 里的 openingShape 枚举,取值是 rectangle、rounded、arch。
矩形最简单,四条直线闭合。
拱形(arch)用 32 段折线拟合半椭圆:起拱线(拱开始往上弯的那条水平线,以下是直墙、以上是弧)在 top - archHeight,archHeight 被夹在 0.01 到洞高之间,每一段按 sqrt(1 - x²) 算出高度。折线而非真曲线,是因为后面要挤出成实体,段数直接决定了三角面数。
圆角(rounded)分门和窗两套规则,这是个建筑常识落进代码的地方:门只圆上面两个角(openingTopRadii),下面两角必须是直角,因为门要落地;窗四个角都能圆(openingCornerRadii)。半径来源看 openingRadiusMode,all 表示四角共用一个 cornerRadius,individual 表示逐角指定。逐角模式下还有一道 normalizeCornerRadii:相邻两角的半径之和不能超过对应边长,超了就整组等比缩小。这一步防的是用户把两个圆角都调到很大、圆弧互相穿插导致轮廓自交——自交轮廓喂给布尔运算,出来的结果不可预期。
挤出时 buildOpeningCutoutGeometry 还带一个细节:只有圆角形状才启用倒角(bevel),倒角尺寸取三个值里的最小者——openingRevealRadius、墙厚的 0.45 倍、cornerRadius 的 0.45 倍。这个倒角做的是洞口侧壁与墙面交界处的圆润过渡。挤出完成后 geometry.translate(0, 0, -depth / 2) 把实体挪到以中面对称。
同一套轮廓被 packages/nodes/src/shared/roof-wall-opening-cut.ts 里的 buildRoofWallOpeningCut 复用,用于挖在屋顶段侧墙面上的门窗。那边的挖切深度是 wallThickness * 2 + 0.04,注释说多出来的量是为了穿透两侧、又不至于碰到挑檐部位(挑檐指屋面伸出墙体外的那一圈,含檐口封板与封檐板,是屋顶几何自己的构造,不该被门窗洞切到)。这个函数对宿主是墙的洞口直接返回 null,把活交回墙系统——两条管线共享轮廓,不共享定位。
四、真正的减法,以及共面这个老对手
到 generateExtrudedWall 的后半段,要挖的东西被收进一个数组:底部台阶的挖切实体,加上 collectCutoutBrushes 收集的门窗和 item。数组为空时代码走一条完全不碰布尔运算的分支,直接返回挤出结果——一面没有门窗的普通墙不该为 CSG 付任何代价,这条早退很重要。
门窗的挖切实体由 createOpeningCutoutBrush 造,挖切深度传的是 wallThickness * 2,即墙厚的两倍。为什么不是刚好一个墙厚?因为刚好等于墙厚意味着挖切实体的两个端面和墙的两个表面完全重合,浮点误差下重合面是布尔运算最经典的病灶,结果可能出现残留薄片或者破面。留出富余,让相交是干净的穿透。
同样的思路还有一个更精细的版本。落地的门,洞底和墙底是同一个平面,这就是共面。getOpeningCutoutBottomPadding 判断洞底低于 0.005 米且底边是平的(hasFlatOpeningCutoutBottom),就把洞往下延长一个 OPENING_CUTOUT_BOTTOM_PADDING(0.02 米)。为什么加”底边是平的”这个条件?因为圆底轮廓一旦整体下移,圆弧的形状就变了;而圆底本来也不会跟墙底共面,不需要这个补丁。一个两行的函数,把”该补的补、不该动的别动”分得很清楚。
减法本身是串行的:拿到墙的 Brush,对每个挖切实体调一次 csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION),每一步之后立刻对新结果调 prepareBrushForCSG 重建包围层次结构,再把上一轮的中间结果释放掉。之所以每步都要重建,是因为下一次减法要拿这个结果当被减数,而 three-bvh-csg 依赖 three-mesh-bvh 的加速结构来找相交三角形。computeGeometryBoundsTree 里给的参数是 { maxLeafSize: 10 }。
csg-utils.ts 里最实用的是 ensureRenderableGeometryAttributes。布尔运算之后的网格属性经常不齐:法线数量对不上就删掉重算,重算再失败就整体填成朝上的法线;uv 缺了补零;uv2 缺了从 uv 复制一份。最狠的是退化分支——位置属性为空或者维度不对时,它清掉索引和分组,塞进三个零顶点顶上去。这不好看,但它保证了下游拿到的永远是一个能渲染的东西,而不是一个让整帧崩掉的空壳。做几何流水线的人应该都懂这种防御的必要性。
还有一处细节值得留意:csg-utils.ts 里导出了一个配置过的 csgEvaluator(useGroups 打开、consolidateGroups 关掉),而 wall-system.tsx 里另外新建了一个自己的 Evaluator,只设了 attributes。两个求值器配置不同,改动时别默认它们是同一个。
五、边界与代价:这套设计放弃了什么
放弃了实时的完美接头。 拖动墙端点时,被拖的那面墙每一帧都完整重建(DRAG_FLUSH_MS 那段注释写明是为了让墙跟手),但共享墙角的邻墙重建被推迟到脏数据流停下之后 80 毫秒才批量刷。视觉上就是拖动过程里邻墙的接头停在旧姿势,松手后才归位。代码把这个取舍称作标准 CAD 行为,并记了收益:T 形接头拖动约快 3 倍,四角房间约快 4 倍。你要的是”所见即所得”的话,这条不满足。
放弃了大模型的即时完整性。 脏墙超过阈值时进入渐进重建:每帧最多 MAX_WALL_REBUILDS_PER_FRAME(8)面墙,且有 WALL_PROGRESSIVE_TIME_BUDGET_MS(8 毫秒)的时间预算,超了就下一帧继续。好处是导入大模型不会锁死标签页,代价是墙一批一批地长出来。
T 形接头不对称。 被穿过的墙不改几何,只有撞上去的墙做斜接。这在多数户型里没问题,在需要两侧都收口的场景里不适用。
近平行的墙不做斜接。 MITER_LIMIT 卡死之后一律退平口。这是拿角落的视觉精度换取不出尖刺,是有意为之的降级,不是 bug。
布尔运算的开销随洞口数量线性增长且串行。 一面墙上八扇窗就是八轮减法加八次包围结构重建。这也是为什么”没有洞就完全跳过 CSG”那条早退分支存在。
它明确不管的事:不校验建筑规范,不算洞口对结构承载的影响,不判断门洞开在承重墙上合不合理。packages/mcp/src/tools/cut-opening.ts 里唯一的几何校验是墙长小于洞宽时报错。别把它当合规工具用。
六、上手与避坑清单
别去 wall-cutout.tsx 找开洞逻辑。 会踩是因为文件名有 cutout,直觉认为它就是主角。避法:开洞几何看 opening-cutout-geometry.ts 和 wall-system.tsx 里的 generateExtrudedWall,wall-cutout.tsx 里的是相机驱动的墙体显隐。
别把挖切深度改成刚好等于墙厚。 会踩是因为看到 wallThickness * 2 觉得浪费,想”优化”掉那一倍。避法:那一倍是在躲共面,改小了会在墙面上留下随机的破面和薄片,而且不一定每次都复现,调试成本极高。
别在圆底洞口上套用底部补偿。 会踩是因为只看到 getOpeningCutoutBottomPadding 判了”洞底接近零”,忽略了 hasFlatOpeningCutoutBottom 那个与条件。避法:改这段前先跑 opening-cutout-geometry.test.ts,里面对圆底窗和平底洞分别有断言。
改了斜接输入字段却忘了同步缓存的比较函数。 会踩是因为 sameMiterInputs 手写枚举了它关心的字段;你给墙节点加一个影响接头的属性,缓存会一直命中旧解,接头静默画错,而且不报任何错。避法:改斜接输入的同时改 sameMiterInputs,并且知道它是故意精确比较、不是哈希。
逐角圆角调大之后轮廓穿帮。 会踩是因为绕过了 normalizeCornerRadii 直接构造 shape。避法:所有轮廓都从 buildOpeningCutoutShape 走,别自己拼 THREE.Shape。
让 Agent 挖洞之前先看清它能碰什么。 MCP 工具 cut_opening 的入参是 wallId、type(door 或 window)、position、width、height,其中 position 是沿墙中心线的 0 到 1 参数化位置,工具内部转成节点存的墙局部米数。它直接在场景里创建节点,然后推送一次实时场景快照。也就是说模型说改就改,没有确认环节。场景数据落在本地 SQLite:packages/mcp/src/storage/index.ts 的注释写明默认路径是 ~/.pascal/data/pascal.db,可以用 PASCAL_DB_PATH 指定具体文件、或用 PASCAL_DATA_DIR 指定目录。传输层 packages/mcp/src/transports/ 下同时有 stdio 和 http 两种;用 http 就意味着本机开了一个端口,谁能连上谁就能改你的模型,别在不可信网络里裸奔。至于 Agent 该拿到多大权限、这类工具怎么设计返回值,本站另有专门讨论,可以配合看Agent 最小权限设计和MCP 的安全边界。
收个尾
这篇讲的是一个具体项目里一段具体几何代码的实现取舍;如果你想看的是让 AI 写脚本处理数据这类偏工作流的内容,去让 AI 写数据分析脚本,想看抽象层怎么划、工具怎么定义,去模型抽象层的组织方式和Agent 工具设计——本篇不重复它们,只补”真实项目里几何层长什么样”这一块。
想自己动手核对,建议按这个顺序读:packages/core/src/systems/wall/wall-mitering.ts 看墙角怎么解出来,packages/viewer/src/systems/wall/opening-cutout-geometry.ts 看洞的轮廓怎么生成,最后回到 packages/viewer/src/systems/wall/wall-system.tsx 的 generateExtrudedWall 看这两条线怎么合流。两个测试文件 opening-cutout-geometry.test.ts 和 wall-opening-cutout.test.ts 可以当规格说明看,后者的做法很值得学——它不截图比对,而是遍历生成的三角形,断言洞口范围内既没有墙面三角形也没有底面三角形,用可断言的几何性质代替肉眼验收。
仓库全局结构可以自己数一遍确认:apps/ 下 2 个应用,packages/ 下 9 个包,packages/nodes/src/ 下 46 个子目录(去掉不是节点类型的 shared/,实为 45 种节点类型),wiki/architecture/ 下 20 份 markdown(1 份 README 索引加 19 篇分主题),根目录同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件——这个项目对”让模型读懂自己”这件事是认真的。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 工具层拆解:开源 3D 建筑编辑器一次画墙要管住多少状态 和 开源 3D 建筑编辑器 Pascal Editor 的地形系统与接缝处理。