From 6edcbf01c9a4c6d07a1dadbf030e629a12190762 Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Thu, 20 Aug 2026 15:45:10 -0400 Subject: [PATCH 1/7] fix(nodes/wall): hidden walls join hover/selection raycasts nearest-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/wall/pointer-transparency.test.ts | 209 +++++++++++++++++- .../nodes/src/wall/pointer-transparency.ts | 173 ++++++++++++++- packages/nodes/src/wall/renderer.tsx | 28 ++- 3 files changed, 385 insertions(+), 25 deletions(-) diff --git a/packages/nodes/src/wall/pointer-transparency.test.ts b/packages/nodes/src/wall/pointer-transparency.test.ts index bfe1cb2ab..15d0e88fb 100644 --- a/packages/nodes/src/wall/pointer-transparency.test.ts +++ b/packages/nodes/src/wall/pointer-transparency.test.ts @@ -1,20 +1,33 @@ import { describe, expect, test } from 'bun:test' import { hiddenWallPointerEventsHeld, holdHiddenWallPointerEvents } from '@pascal-app/core' -import { wallPointerEventsSuppressed } from './pointer-transparency' +import { + extractWallSelectionRay, + HIDDEN_WALL_SELECTION_EPSILON, + hiddenWallOutrankedOnRay, + WALL_COLLISION_MESH_NAME, + type WallRayObjectLike, + 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. +// - nearest-first selection: a wall hidden by the wall-mode pass (Bones +// X-ray 'down' mode) handles hover / selection events when it is the +// closest thing on the ray — mousing over the framing highlights the +// WALL, not the sofa two meters behind it. +// - #683 / night-5 D4 stays fixed: the hidden wall yields to its own hosted +// openings, to anything at ~equal-or-nearer depth (device boxes at the +// face), and to wall-mounted gear on walls further down the ray (the +// receptacle behind an interposed hidden wall). +// - night-6 door-drag (#689): while a door / window move / place tool holds +// hidden-wall pointer events, hidden walls keep raycasting outright — +// the tools track the cursor through wall:enter / wall:move / wall:click +// (#694's own-wall gate then filters those downstream). // - delete mode keeps events regardless (deleteInvisible hover flow). // - visible walls never suppress. +const EPS = HIDDEN_WALL_SELECTION_EPSILON + describe('wallPointerEventsSuppressed', () => { const base = { wallHidden: true, @@ -22,12 +35,59 @@ describe('wallPointerEventsSuppressed', () => { hiddenWallHoldActive: false, } - test('hidden wall, no tool: pointer-transparent (the D4 outlet fix)', () => { + test('hidden wall, no ray data: pointer-transparent (#683 fallback)', () => { 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, nothing else on the ray: events flow (nearest-first)', () => { + expect( + wallPointerEventsSuppressed({ + ...base, + selectionRay: { wallHitDistance: 5, otherHits: [] }, + }), + ).toBe(false) + }) + + test('hidden wall in front of free-standing furniture: the WALL wins (the reported bug)', () => { + expect( + wallPointerEventsSuppressed({ + ...base, + selectionRay: { + wallHitDistance: 5, + otherHits: [ + { distance: 7, isWallCollision: false, hostedByThisWall: false }, // sofa mid-room + { distance: 12, isWallCollision: false, hostedByThisWall: false }, // grid / far slab + ], + }, + }), + ).toBe(false) + }) + + test('hidden wall vs device box at the face: the device wins (D4 epsilon tie-break)', () => { + expect( + wallPointerEventsSuppressed({ + ...base, + selectionRay: { + wallHitDistance: 5, + otherHits: [{ distance: 5 + EPS / 2, isWallCollision: false, hostedByThisWall: false }], + }, + }), + ).toBe(true) + }) + + test('hidden wall, opening tool hold: events flow regardless of the ray (#689/#694)', () => { + expect( + wallPointerEventsSuppressed({ + ...base, + hiddenWallHoldActive: true, + // Even a ray that would yield in select mode flows during a hold — + // the MOVE tools' own-wall gate handles interposed walls downstream. + selectionRay: { + wallHitDistance: 5, + otherHits: [{ distance: 5, isWallCollision: false, hostedByThisWall: false }], + }, + }), + ).toBe(false) }) test('hidden wall, delete mode: events flow (deleteInvisible hover)', () => { @@ -58,3 +118,128 @@ describe('wallPointerEventsSuppressed', () => { expect(suppressedNow()).toBe(true) }) }) + +describe('hiddenWallOutrankedOnRay', () => { + test('hosted children (doors / windows) outrank at ANY depth gap — grazing angles included', () => { + expect( + hiddenWallOutrankedOnRay({ + wallHitDistance: 5, + // A door panel hit far beyond epsilon along a grazing ray. + otherHits: [{ distance: 5 + 3 * EPS, isWallCollision: false, hostedByThisWall: true }], + }), + ).toBe(true) + }) + + test('hits nearer than the wall outrank it (plain distance order)', () => { + expect( + hiddenWallOutrankedOnRay({ + wallHitDistance: 5, + otherHits: [{ distance: 3, isWallCollision: false, hostedByThisWall: false }], + }), + ).toBe(true) + }) + + test('wall-mounted gear BEHIND an interposed hidden wall outranks it (D4: receptacle 2m back)', () => { + expect( + hiddenWallOutrankedOnRay({ + wallHitDistance: 5, + otherHits: [ + // The receptacle, sitting at its own wall's face 2m behind this one… + { distance: 7, isWallCollision: false, hostedByThisWall: false }, + // …anchored by that wall's collision hit right behind it. + { distance: 7 + EPS / 2, isWallCollision: true, hostedByThisWall: false }, + ], + }), + ).toBe(true) + }) + + test('free-standing furniture behind the wall does NOT outrank it, even with a far wall beyond', () => { + expect( + hiddenWallOutrankedOnRay({ + wallHitDistance: 5, + otherHits: [ + // Sofa mid-room: not near ANY wall hit on the ray. + { distance: 7, isWallCollision: false, hostedByThisWall: false }, + // The room's far wall, well beyond the sofa. + { distance: 10, isWallCollision: true, hostedByThisWall: false }, + ], + }), + ).toBe(false) + }) + + test('other walls never compete directly — the nearest hidden wall keeps the event', () => { + // Double-wall assembly: if parallel hidden walls counted as competitors, + // BOTH would yield and the event would fall through to the room behind. + expect( + hiddenWallOutrankedOnRay({ + wallHitDistance: 5, + otherHits: [{ distance: 5.1, isWallCollision: true, hostedByThisWall: false }], + }), + ).toBe(false) + }) +}) + +describe('extractWallSelectionRay', () => { + const chain = (parent: WallRayObjectLike | null, name?: string): WallRayObjectLike => ({ + name, + parent, + }) + + test('reduces a live event: self excluded, wall collisions flagged, subtree hits marked hosted', () => { + const wallRoot = chain(null) + const selfCollision = chain(wallRoot, WALL_COLLISION_MESH_NAME) + const hostedDoorMesh = chain(chain(wallRoot)) // door mesh nested under the wall root + const otherWallCollision = chain(chain(null), WALL_COLLISION_MESH_NAME) + const sofaMesh = chain(chain(null)) + + const ray = extractWallSelectionRay( + { + distance: 5, + object: selfCollision, + intersections: [ + { distance: 5, object: selfCollision }, + { distance: 5.2, object: hostedDoorMesh }, + { distance: 7, object: sofaMesh }, + { distance: 7.1, object: otherWallCollision }, + ], + }, + wallRoot, + ) + + expect(ray).toEqual({ + wallHitDistance: 5, + otherHits: [ + { distance: 5.2, isWallCollision: false, hostedByThisWall: true }, + { distance: 7, isWallCollision: false, hostedByThisWall: false }, + { distance: 7.1, isWallCollision: true, hostedByThisWall: false }, + ], + }) + }) + + test('events without ray data reduce to undefined (→ #683 transparent fallback)', () => { + expect(extractWallSelectionRay(undefined, null)).toBeUndefined() + expect(extractWallSelectionRay({}, null)).toBeUndefined() + expect(extractWallSelectionRay({ distance: 5, object: chain(null) }, null)).toBeUndefined() + expect( + extractWallSelectionRay({ object: chain(null), intersections: [] }, null), + ).toBeUndefined() + }) + + test('a null wall root marks nothing as hosted (wall not registered yet)', () => { + const self = chain(null, WALL_COLLISION_MESH_NAME) + const ray = extractWallSelectionRay( + { + distance: 5, + object: self, + intersections: [ + { distance: 5, object: self }, + { distance: 5.1, object: chain(null) }, + ], + }, + null, + ) + expect(ray?.otherHits).toEqual([ + { distance: 5.1, isWallCollision: false, hostedByThisWall: false }, + ]) + }) +}) diff --git a/packages/nodes/src/wall/pointer-transparency.ts b/packages/nodes/src/wall/pointer-transparency.ts index 3c9d27377..5d4db75c8 100644 --- a/packages/nodes/src/wall/pointer-transparency.ts +++ b/packages/nodes/src/wall/pointer-transparency.ts @@ -2,9 +2,41 @@ * 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: + * partitions) keep invisible full-height collision meshes that raycast for + * every pointer event. #683 made them blanket pointer-TRANSPARENT so clicks + * reached the visible objects behind them (wall-mounted plugin device / + * service boxes, items). That over-corrected hover + selection: with the + * Bones X-ray on (walls hidden, framing members — handler-less + * InstancedMeshes — rendering where the walls are), mousing over a wall + * highlighted and selected the furniture BEHIND it, because nothing at the + * wall's depth was a ray candidate at all. + * + * The rule is now NEAREST-FIRST with wall-furniture priority: a hidden wall + * participates in hover/selection raycasts and wins when it is genuinely the + * closest thing on the ray, but it YIELDS (early-return, no stopPropagation, + * so R3F falls through to the real target) whenever any other interactive + * hit outranks it: + * + * - a hit HOSTED by this wall (its own doors / windows / wall-mounted + * children — subtree membership, so grazing angles can't inflate the + * depth gap past any epsilon); + * - a hit at ~equal-or-nearer depth (`HIDDEN_WALL_SELECTION_EPSILON` + * tie-break: device boxes flush with / proud of / recessed into the face, + * items standing in front of the wall); + * - a WALL-MOUNTED hit anywhere further down the ray — a non-wall hit + * within epsilon of some other wall's collision hit (the #683 / night-5 + * D4 class: a visible receptacle on a wall two meters BEHIND an + * interposed hidden wall must still win — the interposed wall falls + * through exactly like the #694 MOVE gate does). + * + * Free-standing hits clearly behind the wall (a sofa mid-room, the floor + * slab, the grid) no longer outrank it: the wall in front highlights, which + * is what the ray visually strikes when the Bones framing renders there. + * Trade-off (deliberate, host-side only — no plugin presence flag): in a + * plain manual 'down' mode with NO overlay rendering at the wall, that same + * wall strip becomes hover/selectable again even though it draws nothing. + * + * Two pre-existing exceptions keep ALL events flowing unconditionally: * * - DELETE hover mode: hidden walls must stay hover-targetable for the * deleteInvisible highlight flow. @@ -13,16 +45,149 @@ * `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. + * (#694's own-wall MOVE gate then filters those events downstream — + * this predicate never runs for held events, so the two compose.) * * Visible walls never suppress. Pure so the truth table is testable without * an R3F rig; the renderer supplies live values per event. */ + +/** + * Depth tie-break for "at the wall face": in-wall boxes sit flush-to- + * recessed within a wall thickness (0.09–0.3 m); openings sit inside the + * slab. Along-ray gaps inflate by 1/cos(incidence), so this carries typical + * face-mounted gear through moderate grazing angles without letting a sofa + * a metre behind the wall win. + */ +export const HIDDEN_WALL_SELECTION_EPSILON = 0.35 + +/** The wall renderer names its invisible pick mesh this (see renderer.tsx). */ +export const WALL_COLLISION_MESH_NAME = 'collision-mesh' + +/** One interactive raycast hit, reduced to what the yield rule needs. */ +export type WallRayHit = { + /** Distance along the ray, in meters (three.js Intersection.distance). */ + distance: number + /** True when the hit object is some wall's invisible collision mesh. */ + isWallCollision: boolean + /** True when the hit object lives inside THIS wall's rendered subtree. */ + hostedByThisWall: boolean +} + +/** The pointer ray as seen from one hidden wall's collision-mesh hit. */ +export type WallSelectionRay = { + /** Distance of this wall's own collision-mesh hit. */ + wallHitDistance: number + /** Every other interactive hit on the same ray (self excluded). */ + otherHits: ReadonlyArray +} + +/** + * Does any other hit on the ray outrank this hidden wall for hover / + * selection? True → the wall yields the event (pointer-transparent). + */ +export const hiddenWallOutrankedOnRay = ( + ray: WallSelectionRay, + epsilon: number = HIDDEN_WALL_SELECTION_EPSILON, +): boolean => { + // Other walls' hits never compete directly (two hidden walls must not + // BOTH yield and drop the event through to the room behind — the nearest + // one wins by delivery order). They only anchor the wall-mounted test. + const wallAnchors: number[] = [] + for (const hit of ray.otherHits) { + if (hit.isWallCollision) wallAnchors.push(hit.distance) + } + + return ray.otherHits.some((hit) => { + if (hit.isWallCollision) return false + // The wall's own hosted children (doors, windows, wall-mounted items) + // always win, at any incidence angle. + if (hit.hostedByThisWall) return true + // Nearer, or at ~the wall face: devices flush/proud/recessed, items in + // front of the wall. + if (hit.distance <= ray.wallHitDistance + epsilon) return true + // Wall-mounted gear further down the ray (a receptacle on a wall behind + // this one): visible through the framing, deliberately small targets — + // an interposed hidden wall must not swallow them (D4). + return wallAnchors.some((anchor) => Math.abs(hit.distance - anchor) <= epsilon) + }) +} + +/** Minimal structural shapes so extraction is testable without three.js. */ +export type WallRayObjectLike = { + name?: string + parent?: WallRayObjectLike | null +} +export type WallRayIntersectionLike = { + distance: number + object: WallRayObjectLike +} + +const isInSubtree = (object: WallRayObjectLike, root: object | null): boolean => { + if (!root) return false + let current: WallRayObjectLike | null | undefined = object + while (current) { + if (current === root) return true + current = current.parent + } + return false +} + +/** + * Reduce a live R3F pointer event (Intersection & { intersections }) to the + * `WallSelectionRay` the yield rule consumes. `wallRoot` is the wall's + * registered outer mesh — its subtree hosts the collision mesh, treatments, + * and the hosted door / window / item renderers. Returns undefined when the + * event carries no usable ray data (synthetic replays); the caller then + * falls back to full transparency, #683's original behavior. + */ +export const extractWallSelectionRay = ( + event: unknown, + wallRoot: object | null, +): WallSelectionRay | undefined => { + const e = event as { + distance?: unknown + object?: WallRayObjectLike + intersections?: unknown + } + if (typeof e?.distance !== 'number' || !e.object || !Array.isArray(e.intersections)) { + return undefined + } + const self = e.object + const otherHits: WallRayHit[] = [] + for (const hit of e.intersections as WallRayIntersectionLike[]) { + if (!hit || typeof hit.distance !== 'number' || !hit.object) continue + if (hit.object === self) continue + otherHits.push({ + distance: hit.distance, + isWallCollision: hit.object.name === WALL_COLLISION_MESH_NAME, + // Self is excluded above, so subtree membership here means a HOSTED + // child (door / window / wall-mounted item), not the pick mesh. + hostedByThisWall: isInSubtree(hit.object, wallRoot), + }) + } + return { wallHitDistance: e.distance, otherHits } +} + export const wallPointerEventsSuppressed = ({ wallHidden, hoverHighlightMode, hiddenWallHoldActive, + selectionRay, }: { wallHidden: boolean hoverHighlightMode: string | null | undefined hiddenWallHoldActive: boolean -}): boolean => wallHidden && hoverHighlightMode !== 'delete' && !hiddenWallHoldActive + /** + * The pointer ray context for hover/selection events. Omitted or + * undefined → the hidden wall stays fully transparent (#683 fallback for + * events without intersection data). + */ + selectionRay?: WallSelectionRay +}): boolean => { + if (!wallHidden) return false + if (hoverHighlightMode === 'delete') return false + if (hiddenWallHoldActive) return false + if (!selectionRay) return true + return hiddenWallOutrankedOnRay(selectionRay) +} diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index c11773591..e00e598f2 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -13,7 +13,11 @@ 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 { + extractWallSelectionRay, + WALL_COLLISION_MESH_NAME, + wallPointerEventsSuppressed, +} from './pointer-transparency' import { useWallTreatmentLevelData } from './treatment-level-data' import { createWallExtraSlotMaterials, WallTreatments } from './treatments' @@ -55,13 +59,18 @@ const WallRenderer = ({ node }: { node: WallNode }) => { }, [collisionPlaceholderGeometry, placeholderGeometry]) 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. - // Two exceptions keep the events (see `wallPointerEventsSuppressed`): + // Hidden walls participate in hover/selection NEAREST-FIRST: 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 handles the event + // only when nothing else on the ray outranks it — its own hosted doors / + // windows / wall-mounted children, any hit at ~equal-or-nearer depth + // (device boxes at the face), or wall-mounted gear on a wall behind it + // (the #683 D4 receptacle class) all win instead. Returning early without + // stopPropagation lets R3F continue to that next intersection. Free- + // standing objects clearly BEHIND the wall no longer steal the hover: the + // wall in front highlights (the Bones framing renders exactly there). + // Two exceptions keep ALL 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 @@ -76,6 +85,7 @@ const WallRenderer = ({ node }: { node: WallNode }) => { wallHidden: ref.current?.userData?.wallHidden === true, hoverHighlightMode: useViewer.getState().hoverHighlightMode, hiddenWallHoldActive: hiddenWallPointerEventsHeld(), + selectionRay: extractWallSelectionRay(e, ref.current), }) ) { return @@ -136,7 +146,7 @@ const WallRenderer = ({ node }: { node: WallNode }) => { > From cb6faa83258ba1c3243ca6d54bdc9ad8a3dd0ad3 Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Thu, 20 Aug 2026 15:48:54 -0400 Subject: [PATCH 2/7] =?UTF-8?q?perf(nodes/wall):=20reduce=20the=20pointer?= =?UTF-8?q?=20ray=20lazily=20=E2=80=94=20visible=20walls=20skip=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/nodes/src/wall/renderer.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index e00e598f2..935ab3022 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -80,12 +80,15 @@ const WallRenderer = ({ node }: { node: WallNode }) => { 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) => { + const wallHidden = ref.current?.userData?.wallHidden === true if ( wallPointerEventsSuppressed({ - wallHidden: ref.current?.userData?.wallHidden === true, + wallHidden, hoverHighlightMode: useViewer.getState().hoverHighlightMode, hiddenWallHoldActive: hiddenWallPointerEventsHeld(), - selectionRay: extractWallSelectionRay(e, ref.current), + // Reduced lazily: visible walls never suppress, so don't walk + // the intersection list for every hover move over them. + selectionRay: wallHidden ? extractWallSelectionRay(e, ref.current) : undefined, }) ) { return From fb86f2330f9cceb4b9116b63ebbbe49edc69d97c Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Thu, 20 Aug 2026 17:31:46 -0400 Subject: [PATCH 3/7] fix(nodes/wall): rank hidden-wall selection by hit OWNERSHIP, not raw depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/wall/pointer-transparency.test.ts | 131 +++++++++++---- .../nodes/src/wall/pointer-transparency.ts | 105 ++++++------ packages/nodes/src/wall/renderer.tsx | 25 ++- .../src/wall/selection-hit-owner.test.ts | 155 ++++++++++++++++++ .../nodes/src/wall/selection-hit-owner.ts | 130 +++++++++++++++ 5 files changed, 455 insertions(+), 91 deletions(-) create mode 100644 packages/nodes/src/wall/selection-hit-owner.test.ts create mode 100644 packages/nodes/src/wall/selection-hit-owner.ts diff --git a/packages/nodes/src/wall/pointer-transparency.test.ts b/packages/nodes/src/wall/pointer-transparency.test.ts index 15d0e88fb..2250a4ccf 100644 --- a/packages/nodes/src/wall/pointer-transparency.test.ts +++ b/packages/nodes/src/wall/pointer-transparency.test.ts @@ -4,19 +4,25 @@ import { extractWallSelectionRay, HIDDEN_WALL_SELECTION_EPSILON, hiddenWallOutrankedOnRay, - WALL_COLLISION_MESH_NAME, + type WallRayHit, type WallRayObjectLike, wallPointerEventsSuppressed, } from './pointer-transparency' +import type { WallRayHitOwnership } from './selection-hit-owner' // Semantics pinned here (the wall renderer's gated handlers evaluate this // predicate per pointer event): -// - nearest-first selection: a wall hidden by the wall-mode pass (Bones -// X-ray 'down' mode) handles hover / selection events when it is the -// closest thing on the ray — mousing over the framing highlights the -// WALL, not the sofa two meters behind it. +// - nearest-first selection over hits that OWN selection semantics: a wall +// hidden by the wall-mode pass (Bones X-ray 'down' mode) handles hover / +// selection events when no selectable hit outranks it — mousing over the +// framing highlights the WALL, not the sofa two meters behind it. +// - passive hits never outrank: the live event raycast recurses through the +// level/building wrapper groups, so Bones framing InstancedMeshes (and +// the wall's own render mesh) land in event.intersections at the wall's +// own depth (QA f2 probe6/probe7). Ranking by distance alone would make +// the wall yield everywhere its overlay renders. // - #683 / night-5 D4 stays fixed: the hidden wall yields to its own hosted -// openings, to anything at ~equal-or-nearer depth (device boxes at the +// openings, to selectables at ~equal-or-nearer depth (device boxes at the // face), and to wall-mounted gear on walls further down the ray (the // receptacle behind an interposed hidden wall). // - night-6 door-drag (#689): while a door / window move / place tool holds @@ -28,6 +34,12 @@ import { const EPS = HIDDEN_WALL_SELECTION_EPSILON +const hit = ( + distance: number, + ownership: WallRayHitOwnership, + hostedByThisWall = false, +): WallRayHit => ({ distance, ownership, hostedByThisWall }) + describe('wallPointerEventsSuppressed', () => { const base = { wallHidden: true, @@ -55,8 +67,8 @@ describe('wallPointerEventsSuppressed', () => { selectionRay: { wallHitDistance: 5, otherHits: [ - { distance: 7, isWallCollision: false, hostedByThisWall: false }, // sofa mid-room - { distance: 12, isWallCollision: false, hostedByThisWall: false }, // grid / far slab + hit(7, 'selectable'), // sofa mid-room + hit(12, 'passive'), // grid / helper far behind ], }, }), @@ -69,7 +81,7 @@ describe('wallPointerEventsSuppressed', () => { ...base, selectionRay: { wallHitDistance: 5, - otherHits: [{ distance: 5 + EPS / 2, isWallCollision: false, hostedByThisWall: false }], + otherHits: [hit(5 + EPS / 2, 'selectable')], }, }), ).toBe(true) @@ -84,7 +96,7 @@ describe('wallPointerEventsSuppressed', () => { // the MOVE tools' own-wall gate handles interposed walls downstream. selectionRay: { wallHitDistance: 5, - otherHits: [{ distance: 5, isWallCollision: false, hostedByThisWall: false }], + otherHits: [hit(5, 'selectable')], }, }), ).toBe(false) @@ -120,21 +132,50 @@ describe('wallPointerEventsSuppressed', () => { }) describe('hiddenWallOutrankedOnRay', () => { + test('passive hits at the wall depth do NOT outrank (QA f2: Bones framing members)', () => { + // probe7 session B verbatim shape: framing InstancedMesh hits ride the + // level wrapper's handlers into the intersection list at d≈4.246–4.422, + // the wall's own render + collision hits sit at 4.246, the bed at 6.081. + expect( + hiddenWallOutrankedOnRay({ + wallHitDistance: 4.246, + otherHits: [ + hit(4.246, 'passive'), // framing stud bucket + hit(4.246, 'self-wall'), // own render mesh (invisible-variant material) + hit(4.265, 'passive'), + hit(4.266, 'passive'), + hit(4.422, 'passive'), + hit(6.081, 'selectable'), // Double Bed + hit(6.271, 'selectable'), + ], + }), + ).toBe(false) + }) + + test("the wall's own render/collision hits are neutral — never self-defeating", () => { + expect( + hiddenWallOutrankedOnRay({ + wallHitDistance: 5, + otherHits: [hit(5, 'self-wall'), hit(5.01, 'self-wall')], + }), + ).toBe(false) + }) + test('hosted children (doors / windows) outrank at ANY depth gap — grazing angles included', () => { expect( hiddenWallOutrankedOnRay({ wallHitDistance: 5, // A door panel hit far beyond epsilon along a grazing ray. - otherHits: [{ distance: 5 + 3 * EPS, isWallCollision: false, hostedByThisWall: true }], + otherHits: [hit(5 + 3 * EPS, 'selectable', true)], }), ).toBe(true) }) - test('hits nearer than the wall outrank it (plain distance order)', () => { + test('selectables nearer than the wall outrank it (plain distance order)', () => { expect( hiddenWallOutrankedOnRay({ wallHitDistance: 5, - otherHits: [{ distance: 3, isWallCollision: false, hostedByThisWall: false }], + otherHits: [hit(3, 'selectable')], }), ).toBe(true) }) @@ -145,9 +186,9 @@ describe('hiddenWallOutrankedOnRay', () => { wallHitDistance: 5, otherHits: [ // The receptacle, sitting at its own wall's face 2m behind this one… - { distance: 7, isWallCollision: false, hostedByThisWall: false }, - // …anchored by that wall's collision hit right behind it. - { distance: 7 + EPS / 2, isWallCollision: true, hostedByThisWall: false }, + hit(7, 'selectable'), + // …anchored by that wall's hit right behind it. + hit(7 + EPS / 2, 'other-wall'), ], }), ).toBe(true) @@ -159,9 +200,9 @@ describe('hiddenWallOutrankedOnRay', () => { wallHitDistance: 5, otherHits: [ // Sofa mid-room: not near ANY wall hit on the ray. - { distance: 7, isWallCollision: false, hostedByThisWall: false }, + hit(7, 'selectable'), // The room's far wall, well beyond the sofa. - { distance: 10, isWallCollision: true, hostedByThisWall: false }, + hit(10, 'other-wall'), ], }), ).toBe(false) @@ -173,7 +214,7 @@ describe('hiddenWallOutrankedOnRay', () => { expect( hiddenWallOutrankedOnRay({ wallHitDistance: 5, - otherHits: [{ distance: 5.1, isWallCollision: true, hostedByThisWall: false }], + otherHits: [hit(5.1, 'other-wall')], }), ).toBe(false) }) @@ -185,11 +226,19 @@ describe('extractWallSelectionRay', () => { parent, }) - test('reduces a live event: self excluded, wall collisions flagged, subtree hits marked hosted', () => { + // Classifier stand-in: ownership by an explicit map, 'passive' otherwise — + // the real classifier (selection-hit-owner.ts) is tested separately. + const classifierFor = + (owners: Map) => (object: WallRayObjectLike) => + owners.get(object) ?? 'passive' + + test('reduces a live event: self excluded, ownership applied, subtree hits marked hosted', () => { const wallRoot = chain(null) - const selfCollision = chain(wallRoot, WALL_COLLISION_MESH_NAME) + const selfCollision = chain(wallRoot, 'collision-mesh') + const selfRenderMesh = wallRoot // the outer render mesh IS the registered root const hostedDoorMesh = chain(chain(wallRoot)) // door mesh nested under the wall root - const otherWallCollision = chain(chain(null), WALL_COLLISION_MESH_NAME) + const framingMesh = chain(chain(null)) + const otherWallCollision = chain(chain(null), 'collision-mesh') const sofaMesh = chain(chain(null)) const ray = extractWallSelectionRay( @@ -198,48 +247,66 @@ describe('extractWallSelectionRay', () => { object: selfCollision, intersections: [ { distance: 5, object: selfCollision }, + { distance: 5, object: selfRenderMesh }, + { distance: 5.01, object: framingMesh }, { distance: 5.2, object: hostedDoorMesh }, { distance: 7, object: sofaMesh }, { distance: 7.1, object: otherWallCollision }, ], }, wallRoot, + classifierFor( + new Map([ + [selfRenderMesh, 'self-wall'], + [hostedDoorMesh, 'selectable'], + [framingMesh, 'passive'], + [otherWallCollision, 'other-wall'], + [sofaMesh, 'selectable'], + ]), + ), ) expect(ray).toEqual({ wallHitDistance: 5, otherHits: [ - { distance: 5.2, isWallCollision: false, hostedByThisWall: true }, - { distance: 7, isWallCollision: false, hostedByThisWall: false }, - { distance: 7.1, isWallCollision: true, hostedByThisWall: false }, + { distance: 5, ownership: 'self-wall', hostedByThisWall: false }, + { distance: 5.01, ownership: 'passive', hostedByThisWall: false }, + { distance: 5.2, ownership: 'selectable', hostedByThisWall: true }, + { distance: 7, ownership: 'selectable', hostedByThisWall: false }, + { distance: 7.1, ownership: 'other-wall', hostedByThisWall: false }, ], }) }) test('events without ray data reduce to undefined (→ #683 transparent fallback)', () => { - expect(extractWallSelectionRay(undefined, null)).toBeUndefined() - expect(extractWallSelectionRay({}, null)).toBeUndefined() - expect(extractWallSelectionRay({ distance: 5, object: chain(null) }, null)).toBeUndefined() + const classify = classifierFor(new Map()) + expect(extractWallSelectionRay(undefined, null, classify)).toBeUndefined() + expect(extractWallSelectionRay({}, null, classify)).toBeUndefined() + expect( + extractWallSelectionRay({ distance: 5, object: chain(null) }, null, classify), + ).toBeUndefined() expect( - extractWallSelectionRay({ object: chain(null), intersections: [] }, null), + extractWallSelectionRay({ object: chain(null), intersections: [] }, null, classify), ).toBeUndefined() }) test('a null wall root marks nothing as hosted (wall not registered yet)', () => { - const self = chain(null, WALL_COLLISION_MESH_NAME) + const self = chain(null, 'collision-mesh') + const selectable = chain(null) const ray = extractWallSelectionRay( { distance: 5, object: self, intersections: [ { distance: 5, object: self }, - { distance: 5.1, object: chain(null) }, + { distance: 5.1, object: selectable }, ], }, null, + classifierFor(new Map([[selectable, 'selectable' as const]])), ) expect(ray?.otherHits).toEqual([ - { distance: 5.1, isWallCollision: false, hostedByThisWall: false }, + { distance: 5.1, ownership: 'selectable', hostedByThisWall: false }, ]) }) }) diff --git a/packages/nodes/src/wall/pointer-transparency.ts b/packages/nodes/src/wall/pointer-transparency.ts index 5d4db75c8..862ad2548 100644 --- a/packages/nodes/src/wall/pointer-transparency.ts +++ b/packages/nodes/src/wall/pointer-transparency.ts @@ -6,35 +6,46 @@ * every pointer event. #683 made them blanket pointer-TRANSPARENT so clicks * reached the visible objects behind them (wall-mounted plugin device / * service boxes, items). That over-corrected hover + selection: with the - * Bones X-ray on (walls hidden, framing members — handler-less - * InstancedMeshes — rendering where the walls are), mousing over a wall - * highlighted and selected the furniture BEHIND it, because nothing at the - * wall's depth was a ray candidate at all. + * Bones X-ray on (walls hidden, framing rendering where the walls are), + * mousing over a wall highlighted and selected the furniture BEHIND it, + * because nothing at the wall's depth was allowed to win. * - * The rule is now NEAREST-FIRST with wall-furniture priority: a hidden wall - * participates in hover/selection raycasts and wins when it is genuinely the - * closest thing on the ray, but it YIELDS (early-return, no stopPropagation, - * so R3F falls through to the real target) whenever any other interactive - * hit outranks it: + * The rule is NEAREST-FIRST over hits that OWN SELECTION SEMANTICS. The + * live event raycast recurses through the level/building wrapper groups + * (they carry pointer handlers), so `event.intersections` also contains + * passive geometry — Bones framing InstancedMeshes sit exactly at the + * wall's own depth (QA f2 probe6/probe7), and the wall's own render mesh + * rides the same list. Rank by distance alone and the hidden wall yields + * everywhere its overlay (or its own body) renders — i.e. always. So every + * hit is first classified by its nearest REGISTERED node ancestor + * (`selection-hit-owner.ts`): * - * - a hit HOSTED by this wall (its own doors / windows / wall-mounted - * children — subtree membership, so grazing angles can't inflate the - * depth gap past any epsilon); - * - a hit at ~equal-or-nearer depth (`HIDDEN_WALL_SELECTION_EPSILON` - * tie-break: device boxes flush with / proud of / recessed into the face, - * items standing in front of the wall); - * - a WALL-MOUNTED hit anywhere further down the ray — a non-wall hit - * within epsilon of some other wall's collision hit (the #683 / night-5 - * D4 class: a visible receptacle on a wall two meters BEHIND an - * interposed hidden wall must still win — the interposed wall falls - * through exactly like the #694 MOVE gate does). + * - 'self-wall' hits (own render/collision/treatment meshes) are neutral; + * - 'other-wall' hits never compete directly (two hidden walls must not + * both yield and drop the event into the room behind — delivery order + * gives the nearest one the event) but ANCHOR the wall-mounted test; + * - 'passive' hits (framing members, gizmos, the grid — no selectable-node + * ancestry, or a level/building wrapper as their nearest handler owner) + * never outrank the wall; + * - 'selectable' hits (furniture, devices, openings, slabs …) outrank the + * hidden wall when any of these hold: + * 1. HOSTED by this wall (its own doors / windows / wall-mounted + * children — subtree membership, so grazing angles can't inflate + * the depth gap past any epsilon); + * 2. at ~equal-or-nearer depth (`HIDDEN_WALL_SELECTION_EPSILON` + * tie-break: device boxes flush with / proud of / recessed into the + * face, items standing in front of the wall); + * 3. WALL-MOUNTED further down the ray — within epsilon of some other + * wall's hit (the #683 / night-5 D4 class: a visible receptacle on + * a wall two meters BEHIND an interposed hidden wall still wins — + * the interposed wall falls through, like the #694 MOVE gate). * - * Free-standing hits clearly behind the wall (a sofa mid-room, the floor - * slab, the grid) no longer outrank it: the wall in front highlights, which - * is what the ray visually strikes when the Bones framing renders there. - * Trade-off (deliberate, host-side only — no plugin presence flag): in a - * plain manual 'down' mode with NO overlay rendering at the wall, that same - * wall strip becomes hover/selectable again even though it draws nothing. + * Free-standing selectables clearly behind the wall (a sofa mid-room) no + * longer outrank it: the wall in front highlights, which is what the ray + * visually strikes when the Bones framing renders there. Trade-off + * (deliberate, host-side only — no plugin presence flag): in a plain manual + * 'down' mode with NO overlay rendering at the wall, that wall strip is + * hover/selectable even though it draws nothing. * * Two pre-existing exceptions keep ALL events flowing unconditionally: * @@ -52,6 +63,8 @@ * an R3F rig; the renderer supplies live values per event. */ +import type { WallRayHitOwnership } from './selection-hit-owner' + /** * Depth tie-break for "at the wall face": in-wall boxes sit flush-to- * recessed within a wall thickness (0.09–0.3 m); openings sit inside the @@ -64,13 +77,13 @@ export const HIDDEN_WALL_SELECTION_EPSILON = 0.35 /** The wall renderer names its invisible pick mesh this (see renderer.tsx). */ export const WALL_COLLISION_MESH_NAME = 'collision-mesh' -/** One interactive raycast hit, reduced to what the yield rule needs. */ +/** One raycast hit, reduced to what the yield rule needs. */ export type WallRayHit = { /** Distance along the ray, in meters (three.js Intersection.distance). */ distance: number - /** True when the hit object is some wall's invisible collision mesh. */ - isWallCollision: boolean - /** True when the hit object lives inside THIS wall's rendered subtree. */ + /** Who owns the hit — see `selection-hit-owner.ts`. */ + ownership: WallRayHitOwnership + /** True when a 'selectable' hit lives inside THIS wall's rendered subtree. */ hostedByThisWall: boolean } @@ -78,7 +91,7 @@ export type WallRayHit = { export type WallSelectionRay = { /** Distance of this wall's own collision-mesh hit. */ wallHitDistance: number - /** Every other interactive hit on the same ray (self excluded). */ + /** Every other hit on the same ray (the delivered hit itself excluded). */ otherHits: ReadonlyArray } @@ -90,25 +103,15 @@ export const hiddenWallOutrankedOnRay = ( ray: WallSelectionRay, epsilon: number = HIDDEN_WALL_SELECTION_EPSILON, ): boolean => { - // Other walls' hits never compete directly (two hidden walls must not - // BOTH yield and drop the event through to the room behind — the nearest - // one wins by delivery order). They only anchor the wall-mounted test. const wallAnchors: number[] = [] for (const hit of ray.otherHits) { - if (hit.isWallCollision) wallAnchors.push(hit.distance) + if (hit.ownership === 'other-wall') wallAnchors.push(hit.distance) } return ray.otherHits.some((hit) => { - if (hit.isWallCollision) return false - // The wall's own hosted children (doors, windows, wall-mounted items) - // always win, at any incidence angle. + if (hit.ownership !== 'selectable') return false if (hit.hostedByThisWall) return true - // Nearer, or at ~the wall face: devices flush/proud/recessed, items in - // front of the wall. if (hit.distance <= ray.wallHitDistance + epsilon) return true - // Wall-mounted gear further down the ray (a receptacle on a wall behind - // this one): visible through the framing, deliberately small targets — - // an interposed hidden wall must not swallow them (D4). return wallAnchors.some((anchor) => Math.abs(hit.distance - anchor) <= epsilon) }) } @@ -137,13 +140,16 @@ const isInSubtree = (object: WallRayObjectLike, root: object | null): boolean => * Reduce a live R3F pointer event (Intersection & { intersections }) to the * `WallSelectionRay` the yield rule consumes. `wallRoot` is the wall's * registered outer mesh — its subtree hosts the collision mesh, treatments, - * and the hosted door / window / item renderers. Returns undefined when the - * event carries no usable ray data (synthetic replays); the caller then - * falls back to full transparency, #683's original behavior. + * and the hosted door / window / item renderers. `classify` resolves each + * hit's owner (`createWallRayHitClassifier(node.id)` in the renderer). + * Returns undefined when the event carries no usable ray data (synthetic + * replays); the caller then falls back to full transparency, #683's + * original behavior. */ export const extractWallSelectionRay = ( event: unknown, wallRoot: object | null, + classify: (object: WallRayObjectLike) => WallRayHitOwnership, ): WallSelectionRay | undefined => { const e = event as { distance?: unknown @@ -158,12 +164,11 @@ export const extractWallSelectionRay = ( for (const hit of e.intersections as WallRayIntersectionLike[]) { if (!hit || typeof hit.distance !== 'number' || !hit.object) continue if (hit.object === self) continue + const ownership = classify(hit.object) otherHits.push({ distance: hit.distance, - isWallCollision: hit.object.name === WALL_COLLISION_MESH_NAME, - // Self is excluded above, so subtree membership here means a HOSTED - // child (door / window / wall-mounted item), not the pick mesh. - hostedByThisWall: isInSubtree(hit.object, wallRoot), + ownership, + hostedByThisWall: ownership === 'selectable' && isInSubtree(hit.object, wallRoot), }) } return { wallHitDistance: e.distance, otherHits } diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 935ab3022..f80e77ff8 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -18,6 +18,7 @@ import { WALL_COLLISION_MESH_NAME, wallPointerEventsSuppressed, } from './pointer-transparency' +import { createWallRayHitClassifier } from './selection-hit-owner' import { useWallTreatmentLevelData } from './treatment-level-data' import { createWallExtraSlotMaterials, WallTreatments } from './treatments' @@ -63,18 +64,22 @@ const WallRenderer = ({ node }: { node: WallNode }) => { // 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 handles the event - // only when nothing else on the ray outranks it — its own hosted doors / - // windows / wall-mounted children, any hit at ~equal-or-nearer depth - // (device boxes at the face), or wall-mounted gear on a wall behind it - // (the #683 D4 receptacle class) all win instead. Returning early without - // stopPropagation lets R3F continue to that next intersection. Free- - // standing objects clearly BEHIND the wall no longer steal the hover: the - // wall in front highlights (the Bones framing renders exactly there). + // only when no hit that OWNS selection semantics outranks it — its own + // hosted doors / windows / wall-mounted children, any selectable at + // ~equal-or-nearer depth (device boxes at the face), or wall-mounted gear + // on a wall behind it (the #683 D4 receptacle class) all win instead. + // Passive geometry (Bones framing members, the wall's own render mesh, + // gizmos — see `selection-hit-owner.ts`) never outranks it. Returning + // early without stopPropagation lets R3F continue to that next + // intersection. Free-standing objects clearly BEHIND the wall no longer + // steal the hover: the wall in front highlights (the Bones framing + // renders exactly there). // Two exceptions keep ALL 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 classifyRayHit = useMemo(() => createWallRayHitClassifier(node.id), [node.id]) const handlers = useMemo(() => { const gated = {} as typeof rawHandlers for (const key of Object.keys(rawHandlers) as (keyof typeof rawHandlers)[]) { @@ -88,7 +93,9 @@ const WallRenderer = ({ node }: { node: WallNode }) => { hiddenWallHoldActive: hiddenWallPointerEventsHeld(), // Reduced lazily: visible walls never suppress, so don't walk // the intersection list for every hover move over them. - selectionRay: wallHidden ? extractWallSelectionRay(e, ref.current) : undefined, + selectionRay: wallHidden + ? extractWallSelectionRay(e, ref.current, classifyRayHit) + : undefined, }) ) { return @@ -97,7 +104,7 @@ const WallRenderer = ({ node }: { node: WallNode }) => { } } return gated - }, [rawHandlers]) + }, [classifyRayHit, rawHandlers]) const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) diff --git a/packages/nodes/src/wall/selection-hit-owner.test.ts b/packages/nodes/src/wall/selection-hit-owner.test.ts new file mode 100644 index 000000000..641592bc0 --- /dev/null +++ b/packages/nodes/src/wall/selection-hit-owner.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from 'bun:test' +import { createWallRayHitClassifier, type HitOwnerDeps } from './selection-hit-owner' + +// Ownership resolution for the hidden-wall nearest-first rule: each hit is +// classified by its NEAREST sceneRegistry-registered ancestor. This is what +// keeps passive geometry — Bones framing InstancedMeshes riding the level +// wrapper's pointer handlers into event.intersections (QA f2 probe6), the +// wall's own render mesh, the grid — from outranking a hidden wall, while +// real selection targets (furniture, devices, openings) still do. + +type Obj = { name?: string; parent?: Obj | null } +const node = (parent: Obj | null, name?: string): Obj => ({ name, parent }) + +/** A tiny fake scene graph + registry, mirroring the live editor's shape. */ +function buildFixture() { + const levelGroup = node(null, 'level-wrapper') // carries pointer handlers live + const wallRoot = node(levelGroup, 'wall-mesh') + const wallCollision = node(wallRoot, 'collision-mesh') + const wallTrim = node(node(wallRoot), 'trim') + const doorRoot = node(wallRoot, 'door-root') + const doorPanel = node(node(doorRoot), 'panel') + const otherWallRoot = node(levelGroup, 'wall-mesh') + const framingRoot = node(levelGroup, 'framing-root') + const framingMember = node(node(framingRoot), 'Mesh') // InstancedMesh bucket + const deviceRoot = node(levelGroup, 'device-root') + const deviceBox = node(deviceRoot, 'box') + const bedRoot = node(levelGroup, 'bed-root') + const bedMesh = node(node(bedRoot), 'bed_015') + const zoneRoot = node(levelGroup, 'zone-root') + const gizmo = node(null, 'arrow-handle') // never registered + + const registered: [string, object][] = [ + ['level1', levelGroup], + ['wallA', wallRoot], + ['wallB', otherWallRoot], + ['door1', doorRoot], + ['framing1', framingRoot], + ['device1', deviceRoot], + ['bed1', bedRoot], + ['zone1', zoneRoot], + ] + const kinds: Record = { + level1: 'level', + wallA: 'wall', + wallB: 'wall', + door1: 'door', + framing1: 'bones:framing', + device1: 'bones:device', + bed1: 'item', + zone1: 'zone', + } + + let revision = 1 + const deps: HitOwnerDeps = { + registryRevision: () => revision, + registeredEntries: () => registered.values(), + kindOf: (id) => kinds[id], + // Plugin registry: bones:device declares `selectable`, bones:framing + // does not (panel-only UI, hidden in 3D). + isRegistrySelectableKind: (kind) => kind === 'bones:device', + } + + return { + deps, + bumpRevision: (mutate: () => void) => { + mutate() + revision += 1 + }, + registered, + kinds, + objects: { + wallCollision, + wallTrim, + doorPanel, + otherWallRoot, + framingMember, + deviceBox, + bedMesh, + zoneRoot, + gizmo, + levelGroup, + }, + } +} + +describe('createWallRayHitClassifier', () => { + test("own collision / trim meshes are 'self-wall'; another wall is 'other-wall'", () => { + const { deps, objects } = buildFixture() + const classify = createWallRayHitClassifier('wallA', deps) + expect(classify(objects.wallCollision)).toBe('self-wall') + expect(classify(objects.wallTrim)).toBe('self-wall') + expect(classify(objects.otherWallRoot)).toBe('other-wall') + }) + + test('hosted door meshes resolve to the DOOR (registered deeper than the host wall)', () => { + const { deps, objects } = buildFixture() + const classify = createWallRayHitClassifier('wallA', deps) + expect(classify(objects.doorPanel)).toBe('selectable') + }) + + test("framing members are 'passive' — registered overlay node without the selectable capability", () => { + const { deps, objects } = buildFixture() + const classify = createWallRayHitClassifier('wallA', deps) + expect(classify(objects.framingMember)).toBe('passive') + }) + + test('plugin device boxes are selectable via the registry capability', () => { + const { deps, objects } = buildFixture() + const classify = createWallRayHitClassifier('wallA', deps) + expect(classify(objects.deviceBox)).toBe('selectable') + }) + + test('furniture resolves to its item node — selectable', () => { + const { deps, objects } = buildFixture() + const classify = createWallRayHitClassifier('wallA', deps) + expect(classify(objects.bedMesh)).toBe('selectable') + }) + + test("level wrappers and zones are 'passive' — QA's rule: wrapper-owned hits must not outrank", () => { + const { deps, objects } = buildFixture() + const classify = createWallRayHitClassifier('wallA', deps) + // A hit whose nearest registered ancestor is the LEVEL wrapper itself. + expect(classify(objects.levelGroup)).toBe('passive') + expect(classify(objects.zoneRoot)).toBe('passive') + }) + + test("unregistered ancestry (gizmos, grid) is 'passive'", () => { + const { deps, objects } = buildFixture() + const classify = createWallRayHitClassifier('wallA', deps) + expect(classify(objects.gizmo)).toBe('passive') + }) + + test("a registered id whose node is gone from the scene is 'passive'", () => { + const { deps, kinds, objects } = buildFixture() + delete kinds.bed1 + const classify = createWallRayHitClassifier('wallA', deps) + expect(classify(objects.bedMesh)).toBe('passive') + }) + + test('the reverse lookup follows registry revisions (late-registering nodes classify)', () => { + const { deps, bumpRevision, registered, kinds, objects } = buildFixture() + const classify = createWallRayHitClassifier('wallA', deps) + // Prime the cache… + expect(classify(objects.bedMesh)).toBe('selectable') + // …then a new selectable node registers (plugin load, new furniture). + const lateRoot: Obj = { name: 'late-root', parent: objects.levelGroup } + const lateMesh: Obj = { name: 'late-mesh', parent: lateRoot } + expect(classify(lateMesh)).toBe('passive') // cached map: not registered yet + bumpRevision(() => { + registered.push(['late1', lateRoot as object]) + kinds.late1 = 'item' + }) + expect(classify(lateMesh)).toBe('selectable') + }) +}) diff --git a/packages/nodes/src/wall/selection-hit-owner.ts b/packages/nodes/src/wall/selection-hit-owner.ts new file mode 100644 index 000000000..8689dbfc6 --- /dev/null +++ b/packages/nodes/src/wall/selection-hit-owner.ts @@ -0,0 +1,130 @@ +import { isRegistrySelectable, sceneRegistry, useScene } from '@pascal-app/core' + +/** + * Who "owns" a raycast hit, for the hidden-wall nearest-first selection rule + * (`pointer-transparency.ts`)? + * + * The live R3F event raycast recurses through the level/building wrapper + * groups (they carry pointer handlers), so `event.intersections` contains + * every mesh under them — including PASSIVE geometry that owns no selection + * semantics: plugin overlay members (the Bones framing InstancedMeshes sit + * exactly at the wall's depth), helper meshes, the grid. Distance alone + * cannot rank those against a hidden wall; ownership can: + * + * - 'self-wall' — the hit resolves to THIS wall (its own collision mesh, + * render mesh, treatments). Neutral: a wall cannot outrank + * itself, and must not yield to itself either. + * - 'other-wall' — the hit resolves to a different wall. Never a direct + * competitor (two hidden walls must not both yield and + * drop the event into the room behind — delivery order + * already gives the nearest one the event), but an ANCHOR + * for the wall-mounted test. + * - 'selectable' — the hit resolves to a node the editor can select + * (furniture, devices, openings, slabs …). These are the + * real competitors. + * - 'passive' — no selectable-node ancestry (framing members, gizmos, + * the grid, unregistered helpers). Never outranks a wall. + * + * Ownership = the hit object's NEAREST ancestor registered in + * `sceneRegistry` (every node's renderer registers its root). A hosted + * door's meshes resolve to the door (registered deeper than its host wall), + * a wall's own trim resolves to the wall, a framing member resolves to the + * plugin's overlay node (registered, but not selectable → passive). + */ +export type WallRayHitOwnership = 'self-wall' | 'other-wall' | 'selectable' | 'passive' + +/** + * Built-in kinds with selection semantics in the editor (mirrors + * SelectionManager's structure/furnish lists until Phase 4 makes + * `capabilities.selectable` the single source of truth). Wrapper kinds + * (level/building/site) and the zone volume are deliberately absent: a hit + * whose nearest owner is a wrapper is passive scenery, and zone volumes + * share the wall's own planes. + */ +const BUILTIN_SELECTABLE_COMPETITOR_KINDS = new Set([ + 'fence', + 'item', + 'column', + 'elevator', + 'slab', + 'ceiling', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'spawn', + 'window', + 'door', + 'shelf', +]) + +/** Injectable seams so the classifier is testable without the live editor. */ +export type HitOwnerDeps = { + /** Bumped whenever a node (un)registers — invalidates the reverse map. */ + registryRevision: () => number + /** All registered (nodeId, root Object3D) pairs. */ + registeredEntries: () => Iterable<[string, object]> + /** The node kind for a registered id (undefined once the node is gone). */ + kindOf: (id: string) => string | undefined + /** Plugin kinds that declare `capabilities.selectable`. */ + isRegistrySelectableKind: (kind: string) => boolean +} + +const liveDeps: HitOwnerDeps = { + registryRevision: () => sceneRegistry.revision, + registeredEntries: () => sceneRegistry.nodes.entries(), + // The store index is keyed by AnyNodeId; keep the loose read in one place. + kindOf: (id) => + (useScene.getState().nodes as Record)[id]?.type, + isRegistrySelectableKind: isRegistrySelectable, +} + +type ObjectLike = { parent?: ObjectLike | null } + +/** + * Reverse lookup (Object3D → registered node id), rebuilt lazily when the + * scene registry's revision moves. One map per classifier factory; the + * default factory below shares a single module-level instance. + */ +const createRegisteredObjectLookup = (deps: HitOwnerDeps) => { + let revision = -1 + let reverse = new Map() + return (object: ObjectLike): string | null => { + const currentRevision = deps.registryRevision() + if (currentRevision !== revision) { + revision = currentRevision + reverse = new Map() + for (const [id, root] of deps.registeredEntries()) reverse.set(root, id) + } + let current: ObjectLike | null | undefined = object + while (current) { + const id = reverse.get(current as object) + if (id !== undefined) return id + current = current.parent + } + return null + } +} + +const isSelectableCompetitorKind = (kind: string, deps: HitOwnerDeps): boolean => + BUILTIN_SELECTABLE_COMPETITOR_KINDS.has(kind) || deps.isRegistrySelectableKind(kind) + +/** + * Build a classifier for one wall's pointer gate. `selfWallId` is that + * wall's node id; hits resolving to it are 'self-wall'. + */ +export const createWallRayHitClassifier = ( + selfWallId: string, + deps: HitOwnerDeps = liveDeps, +): ((object: ObjectLike) => WallRayHitOwnership) => { + const nearestRegisteredId = createRegisteredObjectLookup(deps) + return (object) => { + const ownerId = nearestRegisteredId(object) + if (ownerId === null) return 'passive' + if (ownerId === selfWallId) return 'self-wall' + const kind = deps.kindOf(ownerId) + if (kind === undefined) return 'passive' + if (kind === 'wall') return 'other-wall' + return isSelectableCompetitorKind(kind, deps) ? 'selectable' : 'passive' + } +} From ffc98cb14585f3b2446c28f19fa121ffcffac4ec Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Thu, 20 Aug 2026 17:35:10 -0400 Subject: [PATCH 4/7] =?UTF-8?q?fix(viewer):=20walls=20can=20never=20stay?= =?UTF-8?q?=20degenerate=20=E2=80=94=20system=20resilience=20+=20self-heal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../nodes/src/shared/placeholder-geometry.ts | 4 + .../components/viewer/registered-systems.tsx | 35 ++++++-- .../wall/wall-placeholder-sweep.test.ts | 83 +++++++++++++++++++ .../systems/wall/wall-placeholder-sweep.ts | 66 +++++++++++++++ .../viewer/src/systems/wall/wall-system.tsx | 36 ++++++-- 5 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 packages/viewer/src/systems/wall/wall-placeholder-sweep.test.ts create mode 100644 packages/viewer/src/systems/wall/wall-placeholder-sweep.ts diff --git a/packages/nodes/src/shared/placeholder-geometry.ts b/packages/nodes/src/shared/placeholder-geometry.ts index c3c2cb349..ccd864af8 100644 --- a/packages/nodes/src/shared/placeholder-geometry.ts +++ b/packages/nodes/src/shared/placeholder-geometry.ts @@ -21,6 +21,10 @@ import { BufferGeometry, Float32BufferAttribute } from 'three' */ export function createPlaceholderGeometry(groupCount = 0): BufferGeometry { const geometry = new BufferGeometry() + // Owning systems (and the wall self-heal sweep in + // viewer/systems/wall/wall-placeholder-sweep.ts) can tell "never built" + // from "built" without guessing off vertex counts. + geometry.userData.placeholder = true geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3)) geometry.setAttribute('normal', new Float32BufferAttribute(new Float32Array(9), 3)) geometry.setAttribute('uv', new Float32BufferAttribute(new Float32Array(6), 2)) diff --git a/packages/viewer/src/components/viewer/registered-systems.tsx b/packages/viewer/src/components/viewer/registered-systems.tsx index 03dba2dc2..e3930270a 100644 --- a/packages/viewer/src/components/viewer/registered-systems.tsx +++ b/packages/viewer/src/components/viewer/registered-systems.tsx @@ -5,6 +5,7 @@ import { createSceneApi, isNodeKindEnabled, nodeRegistry, + useRegistryVersion, useScene, } from '@pascal-app/core' import { type ComponentType, lazy, Suspense, useMemo } from 'react' @@ -32,16 +33,28 @@ function loadSystem(def: AnyNodeDefinition): ComponentType`. Once kinds register via - * `@pascal-app/nodes`, each kind's registry-driven system takes over and - * its legacy counterpart short-circuits via the `nodeRegistry.has(kind)` - * guard added to each legacy system. + * Two resilience rules, both learned from a live session in which the wall + * systems bundle (geometry rebuild + cutout stamps + batching) never ran + * while everything else did (QA f2 probe6: 24 walls stuck on placeholder + * geometry, no `wallHidden` stamps, base materials untouched): + * + * 1. `entries` re-derives on `useRegistryVersion()` — kinds register + * asynchronously (plugin discovery, HMR), and a list snapshotted once at + * mount permanently drops any system whose kind registers later. Same + * staleness class SelectionManager already guards against ("plugin + * nodes select-but-never-hover"). + * 2. Each system gets its OWN Suspense boundary. With one shared boundary, + * ANY lazily-loading (or load-failing) system chunk unmounts every + * other system while it is pending — one bad chunk must not take the + * wall pipeline down with it. */ export function RegisteredSystems() { const sceneApi = useMemo(() => createSceneApi(useScene), []) const installedPlugins = useScene((state) => state.installedPlugins) + const registryVersion = useRegistryVersion() const entries = useMemo(() => { + // re-derive when kinds register after mount (async plugin load) + void registryVersion return Array.from(nodeRegistry.entries()) .filter(([, def]) => def.system != null) .sort(([, a], [, b]) => { @@ -49,18 +62,22 @@ export function RegisteredSystems() { const pb = b.system?.priority ?? DEFAULT_PRIORITY return pa - pb }) - }, []) + }, [registryVersion]) if (entries.length === 0) return null return ( - + <> {entries.map(([kind, def]) => { if (!isNodeKindEnabled(kind, installedPlugins)) return null const Comp = loadSystem(def) if (!Comp) return null - return + return ( + + + + ) })} - + ) } diff --git a/packages/viewer/src/systems/wall/wall-placeholder-sweep.test.ts b/packages/viewer/src/systems/wall/wall-placeholder-sweep.test.ts new file mode 100644 index 000000000..c9530ce07 --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-placeholder-sweep.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' +import { isPlaceholderWallGeometry, sweepUnbuiltWalls } from './wall-placeholder-sweep' + +// The wall-geometry self-heal: walls stuck on their mount-time placeholder +// (QA f2 probe5/probe6 — a scene loaded with the X-ray already active kept +// all 24 collision meshes as degenerate points forever) get their dirty +// mark re-issued so the normal rebuild loop picks them up. + +const placeholderStamped = { userData: { placeholder: true } } +const placeholderLegacy = { + userData: {}, + getAttribute: (name: string) => (name === 'position' ? { count: 3 } : undefined), +} +const builtWall = { + userData: {}, + getAttribute: (name: string) => (name === 'position' ? { count: 264 } : undefined), +} + +describe('isPlaceholderWallGeometry', () => { + test('stamped placeholders and 3-vertex degenerate triangles are placeholders', () => { + expect(isPlaceholderWallGeometry(placeholderStamped)).toBe(true) + expect(isPlaceholderWallGeometry(placeholderLegacy)).toBe(true) + }) + + test('built wall geometry and missing geometry are not', () => { + expect(isPlaceholderWallGeometry(builtWall)).toBe(false) + expect(isPlaceholderWallGeometry(null)).toBe(false) + expect(isPlaceholderWallGeometry({ userData: {} })).toBe(false) + }) +}) + +describe('sweepUnbuiltWalls', () => { + test('re-marks placeholder walls that lost their dirty mark; leaves the rest alone', () => { + const marked: string[] = [] + const result = sweepUnbuiltWalls({ + wallIds: ['stuck', 'built', 'pending', 'unmounted'], + geometryOf: (id) => + id === 'stuck' || id === 'pending' ? placeholderStamped : id === 'built' ? builtWall : null, + isDirty: (id) => id === 'pending', // rebuild already queued — don't double-mark + markDirty: (id) => marked.push(id), + }) + expect(result).toEqual(['stuck']) + expect(marked).toEqual(['stuck']) + }) + + test('idempotent once the rebuild lands: a built wall is never re-marked', () => { + const marked: string[] = [] + sweepUnbuiltWalls({ + wallIds: ['w1'], + geometryOf: () => builtWall, + isDirty: () => false, + markDirty: (id) => marked.push(id), + }) + expect(marked).toEqual([]) + }) + + test('a wall whose mark is consumed without a rebuild converges: sweep → dirty → built', () => { + // Frame 1: the mark exists (mount). Something consumes it without + // building. Frame N (sweep): re-marked. Frame N+1: system builds, + // geometry stops being a placeholder — the sweep goes quiet. + let dirty = new Set() + let geometry: typeof placeholderStamped | typeof builtWall = placeholderStamped + + // the mark was lost + dirty.clear() + + const sweep = () => + sweepUnbuiltWalls({ + wallIds: ['w1'], + geometryOf: () => geometry, + isDirty: (id) => dirty.has(id), + markDirty: (id) => dirty.add(id), + }) + + expect(sweep()).toEqual(['w1']) + expect(dirty.has('w1')).toBe(true) + + // the rebuild loop consumes the mark and fills the geometry + dirty = new Set() + geometry = builtWall + expect(sweep()).toEqual([]) + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-placeholder-sweep.ts b/packages/viewer/src/systems/wall/wall-placeholder-sweep.ts new file mode 100644 index 000000000..209bdaba6 --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-placeholder-sweep.ts @@ -0,0 +1,66 @@ +/** + * Self-healing backstop for wall geometry: every registered wall whose mesh + * still carries the mount-time PLACEHOLDER geometry must eventually rebuild, + * no matter what consumed its dirty mark or when the wall system mounted. + * + * Why it exists: a live session (QA f2 probe5/probe6 — scene loaded with + * the Bones X-ray already active) surfaced all 24 walls stuck on their + * degenerate placeholder collision meshes indefinitely: no wall on the + * level ever became a ray target, so the hidden-wall selection gate could + * not engage even in principle. The renderer marks a wall dirty exactly + * once, on mount — if that one mark is consumed while the rebuild loop + * isn't looking (system mounted late, mark cleared by an unrelated flow, + * suspense unmount/remount), nothing ever re-marks it. This sweep closes + * that class: placeholder + not dirty → re-mark, and the normal rebuild + * path takes it from there. Idempotent and cheap (one geometry probe per + * wall, run every `WALL_PLACEHOLDER_SWEEP_INTERVAL` frames). + * + * Placeholder detection: `createPlaceholderGeometry` (packages/nodes, + * shared/placeholder-geometry.ts) mints a 3-vertex degenerate triangle and + * stamps `userData.placeholder`. Real wall extrusions always carry far more + * vertices, so the vertex-count signature doubles as a fallback for + * placeholders minted before the stamp existed. + */ + +export const WALL_PLACEHOLDER_SWEEP_INTERVAL = 30 + +type GeometryLike = { + userData?: { placeholder?: unknown } + getAttribute?: (name: string) => { count: number } | undefined +} | null + +/** Is this still the mount-time placeholder (never built by the system)? */ +export const isPlaceholderWallGeometry = (geometry: GeometryLike): boolean => { + if (!geometry) return false + if (geometry.userData?.placeholder === true) return true + const position = geometry.getAttribute?.('position') + return position !== undefined && position.count === 3 +} + +/** + * Re-mark every registered wall still on placeholder geometry that carries + * no dirty mark. Returns the ids it marked (for tests / diagnostics). + */ +export const sweepUnbuiltWalls = ({ + wallIds, + geometryOf, + isDirty, + markDirty, +}: { + /** Registered wall node ids (sceneRegistry.byType.wall). */ + wallIds: Iterable + /** The wall root mesh's current geometry, or null when unmounted. */ + geometryOf: (wallId: string) => GeometryLike + /** Whether the id already has a dirty mark (rebuild pending). */ + isDirty: (wallId: string) => boolean + markDirty: (wallId: string) => void +}): string[] => { + const marked: string[] = [] + for (const wallId of wallIds) { + if (isDirty(wallId)) continue + if (!isPlaceholderWallGeometry(geometryOf(wallId))) continue + markDirty(wallId) + marked.push(wallId) + } + return marked +} diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 1ae16fb64..cbbc5b476 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -42,6 +42,7 @@ import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../li import { setGroupsSortedByMaterial } from '../../lib/geometry-groups' import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill' import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache' +import { sweepUnbuiltWalls, WALL_PLACEHOLDER_SWEEP_INTERVAL } from './wall-placeholder-sweep' import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, @@ -506,15 +507,17 @@ export function getPendingWallRebuildCount(): number { return count } +let placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL + export const WallSystem = () => { - const dirtyNodes = useScene((state) => state.dirtyNodes) + // Subscribe so scene writes and override-only changes (no scene write) + // still re-run this component. The frame body reads the LIVE set via + // `useScene.getState()` — a closure over the subscribed value goes stale + // whenever the store REPLACES the set (scene load, plugin install) in the + // window before React commits the re-render, and marks added to the new + // set in that window would be invisible to the frame. + useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) - // Subscribe so override-only changes (no scene write) still re-run - // this component, which lets the gate below pick up the latest - // `dirtyNodes` set from the same render pass that received the - // override-publishing `markDirty` call. Without this, very fast - // drags could land an override and a markDirty in the same React - // tick and the next `useFrame` would still see the stale closure. useLiveNodeOverrides((s) => s.overrides) // The miter cache is module-level, so it outlives this mount. Editor @@ -523,6 +526,25 @@ export const WallSystem = () => { useEffect(() => () => clearLevelMiterCache(), []) useFrame(() => { + // Self-heal: any registered wall still on its mount-time placeholder + // geometry with NO dirty mark gets re-marked, so a lost mark (system + // mounted late, suspense remount, mark consumed elsewhere) can never + // strand a wall as a degenerate point forever (QA f2 probe5/probe6 — + // scene loaded with the X-ray active never built any of its 24 walls). + placeholderSweepCountdown -= 1 + if (placeholderSweepCountdown <= 0) { + placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL + const sceneState = useScene.getState() + sweepUnbuiltWalls({ + wallIds: sceneRegistry.byType.wall ?? [], + geometryOf: (wallId) => + (sceneRegistry.nodes.get(wallId) as THREE.Mesh | undefined)?.geometry ?? null, + isDirty: (wallId) => sceneState.dirtyNodes.has(wallId as AnyNodeId), + markDirty: (wallId) => sceneState.markDirty(wallId as AnyNodeId), + }) + } + + const dirtyNodes = useScene.getState().dirtyNodes const hasDirty = dirtyNodes.size > 0 const hasPending = pendingAdjacentByLevel.size > 0 if (!hasDirty && !hasPending) return From bc7d2636f8fc11cbb1eb2e3254f9d977e24e363c Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Thu, 20 Aug 2026 23:44:13 -0400 Subject: [PATCH 5/7] style(viewer): biome import order for the wall-system sweep import Co-Authored-By: Claude Fable 5 --- packages/viewer/src/systems/wall/wall-system.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index cbbc5b476..49ea7a9c9 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -42,11 +42,11 @@ import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../li import { setGroupsSortedByMaterial } from '../../lib/geometry-groups' import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill' import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache' -import { sweepUnbuiltWalls, WALL_PLACEHOLDER_SWEEP_INTERVAL } from './wall-placeholder-sweep' import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, } from './opening-cutout-geometry' +import { sweepUnbuiltWalls, WALL_PLACEHOLDER_SWEEP_INTERVAL } from './wall-placeholder-sweep' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() From 0d278b8e8dacf13dd0b7a7da82a37d0799f60d5a Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Thu, 20 Aug 2026 23:48:23 -0400 Subject: [PATCH 6/7] =?UTF-8?q?feat(viewer):=20hovered=20hidden=20walls=20?= =?UTF-8?q?glow=20=E2=80=94=20the=20X-ray=20hover=20affordance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../viewer/src/systems/wall/wall-cutout.tsx | 75 +++++++++++++----- .../wall/wall-material-variant.test.ts | 76 +++++++++++++++++++ .../src/systems/wall/wall-material-variant.ts | 53 +++++++++++++ .../viewer/src/systems/wall/wall-materials.ts | 51 +++++++++++-- 4 files changed, 227 insertions(+), 28 deletions(-) create mode 100644 packages/viewer/src/systems/wall/wall-material-variant.test.ts create mode 100644 packages/viewer/src/systems/wall/wall-material-variant.ts diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 5ab0574a1..081f4f1ac 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -15,10 +15,13 @@ import { useEffect, useRef } from 'react' import type { Material } from 'three' import { type Mesh, Vector3 } from 'three/webgpu' import useViewer, { type WallMode } from '../../store/use-viewer' +import { resolveWallMaterialVariant, type WallMaterialVariant } from './wall-material-variant' import { + getHoverHighlightMaterials, getMaterialsForWall, getSelectionHighlightMaterials, getWallMaterialHash, + type WallMaterials, } from './wall-materials' const tmpVec = new Vector3() @@ -63,6 +66,36 @@ function sameMaterialArray(a: Material | Material[], b: Material[]): boolean { return Array.isArray(a) && a.length === b.length && a.every((material, i) => material === b[i]) } +/** Materialize a resolved variant from the wall's cached material set. */ +function materialsForVariant(variant: WallMaterialVariant, materials: WallMaterials) { + switch (variant) { + case 'visible': + return materials.visible + case 'invisible': + return materials.invisible + case 'translucent': + return materials.translucent + case 'delete-visible': + return materials.deleteVisible + case 'delete-invisible': + return materials.deleteInvisible + case 'delete-translucent': + return materials.deleteTranslucent + case 'selection-visible': + return getSelectionHighlightMaterials(materials.visible) + case 'selection-invisible': + return getSelectionHighlightMaterials(materials.invisible) + case 'selection-translucent': + return getSelectionHighlightMaterials(materials.translucent) + case 'hover-invisible': + return getHoverHighlightMaterials(materials.invisible) + default: { + const exhaustive: never = variant + return exhaustive + } + } +} + export const WallCutout = () => { const lastCameraPosition = useRef(new Vector3()) const lastCameraTarget = useRef(new Vector3()) @@ -109,7 +142,20 @@ export const WallCutout = () => { sceneState.nodes[hoveredId as AnyNodeId]?.type === 'wall' ? hoveredId : null - const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}` + // Select-mode hover on a wall — the affordance for HIDDEN walls, which + // are hover/selection ray targets in X-ray (nearest-first) but draw + // (almost) nothing: the hovered wall's stipple film glows so the user + // sees WHAT the click would select instead of the furniture behind it + // lighting up through the wall. Scoped to the default hover mode so the + // paint-preview flows (which snapshot + restore mesh.material + // themselves) never interleave with this swap. + const selectHoveredWallId = + hoverHighlightMode === 'default' && + hoveredId && + sceneState.nodes[hoveredId as AnyNodeId]?.type === 'wall' + ? hoveredId + : null + const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}::${selectHoveredWallId ?? ''}` // Sorting every wall id, hashing each wall's material and JSON-dumping its // face bands is a full-scene scan; its inputs are immutable store slices, // so identity is enough to know the key cannot have changed. @@ -204,25 +250,14 @@ export const WallCutout = () => { sceneState.materials, ) - if (wallMode === 'translucent') { - ;(wallMesh as Mesh).material = isDeleteHighlighted - ? materials.deleteTranslucent - : shouldSelectionHighlight - ? getSelectionHighlightMaterials(materials.translucent) - : materials.translucent - } else if (hideWall) { - ;(wallMesh as Mesh).material = isDeleteHighlighted - ? materials.deleteInvisible - : shouldSelectionHighlight - ? getSelectionHighlightMaterials(materials.invisible) - : materials.invisible - } else { - ;(wallMesh as Mesh).material = isDeleteHighlighted - ? materials.deleteVisible - : shouldSelectionHighlight - ? getSelectionHighlightMaterials(materials.visible) - : materials.visible - } + const variant = resolveWallMaterialVariant({ + translucentMode: wallMode === 'translucent', + hidden: hideWall, + deleteHighlighted: isDeleteHighlighted, + selectionHighlighted: shouldSelectionHighlight, + hoverHighlighted: selectHoveredWallId === wallId, + }) + ;(wallMesh as Mesh).material = materialsForVariant(variant, materials) }) lastWallMode.current = wallMode lastShading.current = shading diff --git a/packages/viewer/src/systems/wall/wall-material-variant.test.ts b/packages/viewer/src/systems/wall/wall-material-variant.test.ts new file mode 100644 index 000000000..047f1d70f --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-material-variant.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'bun:test' +import { resolveWallMaterialVariant } from './wall-material-variant' + +// Truth table for WallCutout's per-wall material choice. The new arm: a +// HIDDEN wall hovered in select mode glows (hover-invisible) — QA f2 found +// hidden walls were hover targets with NO visible affordance, so the only +// thing lighting up on hover was the furniture behind them. + +const base = { + translucentMode: false, + hidden: false, + deleteHighlighted: false, + selectionHighlighted: false, + hoverHighlighted: false, +} + +describe('resolveWallMaterialVariant', () => { + test('base variants by display mode', () => { + expect(resolveWallMaterialVariant(base)).toBe('visible') + expect(resolveWallMaterialVariant({ ...base, hidden: true })).toBe('invisible') + expect(resolveWallMaterialVariant({ ...base, translucentMode: true })).toBe('translucent') + // translucent mode overrides the hide state (matches getWallHideState use) + expect(resolveWallMaterialVariant({ ...base, translucentMode: true, hidden: true })).toBe( + 'translucent', + ) + }) + + test('hovered hidden wall glows — the X-ray hover affordance', () => { + expect(resolveWallMaterialVariant({ ...base, hidden: true, hoverHighlighted: true })).toBe( + 'hover-invisible', + ) + }) + + test('hover never restyles visible or translucent walls (outline pass owns those)', () => { + expect(resolveWallMaterialVariant({ ...base, hoverHighlighted: true })).toBe('visible') + expect( + resolveWallMaterialVariant({ ...base, translucentMode: true, hoverHighlighted: true }), + ).toBe('translucent') + }) + + test('selection outranks hover; delete outranks both', () => { + expect( + resolveWallMaterialVariant({ + ...base, + hidden: true, + selectionHighlighted: true, + hoverHighlighted: true, + }), + ).toBe('selection-invisible') + expect( + resolveWallMaterialVariant({ + ...base, + hidden: true, + deleteHighlighted: true, + selectionHighlighted: true, + hoverHighlighted: true, + }), + ).toBe('delete-invisible') + }) + + test('delete and selection variants track the display mode', () => { + expect(resolveWallMaterialVariant({ ...base, deleteHighlighted: true })).toBe('delete-visible') + expect( + resolveWallMaterialVariant({ ...base, translucentMode: true, deleteHighlighted: true }), + ).toBe('delete-translucent') + expect(resolveWallMaterialVariant({ ...base, selectionHighlighted: true })).toBe( + 'selection-visible', + ) + expect( + resolveWallMaterialVariant({ ...base, translucentMode: true, selectionHighlighted: true }), + ).toBe('selection-translucent') + expect(resolveWallMaterialVariant({ ...base, hidden: true, selectionHighlighted: true })).toBe( + 'selection-invisible', + ) + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-material-variant.ts b/packages/viewer/src/systems/wall/wall-material-variant.ts new file mode 100644 index 000000000..0da6255ae --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-material-variant.ts @@ -0,0 +1,53 @@ +/** + * Which material variant does a wall draw this pass? + * + * Extracted from `WallCutout`'s per-wall assignment so the precedence is a + * testable truth table. Precedence within each display mode: + * + * delete-hover > selection > select-hover > base + * + * The SELECT-HOVER variant exists for HIDDEN walls only (QA f2, REVISE): + * with the Bones X-ray on, hidden walls are hover/selection ray targets + * (nearest-first, see nodes/wall/pointer-transparency.ts) — but an + * invisible wall gave no hover feedback at all, so what the user saw + * lighting up on hover was the furniture BEHIND the wall. Hovering a hidden + * wall now glows its stipple film. Visible and translucent walls keep their + * existing hover affordance (the post-processing outline pass) — a material + * glow there would double-highlight. + */ +export type WallMaterialVariant = + | 'visible' + | 'invisible' + | 'translucent' + | 'delete-visible' + | 'delete-invisible' + | 'delete-translucent' + | 'selection-visible' + | 'selection-invisible' + | 'selection-translucent' + | 'hover-invisible' + +export const resolveWallMaterialVariant = ({ + translucentMode, + hidden, + deleteHighlighted, + selectionHighlighted, + hoverHighlighted, +}: { + /** wallMode === 'translucent' (overrides the hide state). */ + translucentMode: boolean + /** The wall-mode pass hides this wall ('down', cutaway, auto-interior). */ + hidden: boolean + /** Delete-mode hover on this wall (deleteInvisible flow). */ + deleteHighlighted: boolean + /** Selected (and the wall's face-band config allows the highlight). */ + selectionHighlighted: boolean + /** Select-mode hover on this wall (useViewer.hoveredId). */ + hoverHighlighted: boolean +}): WallMaterialVariant => { + const base = translucentMode ? 'translucent' : hidden ? 'invisible' : 'visible' + if (deleteHighlighted) return `delete-${base}` + if (selectionHighlighted) return `selection-${base}` + if (hoverHighlighted && base === 'invisible') return 'hover-invisible' + return base +} diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 4df61351b..295fd812f 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -299,6 +299,14 @@ const SELECTION_HIGHLIGHT_COLOR = new Color('#818cf8') const SELECTION_EMISSIVE_BLEND = 0.4 const SELECTION_EMISSIVE_INTENSITY = 0.12 +// Softer sibling of the selection glow, for HOVERING a hidden wall (the +// X-ray nearest-first selection made hidden walls hover targets; without a +// material affordance the only thing lighting up was the furniture behind +// them). Same indigo so hover reads as "this will select", weaker so a +// hovered-then-selected wall still steps up on click. +const HOVER_EMISSIVE_BLEND = 0.28 +const HOVER_EMISSIVE_INTENSITY = 0.07 + const SELECTION_TEXTURE_MAP_KEYS = [ 'map', 'normalMap', @@ -313,10 +321,16 @@ const SELECTION_TEXTURE_MAP_KEYS = [ ] as const const selectionHighlightCache = new WeakMap() +const hoverHighlightCache = new WeakMap() -function getSelectionHighlightMaterial(base: Material): Material { +function getEmissiveHighlightMaterial( + base: Material, + cache: WeakMap, + emissiveBlend: number, + emissiveIntensity: number, +): Material { const baseMap = (base as { map?: unknown }).map ?? null - const cached = selectionHighlightCache.get(base) + const cached = cache.get(base) if (cached && cached.map === baseMap) return cached.clone const clone = base.clone() as Material & { @@ -331,21 +345,42 @@ function getSelectionHighlightMaterial(base: Material): Material { if (src[key]) dst[key] = src[key] } if ('emissive' in clone && clone.emissive) { - clone.emissive = clone.emissive - .clone() - .lerp(SELECTION_HIGHLIGHT_COLOR, SELECTION_EMISSIVE_BLEND) + clone.emissive = clone.emissive.clone().lerp(SELECTION_HIGHLIGHT_COLOR, emissiveBlend) } if ('emissiveIntensity' in clone) { - clone.emissiveIntensity = Math.max(clone.emissiveIntensity ?? 0, SELECTION_EMISSIVE_INTENSITY) + clone.emissiveIntensity = Math.max(clone.emissiveIntensity ?? 0, emissiveIntensity) } clone.needsUpdate = true - selectionHighlightCache.set(base, { clone, map: baseMap }) + cache.set(base, { clone, map: baseMap }) return clone } /** Lazy light-emissive selection variant of a wall's material array (keeps texture). */ export function getSelectionHighlightMaterials(materials: WallMaterialArray): WallMaterialArray { - return materials.map(getSelectionHighlightMaterial) as WallMaterialArray + return materials.map((material) => + getEmissiveHighlightMaterial( + material, + selectionHighlightCache, + SELECTION_EMISSIVE_BLEND, + SELECTION_EMISSIVE_INTENSITY, + ), + ) as WallMaterialArray +} + +/** + * Softer hover sibling of the selection variant — the affordance for a + * hovered HIDDEN wall (`WallCutout` applies it to the invisible stipple + * film so the wall the click would select reads under the cursor). + */ +export function getHoverHighlightMaterials(materials: WallMaterialArray): WallMaterialArray { + return materials.map((material) => + getEmissiveHighlightMaterial( + material, + hoverHighlightCache, + HOVER_EMISSIVE_BLEND, + HOVER_EMISSIVE_INTENSITY, + ), + ) as WallMaterialArray } function createInvisibleWallMaterial(color: string, shading: RenderShading): Material { From 655a6f7ba7503faabe751661297cc81eb35ac2a4 Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Fri, 21 Aug 2026 01:56:32 -0400 Subject: [PATCH 7/7] tune(viewer): hover glow visible at normal zoom (QA: was ~2-5/255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/viewer/src/systems/wall/wall-materials.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 295fd812f..6cd0a5276 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -304,8 +304,8 @@ const SELECTION_EMISSIVE_INTENSITY = 0.12 // material affordance the only thing lighting up was the furniture behind // them). Same indigo so hover reads as "this will select", weaker so a // hovered-then-selected wall still steps up on click. -const HOVER_EMISSIVE_BLEND = 0.28 -const HOVER_EMISSIVE_INTENSITY = 0.07 +const HOVER_EMISSIVE_BLEND = 0.4 +const HOVER_EMISSIVE_INTENSITY = 0.2 const SELECTION_TEXTURE_MAP_KEYS = [ 'map',