From c40d7fd92a1b7a23df6c23983a4f1ebe03cec4bb Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Wed, 19 Aug 2026 01:05:21 -0400 Subject: [PATCH] fix(viewer/nodes): hidden walls are pointer-transparent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wall hidden by the wall-mode pass ('down' mode, cutaway-hidden faces, auto-mode interior-interior partitions) still raycasts at full height via its invisible collision mesh, and the selection path stopPropagation's on it — so clicks aimed at VISIBLE objects standing behind the hidden wall (wall-mounted plugin device/service boxes in X-ray mode, items) select an invisible wall instead. Night-5 evidence: arming a receptacle drag on the demo scene's south wall selected wall_x86… two meters in front of it (raycast hit at 1.03m vs the device at 3.93m), and the follow-up click committed an accidental WALL move plus its auto slab/ceiling/zone sync. WallCutout now stamps userData.wallHidden with the effective hide state, and the wall renderer's pointer handlers early-return (no emit, no stopPropagation) while it's set — R3F continues to the next intersection, so hover/select/click pass through to whatever the user actually sees. Delete mode keeps the events: hidden walls stay hover-targetable for the deleteInvisible highlight flow. Co-Authored-By: Claude Fable 5 --- packages/nodes/src/wall/renderer.tsx | 26 ++++++++++++++++++- .../viewer/src/systems/wall/wall-cutout.tsx | 13 ++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) 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)