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/nodes/src/wall/pointer-transparency.test.ts b/packages/nodes/src/wall/pointer-transparency.test.ts index bfe1cb2ab..2250a4ccf 100644 --- a/packages/nodes/src/wall/pointer-transparency.test.ts +++ b/packages/nodes/src/wall/pointer-transparency.test.ts @@ -1,20 +1,45 @@ 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, + 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): -// - #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 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 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 +// 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 + +const hit = ( + distance: number, + ownership: WallRayHitOwnership, + hostedByThisWall = false, +): WallRayHit => ({ distance, ownership, hostedByThisWall }) + describe('wallPointerEventsSuppressed', () => { const base = { wallHidden: true, @@ -22,12 +47,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: [ + hit(7, 'selectable'), // sofa mid-room + hit(12, 'passive'), // grid / helper far behind + ], + }, + }), + ).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: [hit(5 + EPS / 2, 'selectable')], + }, + }), + ).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: [hit(5, 'selectable')], + }, + }), + ).toBe(false) }) test('hidden wall, delete mode: events flow (deleteInvisible hover)', () => { @@ -58,3 +130,183 @@ describe('wallPointerEventsSuppressed', () => { expect(suppressedNow()).toBe(true) }) }) + +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: [hit(5 + 3 * EPS, 'selectable', true)], + }), + ).toBe(true) + }) + + test('selectables nearer than the wall outrank it (plain distance order)', () => { + expect( + hiddenWallOutrankedOnRay({ + wallHitDistance: 5, + otherHits: [hit(3, 'selectable')], + }), + ).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… + hit(7, 'selectable'), + // …anchored by that wall's hit right behind it. + hit(7 + EPS / 2, 'other-wall'), + ], + }), + ).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. + hit(7, 'selectable'), + // The room's far wall, well beyond the sofa. + hit(10, 'other-wall'), + ], + }), + ).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: [hit(5.1, 'other-wall')], + }), + ).toBe(false) + }) +}) + +describe('extractWallSelectionRay', () => { + const chain = (parent: WallRayObjectLike | null, name?: string): WallRayObjectLike => ({ + name, + parent, + }) + + // 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, '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 framingMesh = chain(chain(null)) + const otherWallCollision = chain(chain(null), 'collision-mesh') + const sofaMesh = chain(chain(null)) + + const ray = extractWallSelectionRay( + { + distance: 5, + 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, 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)', () => { + 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, classify), + ).toBeUndefined() + }) + + test('a null wall root marks nothing as hosted (wall not registered yet)', () => { + 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: selectable }, + ], + }, + null, + classifierFor(new Map([[selectable, 'selectable' as const]])), + ) + expect(ray?.otherHits).toEqual([ + { 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 3c9d27377..862ad2548 100644 --- a/packages/nodes/src/wall/pointer-transparency.ts +++ b/packages/nodes/src/wall/pointer-transparency.ts @@ -2,9 +2,52 @@ * 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 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 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`): + * + * - '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 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: * * - DELETE hover mode: hidden walls must stay hover-targetable for the * deleteInvisible highlight flow. @@ -13,16 +56,143 @@ * `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. */ + +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 + * 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 raycast hit, reduced to what the yield rule needs. */ +export type WallRayHit = { + /** Distance along the ray, in meters (three.js Intersection.distance). */ + distance: number + /** 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 +} + +/** 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 hit on the same ray (the delivered hit itself 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 => { + const wallAnchors: number[] = [] + for (const hit of ray.otherHits) { + if (hit.ownership === 'other-wall') wallAnchors.push(hit.distance) + } + + return ray.otherHits.some((hit) => { + if (hit.ownership !== 'selectable') return false + if (hit.hostedByThisWall) return true + if (hit.distance <= ray.wallHitDistance + epsilon) return true + 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. `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 + 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 + const ownership = classify(hit.object) + otherHits.push({ + distance: hit.distance, + ownership, + hostedByThisWall: ownership === 'selectable' && 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..f80e77ff8 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -13,7 +13,12 @@ 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 { createWallRayHitClassifier } from './selection-hit-owner' import { useWallTreatmentLevelData } from './treatment-level-data' import { createWallExtraSlotMaterials, WallTreatments } from './treatments' @@ -55,27 +60,42 @@ 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 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)[]) { 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(), + // 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, classifyRayHit) + : undefined, }) ) { return @@ -84,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) @@ -136,7 +156,7 @@ const WallRenderer = ({ node }: { node: WallNode }) => { > 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' + } +} 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-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..6cd0a5276 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.4 +const HOVER_EMISSIVE_INTENSITY = 0.2 + 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 { 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..49ea7a9c9 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -46,6 +46,7 @@ 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() @@ -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