Skip to content

fix: hidden walls stay ray targets while a door/window tool is active - #689

Merged
Snoopy147 merged 2 commits into
mainfrom
fix/door-drag-xray
Aug 20, 2026
Merged

fix: hidden walls stay ray targets while a door/window tool is active#689
Snoopy147 merged 2 commits into
mainfrom
fix/door-drag-xray

Conversation

@Snoopy147

@Snoopy147 Snoopy147 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

User report with Bones' X-ray on: dragging a door/window "disconnects, rotates in place in red 90° from the wall".

Root cause: #683 made hidden walls pointer-transparent (the outlet-click fix) — but the door/window MOVE and PLACE tools drive their entire cursor model from the wall pointer events those handlers emit. With hidden walls silent, the ray falls through to the ground grid, grid:move free-follows, and the opening detaches as a red world-axis-aligned ghost instead of sliding along its wall. Before #683 those drags worked in X-ray only by accident of the same defect that misfired outlet clicks.

Fix: a counter-based hidden-wall pointer HOLD in core (holdHiddenWallPointerEvents). The four wall-opening tools acquire it for their active lifetime; the wall renderer keeps pointer events flowing while a hold is live. No tool active = no hold, so plain selection clicks still pass through hidden walls — the night-5 outlet fix is intact (MoveRegistryNodeTool never touches the hold). Delete-mode hover keeps its exception. The gate predicate is extracted (wallPointerEventsSuppressed) so the truth table is pinned by tests without an R3F rig.

Tests: hold compose/idempotence + the suppression truth table. Verified live in X-ray: doors slide along their wall and commit with framing recomputing around the new opening; outlet click-through unchanged.

🤖 Generated with Claude Code


Note

Medium Risk
Touches editor pointer routing on hidden wall collision meshes across four tools and the wall renderer; behavior is test-backed but regressions could affect X-ray selection vs opening placement.

Overview
Fixes door/window move and place tools breaking in X-ray when walls are visually hidden but still have collision: those tools depend on wall:* events, so pointer-transparent hidden walls caused floor free-follow and a detached red ghost instead of sliding along the wall.

Adds a counter-based holdHiddenWallPointerEvents / hiddenWallPointerEventsHeld API in core (exported from @pascal-app/core). All four opening tools acquire a hold for their effect lifetime and release it on cleanup (idempotent release for safe React teardown).

Refactors the wall renderer gate into wallPointerEventsSuppressed: hidden walls stay pointer-transparent for plain selection (outlet click-through unchanged), but keep events when delete hover mode is active or any hidden-wall hold is live. Unit tests cover hold compose/idempotence and the suppression truth table.

Reviewed by Cursor Bugbot for commit 96d518e. Bugbot is set up for automated code reviews on this repo. Configure here.

Snoopy147 and others added 2 commits August 20, 2026 00:13
…ol is active

