From c59e690fd149d9cb24bdc4c44d0d0efe6ac47b03 Mon Sep 17 00:00:00 2001 From: Jonathan Nusbaum Date: Sun, 16 Aug 2026 00:50:54 -0400 Subject: [PATCH] fix(view): panel owns the X-ray wall-mode takeover; export the headless engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes a host needs: 1. The wall-mode takeover ('down' while X-raying) moves from the 3D renderer to the panel. The renderer lives as long as the bones:framing node does, so leaving the Bones tab kept the host's walls hidden — and because wallMode is a persisted viewer preference, the stuck view survived reloads, and merely OPENING a scene that contained an X-ray node blanked the walls. The panel unmounts exactly when the user leaves, which is the lifetime this intent actually has. The state machine lives in view-takeover.ts (viewer-import-free, unit tested: restore on leave, never stomp a user-picked mode, no ownership when walls were already down). Also aligns with the plugin appearance contract (#625): the renderer no longer writes host presentation state. 2. index.ts exports the pure derivation surface — computeLevel, computeLevelUncached, computeTakeoff, cutList, the row/member types, extractLevels/extractRoofs — so a host estimator can compute the same quantities the panel shows without mounting anything. computeLevelUncached is exported because a whole-building rollup loop would otherwise thrash the 1-deep memo the panel and renderer share. --- src/framing/compute.ts | 2 +- src/framing/renderer.tsx | 43 ++++--------------------- src/index.ts | 25 +++++++++++++++ src/panel.tsx | 14 ++++++++ src/view-takeover.test.ts | 67 +++++++++++++++++++++++++++++++++++++++ src/view-takeover.ts | 52 ++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 38 deletions(-) create mode 100644 src/view-takeover.test.ts create mode 100644 src/view-takeover.ts diff --git a/src/framing/compute.ts b/src/framing/compute.ts index acad63f..8cf0645 100644 --- a/src/framing/compute.ts +++ b/src/framing/compute.ts @@ -74,7 +74,7 @@ export function computeLevel( return result } -function computeLevelUncached( +export function computeLevelUncached( nodes: Record>, config: FramingNode, ): ComputeResult { diff --git a/src/framing/renderer.tsx b/src/framing/renderer.tsx index 75f932f..e07671c 100644 --- a/src/framing/renderer.tsx +++ b/src/framing/renderer.tsx @@ -336,43 +336,12 @@ export const FramingRenderer = ({ node }: { node: FramingNode }) => { } } - // Auto-switch the host to its most revealing wall mode while the X-ray is - // on (round-13 user feedback). 'down' — host walls fully hidden — not - // 'cutaway': the host's cutaway needs per-face exterior tags the scene - // data doesn't carry, so it painted every wall with its dot-stipple film - // (quality rounds 1-2). With the host shells gone, Bones' own assembly - // layers ARE the walls, and the per-face camera culling below gives the - // true dollhouse: near faces open, far drywall is the backdrop. - // Restores the previous mode on unmount UNLESS the user changed it since. - useEffect(() => { - if (node.seeThrough === false) return - // Dynamic import: the viewer package drags browser-only deps that must - // never evaluate under bun test (this effect only runs in the host). - let previous: string | undefined - let restore: (() => void) | undefined - let cancelled = false - import('@pascal-app/viewer').then(({ useViewer }) => { - if (cancelled) return - const viewer = useViewer.getState() as unknown as { - wallMode?: string - setWallMode?: (mode: string) => void - } - if (!viewer.setWallMode || viewer.wallMode === 'down') return - previous = viewer.wallMode - viewer.setWallMode('down') - restore = () => { - const now = useViewer.getState() as unknown as { - wallMode?: string - setWallMode?: (m: string) => void - } - if (now.wallMode === 'down' && previous && now.setWallMode) now.setWallMode(previous) - } - }) - return () => { - cancelled = true - restore?.() - } - }, [node.seeThrough]) + // NOTE: the wall-mode takeover ('down' while X-raying) used to live here, + // keyed to this renderer's lifetime — which is the NODE's lifetime, so + // leaving the Bones panel (or merely loading a scene that contained an + // X-ray node) left the host's walls hidden, persisted across reloads. + // It moved to the panel (see ../view-takeover.ts), whose mount/unmount is + // the lifetime the intent actually has. This renderer only draws. // Dollhouse cut (round 13): assembly-layer buckets carry their face // normal — hide the stacks whose face points TOWARD the camera so you diff --git a/src/index.ts b/src/index.ts index 6db5dd0..8a3daac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,3 +52,28 @@ export const bonesHostPanel: PluginHostPanel = { export { lumberDefinition } from './definition' export { LumberNode } from './schema' export { LUMBER_CROSS_SECTIONS, LUMBER_SIZES, lumberBoxDims } from './lumber' + +// Headless engine surface. The derivation pipeline is pure (no React, no +// three, no stores) — a host estimator can compute the same members and +// quantities the panel shows, per level, without mounting anything. +// `computeLevelUncached` exists so a multi-level rollup loop doesn't thrash +// the 1-deep render memo that the panel and 3D renderer share. +export { + computeLevel, + computeLevelUncached, + type ComputeResult, + wallConstruction, +} from './framing/compute' +export { FramingNode } from './framing/schema' +export { + computeTakeoff, + cutList, + cutListCsv, + takeoffCsv, + type CutRow, + type TakeoffAreas, + type TakeoffRow, +} from './engines/takeoff' +export { extractRoofs } from './engines/roof-framing' +export { extractLevels, type LevelSlice } from './core/wall-model' +export type { BonesSystem, Fixture, FixtureKind, Member, MemberRole } from './core/types' diff --git a/src/panel.tsx b/src/panel.tsx index d270518..3a98c2b 100644 --- a/src/panel.tsx +++ b/src/panel.tsx @@ -13,6 +13,7 @@ import { guessJurisdiction } from './jurisdiction/guess' import { jurisdictionOptions, profileFor } from './jurisdiction/profiles' import { LUMBER_CROSS_SECTIONS, LUMBER_SIZES, type LumberSize } from './lumber' import { useBonesStore } from './store' +import { createWallModeTakeover, type WallModeViewer } from './view-takeover' const LUMBER_KIND: string = 'bones:lumber' const FRAMING_KIND: string = 'bones:framing' @@ -40,6 +41,19 @@ export default function BonesPanel() { (n) => (n.type as string) === FRAMING_KIND && n.parentId === activeLevelId, ) as (FramingNode & { id: string }) | undefined }) + // While this panel is OPEN with a live X-ray, hide the host's wall shells + // so the skeleton reads as the building; restore on leave. Panel-owned on + // purpose — see view-takeover.ts for why the renderer must not do this. + const takeoverActive = Boolean(framingNode) && framingNode?.seeThrough !== false + useEffect(() => { + if (!takeoverActive) return + const takeover = createWallModeTakeover( + () => useViewer.getState() as unknown as WallModeViewer, + ) + takeover.engage() + return () => takeover.release() + }, [takeoverActive]) + // ONE derivation per scene edit, shared by the X-Ray status line and the // takeoff — the renderer runs its own (also once). Reviewer advisory r1. const nodes = useScene((s) => s.nodes) diff --git a/src/view-takeover.test.ts b/src/view-takeover.test.ts new file mode 100644 index 0000000..53d87f7 --- /dev/null +++ b/src/view-takeover.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'bun:test' +import { createWallModeTakeover, type WallModeViewer } from './view-takeover' + +function fakeViewer(initial = 'full'): WallModeViewer & { log: string[] } { + const viewer = { + wallMode: initial, + log: [] as string[], + setWallMode(mode: string) { + viewer.wallMode = mode + viewer.log.push(mode) + }, + } + return viewer +} + +describe('wall-mode takeover', () => { + it('hides walls on engage and restores the previous mode on release', () => { + const viewer = fakeViewer('full') + const takeover = createWallModeTakeover(() => viewer) + takeover.engage() + expect(viewer.wallMode).toBe('down') + takeover.release() + expect(viewer.wallMode).toBe('full') + expect(viewer.log).toEqual(['down', 'full']) + }) + + it('takes no ownership when the user already had walls down', () => { + const viewer = fakeViewer('down') + const takeover = createWallModeTakeover(() => viewer) + takeover.engage() + takeover.release() + // Never toggled: the 'down' was the user's own preference. + expect(viewer.log).toEqual([]) + expect(viewer.wallMode).toBe('down') + }) + + it('does not stomp a mode the user picked while engaged', () => { + const viewer = fakeViewer('full') + const takeover = createWallModeTakeover(() => viewer) + takeover.engage() + viewer.setWallMode?.('cutaway') // user changed it manually + takeover.release() + expect(viewer.wallMode).toBe('cutaway') + }) + + it('is idempotent: double engage keeps the ORIGINAL previous mode', () => { + const viewer = fakeViewer('cutaway') + const takeover = createWallModeTakeover(() => viewer) + takeover.engage() + takeover.engage() + takeover.release() + expect(viewer.wallMode).toBe('cutaway') + }) + + it('release without engage is a no-op', () => { + const viewer = fakeViewer('full') + const takeover = createWallModeTakeover(() => viewer) + takeover.release() + expect(viewer.log).toEqual([]) + }) + + it('survives a viewer with no setter', () => { + const takeover = createWallModeTakeover(() => ({})) + takeover.engage() + takeover.release() + }) +}) diff --git a/src/view-takeover.ts b/src/view-takeover.ts new file mode 100644 index 0000000..0dbada1 --- /dev/null +++ b/src/view-takeover.ts @@ -0,0 +1,52 @@ +/** + * Wall-mode takeover for the X-ray: while the user is LOOKING at the Bones + * panel, hide the host's wall shells ('down') so the skeleton and Bones' own + * assembly layers read as the walls; put everything back when they leave. + * + * Owned by the PANEL, not the renderer. The renderer lives as long as the + * `bones:framing` node does, so a renderer-owned takeover kept the host's + * walls hidden after the user switched sidebar tabs — and, because wallMode + * is a persisted viewer preference, the "stuck" state survived reloads and + * even fired on merely opening a scene that contained an X-ray node. The + * panel unmounts exactly when the user leaves, which is the lifetime this + * intent actually has. + * + * Kept free of viewer imports so the state machine tests headlessly; the + * panel passes `() => useViewer.getState()`. + */ + +export type WallModeViewer = { + wallMode?: string + setWallMode?: (mode: string) => void +} + +export type WallModeTakeover = { + engage: () => void + release: () => void +} + +export function createWallModeTakeover(getViewer: () => WallModeViewer): WallModeTakeover { + let previous: string | undefined + let active = false + return { + engage() { + if (active) return + const viewer = getViewer() + // Already 'down' (user preference) — take no ownership, restore nothing. + if (!viewer.setWallMode || viewer.wallMode === 'down') return + previous = viewer.wallMode + active = true + viewer.setWallMode('down') + }, + release() { + if (!active) return + active = false + const viewer = getViewer() + // Don't stomp a mode the user picked while the panel was open. + if (viewer.wallMode === 'down' && previous && viewer.setWallMode) { + viewer.setWallMode(previous) + } + previous = undefined + }, + } +}