Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/framing/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function computeLevel(
return result
}

function computeLevelUncached(
export function computeLevelUncached(
nodes: Record<string, Record<string, unknown>>,
config: FramingNode,
): ComputeResult {
Expand Down
43 changes: 6 additions & 37 deletions src/framing/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
14 changes: 14 additions & 0 deletions src/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions src/view-takeover.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
52 changes: 52 additions & 0 deletions src/view-takeover.ts
Original file line number Diff line number Diff line change
@@ -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
},
}
}