Skip to content

fix: opening MOVE drags ride the node's own wall; one undo entry per gesture - #694

Merged
Snoopy147 merged 2 commits into
mainfrom
fix/move-own-wall
Aug 20, 2026
Merged

fix: opening MOVE drags ride the node's own wall; one undo entry per gesture#694
Snoopy147 merged 2 commits into
mainfrom
fix/move-own-wall

Conversation

@Snoopy147

@Snoopy147 Snoopy147 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Two defects in the door/window MOVE experience with the X-ray active, found by full-stack visual QA after #689:

A — wrong-wall capture. #689 keeps every hidden wall a ray target while a wall-opening tool is active. During a MOVE, the nearest hidden wall along the cursor ray could be an interposed wall between the camera and the dragged node's own wall — the drag silently rode it and the commit re-parented the opening onto a wall the user cannot see (API-verified repro: window re-parented from its own wall to an invisible shed wall). Fix: while moving an existing opening, wall events from HIDDEN walls are ignored unless they come from the node's own wall (original or current mid-drag host). Ignored events don't stop propagation, so the own wall behind emits its own event and the drag keeps riding it. Cross-wall re-parenting still works — onto visible walls (an explicit target). PLACE and duplicate placement keep the all-walls behavior; X-ray drags along the node's own hidden wall keep working (#689's intent).

B — multi-entry undo. A completed door drag produced multiple undo entries, none of which restored the pre-drag state. Root cause: the move tools' raw temporal.pause() is invisible to the refcounted getSceneHistoryPauseDepth(); zundo reads isTracking after a write's subscribers run, so the space-detection sync's balanced pause/resume pair (woken by any mid-drag write touching wall children) zeroed the refcount and resumed tracking mid-gesture. Fix: the gesture holds a refcounted acquireSceneHistoryPause lease (beginOpeningMoveHistorySession); mid-drag writes track nothing and cooperating systems see the interaction through the depth gate; the drop releases the lease for exactly ONE tracked write. Undo of a completed drag restores the exact pre-drag state in one step.

13 new tests (gate predicate incl. the interposed-hidden-wall and own-hidden-wall cases; history session incl. the raw-pause leak regression). Root suite 3591 pass / 0 fail, turbo test 13/13, biome clean.

Known follow-up (out of scope): ~35 other files use raw temporal.pause() with the same latent resume-leak pattern, including the PLACE tools' repeat-place flow.

🤖 Generated with Claude Code


Note

Medium Risk
Touches scene undo/history pause ownership and wall-hit routing during opening moves, so a lease or gate mistake can corrupt undo stacks or re-parent openings. Changes are scoped to the door/window MOVE tools with targeted tests.

Overview
Fixes two X-ray MOVE bugs for doors and windows: hidden walls no longer steal the drag, and a completed move is a single undo step.

While moving an existing opening, hidden-wall events are ignored unless they come from the grab wall or current host. Ignored events do not stop propagation, so the ray falls through to the own wall. Visible walls still accept cross-wall re-parenting; PLACE/duplicate keep all-walls X-ray behavior.

Move tools now hold a refcounted acquireSceneHistoryPause lease via beginOpeningMoveHistorySession instead of a raw temporal.pause(). Mid-drag writes stay untracked; commitStep records exactly one drop. Cancel leaves no undo entry. Tests cover the wall-gate truth table and the history-lease leak.

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

Snoopy147 and others added 2 commits August 20, 2026 14:19
…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>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f028cc2. Configure here.

if (committed) return
// A click on an interposed hidden wall must not commit / re-parent;
// let it fall through to the own wall behind (see onWallEnter).
if (wallEventIgnored(event)) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leave from ignored walls resets drag

Medium Severity

The new hidden-wall gate skips wall:enter, wall:move, and wall:click from interposed hidden walls, but onWallLeave still runs for those same walls. Because ignored events do not call stopPropagation, those walls still enter R3F hover and later emit wall:leave while the opening is riding its own wall. That handler clears dragAnchor and lastTarget, so the next own-wall move reseeds the grab offset and the door or window snaps back toward its original along-wall position—the X-ray MOVE path this change is meant to fix.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f028cc2. Configure here.

@Snoopy147
Snoopy147 merged commit f0ca911 into main Aug 20, 2026
3 checks passed
@Snoopy147
Snoopy147 deleted the fix/move-own-wall branch August 20, 2026 19:47
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