#683 made walls hidden by the wall-mode pass ('down' X-ray mode, cutaway
faces, auto-mode interior partitions) pointer-transparent so clicks reach
the visible device/service boxes behind their invisible full-height
collision meshes. But the door/window MOVE and PLACE tools drive their
whole cursor model from the wall pointer events those handlers emit
(wall:enter/move/click/leave): with hidden walls silent, the ray falls
through to the ground grid, grid:move free-follows, and the opening
detaches from its wall as a red world-axis-aligned ghost ("rotates in
place in red 90 degrees") instead of sliding along the wall. Before #683
those drags worked in X-ray only by accident of the same defect that
misfired outlet clicks.

Fix: a counter-based hidden-wall pointer HOLD in core
(holdHiddenWallPointerEvents / hiddenWallPointerEventsHeld). The four
wall-opening tools (door + window, move + place) acquire it for their
active lifetime; the wall renderer's gated handlers keep events flowing
while a hold is live. No tool active means no hold, so plain selection
clicks still pass through hidden walls — the night-5 D4 outlet fix is
intact (MoveRegistryNodeTool never touches the hold; its cursor is the
ground plane). Delete-mode hover keeps its existing exception. The gate
predicate is extracted to wallPointerEventsSuppressed so the truth table
is pinned by tests without an R3F rig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Snoopy147
Snoopy147 merged commit 1b9300f into main Aug 20, 2026
4 of 7 checks passed
@Snoopy147
Snoopy147 deleted the fix/door-drag-xray branch August 20, 2026 15:58
Snoopy147 added a commit that referenced this pull request Aug 20, 2026
…gesture (#694)

* fix(nodes): door/window MOVE drags ride the node's own wall and undo as one step

Two night-6 QA defects in the opening move tools, both fallout around #689's
hidden-wall pointer hold:

1. Wrong-wall capture: with every hidden wall a ray target, nearest-hit-wins
   let a hidden wall interposed between the camera and the dragged opening's
   own wall catch the wall:move stream — the drag rode a wall the user cannot
   see and the commit silently re-parented the opening onto it. New
   `shouldIgnoreWallEventForOpeningMove` gate (shared/opening-move-wall-gate):
   while MOVING an existing opening, a hidden wall may drive the drag only if
   it is the node's own wall (grab wall / current mid-drag host); visible
   walls always pass, so cross-wall re-parenting needs an explicit, visible
   target. Ignored events don't stop propagation, so the ray falls through to
   the own wall behind. PLACE / isNew duplicates keep the all-walls behavior.

2. Multi-entry undo per drag gesture: the tools paused history with a RAW
   `temporal.pause()`, invisible to the refcounted getSceneHistoryPauseDepth.
   zundo reads isTracking AFTER a write's subscribers run, so a cooperating
   system's balanced pauseSceneHistory/resumeSceneHistory pair (the
   space-detection sync, on any mid-drag reparent that touches wall children)
   zeroed the refcount and resumed tracking mid-gesture — the mid-drag writes
   became their own undo entries (transient states: commit fired at drag-arm,
   door hidden/reparented, orphan opening at the drop spot). New
   `beginOpeningMoveHistorySession` (shared/opening-move-history) holds the
   refcounted LEASE for the gesture and opens one deliberate tracking window
   (`commitStep`) for the drop write: mid-drag tracks nothing, drop records
   exactly one entry whose past state is the restored pre-drag baseline,
   cancel records nothing. Store-level tests pin the one-entry contract, the
   exact-baseline undo, lease composition, and the raw-pause leak this
   replaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(nodes): biome format for the opening-move-history test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Snoopy147 added a commit that referenced this pull request Aug 20, 2026
…irst

INVESTIGATION (user report: with the Bones X-ray on, mousing over a wall
highlights/selects the furniture BEHIND it):

(a) The hover + click-select path is R3F's per-mesh pointer events
    (useNodeEvents on each node's meshes -> mitt emitter wall:enter /
    item:click / ... -> SelectionManager's select-mode subscriptions, which
    set hoveredId / selection and stopPropagation). R3F delivers through
    the distance-sorted intersection list until propagation stops.

(b) Why walls lost: the Bones framing renderer auto-switches the host
    wallMode to 'down'; WallCutout stamps userData.wallHidden=true on every
    wall; the wall renderer's #683 gate then early-returned EVERY pointer
    event (blanket pointer transparency, no stopPropagation), so R3F fell
    through to the furniture behind. The framing members that visually
    occupy the wall's volume are handler-less InstancedMeshes (never
    raycast candidates), so nothing at the wall's depth could win.

(c) SOLID / visible walls do NOT lose: wallPointerEventsSuppressed returns
    false for visible walls, the wall is the nearest interactive hit, and
    SelectionManager stops propagation on it — no ordering bug in the
    selection raycast itself. The defect is exclusively the hidden-wall
    blanket transparency.

FIX: nearest-first with wall-furniture priority. A hidden wall's gated
handlers now reduce the event's intersection list (extractWallSelectionRay)
and handle the event unless something outranks the wall:
  - its own hosted subtree (doors / windows / wall-mounted children) at ANY
    depth gap — immune to grazing-angle inflation;
  - any non-wall hit at <= wallHit + 0.35m (devices flush/proud/recessed at
    the face, objects in front of the wall);
  - wall-MOUNTED hits further down the ray — non-wall hits within epsilon
    of another wall's collision hit (the #683 night-5 D4 receptacle behind
    an interposed hidden wall keeps winning).
Other walls' hits never compete directly, so parallel hidden walls can't
both yield and drop the event into the room behind — the nearest wall wins
by delivery order. Events without ray data fall back to #683 transparency.

Unchanged: delete-mode hover, the #689 hidden-wall pointer hold (door /
window MOVE+PLACE tools — #694's own-wall gate keeps filtering those
downstream), visible walls never suppress.

