Pascal Editor 工具层拆解:开源 3D 建筑编辑器一次画墙要管住多少状态
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
看完 Pascal Editor 这套工具层,最该带走的判断是:所谓「工具」根本不是一个函数,而是一段有生命周期的临时状态;真正难的不是把墙画出来,而是在用户中途反悔、切走、双击、按 Esc、或者把终点扔到另一面墙身上的时候,把这堆临时状态干净地收回去。 这个开源项目把这件事拆成了两半——一个只管「现在该挂谁」的挂载器,和一批各自成篇、互不知情的绘制逻辑。这个切法对写 AI Agent 工具层的人有直接参考价值,因为 Agent 的工具调用同样面临「一次调用背后其实是一串带状态的操作」这个老问题。
先做个消歧:本文说的 Pascal Editor,是一个跑在浏览器里的开源 3D 建筑编辑器(仓库 README 把自己定位为「A 3D building editor built with React Three Fiber and WebGPU」,即基于 React Three Fiber 与 WebGPU 的 3D 建筑编辑器),和 Pascal 这门编程语言、和压强单位帕斯卡都没有关系。它同时自带一套 MCP 服务器,让 AI Agent 可以直接驱动同一份场景图建模。
站内已有几篇讲 Agent 工具设计的文章分工不同:Agent 工具设计的通用原则讲的是该怎么切工具边界,状态机还是自由发挥讲的是控制流该收紧到什么程度,工具调错了怎么办讲的是调用出错后的兜底;本篇不重复这些抽象讨论,而是拿一个能当场 clone 下来核对的真实仓库,把「一个成熟工具层长什么样」这件事逐文件摊开看。
一、这一层解决的问题:把「用户此刻在干什么」从工具里拿走
三维编辑器里的工具有个天然麻烦:同一时刻,画墙工具、移动工具、楼板边界编辑器、洞口编辑器可能都想响应鼠标。如果每个工具自己判断「我该不该干活」,判断条件很快会互相打架。
这里先把几个建筑名词交代清楚,后面会反复出现:楼板(slab)就是你脚下踩的那层水平结构板,在编辑器里是一个由若干顶点围成的多边形;吊顶(ceiling)是同样画法的顶面板,一上一下对称;洞口(hole)是在楼板或吊顶上挖掉的一块,楼梯井、天井都是这么开的;门和窗则是挂在墙上的附件(attachment),它们的位置不是世界坐标,而是「沿着这面墙走了多远」这样一个墙上的局部量——正因为如此,一旦这面墙被拆成两段,附件就必须被重新分配给其中一段并重算这个局部量,这是后面第四节的重头戏。
Pascal Editor 的做法是把这个判断集中到一个组件里。packages/editor/src/components/tools/tool-manager.tsx 里的 ToolManager 从 useEditor 读三个值——phase(阶段,比如 site 场地 / structure 结构 / furnish 布置)、mode(模式,比如 select / build / terrain-sculpt)、tool(当前工具),再叠加交互作用域里的一堆状态(useMovingNode、useEndpointReshape、useEditingHole、useReshapingNode 等),算出一串布尔量:showBuildTool、showMover、showSlabBoundaryEditor、showSlabHoleEditor、showCeilingBoundaryEditor、showZoneBoundaryEditor……然后按这些布尔量决定挂载哪些组件。
这个文件里几乎没有绘制逻辑。它只做三件事:算挂载条件、把工具组件放进正确的坐标系、顺带挂上几个共享的反馈图层。
坐标系那一步值得单独说。文件里所有跟建筑相关的工具都被包在一个 <group> 里,这个 group 直接取当前选中建筑节点的 position 和 rotation。这样一来,工具内部拿到的鼠标坐标天然就是「建筑局部坐标」——建筑本身在世界里被旋转过也不影响,工具不需要自己做一次逆变换。这是把一类高频 bug(旋转过的建筑里画出来的墙偏了)从工具代码里消掉的结构性手段。
ToolManager 里还有一段注释解释了为什么 2D 平面图发起的移动要专门屏蔽 3D 移动工具:两者会同时认领同一个节点,3D 那一方卸载时会把「认领时刻的位置」写回去,结果 2D 刚提交的移动被弹回起点。所以有了 movingNodeOrigin !== '2d' 这个门。这种注释在这个仓库里很多,基本都是踩过坑之后留下的。
二、注册表优先:工具是节点类型的一项「贡献」
早期的写法是一张手写映射表——tools: Record<Phase, Partial<Record<Tool, React.FC>>>,哪个阶段哪个工具对应哪个组件,全写死。现在这张表只剩四条(property-line、roof、stair、zone),文件里的注释直接标了这是 legacy fallback:墙、栅栏、楼板、吊顶、门、窗、家具、货架、出生点都已经改走注册表路径。
新路径是 getRegistryTool():拿当前 tool 名字去 nodeRegistry.get(tool) 查节点定义,如果这个定义带了 def.tool 这项贡献,就用 React.lazy 包出组件。也就是说,工具不再是「编辑器的一个功能」,而是「某个节点类型自带的一项能力」——想让新节点类型可画,就在它自己的定义里挂一个 tool,不用去改 ToolManager。
这里有个容易被忽略的细节,叫 preloadRegistryToolModules。它在真正加载工具组件之前,会先把这个节点类型的 def.preview(放置预览)、def.renderer.module(已提交节点的渲染器)、def.system.module(几何系统)、def.parametrics.customPanel(参数面板)、def.affordanceTools.move(移动工具)一并预热,用 Promise.allSettled 等齐。注释写得很直白:这是为了让「点击」这个动作本身保持同步——否则用户点下去的瞬间还在加载渲染器模块,就会看到一次卡顿。
这套预热还有配套的缓存:lazyToolCache 和 registryToolPreloadCache 都是 WeakMap,前者以 loader 函数为键,避免 React.lazy 在每次渲染时被重新调用。
除了主工具,还有一类「操作把手工具」(affordance tool)走 getRegistryAffordanceTool(kind, name) 查,名字包括 'boundary-edit'(边界编辑)、'hole-edit'(开洞编辑)、'move-endpoint'(端点拖动)、'curve'、'move-control-point'、'move-tangent'。这些都用 <Suspense fallback={null}> 包着挂载。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
ToolManager | 读阶段/模式/工具与交互作用域,算挂载条件,套建筑局部坐标系 | packages/editor/src/components/tools/tool-manager.tsx | 新增一类工具,或某个把手该出现却没出现 |
| 注册表工具查找 | getRegistryTool / getRegistryAffordanceTool 按节点定义取工具 | 同上,配合 packages/nodes/src/<kind>/definition.ts | 给新节点类型加放置能力时 |
| 画墙工具组件 | 两次点击的绘制状态机、预览盒、测量 HUD、链式续画 | packages/nodes/src/wall/tool.tsx | 改画墙交互、改预览外观 |
| 绘制与提交逻辑 | 网格/角度吸附编排、提交建墙、拆墙、门窗附件迁移 | packages/editor/src/components/tools/wall/wall-drafting.ts | 改吸附行为、改「终点落在别的墙上」的后果 |
| 纯吸附几何 | 端点/中点/交点/墙身的候选点计算,无 store 依赖 | packages/editor/src/components/tools/wall/wall-snap-geometry.ts | 调吸附半径、加新的吸附目标类型 |
| 架构约定文档 | 工具层的规则、反例与踩坑记录 | wiki/architecture/tools.md | 动手改这一层之前 |
| MCP 建墙工具 | 给 Agent 用的 create_wall,入参 levelId/start/end/thickness/height | packages/mcp/src/tools/create-wall.ts | 让 Agent 直接建模时 |
顺带说几个自己就能数出来的结构性事实,方便你估工作量:ls packages/ 有 9 个包,ls apps/ 有 2 个应用;packages/nodes/src/ 下有 46 个子目录,其中 shared/ 不是节点类型,所以内置节点类型是 45 种;packages/editor/src/components/tools/ 下是 11 个子目录加一个 tool-manager.tsx;wiki/architecture/ 有 20 份 md(1 份 README 索引 + 19 篇分主题)。全仓受版本控制的文件 2508 个,体量分布上 nodes 733、editor 445、core 233、mcp 152、viewer 107。根目录同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件,说明这个项目是把 AI 协作当常规开发方式在用的。
三、画墙这条线:一次拖拽到底要维护多少东西
packages/nodes/src/wall/tool.tsx 里的 WallTool 是个很好的解剖对象——它不是拖拽式(按下、拖、松开),而是两次点击:第一次点击定起点,第二次点击建墙,中间跟着鼠标画预览。文件顶部的注释直接说明了为什么不做成 DragAction:这是一串有状态的 grid:click 事件序列,不是一次 drag-up。
它通过全局事件总线接三个事件:grid:move、grid:click、tool:cancel,在 useEffect 里注册,在返回的清理函数里注销。整个状态机的核心其实只是一个 ref:buildingState,取值 0(还没定起点)或 1(起点已定、正在画)。
但围绕这个 0/1,实际要维护的东西是这些:
- 位置类 ref:
startingPoint、endingPoint两个Vector3;chainFirstVertex记住这一条链的第一个顶点(用来判断是否首尾闭合);chainWallIds记住本链已提交的墙 id(判断「接到已有墙」时要排除自己刚画的那几段)。 - 构造平面
constructionPlane:三维里画墙要先确定「画在哪个高度的水平面上」。鼠标指到一个抬高的平台,平面就抬到平台顶面;指过平台边缘,又落回地面。这个由resolvePointerSupportSurface/resolveEventConstructionPlane/resampleTerrainConstructionPlane一起解出来,之后整条链都冻结在这个平面上。 - React 状态:
draftMeasurement(长度与角度标注)、axisGuide(轴向辅助线)。这两个要触发重渲染,所以走useState而不是 ref。 - 四个外部 store:
useFloorplanDraftPreview(把草稿同步给 2D 平面图视图)、useAlignmentGuides(对齐参考线)、useWallSnapIndicator(吸附上以后显示的磁吸信标)、useSegmentDraftChain(发布链式续画的起点,让 2D 侧从同一点接着画)。 - 一次性的放置面发布:
publishPlacementSurface/publishHorizontalConstructionPlane,退出时要clearPlacementSurface()。
这些东西在 stopDrafting() 里被逐条清掉,在 useEffect 的卸载清理里又被清一遍。两份清单高度重合但不完全相同——这正是这类工具最容易漏的地方:新加一个 store 写入,很容易只在其中一处补了清理。
还有一层:WallTool 挂载时会读 useEditor 的 toolDefaults.wall。如果用户是从预设里点的「某种墙」,预设会先把厚度、高度、材质写进 toolDefaults,预览盒就按预设尺寸画,画出来的墙才和预览一致。卸载时用 useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), []) 把它清掉,免得下次手动画墙用到了上次的预设参数。
四、吸附与提交:从「鼠标在哪」到「墙在哪」的两次翻译
第一次翻译发生在每次 grid:move:原始鼠标点要变成一个「候选落点」。这一步在 wall-drafting.ts 的 snapWallDraftPointDetailed 里,逻辑分支比想象中多。
先解释一个前提:这个编辑器的吸附不是「按住 Shift 临时关掉」的老套路。wiki/architecture/tools.md 里写得很硬——吸附是常驻的模式(grid / lines / angles / off),Shift 是切换模式,Alt 才是那个「强制、无视吸附与碰撞」的临时键,Ctrl 切网格步长。文档明确禁止在工具里读 event.shiftKey 去绕过吸附。
在这个前提下,snapWallDraftPointDetailed 的分支是:
bypassSnap为真,原样返回。- 磁吸模式(
magnetic)下先做离散特殊点吸附findWallSpecialPointSnap,且是从原始鼠标点做的,理由写在注释里:如果先做一次网格量化,鼠标可能已经被推离墙角,强意图的角点吸附就被网格挡掉了。优先级上角点先赢,角点没吸上才在中点与交点里取更近的那个。半径也分级:角点WALL_ENDPOINT_SNAP_RADIUS = 0.7,中点WALL_MIDPOINT_SNAP_RADIUS = 0.5,交点WALL_INTERSECTION_SNAP_RADIUS = 0.5(单位是米)。 - 没吸上特殊点,才算基准点:角度锁开着就沿角度射线量化距离(注释里写的是 15° 射线,因为沿射线的距离是标量,世界系和局部系一致),否则做网格量化。
- 磁吸模式下再用
findWallSnapTarget试着吸到墙身(含曲墙——曲墙按弧长采样出候选点)。 - 非磁吸模式仍然保留一个极小的连接吸附:
WALL_CONNECT_SNAP_RADIUS = 0.05。注释解释得很清楚——这是「连通性」不是「对齐」,目的是让房间在grid/angles/off模式下也能闭合;它从已经按模式定好位的基准点出发,所以模式的定位规则一路被尊重,只有最后几厘米黏上去。
第二次翻译发生在提交:createWallOnCurrentLevel。这个函数干的事比「创建一个节点」多得多,整体被 runAsSingleSceneHistoryStep 包住——因为拆墙会产生好几次写入,一次 Ctrl-Z 不能只撤掉一半,留下一个断裂的墙网。
它内部做的判断依次是:
- 用
findWallIntersection找终点、起点是否落在某面已有墙的内部。捕获半径跟着模式走:磁吸模式用WALL_JOIN_SNAP_RADIUS = 0.35,其余模式用那个 0.05 的连接半径。 - 如果落点离那面墙的某个端点特别近(
WALL_SPLIT_ENDPOINT_EPSILON = 0.02),就直接归到那个端点,不拆墙。注释给了理由:在那儿拆会造出一段比最小长度只长一点点的碎墙,之后任何吸附半径都再也够不着它了。 - 真要拆的时候走
splitWallAtPoint:把原墙的所有属性复制给两段新墙,再重新解算地形支撑偏移(terrainSupportLift处理的是「地面不是平的」这件事——同一平面坐标对应一个地形高度,拆开后两段的起点地形高度不同,要重算supportOffset才能让两段维持在原来的绝对标高)。 - 拆之前先算门窗迁移方案
buildAttachmentMigrationPlan:遍历原墙的子节点,按每个门/窗/贴墙家具在墙上占的区间(getWallAttachmentSpan返回 min/max/center),判断它整体落在拆点的哪一侧,然后remapAttachmentToWall改父节点、改墙上局部坐标。如果有一个洞口正好横跨拆点,方案返回null,整个拆墙被放弃——这一步既不硬拆也不报错,只是不拆。 - 最后还有重复墙检查(起终点相同或相反的墙已存在就不建)、最小长度检查(
WALL_MIN_LENGTH = 0.01)。 - 建完之后再解一次「这面墙站在哪块楼板上」(
resolveWallSupportSlabPatch+spatialGridManager.getSlabSupportForWall),最后触发一次音效sfx:structure-build。
链式续画的终止条件也不止一个。除了用户双击(event.nativeEvent.detail >= 2)和连续性设置为 single,还有三个自动终止:链首尾闭合、chainEndJoinsExistingWall(终点接到了链外已有墙)、wallClosesRoom(这一段把房间围合上了,和自动生成楼板/吊顶共用同一套房间图,避免两边判断不一致)。
顺便看一眼 Agent 那一侧。packages/mcp/src/tools/create-wall.ts 里的 create_wall 入参只有五项:
export const createWallInput = {
levelId: NodeIdSchema,
start: Vec2Schema,
end: Vec2Schema,
thickness: measurement('length', 'm', {
positive: true,
description: 'Wall thickness.',
}).optional(),
height: measurement('length', 'm', { positive: true, description: 'Wall height.' }).optional(),
}
它做的校验是「这个 id 存在吗」「它是不是 level 类型」「它是不是屋顶支撑层(metadata.role === 'roof')」,然后 WallNode.parse 直接建节点。也就是说:Agent 路径和人手路径共享 schema 和场景图,但不共享工具层那套吸附、拆墙、附件迁移的编排。 Agent 给两个精确坐标建墙,不会顺手把挡路的那面墙拆开重连。写 Agent 建模流程的时候,这个差异必须先想明白,否则会以为「Agent 画的墙」和「人画的墙」在拓扑上等价。
五、边界与代价:这套设计明确不管的事
它放弃了「工具自己算几何」。 wiki/architecture/tools.md 的规则里写着 No business logic in tools——几何和约束规则要交给 core 里的系统,工具只负责采集输入、写 store。代价是链路变长:改一个吸附行为,可能要动工具、drafting、snap-geometry 三个文件。好处是几何逻辑可以脱离 React 单测——wall-snap-geometry.ts 顶部就注明了它没有 store / viewer / React 依赖,旁边就放着 wall-snap-geometry.test.ts。
它放弃了「统一的实时预览契约」。 文档里那张 useLiveTransforms 的表格是个诚实的坦白:同一个 store,position 字段的含义按节点类型不同——放置协调器写的是世界平面坐标,门窗移动工具写的是墙局部坐标,楼板/吊顶/栅栏这类多边形节点写的是位移增量。文档自己说了,消费侧因此要按类型分支,长期的正解是在写入侧统一,但现在还没统一。
它区分了两种实时预览,用错就是性能事故。 刚性位移用 useLiveTransforms;但墙这种「几何由数据字段重新算出来」的节点(拖端点会重新计算斜接——两面墙交角处端面要切成斜的,这个角度依赖两面墙的方向),没有刚性偏移可用,必须走 useLiveNodeOverrides,每帧发布变化的字段,几何系统合并后再算。文档把「每帧写 useScene.updateNodes」直接标成 blocker:它会换掉 nodes 这个 map 的引用,全应用订阅 useScene(s => s.nodes) 的组件(面板、HUD、提示、平面图、目录)每帧全量重渲染,帧率直接塌。
2D 与 3D 的一致性靠人工维护,没有机制保证。 文档把「2D 平面图和 3D 视图是同一次编辑的两种呈现」写成默认预期,还点名 {door,window}/move-tool.tsx 和 {door,window}/floorplan-move.ts 是刻意写成近似镜像的两份文件,让你有疑问时直接 diff。这是约定,不是编译器能拦住的东西。
性能上有明确的已知代价。 findWallIntersectionFromRaw 是对本层所有直墙做 O(n²) 两两求交,注释直接写了「编辑器规模下没问题」。这是把复杂度换可读性的自觉选择,但如果有人拿它跑大规模场景,这里会先出问题。
文档路径和代码路径已经对不上了。 wiki/architecture/tools.md 通篇写的是 apps/editor/components/tools/**,而实际代码在 packages/editor/src/components/tools/(画墙工具本身还进一步搬到了 packages/nodes/src/wall/tool.tsx)。文档描述的机制仍然准确,路径已经滞后——照着文档里的路径去 cd 会扑空。
MCP 服务器是要在你机器上跑真东西的。 按 packages/mcp/README.md:场景保存在本地 SQLite 数据库 ~/.pascal/data/pascal.db,可以用 PASCAL_DATA_DIR 换目录或 PASCAL_DB_PATH 指定具体文件;默认走 stdio,也可以用 --http --port 暴露在回环地址上;绑定非回环地址(比如 --host 0.0.0.0)时要求提供 PASCAL_MCP_HTTP_TOKEN 这个 bearer token。这几件事要连起来看:Agent 通过这套工具能创建节点、切洞口、删节点、撤销重做,写入的是你本地那个数据库文件;一旦把端口暴露到回环之外,能连上这个端口的人就能改你的场景库。工具目录下同时还有 export-glb.ts / export-json.ts 这类导出能力,意味着连上来的一方也能把整个场景取走。
六、上手与避坑清单
- 改工具行为前先确认它走的是哪条路径。 为什么会踩:
ToolManager里那张手写的tools表还在,看起来像是唯一入口,但墙/门/窗/楼板早就走注册表了,你在表里怎么改都没反应。怎么避:先在packages/nodes/src/<kind>/definition.ts里找有没有tool贡献,有就去那个包改。 - 加了新的 store 写入,一定要同时补两处清理。 为什么会踩:
WallTool的清理清单存在两份——stopDrafting()和useEffect的卸载返回。只补一处,表现是「按 Esc 正常,切走工具后辅助线还挂在屏幕上」,或者反过来。怎么避:改完把两处并排读一遍,逐条对齐。 - 别在工具里读修饰键来绕过吸附。 为什么会踩:这是绝大多数编辑器的惯例,手就先写出来了。怎么避:文档明确要求走
isGridSnapActive()/isMagneticSnapActive()/isAngleSnapActive()这条单一路径;网格步长也要写成const step = isGridSnapActive() ? gridSnapStep : 0这种被门控住的形式。 - 别把每帧预览写进场景 store。 为什么会踩:
updateNode就在手边,写起来最直观,本地小场景还看不出问题。怎么避:刚性位移走useLiveTransforms,字段驱动的几何走useLiveNodeOverrides,提交时再一次性写updateNodes,让整个手势成为一个撤销步骤。 - 提交处理器不能只监听地面的点击。 为什么会踩:R3F 的射线拾取会把点击派发给最近的那个 mesh,用户以为点的是地面,射线其实打在了墙面或家具上,工具就静默不响应了。怎么避:文档给了固定套路——把最近一次
grid:move的位置存进 ref,然后同时监听grid:click和一批${kind}:click事件,提交时读 ref 里的位置而不是点击事件自带的坐标(竖直面上的命中点可能离用户视觉上瞄准的位置差好几米)。 - 跟随鼠标的 mesh 要关掉射线拾取。 为什么会踩:被移动的 mesh 挡在相机和地面之间,射线先打中它,
grid:move就不再触发,位置快照冻在初始值,用户点了新位置却提交在原地。怎么避:拖动开始时遍历该 mesh 的所有后代把raycast覆盖成空函数,清理时还原;预览网格同理。 - SVG 里想要「看不见但可点」要用
fill="transparent"。 为什么会踩:直觉会写fill="none",看起来效果一样。怎么避:none不是绘制源,默认的pointer-events: visiblePainted不会命中内部区域,点击直接穿过去,节点选不中。 - 跑 MCP 服务器之前先决定数据放哪、端口开不开。 为什么会踩:默认路径
~/.pascal/data/pascal.db藏在家目录里,很容易忘了它的存在,也容易在多个项目之间互相污染。怎么避:显式设PASCAL_DATA_DIR(或PASCAL_DB_PATH)做隔离;非必要不加--http,要加就留在回环地址上;确实要跨机访问再配PASCAL_MCP_HTTP_TOKEN和--cors-origin。 - 别假设 Agent 建的墙和人画的墙拓扑一致。 为什么会踩:
create_wall只收两个端点,不做拆墙与门窗迁移,画出来的墙在几何上重叠、在拓扑上不相连。怎么避:Agent 侧建模后用仓库里的校验类工具(validate-scene.ts、check-collisions.ts这类)做一遍复核,别指望画出来就是对的。
结尾:接下来该读哪几个文件
如果你要把这套工具机制迁移到自己的项目里,读的顺序建议是这样:先 wiki/architecture/tools.md 看约定和反例,它里面记录的坑比代码更有信息量;再 packages/editor/src/components/tools/tool-manager.tsx 看挂载条件怎么算;然后 packages/nodes/src/wall/tool.tsx 通读一遍事件处理器,重点看 stopDrafting 清了什么;最后 wall-drafting.ts 和 wall-snap-geometry.ts 一起看,前者是编排,后者是纯几何,边界切得很清楚,也是这套设计里最值得抄的一刀。
如果你更关心 Agent 这一侧,packages/mcp/src/tools/ 目录下每个工具文件旁边都有同名的 .test.ts,入参 schema 和错误码写在一起,比读文档快。关于 MCP 工具本身该怎么设计,可以对照MCP Server 开发入门和工具入参校验怎么做一起看——create_wall 那三段前置校验(节点存在、类型是 level、不是屋顶支撑层)正好是「在 schema 之外还要补业务校验」的一个具体样本。
最后给一份自检清单,改完这一层之后逐条过:新工具在阶段/模式切换时会不会残留?stopDrafting 和卸载清理是不是同一份清单?每帧写的是 live store 而不是场景 store 吗?提交是不是一个撤销步骤?2D 那一侧的同名行为跟上了吗?这五条都过了,剩下的多半只是外观问题。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 选择机制:开源 3D 建筑编辑器的两层拆分 和 开源 3D 建筑编辑器 Pascal Editor 的墙体开洞与墙角斜接。