diff --git a/packages/core/src/events/hidden-wall-pointer-hold.test.ts b/packages/core/src/events/hidden-wall-pointer-hold.test.ts new file mode 100644 index 0000000000..b13375b4ff --- /dev/null +++ b/packages/core/src/events/hidden-wall-pointer-hold.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test' +import { + hiddenWallPointerEventsHeld, + holdHiddenWallPointerEvents, +} from './hidden-wall-pointer-hold' + +describe('hidden-wall pointer hold', () => { + test('idle by default; a hold flips it; release restores it', () => { + expect(hiddenWallPointerEventsHeld()).toBe(false) + const release = holdHiddenWallPointerEvents() + expect(hiddenWallPointerEventsHeld()).toBe(true) + release() + expect(hiddenWallPointerEventsHeld()).toBe(false) + }) + + test('overlapping holds compose — held until the LAST release', () => { + const releaseA = holdHiddenWallPointerEvents() + const releaseB = holdHiddenWallPointerEvents() + expect(hiddenWallPointerEventsHeld()).toBe(true) + releaseA() + // B (say a move tool mounted while a place tool unwinds) still holds. + expect(hiddenWallPointerEventsHeld()).toBe(true) + releaseB() + expect(hiddenWallPointerEventsHeld()).toBe(false) + }) + + test('release is idempotent — a double effect-cleanup cannot underflow', () => { + const releaseA = holdHiddenWallPointerEvents() + releaseA() + releaseA() + releaseA() + expect(hiddenWallPointerEventsHeld()).toBe(false) + // A later hold must still register despite the extra releases above. + const releaseB = holdHiddenWallPointerEvents() + expect(hiddenWallPointerEventsHeld()).toBe(true) + releaseB() + expect(hiddenWallPointerEventsHeld()).toBe(false) + }) +}) diff --git a/packages/core/src/events/hidden-wall-pointer-hold.ts b/packages/core/src/events/hidden-wall-pointer-hold.ts new file mode 100644 index 0000000000..319174926c --- /dev/null +++ b/packages/core/src/events/hidden-wall-pointer-hold.ts @@ -0,0 +1,39 @@ +/** + * Hidden-wall pointer hold. + * + * Walls hidden by the wall-mode pass ('down' mode, cutaway-hidden faces, + * auto-mode interior partitions) are pointer-TRANSPARENT: their invisible + * full-height collision meshes early-return every pointer event so clicks + * aimed at visible objects behind them (wall-mounted plugin device boxes, + * items) reach their real target (see the wall renderer's gated handlers). + * + * That transparency breaks the tools whose ENTIRE cursor model is the wall + * surface: the door / window move + place tools track the cursor through + * `wall:enter` / `wall:move` / `wall:click` emitted by those same handlers. + * With the wall silent, the floor free-follow takes over and the opening + * floats off its wall as a red world-axis-aligned ghost — un-placeable. + * + * A wall-surface tool ACQUIRES this hold for its active lifetime; while any + * hold is live, hidden walls keep their pointer events (they stay visually + * hidden). Plain selection clicks — no tool active, no hold — keep passing + * through, so the device-box fix this transparency shipped for is intact. + * + * Counter-based so overlapping tools compose; the returned release is + * idempotent so a React effect cleanup can never double-decrement. + */ + +let holdCount = 0 + +/** Keep hidden walls pointer-targetable while the caller's tool is active. */ +export const holdHiddenWallPointerEvents = (): (() => void) => { + holdCount += 1 + let released = false + return () => { + if (released) return + released = true + holdCount -= 1 + } +} + +/** True while any wall-surface tool holds hidden-wall pointer events. */ +export const hiddenWallPointerEventsHeld = (): boolean => holdCount > 0 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cc7825ff01..2f6037e4a0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -43,6 +43,10 @@ export type { ZoneEvent, } from './events/bus' export { emitter, eventSuffixes } from './events/bus' +export { + hiddenWallPointerEventsHeld, + holdHiddenWallPointerEvents, +} from './events/hidden-wall-pointer-hold' export { type ItemClipEntry, itemClipRegistry } from './hooks/scene-registry/item-clip-registry' export { sceneRegistry, diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index e545dfb38c..db9b11ac56 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -3,6 +3,7 @@ import { DoorNode, emitter, type GridEvent, + holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, type RoofNode, @@ -99,6 +100,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => useEffect(() => { useScene.temporal.getState().pause() + // This tool's whole cursor model is the wall surface (`wall:enter` / + // `wall:move` / `wall:click`). Walls hidden by the wall-mode pass (X-ray + // 'down' mode) are pointer-transparent for selection; hold their pointer + // events for the move's lifetime so the door keeps sliding along its wall + // instead of detaching into the floor free-follow. + const releaseHiddenWallHold = holdHiddenWallPointerEvents() const meta = typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null @@ -1005,6 +1012,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => clearOpeningGuides3D() useFacingPose.getState().clear() clearPlacementSurface() + releaseHiddenWallHold() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index bb13193390..9851baeaf4 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -4,6 +4,7 @@ import { DoorNode, emitter, type GridEvent, + holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, type RoofNode, @@ -715,6 +716,11 @@ const DoorTool: React.FC = () => { emitter.on('grid:move', onGridFreeFollow) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) + // Placement tracks the cursor through wall events; keep walls hidden by + // the wall-mode pass (X-ray 'down' mode) pointer-targetable while the + // tool is active so a new door still snaps onto them (see the wall + // renderer's pointer transparency). + const releaseHiddenWallHold = holdHiddenWallPointerEvents() return () => { destroyDraft() @@ -722,6 +728,7 @@ const DoorTool: React.FC = () => { clearPlacementPreview() useAlignmentGuides.getState().clear() clearOpeningGuides3D() + releaseHiddenWallHold() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallHover) emitter.off('wall:move', onWallHover) diff --git a/packages/nodes/src/wall/pointer-transparency.test.ts b/packages/nodes/src/wall/pointer-transparency.test.ts new file mode 100644 index 0000000000..bfe1cb2ab5 --- /dev/null +++ b/packages/nodes/src/wall/pointer-transparency.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test' +import { hiddenWallPointerEventsHeld, holdHiddenWallPointerEvents } from '@pascal-app/core' +import { wallPointerEventsSuppressed } from './pointer-transparency' + +// Semantics pinned here (the wall renderer's gated handlers evaluate this +// predicate per pointer event): +// - #683 / night-5 D4: a wall hidden by the wall-mode pass swallows NO +// pointer events, so clicks reach the visible device / service boxes +// behind its invisible collision mesh. +// - night-6 door-drag: while a door / window move / place tool holds +// hidden-wall pointer events, hidden walls DO keep raycasting — the tools +// track the cursor through wall:enter / wall:move / wall:click, and +// without the wall the opening free-follows the floor as a detached red +// world-axis ghost instead of sliding along its wall. +// - delete mode keeps events regardless (deleteInvisible hover flow). +// - visible walls never suppress. + +describe('wallPointerEventsSuppressed', () => { + const base = { + wallHidden: true, + hoverHighlightMode: 'default' as string | null | undefined, + hiddenWallHoldActive: false, + } + + test('hidden wall, no tool: pointer-transparent (the D4 outlet fix)', () => { + expect(wallPointerEventsSuppressed(base)).toBe(true) + }) + + test('hidden wall, opening tool hold: events flow (door/window drags slide)', () => { + expect(wallPointerEventsSuppressed({ ...base, hiddenWallHoldActive: true })).toBe(false) + }) + + test('hidden wall, delete mode: events flow (deleteInvisible hover)', () => { + expect(wallPointerEventsSuppressed({ ...base, hoverHighlightMode: 'delete' })).toBe(false) + }) + + test('visible wall: never suppressed, in any mode', () => { + for (const hoverHighlightMode of ['default', 'delete', null, undefined]) { + for (const hiddenWallHoldActive of [false, true]) { + expect( + wallPointerEventsSuppressed({ + wallHidden: false, + hoverHighlightMode, + hiddenWallHoldActive, + }), + ).toBe(false) + } + } + }) + + test('composes with the real core hold lifecycle', () => { + const suppressedNow = () => + wallPointerEventsSuppressed({ ...base, hiddenWallHoldActive: hiddenWallPointerEventsHeld() }) + expect(suppressedNow()).toBe(true) + const release = holdHiddenWallPointerEvents() + expect(suppressedNow()).toBe(false) + release() + expect(suppressedNow()).toBe(true) + }) +}) diff --git a/packages/nodes/src/wall/pointer-transparency.ts b/packages/nodes/src/wall/pointer-transparency.ts new file mode 100644 index 0000000000..3c9d273772 --- /dev/null +++ b/packages/nodes/src/wall/pointer-transparency.ts @@ -0,0 +1,28 @@ +/** + * Should a wall's pointer handlers swallow (early-return) this event? + * + * Hidden walls ('down' wall mode, cutaway-hidden faces, auto-mode interior + * partitions) are pointer-transparent so clicks reach the visible objects + * behind their invisible full-height collision meshes (wall-mounted plugin + * device / service boxes, items). Two exceptions keep the events flowing: + * + * - DELETE hover mode: hidden walls must stay hover-targetable for the + * deleteInvisible highlight flow. + * - A live hidden-wall pointer HOLD (`holdHiddenWallPointerEvents`, core): + * the door / window move + place tools drive their cursor entirely from + * `wall:enter` / `wall:move` / `wall:click`, so while one is active the + * hidden wall must keep raycasting or the opening detaches into the floor + * free-follow (red world-axis ghost) instead of sliding along its wall. + * + * Visible walls never suppress. Pure so the truth table is testable without + * an R3F rig; the renderer supplies live values per event. + */ +export const wallPointerEventsSuppressed = ({ + wallHidden, + hoverHighlightMode, + hiddenWallHoldActive, +}: { + wallHidden: boolean + hoverHighlightMode: string | null | undefined + hiddenWallHoldActive: boolean +}): boolean => wallHidden && hoverHighlightMode !== 'delete' && !hiddenWallHoldActive diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 038633f00e..c117735914 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -3,6 +3,7 @@ import { type AnyNode, type AnyNodeId, + hiddenWallPointerEventsHeld, useRegistry, useScene, type WallNode, @@ -12,6 +13,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' import { useShallow } from 'zustand/react/shallow' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' +import { wallPointerEventsSuppressed } from './pointer-transparency' import { useWallTreatmentLevelData } from './treatment-level-data' import { createWallExtraSlotMaterials, WallTreatments } from './treatments' @@ -59,16 +61,22 @@ const WallRenderer = ({ node }: { node: WallNode }) => { // 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). + // Two exceptions keep the events (see `wallPointerEventsSuppressed`): + // delete mode (hidden walls stay hover-targetable for the deleteInvisible + // highlight flow) and a live hidden-wall pointer hold (a door / window + // move / place tool is tracking the cursor via wall events — without the + // wall the opening detaches into the floor free-follow). 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' + wallPointerEventsSuppressed({ + wallHidden: ref.current?.userData?.wallHidden === true, + hoverHighlightMode: useViewer.getState().hoverHighlightMode, + hiddenWallHoldActive: hiddenWallPointerEventsHeld(), + }) ) { return } diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 115ac2536a..c0ba4f2789 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, emitter, type GridEvent, + holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, type RoofNode, @@ -117,6 +118,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useEffect(() => { useScene.temporal.getState().pause() + // This tool's whole cursor model is the wall surface (`wall:enter` / + // `wall:move` / `wall:click`). Walls hidden by the wall-mode pass (X-ray + // 'down' mode) are pointer-transparent for selection; hold their pointer + // events for the move's lifetime so the window keeps sliding along its + // wall instead of detaching into the floor free-follow. + const releaseHiddenWallHold = holdHiddenWallPointerEvents() const meta = typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null @@ -1040,6 +1047,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode clearOpeningGuides3D() useFacingPose.getState().clear() clearPlacementSurface() + releaseHiddenWallHold() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 3987bfaadf..90482adda1 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, emitter, type GridEvent, + holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, type RoofNode, @@ -775,6 +776,11 @@ const WindowTool: React.FC = () => { emitter.on('grid:move', onGridFreeFollow) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) + // Placement tracks the cursor through wall events; keep walls hidden by + // the wall-mode pass (X-ray 'down' mode) pointer-targetable while the + // tool is active so a new window still snaps onto them (see the wall + // renderer's pointer transparency). + const releaseHiddenWallHold = holdHiddenWallPointerEvents() return () => { destroyDraft() @@ -782,6 +788,7 @@ const WindowTool: React.FC = () => { clearPlacementPreview() useAlignmentGuides.getState().clear() clearOpeningGuides3D() + releaseHiddenWallHold() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallHover) emitter.off('wall:move', onWallHover)