Trade-off (pure host-side rule, no plugin presence flag): in a manual
'down' wall mode with no overlay rendering at the wall, the wall strip is
hover/selectable again even though it draws nothing there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Snoopy147 added a commit that referenced this pull request Aug 21, 2026
…ure behind them (#697)

* fix(nodes/wall): hidden walls join hover/selection raycasts nearest-first

INVESTIGATION (user report: with the Bones X-ray on, mousing over a wall
highlights/selects the furniture BEHIND it):

(a) The hover + click-select path is R3F's per-mesh pointer events
    (useNodeEvents on each node's meshes -> mitt emitter wall:enter /
    item:click / ... -> SelectionManager's select-mode subscriptions, which
    set hoveredId / selection and stopPropagation). R3F delivers through
    the distance-sorted intersection list until propagation stops.

(b) Why walls lost: the Bones framing renderer auto-switches the host
    wallMode to 'down'; WallCutout stamps userData.wallHidden=true on every
    wall; the wall renderer's #683 gate then early-returned EVERY pointer
    event (blanket pointer transparency, no stopPropagation), so R3F fell
    through to the furniture behind. The framing members that visually
    occupy the wall's volume are handler-less InstancedMeshes (never
    raycast candidates), so nothing at the wall's depth could win.

(c) SOLID / visible walls do NOT lose: wallPointerEventsSuppressed returns
    false for visible walls, the wall is the nearest interactive hit, and
    SelectionManager stops propagation on it — no ordering bug in the
    selection raycast itself. The defect is exclusively the hidden-wall
    blanket transparency.

FIX: nearest-first with wall-furniture priority. A hidden wall's gated
handlers now reduce the event's intersection list (extractWallSelectionRay)
and handle the event unless something outranks the wall:
  - its own hosted subtree (doors / windows / wall-mounted children) at ANY
    depth gap — immune to grazing-angle inflation;
  - any non-wall hit at <= wallHit + 0.35m (devices flush/proud/recessed at
    the face, objects in front of the wall);
  - wall-MOUNTED hits further down the ray — non-wall hits within epsilon
    of another wall's collision hit (the #683 night-5 D4 receptacle behind
    an interposed hidden wall keeps winning).
Other walls' hits never compete directly, so parallel hidden walls can't
both yield and drop the event into the room behind — the nearest wall wins
by delivery order. Events without ray data fall back to #683 transparency.

Unchanged: delete-mode hover, the #689 hidden-wall pointer hold (door /
window MOVE+PLACE tools — #694's own-wall gate keeps filtering those
downstream), visible walls never suppress.

Trade-off (pure host-side rule, no plugin presence flag): in a manual
'down' wall mode with no overlay rendering at the wall, the wall strip is
hover/selectable again even though it draws nothing there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(nodes/wall): reduce the pointer ray lazily — visible walls skip it

extractWallSelectionRay walks the event's intersection list and each hit's
parent chain; visible walls never suppress, so doing that per hover move
over every visible wall was wasted work. Gate the reduction on the
wallHidden stamp the predicate already consumes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(nodes/wall): rank hidden-wall selection by hit OWNERSHIP, not raw depth

QA f2 browser round falsified the premise that overlay meshes never reach
event.intersections: R3F's event raycast recurses through the level /
building wrapper groups (they carry pointer handlers), so Bones framing
InstancedMeshes land in the list at the wall's own depth (probe6/probe7:
d 4.246-4.422 vs wall 4.246) — all inside the epsilon tie-break, so the
previous rule yielded everywhere framing renders and the click still fell
through to the Double Bed. The wall's own RENDER mesh rides the same list
at identical depth, which would have made the old hostedByThisWall subtree
test self-defeating the moment walls build real geometry.

Every hit is now classified by its NEAREST sceneRegistry-registered
ancestor (selection-hit-owner.ts):
  - 'self-wall'  (own collision/render/trim)      -> neutral
  - 'other-wall'                                  -> anchor only, never a
                                                     direct competitor
  - 'selectable' (built-in selectable kinds +
                  registry capabilities.selectable,
                  e.g. bones:device)              -> real competitor
  - 'passive'    (bones:framing members, level/
                  building wrappers, zones, gizmos,
                  grid, unregistered ancestry)    -> never outranks
