diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 49cb9b8106..038633f00e 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -52,7 +52,31 @@ const WallRenderer = ({ node }: { node: WallNode }) => { } }, [collisionPlaceholderGeometry, placeholderGeometry]) - const handlers = useNodeEvents(node, 'wall') + const rawHandlers = useNodeEvents(node, 'wall') + // Hidden walls are pointer-TRANSPARENT: when the wall-mode pass hides this + // wall (`WallCutout` stamps `userData.wallHidden` — X-ray 'down' mode, + // cutaway-hidden faces, auto-mode interior partitions), its invisible + // full-height collision mesh must not swallow pointer events aimed at + // visible objects behind it (wall-mounted plugin nodes, items). Returning + // early without stopPropagation lets R3F continue to the next intersection. + // Delete mode keeps the events so hidden walls stay hover-targetable for + // deletion (the deleteInvisible highlight flow). + const handlers = useMemo(() => { + const gated = {} as typeof rawHandlers + for (const key of Object.keys(rawHandlers) as (keyof typeof rawHandlers)[]) { + const fn = rawHandlers[key] as (e: unknown) => void + ;(gated as Record void>)[key] = (e: unknown) => { + if ( + ref.current?.userData?.wallHidden === true && + useViewer.getState().hoverHighlightMode !== 'delete' + ) { + return + } + fn(e) + } + } + return gated + }, [rawHandlers]) const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 92af85f31d..5ab0574a1d 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -164,6 +164,19 @@ export const WallCutout = () => { if (wallNode?.type !== 'wall') return const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) + // Pointer transparency for hidden walls: the wall's full-height + // collision mesh keeps raycasting even when the wall draws with the + // invisible material ('down' mode, cutaway-hidden faces, auto-mode + // interior partitions), so it silently swallows clicks aimed at + // VISIBLE objects standing behind it — e.g. a plugin's wall-mounted + // device/service boxes in X-ray mode (night-5 D4: the arm click on a + // south-wall receptacle selected an invisible wall two meters in + // front of it instead, and the follow-up click committed a WALL + // move). The wall renderer's pointer handlers read this stamp and + // pass hidden walls through (delete mode excepted — hidden walls + // must stay hover-targetable for deletion). Translucent walls are + // visible, so they keep their events. + ;(wallMesh as Mesh).userData.wallHidden = wallMode !== 'translucent' && hideWall const isDeleteHighlighted = deleteHoveredWallId === wallId const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) const levelId = resolveLevelId(wallNode, sceneState.nodes)