Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/core/src/events/hidden-wall-pointer-hold.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
39 changes: 39 additions & 0 deletions packages/core/src/events/hidden-wall-pointer-hold.ts
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/nodes/src/door/move-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DoorNode,
emitter,
type GridEvent,
holdHiddenWallPointerEvents,
isCurvedWall,
type RoofEvent,
type RoofNode,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions packages/nodes/src/door/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
DoorNode,
emitter,
type GridEvent,
holdHiddenWallPointerEvents,
isCurvedWall,
type RoofEvent,
type RoofNode,
Expand Down Expand Up @@ -715,13 +716,19 @@ 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()
hideCursor()
clearPlacementPreview()
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
releaseHiddenWallHold()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallHover)
emitter.off('wall:move', onWallHover)
Expand Down
60 changes: 60 additions & 0 deletions packages/nodes/src/wall/pointer-transparency.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
28 changes: 28 additions & 0 deletions packages/nodes/src/wall/pointer-transparency.ts
Original file line number Diff line number Diff line change
@@ -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
16 changes: 12 additions & 4 deletions packages/nodes/src/wall/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
type AnyNode,
type AnyNodeId,
hiddenWallPointerEventsHeld,
useRegistry,
useScene,
type WallNode,
Expand All @@ -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'

Expand Down Expand Up @@ -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<string, (e: unknown) => 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
}
Expand Down
8 changes: 8 additions & 0 deletions packages/nodes/src/window/move-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type AnyNodeId,
emitter,
type GridEvent,
holdHiddenWallPointerEvents,
isCurvedWall,
type RoofEvent,
type RoofNode,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions packages/nodes/src/window/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type AnyNodeId,
emitter,
type GridEvent,
holdHiddenWallPointerEvents,
isCurvedWall,
type RoofEvent,
type RoofNode,
Expand Down Expand Up @@ -775,13 +776,19 @@ 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()
hideCursor()
clearPlacementPreview()
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
releaseHiddenWallHold()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallHover)
emitter.off('wall:move', onWallHover)
Expand Down
Loading