Competitors win via: hosted-by-this-wall (any depth), <= wallHit + 0.35m,
or within epsilon of another wall's hit (D4 interposed-wall receptacle).
A hosted door resolves to the DOOR (registered deeper than its host wall),
so 'self-wall' never swallows it. The reverse Object3D->id lookup rebuilds
lazily off sceneRegistry.revision.

This is robust standalone: even before the plugin-side raycast stub
(plugin-bones d8bcc5d) lands, framing hits classify passive; any future
overlay without the selectable capability behaves the same.

Tests replay probe7 session B's exact hit shape (framing at wall depth +
bed behind -> wall wins) plus the classifier truth table (self/other-wall,
hosted door, framing passive, device selectable, wrapper/zone passive,
deleted-node passive, revision-following lookup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): walls can never stay degenerate — system resilience + self-heal

QA f2 probe5/probe6: a scene LOADED with the Bones X-ray already active
kept all 24 wall collision meshes as degenerate placeholder points with no
wallHidden stamps and untouched base materials — geometry rebuild, cutout
stamping, AND batching were all inert for 16+ seconds while other useFrame
consumers (camera-controls) demonstrably ran. All three live in the ONE
registry-mounted WallSystems bundle, so the failure is the bundle not
running, not a per-wall skip. Toggling the X-ray mid-session (probe7)
works, so the trigger is load-time mounting/ordering. Three fixes close
the class:

1. RegisteredSystems re-derives its kind list on useRegistryVersion().
   The list was snapshotted ONCE at mount (useMemo []), so any kind
   registering after that render — async plugin discovery, HMR — never
   mounted its system; a first render before ANY kinds register mounted
   nothing, permanently. Same staleness class SelectionManager already
   guards against.

2. Per-kind Suspense boundaries. One shared boundary meant any pending or
   failing lazy system chunk unmounted EVERY system (wall pipeline
   included) while it hung.

3. Wall self-heal sweep (wall-placeholder-sweep.ts, every 30 frames): any
   registered wall still on its mount-time placeholder geometry (stamped
   userData.placeholder, 3-vertex fallback signature) with no dirty mark
   is re-marked, so the normal rebuild path converges no matter what
   consumed the mount-time mark or when the system came up. The frame body
   now also reads the LIVE dirty set instead of a render-closure copy —
   a store-level set REPLACEMENT (scene load, plugin install) in the
   window before React commits no longer hides fresh marks.

Walls now build their collision geometry regardless of initial visibility
or load order — the invariant the hidden-wall selection gate needs to
engage at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(viewer): biome import order for the wall-system sweep import

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): hovered hidden walls glow — the X-ray hover affordance

QA f2 (REVISE): hidden walls became hover/selection ray targets
(nearest-first), but an invisible wall gave NO feedback under the cursor —
what the user saw lighting up on hover was the furniture BEHIND it.

WallCutout now tracks the select-mode hovered wall (useViewer.hoveredId,
hoverHighlightMode 'default' only, so the paint-preview snapshot/restore
flows never interleave) and draws a hovered HIDDEN wall with a hover
variant of its invisible stipple film: the same indigo emissive treatment
as the wall selection highlight at a softer blend/intensity (0.28/0.07 vs
0.4/0.12), so hover reads as "this will select" and still steps up on
click. Visible and translucent walls keep their existing hover affordance
(the post-processing outline pass) — no double-highlight.

Mechanics: the per-wall material choice is extracted into a pure
resolveWallMaterialVariant truth table (delete > selection > hover > base,
hover arm only for the invisible base) consumed via materialsForVariant;
getSelectionHighlightMaterial generalizes into getEmissiveHighlightMaterial
with per-variant cache + profile, adding getHoverHighlightMaterials. The
hovered wall id joins WallCutout's highlightKey so hover changes refresh
the material pass immediately.

Known limit (pre-existing, noted per QA): the canvas cursor stays 'auto'
over walls even in solid mode — not addressed here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* tune(viewer): hover glow visible at normal zoom (QA: was ~2-5/255)

The F2 browser round measured the hidden-wall hover glow at mean delta
0.13/255 over the viewport — functionally correct, invisible without
A/B flipping. Blend 0.28->0.4, intensity 0.07->0.2 per the QA's 2-3x
recommendation; selection emphasis (0.4/0.12) still reads stronger
via its blend+dot treatment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant