diff --git a/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts new file mode 100644 index 0000000000..9ad31a19c0 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' +import { spatialGridManager } from './spatial-grid-manager' +import { type FenceSupportInput, resolveFenceSupportSlabPatch } from './support-host-patch' + +const LEVEL_ID = 'level_test' + +/** Deck footprint in plan: x/z ∈ [0, 4] × [0, 3]. */ +const DECK_POLYGON: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + +/** Ground floor slab under (and far beyond) the deck. */ +const GROUND_POLYGON: Array<[number, number]> = [ + [-6, -6], + [6, -6], + [6, 6], + [-6, 6], +] + +const DECK_ELEVATION = 0.9 +const FLOOR_ELEVATION = 0.05 + +function makeLevel(): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +function addSlab(slab: SlabNode) { + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +/** Straight fence fully over the deck footprint. */ +function fenceOnDeck(overrides: Partial = {}): FenceSupportInput { + return { + start: [0.5, 1.5], + end: [3.5, 1.5], + thickness: 0.08, + parentId: LEVEL_ID, + ...overrides, + } +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +function sceneWith(...slabs: SlabNode[]): Record { + const nodes = nodesFor(makeLevel(), ...(slabs as AnyNode[])) + useScene.setState({ nodes }) + for (const slab of slabs) addSlab(slab) + return nodes +} + +beforeEach(() => { + spatialGridManager.clear() + useScene.setState({ nodes: {} }) +}) + +describe('resolveFenceSupportSlabPatch', () => { + test('a fence drawn over a deck stacked on the floor persists the deck (uncapped max election)', () => { + const nodes = sceneWith( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION), + makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION), + ) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: 'slab_deck', + }) + }) + + test('the pointer cap decides between stacked surfaces', () => { + const nodes = sceneWith( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION), + makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION), + ) + // Aiming at the floor under the deck elects (and persists) the floor. + expect( + resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: FLOOR_ELEVATION }), + ).toEqual({ supportSlabId: 'slab_ground' }) + // Aiming at the deck top keeps the deck. + expect( + resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: DECK_ELEVATION }), + ).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('a lone elevated deck (balcony, nothing underneath) still persists its host', () => { + // Unambiguous single candidate — but fences resolve an absent host to + // the level floor, so an elevated winner must be written or the fence + // renders buried under the deck. + const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: 'slab_deck', + }) + }) + + test('a plain default ground slab stays unpersisted (fence keeps sitting at the level base)', () => { + const nodes = sceneWith(makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: undefined, + }) + }) + + test('capped at bare ground under a deck-only overlap resolves to the floor default', () => { + const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: 0 })).toEqual({ + supportSlabId: undefined, + }) + }) + + test('no slabs / off-slab fence persists nothing', () => { + const nodes = sceneWith() + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: undefined, + }) + + const withDeck = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect( + resolveFenceSupportSlabPatch(fenceOnDeck({ start: [10, 10], end: [13, 10] }), withDeck), + ).toEqual({ supportSlabId: undefined }) + }) + + test('a spline fence elects through its path band segments', () => { + const nodes = sceneWith( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION), + makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION), + ) + const spline = fenceOnDeck({ + start: [0.5, 0.5], + end: [3.5, 2.5], + path: [ + [0.5, 0.5], + [2, 1.5], + [3.5, 2.5], + ], + }) + expect(resolveFenceSupportSlabPatch(spline, nodes)).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('a fence not parented to a level persists nothing', () => { + const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck({ parentId: 'not_a_level' }), nodes)).toEqual({ + supportSlabId: undefined, + }) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index f0dfe76fcb..ebdb24c3e2 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -74,6 +74,8 @@ function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab holes: [], holeMetadata: [], elevation, + thickness: Math.max(elevation, 0), + recessed: elevation < 0, autoFromWalls: false, } as SlabNode spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts index b0b8315d7e..ca66b3cc34 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts @@ -8,12 +8,29 @@ import type { import type { AnyNode, AnyNodeId } from '../../schema' import { spatialGridManager } from './spatial-grid-manager' +/** + * Sentinel `supportSlabId` meaning "hosted by the level base (ground)". + * Persisted when a pointer-capped commit elects the ground while one or + * more slabs (e.g. an elevated deck) still overlap the footprint above the + * cap — without it, the uncapped per-frame election would lift the + * committed node back onto the deck. + */ +export const GROUND_SUPPORT_ID = 'ground' + export type FloorPlacedElevationArgs = { node: AnyNode nodes: Record position: [number, number, number] rotation?: unknown levelId?: string | null + /** + * Pointer-decided support cap (level-local Y): only slabs whose walking + * surface sits at or below `maxElevation + SUPPORT_ELEVATION_EPSILON` + * may be elected, and the persisted `supportSlabId` is bypassed — during + * a drag the pointer, not the stored host, decides the target surface. + * Omit (or pass null) for the uncapped committed-read behavior. + */ + maxElevation?: number | null } function finiteSlabElevation(elevation: number): number { @@ -50,6 +67,7 @@ export function getFloorPlacedElevation({ position, rotation, levelId, + maxElevation, }: FloorPlacedElevationArgs): number { const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced if (!floorPlaced) return 0 @@ -66,8 +84,31 @@ export function getFloorPlacedElevation({ const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId if (!resolvedLevelId) return 0 - let maxElevation = Number.NEGATIVE_INFINITY - for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) { + const footprints = getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes }) + + // A persisted support host pins the elevation while it still exists and + // overlaps a footprint — deterministic across stacked slabs. A stale + // host (deleted or reshaped away) silently falls through to the + // election below; this per-frame read path never writes the field. + // Skipped entirely under a pointer cap: the cursor, not the stored + // host, decides the target surface during a drag. + const supportSlabId = (effectiveNode as { supportSlabId?: string | null }).supportSlabId + if (maxElevation == null && supportSlabId) { + if (supportSlabId === GROUND_SUPPORT_ID) return 0 + for (const footprint of footprints) { + const hosted = spatialGridManager.getHostSlabElevationForFootprint( + resolvedLevelId, + supportSlabId, + footprint.position ?? position, + footprint.dimensions, + footprint.rotation, + ) + if (hosted !== null) return finiteSlabElevation(hosted) + } + } + + let elected = Number.NEGATIVE_INFINITY + for (const footprint of footprints) { const footprintPosition = footprint.position ?? position const elevation = finiteSlabElevation( spatialGridManager.getSlabElevationForItem( @@ -75,14 +116,15 @@ export function getFloorPlacedElevation({ footprintPosition, footprint.dimensions, footprint.rotation, + maxElevation, ), ) - if (elevation > maxElevation) { - maxElevation = elevation + if (elevation > elected) { + elected = elevation } } - return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation + return elected === Number.NEGATIVE_INFINITY ? 0 : elected } export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] { diff --git a/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts b/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts new file mode 100644 index 0000000000..1846afa690 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts @@ -0,0 +1,487 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' +import { GROUND_SUPPORT_ID, getFloorPlacedElevation } from './floor-placed-elevation' +import { spatialGridManager } from './spatial-grid-manager' +import { resolveSupportSlabPatch } from './support-host-patch' + +const LEVEL_ID = 'level_test' + +/** Deck footprint in plan: x/z ∈ [-1, 1]. */ +const DECK_POLYGON: Array<[number, number]> = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], +] + +/** Ground floor slab under (and far beyond) the deck: x/z ∈ [-5, 5]. */ +const GROUND_POLYGON: Array<[number, number]> = [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], +] + +const DECK_ELEVATION = 0.9 +const FLOOR_ELEVATION = 0.05 + +function makeDefinition( + kind: AnyNode['type'], + capabilities: AnyNodeDefinition['capabilities'] = {}, +): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + capabilities, + } +} + +function registerFloorPlacedItem() { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + }, + }), + ) +} + +function makeLevel(): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + } as AnyNode +} + +function makeFloorNode(overrides: Partial = {}): AnyNode { + return { + id: 'item_test', + type: 'item', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + asset: { + id: 'asset_test', + category: 'test', + name: 'Test', + thumbnail: '', + src: 'asset:test', + dimensions: [1, 1, 1], + source: 'library', + }, + ...overrides, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +function addSlab(slab: SlabNode) { + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +function addDeckAndFloor() { + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION)) +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + useScene.setState({ nodes: {} }) +}) + +describe('pointer-capped slab support election', () => { + test('hit at the floor under the deck elects the floor, not the deck above', () => { + addDeckAndFloor() + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [0, 0, 0], + [1, 1, 1], + [0, 0, 0], + FLOOR_ELEVATION, + ), + ).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }) + }) + + test('hit on the deck top still elects the deck', () => { + addDeckAndFloor() + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [0, 0, 0], + [1, 1, 1], + [0, 0, 0], + DECK_ELEVATION, + ), + ).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' }) + }) + + test('no cap keeps the historical max election', () => { + addDeckAndFloor() + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]), + ).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' }) + }) + + test('epsilon boundary: a slab within EPS above the cap is elected, beyond EPS is not', () => { + // Cap 0.05 with EPS 0.05: a slab at 0.10 is still electable, 0.11 is not. + addSlab(makeSlab('slab_within', DECK_POLYGON, 0.1)) + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05), + ).toEqual({ elevation: 0.1, slabId: 'slab_within' }) + + spatialGridManager.clear() + addSlab(makeSlab('slab_beyond', DECK_POLYGON, 0.11)) + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05), + ).toEqual({ elevation: 0, slabId: null }) + }) +}) + +describe('getPointedSupportSurface (ray → aimed-at walking surface)', () => { + test('ray aimed at the floor under the deck resolves the floor, aimed at the deck resolves the deck', () => { + addDeckAndFloor() + + // Camera in front of the deck (negative z), high up. Aiming at the + // floor point (0, FLOOR, 0) — a point that lies UNDER the deck in + // plan — crosses the deck's elevation plane before reaching the deck + // polygon, so only the floor is hit. + const origin: [number, number, number] = [0, 5, -10] + const toFloorUnderDeck: [number, number, number] = [ + 0 - origin[0], + FLOOR_ELEVATION - origin[1], + 0 - origin[2], + ] + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toFloorUnderDeck)).toEqual( + { elevation: FLOOR_ELEVATION, slabId: 'slab_floor', point: [0, 0] }, + ) + + // Aiming at the deck's top surface: the deck plane crossing lands + // inside the deck polygon and is nearer along the ray than the floor. + const toDeckTop: [number, number, number] = [ + 0 - origin[0], + DECK_ELEVATION - origin[1], + 0.5 - origin[2], + ] + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toDeckTop)).toEqual({ + elevation: DECK_ELEVATION, + slabId: 'slab_deck', + point: [0, 0.5], + }) + }) + + test('a ray through a deck hole falls through to the surface below', () => { + addSlab( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION, { + holes: [ + [ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + ], + ], + }), + ) + addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION)) + + // Straight down through the hole center. + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, -1, 0])).toEqual({ + elevation: FLOOR_ELEVATION, + slabId: 'slab_floor', + point: [0, 0], + }) + }) + + test('no slab crossing resolves the level base (with the base-plane point)', () => { + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [3, 5, 3], [0, -1, 0])).toEqual({ + elevation: 0, + slabId: null, + point: [3, 3], + }) + }) + + test('a ray that cannot reach any surface has no point', () => { + addDeckAndFloor() + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, 1, 0])).toEqual({ + elevation: 0, + slabId: null, + point: null, + }) + }) +}) + +describe('pointed point — stacked-deck hop repro (ray ∩ pointed-surface plane)', () => { + // Manual repro this pins down: deck slab stacked above a floor slab, + // move an item over the deck near its far edge with an angled camera. + // The grid event plane rides at the ghost's LAST surface height, so the + // same screen ray produces hit points whose XZ differ by metres + // depending on which storey the plane rode at. The cap (ray → pointed + // surface) is plane-height independent, but electing at the RAW hit XZ + // is not: the floor-height hit is perspective-skewed past the deck, its + // footprint misses the deck polygon, and the capped election falls to + // the floor — dropping the ghost, which drops the plane, which keeps + // the hit skewed (a second self-consistent state). Transitions between + // the two states are the hop. Electing at the ray-derived `point` + // leaves a single fixed point per pointer ray. + const origin: [number, number, number] = [0, 5, -10] + /** Aimed at the deck top near its far edge: (0, DECK_ELEVATION, 0.8). */ + const aimAtDeck: [number, number, number] = [ + 0 - origin[0], + DECK_ELEVATION - origin[1], + 0.8 - origin[2], + ] + + test('same ray reconstructed from either plane-height hit: pointed point elects the deck every time', () => { + addDeckAndFloor() + + // The two grid hits the SAME screen ray produces — one per event-plane + // height (plane riding at the deck vs at the floor slab). + const tDeck = (DECK_ELEVATION - origin[1]) / aimAtDeck[1] + const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1] + const planeHits = [tDeck, tFloor].map((t): [number, number, number] => [ + origin[0] + aimAtDeck[0] * t, + origin[1] + aimAtDeck[1] * t, + origin[2] + aimAtDeck[2] * t, + ]) + + for (const hit of planeHits) { + const direction: [number, number, number] = [ + hit[0] - origin[0], + hit[1] - origin[1], + hit[2] - origin[2], + ] + const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, direction) + expect(pointed.slabId).toBe('slab_deck') + expect(pointed.elevation).toBe(DECK_ELEVATION) + expect(pointed.point?.[0]).toBeCloseTo(0, 10) + expect(pointed.point?.[1]).toBeCloseTo(0.8, 10) + + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [pointed.point![0], 0, pointed.point![1]], + [1, 1, 1], + [0, 0, 0], + pointed.elevation, + ), + ).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' }) + } + }) + + test('electing at the raw floor-height hit flips to the floor — the hop mechanism, kept as documentation', () => { + addDeckAndFloor() + + const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1] + const floorPlaneHit: [number, number, number] = [ + origin[0] + aimAtDeck[0] * tFloor, + 0, + origin[2] + aimAtDeck[2] * tFloor, + ] + // The skew carries the hit metres past the deck's far edge (z = 1)… + expect(floorPlaneHit[2]).toBeGreaterThan(2) + // …so the same pointer ray, elected at the raw hit XZ, picks the + // FLOOR while the cap says the pointer is on the deck. + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + floorPlaneHit, + [1, 1, 1], + [0, 0, 0], + DECK_ELEVATION, + ), + ).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }) + }) + + test('pointer past the deck edge: pointed point lands on the floor and elects it', () => { + addDeckAndFloor() + + // Aimed at a floor point far enough out that the deck-plane crossing + // falls outside the deck polygon (the floor there is actually visible). + const aimPastDeck: [number, number, number] = [ + 0 - origin[0], + FLOOR_ELEVATION - origin[1], + 4 - origin[2], + ] + const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, aimPastDeck) + expect(pointed).toEqual({ + elevation: FLOOR_ELEVATION, + slabId: 'slab_floor', + point: [0, 4], + }) + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [0, 0, 4], + [1, 1, 1], + [0, 0, 0], + pointed.elevation, + ), + ).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }) + }) +}) + +describe('getFloorPlacedElevation under a pointer cap', () => { + test('cap at the floor keeps the item on the floor even though the deck overlaps in plan', () => { + registerFloorPlacedItem() + addDeckAndFloor() + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + maxElevation: FLOOR_ELEVATION, + }), + ).toBeCloseTo(FLOOR_ELEVATION) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + maxElevation: DECK_ELEVATION, + }), + ).toBeCloseTo(DECK_ELEVATION) + + // Uncapped read keeps the historical max election. + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(DECK_ELEVATION) + }) + + test('the pointer cap bypasses a persisted host — the cursor decides during a drag', () => { + registerFloorPlacedItem() + addDeckAndFloor() + const level = makeLevel() + const node = makeFloorNode({ supportSlabId: 'slab_deck' } as Partial) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + maxElevation: FLOOR_ELEVATION, + }), + ).toBeCloseTo(FLOOR_ELEVATION) + }) + + test('the ground sentinel pins a committed node to the level base under an overlapping deck', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + const level = makeLevel() + const node = makeFloorNode({ supportSlabId: GROUND_SUPPORT_ID } as Partial) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBe(0) + }) +}) + +describe('resolveSupportSlabPatch under a pointer cap (commit determinism)', () => { + test('a commit under the deck persists the elected lower slab', () => { + registerFloorPlacedItem() + addDeckAndFloor() + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node) + + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({ + supportSlabId: 'slab_floor', + }) + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({ + supportSlabId: 'slab_deck', + }) + // Uncapped commits keep the historical rule (max winner on ambiguity). + expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('a commit on bare ground under the deck persists the ground sentinel', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node) + + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: 0 })).toEqual({ + supportSlabId: GROUND_SUPPORT_ID, + }) + // Aiming at the deck top with only the deck overlapping stays + // unambiguous — no host persisted, same as the uncapped rule. + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({ + supportSlabId: undefined, + }) + }) + + test('a single floor slab under the cap stays unpersisted (unambiguous)', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION)) + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node) + + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({ + supportSlabId: undefined, + }) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 81c322e1c0..fa4b27bc7a 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -2,36 +2,35 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' +import { getWallPlaneTop } from '../../services/storey' import useScene from '../../store/use-scene' -import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' +import { + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallSlabSupport, +} from '../../systems/slab/slab-support' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' +import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' +export { + computeWallSlabElevation, + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallOverlapInput, + type WallSlabSupport, + type WallSlabSupportSegment, + wallOverlapsPolygon, +} from '../../systems/slab/slab-support' + // ============================================================================ // GEOMETRY HELPERS // ============================================================================ -/** - * Point-in-polygon test using ray casting algorithm. - */ -export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean { - let inside = false - const n = polygon.length - for (let i = 0, j = n - 1; i < n; j = i++) { - const xi = polygon[i]![0], - zi = polygon[i]![1] - const xj = polygon[j]![0], - zj = polygon[j]![1] - - if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) { - inside = !inside - } - } - return inside -} - /** * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. */ @@ -295,512 +294,29 @@ export function itemOverlapsPolygon( return false } -function pointSegmentDistance( - px: number, - pz: number, - ax: number, - az: number, - bx: number, - bz: number, -): number { - const dx = bx - ax - const dz = bz - az - const lengthSquared = dx * dx + dz * dz - if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az) - const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared)) - return Math.hypot(px - (ax + dx * t), pz - (az + dz * t)) -} - -// Ray-cast pointInPolygon is unreliable for points exactly on the polygon -// boundary: the answer flips depending on which side of the polygon the edge -// is on. Interval classification below therefore treats "within this distance -// of the boundary" as inside explicitly, so walls sitting exactly on a slab -// edge (the common case — auto-slab polygons derive from wall centerlines) -// classify identically on every side of the slab. -const ON_BOUNDARY_EPSILON = 1e-4 - -function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number, number]>): boolean { - const n = polygon.length - for (let i = 0; i < n; i++) { - const [ax, az] = polygon[i]! - const [bx, bz] = polygon[(i + 1) % n]! - if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true - } - return false -} - -/** Sub-interval along a segment or polyline: [start, end] in length units. */ -type LengthInterval = [number, number] - -function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] { - if (intervals.length <= 1) return intervals - const sorted = [...intervals].sort((a, b) => a[0] - b[0]) - const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]] - for (let i = 1; i < sorted.length; i++) { - const [intervalStart, intervalEnd] = sorted[i]! - const last = merged[merged.length - 1]! - if (intervalStart <= last[1] + 1e-9) { - last[1] = Math.max(last[1], intervalEnd) - } else { - merged.push([intervalStart, intervalEnd]) - } - } - return merged -} - -/** Total length of a merged (sorted, disjoint) interval list. */ -function intervalsLength(intervals: readonly LengthInterval[]): number { - let total = 0 - for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart - return total -} - -/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */ -function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] { - if (base.length === 0 || cut.length === 0) return mergeIntervals(base) - const cuts = mergeIntervals(cut) - const result: LengthInterval[] = [] - for (const [baseStart, baseEnd] of mergeIntervals(base)) { - let cursor = baseStart - for (const [cutStart, cutEnd] of cuts) { - if (cutEnd <= cursor) continue - if (cutStart >= baseEnd) break - if (cutStart > cursor) result.push([cursor, cutStart]) - cursor = cutEnd - if (cursor >= baseEnd) break - } - if (cursor < baseEnd) result.push([cursor, baseEnd]) - } - return result -} - -/** - * Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and, - * when `includeBoundary`, on its boundary), as [t0, t1] fractions of the - * segment. The segment is split at every crossing with a polygon edge and - * each sub-interval is classified by its midpoint, so no test point ever - * sits on a crossing. - */ -function segmentInsideIntervals( - ax: number, - az: number, - bx: number, - bz: number, - polygon: Array<[number, number]>, - includeBoundary: boolean, -): LengthInterval[] { - const dx = bx - ax - const dz = bz - az - const length = Math.hypot(dx, dz) - if (length < 1e-9) return [] - - const ts = [0, 1] - const n = polygon.length - for (let i = 0; i < n; i++) { - const [px, pz] = polygon[i]! - const [qx, qz] = polygon[(i + 1) % n]! - const ex = qx - px - const ez = qz - pz - const denom = dx * ez - dz * ex - if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at - const t = ((px - ax) * ez - (pz - az) * ex) / denom - const s = ((px - ax) * dz - (pz - az) * dx) / denom - if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t) - } - ts.sort((a, b) => a - b) - - const inside: LengthInterval[] = [] - for (let i = 1; i < ts.length; i++) { - const t0 = ts[i - 1]! - const t1 = ts[i]! - if (t1 - t0 < 1e-9) continue - const tm = (t0 + t1) / 2 - const mx = ax + dx * tm - const mz = az + dz * tm - const midpointInside = pointOnPolygonBoundary(mx, mz, polygon) - ? includeBoundary - : pointInPolygon(mx, mz, polygon) - if (midpointInside) inside.push([t0, t1]) - } - return inside -} - -function polylineLength(points: Array<{ x: number; y: number }>): number { - let total = 0 - for (let i = 1; i < points.length; i++) { - total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y) - } - return total -} - -/** - * Inside sub-intervals of a polyline against a polygon, in cumulative - * arc-length units from the polyline start (merged, disjoint). Boundary - * contact counts as inside for slab support (walls sit exactly on slab - * edges — see ON_BOUNDARY_EPSILON above); hole callers pass - * `includeBoundary: false` so a wall running along a stairwell hole's - * rim keeps the rim's support. - */ -function polylineInsideIntervals( - points: Array<{ x: number; y: number }>, - polygon: Array<[number, number]>, - includeBoundary = true, -): LengthInterval[] { - const intervals: LengthInterval[] = [] - let offset = 0 - for (let i = 1; i < points.length; i++) { - const a = points[i - 1]! - const b = points[i]! - const segmentLength = Math.hypot(b.x - a.x, b.y - a.y) - if (segmentLength < 1e-9) continue - for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) { - intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength]) - } - offset += segmentLength - } - return mergeIntervals(intervals) -} - -function polylineInsideLength( - points: Array<{ x: number; y: number }>, - polygon: Array<[number, number]>, -): number { - return intervalsLength(polylineInsideIntervals(points, polygon)) -} - -export type WallOverlapInput = { - start: [number, number] - end: [number, number] - curveOffset?: number - thickness?: number -} - -// Minimum length of wall that must lie on/inside a slab polygon before the -// wall counts as overlapping it. Point contact (a perpendicular wall butting -// into a room's edge) clips to ~zero length and never reaches this, so such -// walls don't follow the slab's elevation. -const WALL_SLAB_MIN_OVERLAP = 0.05 - -/** - * Centerline of the wall plus its two face lines (centerline offset by - * ±halfThickness). The face lines catch walls whose centerline sits on or - * just outside the slab boundary but whose body reaches onto the slab — - * e.g. slab polygons drawn to the room's interior faces. - */ -function wallTestPolylines( - start: [number, number], - end: [number, number], - curveOffset: number, - halfThickness: number, -): Array> { - const wallLike = { start, end, curveOffset } - if (curveOffset !== 0 && isCurvedWall(wallLike)) { - const count = 16 - const center: Array<{ x: number; y: number }> = [] - const left: Array<{ x: number; y: number }> = [] - const right: Array<{ x: number; y: number }> = [] - for (let i = 0; i <= count; i++) { - const frame = getWallCurveFrameAt(wallLike, i / count) - center.push(frame.point) - left.push({ - x: frame.point.x + frame.normal.x * halfThickness, - y: frame.point.y + frame.normal.y * halfThickness, - }) - right.push({ - x: frame.point.x - frame.normal.x * halfThickness, - y: frame.point.y - frame.normal.y * halfThickness, - }) - } - return halfThickness > 0 ? [center, left, right] : [center] - } - - const center = [ - { x: start[0], y: start[1] }, - { x: end[0], y: end[1] }, - ] - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const len = Math.hypot(dx, dz) - if (len < 1e-10 || halfThickness <= 0) return [center] - const nx = (-dz / len) * halfThickness - const nz = (dx / len) * halfThickness - return [ - center, - [ - { x: start[0] + nx, y: start[1] + nz }, - { x: end[0] + nx, y: end[1] + nz }, - ], - [ - { x: start[0] - nx, y: start[1] - nz }, - { x: end[0] - nx, y: end[1] - nz }, - ], - ] -} - -/** - * Test whether a wall overlaps a slab polygon along a segment of its length. - * - * The wall's centerline and both face lines are clipped against the polygon; - * the wall overlaps when the longest clipped inside-or-on-boundary length - * exceeds a threshold (5cm, halved for very short walls). Because interval - * midpoints classify "on the boundary" as inside explicitly (never by - * ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves - * identically on every side of the slab. - * - * A wall that only touches the polygon at a point — a perpendicular wall - * butting into a room's edge, or a corner-to-corner touch — clips to ~zero - * length and does NOT overlap. - */ -export function wallOverlapsPolygon( - startOrWall: [number, number] | WallOverlapInput, - endOrPolygon: [number, number] | Array<[number, number]>, - polygonArg?: Array<[number, number]>, -): boolean { - // Two call shapes: - // wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware - // wallOverlapsPolygon(start, end, polygon) — legacy chord-only - let start: [number, number] - let end: [number, number] - let polygon: Array<[number, number]> - let curveOffset = 0 - let thickness = DEFAULT_WALL_THICKNESS - if (Array.isArray(startOrWall)) { - start = startOrWall as [number, number] - end = endOrPolygon as [number, number] - polygon = polygonArg as Array<[number, number]> - } else { - start = startOrWall.start - end = startOrWall.end - curveOffset = startOrWall.curveOffset ?? 0 - thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS - polygon = endOrPolygon as Array<[number, number]> - } - const halfThickness = Math.max(thickness / 2, 0) - - const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) - const centerLength = polylineLength(polylines[0]!) - if (centerLength < 1e-9) return false - - let overlap = 0 - for (const line of polylines) { - overlap = Math.max(overlap, polylineInsideLength(line, polygon)) - } - const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5)) - return overlap >= threshold -} - -// A slab elevation must support at least this fraction of the wall's -// length before it can dictate the wall's base. Below majority, a raised -// slab reaching one endpoint would hoist the whole wall off the floor -// that actually carries it. -const WALL_SLAB_SUPPORT_MAJORITY = 0.5 - -// Slabs whose elevations differ by less than this pool their support: -// a wall shared between two rooms' slabs is covered roughly half by -// each, and must still follow their common elevation. -const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 - -/** - * Base elevation for a wall, decided by which slabs actually SUPPORT it. - * - * Support is measured as covered length: the wall's centerline and face - * lines are clipped against each slab's RENDERED footprint - * (`getRenderableSlabPolygon` with the level walls + siblings, not the - * stored polygon — legacy polygons stored at wall faces or with old - * baked offsets fall short of the wall body, but their band-adopted - * rendered edge reaches the wall's outer face) minus the slab's stored - * holes (holes are data, never render-offset). A slab supporting less - * than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point - * contact, endpoint grazes). - * - * Same-elevation slabs pool their coverage. `elevation` preserves the - * existing wall-relative origin: the highest elevation covering at - * least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered - * elevation when none reaches majority. `baseElevation` only fills down - * where a lower support remains exposed on a wall face after higher, - * overlapping support is accounted for. Coincident floor/platform slabs - * therefore keep the wall on the platform, while slabs on opposite wall - * sides bridge correctly. A slab touching only one endpoint never enters - * either result. Pure; - * exported for tests. - */ -export type WallSlabSupport = { - /** Existing wall-relative floor elevation used by hosted children and wall height. */ +/** One slab overlapping a queried footprint, as seen by support election. */ +export type SlabSupportCandidate = { + slabId: string elevation: number - /** Lowest exposed adjacent support; wall geometry fills down to this elevation. */ - baseElevation: number - /** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */ - baseSegments: WallSlabSupportSegment[] } -export type WallSlabSupportSegment = { - start: number - end: number +export type ItemSlabSupport = { elevation: number + /** The winning slab, or null when no slab overlaps the footprint. */ + slabId: string | null } -export function computeWallSlabSupport( - wallLike: WallOverlapInput, - slabs: readonly SlabNode[], - levelWalls: WallNode[], -): WallSlabSupport { - const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike - const halfThickness = Math.max(thickness / 2, 0) - const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) - const polylineLengths = polylines.map(polylineLength) - const wallLength = polylineLengths[0]! - if (wallLength < 1e-9) { - return { elevation: 0, baseElevation: 0, baseSegments: [] } - } - - const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5)) - - type ElevationGroup = { elevation: number; perPolyline: LengthInterval[][] } - const groups: ElevationGroup[] = [] - - for (const slab of slabs) { - if (slab.polygon.length < 3) continue - const renderedPolygon = getRenderableSlabPolygon(slab, { - walls: levelWalls, - siblingSlabs: slabs.filter((other) => other.id !== slab.id), - }) - - let supported = 0 - const perPolyline = polylines.map((line) => { - let intervals = polylineInsideIntervals(line, renderedPolygon) - for (const hole of slab.holes || []) { - if (intervals.length === 0) break - if (hole.length < 3) continue - intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) - } - supported = Math.max(supported, intervalsLength(intervals)) - return intervals - }) - if (supported < minSupport) continue - - const elevation = slab.elevation ?? 0.05 - let group = groups.find( - (candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, - ) - if (!group) { - group = { elevation, perPolyline: polylines.map(() => []) } - groups.push(group) - } - for (let i = 0; i < perPolyline.length; i++) { - group.perPolyline[i]!.push(...perPolyline[i]!) - } - } - - type EvaluatedGroup = ElevationGroup & { - coverage: number - mergedPerPolyline: LengthInterval[][] - } - const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => { - let coverage = 0 - const mergedPerPolyline = group.perPolyline.map(mergeIntervals) - for (let i = 0; i < group.perPolyline.length; i++) { - const lineLength = polylineLengths[i]! - if (lineLength < 1e-9) continue - coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength) - } - return { ...group, coverage, mergedPerPolyline } - }) - - let majorityElevation = Number.NEGATIVE_INFINITY - let bestElevation = Number.NEGATIVE_INFINITY - let bestCoverage = -1 - for (const group of evaluatedGroups) { - if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { - majorityElevation = Math.max(majorityElevation, group.elevation) - } - if ( - group.coverage > bestCoverage + 1e-6 || - (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation) - ) { - bestCoverage = group.coverage - bestElevation = group.elevation - } - } - - const elevation = - majorityElevation !== Number.NEGATIVE_INFINITY - ? majorityElevation - : bestElevation === Number.NEGATIVE_INFINITY - ? 0 - : bestElevation - const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => { - const lineLength = polylineLengths[polylineIndex]! - if (lineLength < 1e-9) return [] - return group.mergedPerPolyline[polylineIndex]!.map( - ([intervalStart, intervalEnd]) => - [intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval, - ) - } - - const normalizedByGroup = evaluatedGroups.map((group) => ({ - elevation: group.elevation, - perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)), - })) - const breakpoints = [0, 1] - for (const group of normalizedByGroup) { - for (const intervals of group.perPolyline) { - for (const [intervalStart, intervalEnd] of intervals) { - breakpoints.push(intervalStart, intervalEnd) - } - } - } - breakpoints.sort((left, right) => left - right) - const uniqueBreakpoints = breakpoints.filter( - (value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7, - ) - - const highestAt = (polylineIndex: number, t: number) => { - let highest = Number.NEGATIVE_INFINITY - for (const group of normalizedByGroup) { - if ( - group.perPolyline[polylineIndex]?.some( - ([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7, - ) - ) { - highest = Math.max(highest, group.elevation) - } - } - return highest - } - - const baseSegments: WallSlabSupportSegment[] = [] - for (let index = 1; index < uniqueBreakpoints.length; index++) { - const start = uniqueBreakpoints[index - 1]! - const end = uniqueBreakpoints[index]! - if (end - start < 1e-7) continue - const midpoint = (start + end) / 2 - const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY - const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY - const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) - const segmentElevation = - faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0) - const previous = baseSegments[baseSegments.length - 1] - if ( - previous && - Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON - ) { - previous.end = end - } else { - baseSegments.push({ start, end, elevation: segmentElevation }) - } - } - - if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) - const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) - return { elevation, baseElevation, baseSegments } -} - -export function computeWallSlabElevation( - wallLike: WallOverlapInput, - slabs: readonly SlabNode[], - levelWalls: WallNode[], -): number { - return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation +export type PointedSupportSurface = ItemSlabSupport & { + /** + * Level-local XZ where the ray meets the pointed surface's plane, or + * null when the ray never reaches it (grazing / aimed above the base). + * This is the plan point the pointer actually indicates: unlike a grid + * event-plane hit — whose XZ shifts with whatever height the event + * plane currently rides at — it depends only on the ray and the + * aimed-at surface, so election/preview at this point cannot flip when + * the event plane changes storey. + */ + point: [number, number] | null } export class SpatialGridManager { @@ -842,7 +358,24 @@ export class SpatialGridManager { private getWallHeight(wallId: string): number { const wall = this.walls.get(wallId) - return wall?.height ?? 2.5 // Default wall height + if (!wall) return 0 + if (wall.height != null) return wall.height + + const nodes = useScene.getState().nodes + const levelId = resolveNodeLevelId(wall, nodes) + const support = this.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + ) + return resolveWallEffectiveHeight( + wall, + getWallPlaneTop(wall, levelId, nodes), + support.elevation, + ) } private getCeilingGrid(ceilingId: string): SpatialGrid { @@ -859,15 +392,74 @@ export class SpatialGridManager { return this.slabsByLevel.get(levelId)! } + /** + * Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item + * support queries run per frame and the projection scans the level's + * walls + sibling slabs, so the result is cached per slab id and + * dropped for the whole level whenever a slab or wall on that level + * flows through the manager's create/update/delete handlers. + */ + private readonly renderedSlabPolygons = new Map>() + + private invalidateRenderedSlabPolygons(levelId: string) { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return + for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) + } + + private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { + const cached = this.renderedSlabPolygons.get(slab.id) + if (cached) return cached + + const siblingSlabs: SlabNode[] = [] + for (const other of this.getSlabMap(levelId).values()) { + if (other.id !== slab.id) siblingSlabs.push(other) + } + const polygon = getRenderableSlabPolygon(slab, { + walls: this.getLevelWallNodes(levelId), + siblingSlabs, + }) + this.renderedSlabPolygons.set(slab.id, polygon) + return polygon + } + + /** + * Support test shared by election, candidate listing, and persisted-host + * validation: the footprint overlaps the slab's RENDERED polygon (what + * users see — matching the wall election in `computeWallSlabSupport`), + * with the center-point hole veto kept against the stored holes (holes + * are data, never render-offset). + */ + private slabSupportsFootprint( + levelId: string, + slab: SlabNode, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): boolean { + if (slab.polygon.length < 3) return false + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false + + const [cx, , cz] = position + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false + } + return true + } + // Called when nodes change handleNodeCreated(node: AnyNode, levelId: string) { if (node.type === 'slab') { this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'ceiling') { this.ceilings.set(node.id, node as CeilingNode) } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) + // Rendered slab polygons adopt wall bands — a new wall can extend them. + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'item') { const item = node as ItemNode if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { @@ -920,11 +512,13 @@ export class SpatialGridManager { handleNodeUpdated(node: AnyNode, levelId: string) { if (node.type === 'slab') { this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'ceiling') { this.ceilings.set(node.id, node as CeilingNode) } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'item') { const item = node as ItemNode if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { @@ -982,12 +576,16 @@ export class SpatialGridManager { handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { if (nodeType === 'slab') { + // Invalidate before removal so the deleted slab's own cache entry + // (still keyed in the level map here) is dropped with its siblings'. + this.invalidateRenderedSlabPolygons(levelId) this.getSlabMap(levelId).delete(nodeId) } else if (nodeType === 'ceiling') { this.ceilings.delete(nodeId) this.ceilingGrids.delete(nodeId) } else if (nodeType === 'wall') { this.walls.delete(nodeId) + this.invalidateRenderedSlabPolygons(levelId) // Remove all items attached to this wall from the spatial grid const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) return removedItemIds // Caller can use this to delete the items from scene @@ -1201,45 +799,162 @@ export class SpatialGridManager { /** * Get the slab elevation for an item using its full footprint (bounding box). - * Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes). - * Returns the highest overlapping slab elevation, or 0 if none. + * Thin wrapper over {@link getSlabSupportForItem} for callers (and tests) + * that only need the number. */ getSlabElevationForItem( levelId: string, position: [number, number, number], dimensions: [number, number, number], rotation: [number, number, number], + maxElevation?: number | null, ): number { + return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation) + .elevation + } + + /** + * Elect the supporting slab for a footprint: the highest-elevation slab + * whose RENDERED polygon the footprint overlaps (center-point hole veto + * applies). Returns `{ elevation: 0, slabId: null }` when nothing + * overlaps. + * + * `maxElevation` is the pointer-decided cap: when set, only slabs whose + * walking surface sits at or below `maxElevation + + * SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface + * the cursor ray actually hit never captures the election. + */ + getSlabSupportForItem( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + maxElevation?: number | null, + ): ItemSlabSupport { const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return 0 + if (!slabMap) return { elevation: 0, slabId: null } - let maxElevation = Number.NEGATIVE_INFINITY + let winningElevation = Number.NEGATIVE_INFINITY + let winnerId: string | null = null for (const slab of slabMap.values()) { - if ( - slab.polygon.length >= 3 && - itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01) - ) { - // Check if item is entirely within a hole (if so, ignore this slab) - // We consider it entirely in a hole if the item center is in the hole + const elevation = slab.elevation ?? 0.05 + if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + if (elevation > winningElevation) { + winningElevation = elevation + winnerId = slab.id + } + } + return winnerId === null + ? { elevation: 0, slabId: null } + : { elevation: winningElevation, slabId: winnerId } + } + + /** + * The walking surface the pointer actually points at: the nearest slab + * plane the ray crosses INSIDE that slab's rendered polygon (hole veto + * applies), or the level base (`elevation: 0, slabId: null`) when it + * crosses none. Ray origin/direction are level-local. Deliberately a + * point test, not a footprint test — it answers "which surface is under + * the cursor", which then caps the footprint election so a deck hanging + * above the aimed-at floor never lifts the placement. `point` is the + * ray's crossing of that surface's plane — the stable plan point + * callers should elect/preview at (see {@link PointedSupportSurface}). + */ + getPointedSupportSurface( + levelId: string, + rayOrigin: [number, number, number], + rayDirection: [number, number, number], + ): PointedSupportSurface { + const slabMap = this.slabsByLevel.get(levelId) + const [ox, oy, oz] = rayOrigin + const [dx, dy, dz] = rayDirection + if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null } + + let best: { t: number; elevation: number; slabId: string } | null = null + if (slabMap) { + for (const slab of slabMap.values()) { + if (slab.polygon.length < 3) continue + const elevation = slab.elevation ?? 0.05 + const t = (elevation - oy) / dy + if (t <= 0) continue + if (best && t >= best.t) continue + const x = ox + dx * t + const z = oz + dz * t + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue let inHole = false - const [cx, , cz] = position - const holes = slab.holes || [] - for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) { + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(x, z, hole)) { inHole = true break } } - - if (!inHole) { - const elevation = slab.elevation ?? 0.05 - if (elevation > maxElevation) { - maxElevation = elevation - } - } + if (inHole) continue + best = { t, elevation, slabId: slab.id } } } - return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation + if (best) { + return { + elevation: best.elevation, + slabId: best.slabId, + point: [ox + dx * best.t, oz + dz * best.t], + } + } + const tBase = -oy / dy + return { + elevation: 0, + slabId: null, + point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null, + } + } + + /** + * All slabs supporting a footprint, one entry per overlapping slab + * (highest elevation first; slab id breaks ties deterministically). + * Commit-side ambiguity check: persist a `supportSlabId` only when the + * candidates carry ≥ 2 distinct elevations. + */ + getSupportCandidatesForFootprint( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): SlabSupportCandidate[] { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return [] + + const candidates: SlabSupportCandidate[] = [] + for (const slab of slabMap.values()) { + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 }) + } + candidates.sort( + (a, b) => + b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0), + ) + return candidates + } + + /** + * Elevation of a persisted support host for a footprint, or null when + * the slab no longer exists on the level or no longer overlaps the + * footprint (same overlap test as election). Deliberately read-only: a + * host reshaped away is NOT cleared — callers fall back to election and + * the stale reference resumes hosting if the slab's polygon returns. + * Slab deletion is the only writer (`deleteNodesAction` strips it). + */ + getHostSlabElevationForFootprint( + levelId: string, + slabId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): number | null { + const slab = this.slabsByLevel.get(levelId)?.get(slabId) + if (!slab) return null + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null + return slab.elevation ?? 0.05 } /** @@ -1255,8 +970,10 @@ export class SpatialGridManager { end: [number, number], curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, ): number { - return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation + return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId) + .elevation } getSlabSupportForWall( @@ -1265,11 +982,14 @@ export class SpatialGridManager { end: [number, number], curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, + maxElevation?: number | null, ): WallSlabSupport { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) { return { elevation: 0, + electedSlabId: null, baseElevation: 0, baseSegments: [{ start: 0, end: 1, elevation: 0 }], } @@ -1279,6 +999,8 @@ export class SpatialGridManager { { start, end, curveOffset, thickness }, [...slabMap.values()], this.getLevelWallNodes(levelId), + preferredSlabId, + maxElevation, ) } @@ -1387,6 +1109,7 @@ export class SpatialGridManager { } clearLevel(levelId: string) { + this.invalidateRenderedSlabPolygons(levelId) this.floorGrids.delete(levelId) this.wallGrids.delete(levelId) this.slabsByLevel.delete(levelId) @@ -1400,8 +1123,33 @@ export class SpatialGridManager { this.ceilingGrids.clear() this.ceilings.clear() this.itemCeilingMap.clear() + this.renderedSlabPolygons.clear() } } // Singleton instance export const spatialGridManager = new SpatialGridManager() + +/** + * Effective (extruded) height of a wall resolved from a nodes record: + * {@link resolveWallEffectiveHeight} over the covering-clamped plane top + * (`getWallPlaneTop`) and the singleton manager's slab election — so the + * value always agrees with the rendered wall. One shared resolver for the + * editor overlays (measurement label, action menu, side handles) that used + * to copy this derivation locally. + */ +export function getWallEffectiveHeightForNodes( + wall: WallNode, + nodes: Record, +): number { + const levelId = resolveNodeLevelId(wall, nodes) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + ) + return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), support.elevation) +} diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts new file mode 100644 index 0000000000..280db599b6 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '../../schema' +import useScene, { clearSceneHistory } from '../../store/use-scene' +import { spatialGridManager } from './spatial-grid-manager' +import { + initSpatialGridSync, + markCoveringDependentsBelow, + markLevelHeightDependents, +} from './spatial-grid-sync' + +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +function makeLevel(id: string, ordinal: number, height: number, children: string[]): AnyNode { + return { + id, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children, + level: ordinal, + height, + } as AnyNode +} + +function makeChild(id: string, type: string, parentId: string): AnyNode { + return { + id, + type, + object: 'node', + parentId, + visible: true, + metadata: {}, + children: [], + start: [0, 1], + end: [4, 1], + thickness: 0.1, + polygon: SQUARE, + holes: [], + } as unknown as AnyNode +} + +function makeSlab(id: string, parentId: string, overrides: Partial = {}): AnyNode { + return { + id, + type: 'slab', + object: 'node', + parentId, + visible: true, + metadata: {}, + children: [], + polygon: SQUARE, + holes: [], + holeMetadata: [], + elevation: 0.05, + thickness: 0.05, + autoFromWalls: false, + ...overrides, + } as AnyNode +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +function dirtyIds(): string[] { + return [...useScene.getState().dirtyNodes].sort() +} + +describe('spatial-grid sync dirty rules (vertical model)', () => { + let stopSync = () => {} + + // Two orphan levels sharing the legacy stack: level_0 (below) carries a + // wall, ceiling, stair, fence, and zone; level_1 (above) carries a slab. + const wall = makeChild('wall_a', 'wall', 'level_0') + const ceiling = makeChild('ceiling_a', 'ceiling', 'level_0') + const stair = makeChild('stair_a', 'stair', 'level_0') + const fence = makeChild('fence_a', 'fence', 'level_0') + const zone = makeChild('zone_a', 'zone', 'level_0') + const upperSlab = makeSlab('slab_up', 'level_1', { elevation: 0, thickness: 0.3 }) + const level0 = makeLevel('level_0', 0, 2.5, [ + 'wall_a', + 'ceiling_a', + 'stair_a', + 'fence_a', + 'zone_a', + ]) + const level1 = makeLevel('level_1', 1, 2.5, ['slab_up']) + + function setScene(nodes: Record) { + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + nodes, + readOnly: false, + rootNodeIds: ['level_0', 'level_1'] as AnyNodeId[], + } as never) + clearSceneHistory() + } + + beforeEach(() => { + spatialGridManager.clear() + setScene(nodesFor(level0, level1, wall, ceiling, stair, fence, zone, upperSlab)) + stopSync = initSpatialGridSync() + useScene.setState({ dirtyNodes: new Set() }) + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + }) + + test('changing a level height marks its wall/stair/ceiling/fence children dirty', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + level_0: { ...level0, height: 3 } as AnyNode, + } as never, + }) + + expect(dirtyIds()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a']) + }) + + test('a slab thickness change marks the walls and ceilings of the level below', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_up: { ...upperSlab, thickness: 0.5 } as AnyNode, + } as never, + }) + + expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a']) + }) + + test('a slab recessed toggle marks the walls and ceilings of the level below', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_up: { ...upperSlab, recessed: true } as AnyNode, + } as never, + }) + + expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a']) + }) + + test('creating a slab on the level above marks the level below, deleting it too', () => { + const added = makeSlab('slab_new', 'level_1', { elevation: 0, thickness: 0.2 }) + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_new: added, + level_1: { ...level1, children: ['slab_up', 'slab_new'] } as AnyNode, + } as never, + }) + expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true) + expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true) + + useScene.setState({ dirtyNodes: new Set() }) + const { slab_new: _gone, ...rest } = useScene.getState().nodes as Record + useScene.setState({ + nodes: { ...rest, level_1: { ...level1, children: ['slab_up'] } as AnyNode } as never, + }) + expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true) + expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true) + }) +}) + +describe('spatial-grid sync dirty rules (deck-attached stairs)', () => { + let stopSync = () => {} + + const deck = makeSlab('slab_deck', 'level_0', { elevation: 1.25, thickness: 0.05 }) + const attachedStair = { + ...makeChild('stair_deck', 'stair', 'level_0'), + deckSlabId: 'slab_deck', + } as AnyNode + const otherStair = makeChild('stair_other', 'stair', 'level_0') + const deckLevel = makeLevel('level_0', 0, 2.5, ['slab_deck', 'stair_deck', 'stair_other']) + + beforeEach(() => { + spatialGridManager.clear() + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + nodes: nodesFor(deckLevel, deck, attachedStair, otherStair), + readOnly: false, + rootNodeIds: ['level_0'] as AnyNodeId[], + } as never) + clearSceneHistory() + stopSync = initSpatialGridSync() + useScene.setState({ dirtyNodes: new Set() }) + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + }) + + test('changing a deck elevation marks its attached stair dirty, not other stairs', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_deck: { ...deck, elevation: 1.6 } as AnyNode, + } as never, + }) + + expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(true) + expect(useScene.getState().dirtyNodes.has('stair_other' as AnyNodeId)).toBe(false) + }) + + test('a deck polygon-only change leaves the attached stair alone', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_deck: { + ...deck, + polygon: [ + [0, 0], + [5, 0], + [5, 5], + [0, 5], + ], + } as AnyNode, + } as never, + }) + + expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(false) + }) +}) + +describe('sync dirty helpers (pure)', () => { + const collect = () => { + const marked: string[] = [] + return { marked, markDirty: (id: AnyNodeId) => marked.push(id) } + } + + test('markLevelHeightDependents marks only wall/stair/ceiling/fence children', () => { + const level = makeLevel('level_0', 0, 2.5, [ + 'wall_a', + 'stair_a', + 'ceiling_a', + 'fence_a', + 'zone_a', + 'missing', + ]) + const nodes = nodesFor( + level, + makeChild('wall_a', 'wall', 'level_0'), + makeChild('stair_a', 'stair', 'level_0'), + makeChild('ceiling_a', 'ceiling', 'level_0'), + makeChild('fence_a', 'fence', 'level_0'), + makeChild('zone_a', 'zone', 'level_0'), + ) + + const { marked, markDirty } = collect() + markLevelHeightDependents(level as never, nodes, markDirty) + expect(marked.sort()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a']) + }) + + test('markCoveringDependentsBelow marks walls and ceilings of the level below only', () => { + const nodes = nodesFor( + makeLevel('level_0', 0, 2.5, ['wall_a', 'ceiling_a', 'zone_a']), + makeLevel('level_1', 1, 2.5, []), + makeChild('wall_a', 'wall', 'level_0'), + makeChild('ceiling_a', 'ceiling', 'level_0'), + makeChild('zone_a', 'zone', 'level_0'), + ) + + const { marked, markDirty } = collect() + markCoveringDependentsBelow('level_1', nodes, markDirty) + expect(marked.sort()).toEqual(['ceiling_a', 'wall_a']) + }) + + test('markCoveringDependentsBelow is a no-op for the lowest level', () => { + const nodes = nodesFor( + makeLevel('level_0', 0, 2.5, ['wall_a']), + makeChild('wall_a', 'wall', 'level_0'), + ) + + const { marked, markDirty } = collect() + markCoveringDependentsBelow('level_0', nodes, markDirty) + expect(marked).toEqual([]) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 8f37156e5e..886d597012 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,6 +1,7 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' -import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' +import type { AnyNode, AnyNodeId, LevelNode, SlabNode, WallNode } from '../../schema' +import { getLevelBelow } from '../../services/storey' import useScene from '../../store/use-scene' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { @@ -116,6 +117,7 @@ export function initSpatialGridSync(): () => void { // When a slab is added, mark overlapping items/walls dirty if (node.type === 'slab') { markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) } } } @@ -129,6 +131,7 @@ export function initSpatialGridSync(): () => void { // When a slab is removed, mark items/walls that were on it dirty (using current state) if (node.type === 'slab') { markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) } } } @@ -156,11 +159,11 @@ export function initSpatialGridSync(): () => void { } } } else if (node.type === 'slab' && prev.type === 'slab') { - if ( + const supportChanged = node.polygon !== prev.polygon || node.elevation !== prev.elevation || node.holes !== prev.holes - ) { + if (supportChanged) { const levelId = resolveLevelId(node, state.nodes) spatialGridManager.handleNodeUpdated(node, levelId) @@ -168,6 +171,35 @@ export function initSpatialGridSync(): () => void { markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty) markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) } + if (node.elevation !== prev.elevation) { + markDeckAttachedStairs(node.id, state.nodes, markDirty) + } + // The covering bound over the level below also moves with thickness + // (underside = elevation − thickness) and recessed (pools never + // cover), which same-level support ignores. + if ( + supportChanged || + node.thickness !== prev.thickness || + node.recessed !== prev.recessed + ) { + markCoveringDependentsBelow(resolveLevelId(node, state.nodes), state.nodes, markDirty) + } + } else if (node.type === 'level' && prev.type === 'level') { + if (node.height !== prev.height) { + markLevelHeightDependents(node as LevelNode, state.nodes, markDirty) + } + } else if (node.type === 'wall' && prev.type === 'wall') { + if ( + node.start !== prev.start || + node.end !== prev.end || + node.curveOffset !== prev.curveOffset || + node.thickness !== prev.thickness + ) { + // Rendered slab polygons adopt wall bands, so a wall reshape + // must reach the manager to refresh its wall map and drop the + // level's rendered-polygon cache. + spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes)) + } } } }) @@ -179,6 +211,68 @@ function arraysEqual(a: number[], b: number[]): boolean { return a.length === b.length && a.every((v, i) => v === b[i]) } +/** + * A level's stored height moved: plane-bound walls follow the new plane, + * stair rise re-derives, and ceilings/fences re-resolve their clamp — mark + * them all so their systems rebuild. Restacking the level containers alone + * leaves their geometry stale. + */ +export function markLevelHeightDependents( + level: LevelNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const childId of level.children) { + const child = nodes[childId] + if (!child) continue + if ( + child.type === 'wall' || + child.type === 'stair' || + child.type === 'ceiling' || + child.type === 'fence' + ) { + markDirty(child.id) + } + } +} + +/** + * A deck slab's walking surface moved: stairs attached to it via + * `deckSlabId` derive their rise from that elevation, so their geometry + * (and rise-derived affordances) must rebuild. + */ +export function markDeckAttachedStairs( + slabId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const node of Object.values(nodes)) { + if (node.type === 'stair' && node.deckSlabId === slabId) { + markDirty(node.id) + } + } +} + +/** + * A slab on `slabLevelId` was created/deleted or changed shape/placement: + * the covering bound (slab underside) over the level BELOW moved, so that + * level's plane-bound walls and clamped ceilings must rebuild. + */ +export function markCoveringDependentsBelow( + slabLevelId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + const below = getLevelBelow(slabLevelId, nodes) + if (!below) return + for (const childId of below.children) { + const child = nodes[childId] + if (child?.type === 'wall' || child?.type === 'ceiling') { + markDirty(child.id) + } + } +} + /** * Mark all floor items and walls that may be affected by a slab change as dirty. */ @@ -190,10 +284,11 @@ function markNodesOverlappingSlab( if (slab.polygon.length < 3) return const slabLevelId = resolveLevelId(slab, nodes) - // Walls follow the slab's RENDERED footprint (band-adopted edges reach - // the wall's outer face), so the dirty gate must test the same polygon - // `getSlabElevationForWall` will re-evaluate — a stored polygon that - // stops short of the wall body would otherwise never re-elevate it. + // Walls AND floor-placed nodes follow the slab's RENDERED footprint + // (band-adopted edges reach the wall's outer face), so the dirty gate + // must test the same polygon the support queries re-evaluate — a stored + // polygon that stops short of the wall body would otherwise never + // re-elevate nodes sitting over the adopted band. const levelWalls: WallNode[] = [] const siblingSlabs: SlabNode[] = [] for (const node of Object.values(nodes)) { @@ -249,7 +344,7 @@ function markNodesOverlappingSlab( footprint.position ?? position, footprint.dimensions, footprint.rotation, - slab.polygon, + renderedPolygon, 0.01, ) ) { diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts new file mode 100644 index 0000000000..588ca7ee65 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -0,0 +1,217 @@ +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, FenceNode, SlabNode, WallNode } from '../../schema' +import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' +import { GROUND_SUPPORT_ID, getFloorPlacedFootprints } from './floor-placed-elevation' +import { SUPPORT_ELEVATION_EPSILON, spatialGridManager } from './spatial-grid-manager' + +export type SupportSlabPatch = { supportSlabId: string | undefined } + +export type SupportSlabPatchOptions = { + /** + * Pointer-decided support cap (level-local Y) — see + * `FloorPlacedElevationArgs.maxElevation`. When set, the persisted host + * reproduces the CAPPED election: the elected lower slab wins over a + * deck hanging above the cap, and `GROUND_SUPPORT_ID` is stored when the + * ground is elected while capped-out slabs still overlap the footprint. + */ + maxElevation?: number | null +} + +export function resolveSupportSlabPatch( + node: AnyNode, + nodes: Record, + options?: SupportSlabPatchOptions, +): SupportSlabPatch { + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) { + return { supportSlabId: undefined } + } + + const parentId = (node as { parentId?: AnyNodeId | null }).parentId ?? null + const parent = parentId ? nodes[parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + const maxElevation = options?.maxElevation + const footprints = getFloorPlacedFootprints(floorPlaced, node, { nodes }) + const candidateElevations = new Set() + let winner: { slabId: string; elevation: number } | null = null + let cappedOut = false + + for (const footprint of footprints) { + const position = footprint.position ?? (node as { position?: unknown }).position + if (!Array.isArray(position) || position.length !== 3) continue + const candidates = spatialGridManager.getSupportCandidatesForFootprint( + parent.id, + position as [number, number, number], + footprint.dimensions, + footprint.rotation, + ) + for (const candidate of candidates) candidateElevations.add(candidate.elevation) + + const support = spatialGridManager.getSlabSupportForItem( + parent.id, + position as [number, number, number], + footprint.dimensions, + footprint.rotation, + maxElevation, + ) + if (support.slabId && (!winner || support.elevation > winner.elevation)) { + winner = { slabId: support.slabId, elevation: support.elevation } + } + if (maxElevation != null && support.slabId === null && candidates.length > 0) { + cappedOut = true + } + } + + if (winner !== null) { + return { supportSlabId: candidateElevations.size >= 2 ? winner.slabId : undefined } + } + // Capped election chose the ground while overlapping slabs sit above the + // cap: persist the ground host, or the uncapped per-frame election would + // lift the committed node back onto the deck. + return { supportSlabId: cappedOut ? GROUND_SUPPORT_ID : undefined } +} + +export function resolveWallSupportSlabPatch( + wall: WallNode, + nodes: Record, + options?: SupportSlabPatchOptions, +): SupportSlabPatch { + const parent = wall.parentId ? nodes[wall.parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + // Winner under the pointer cap (when given): a deck hanging above the + // aimed-at surface can't capture the elected base, so a wall drawn at the + // floor underneath it persists the floor slab the user actually targeted. + const support = spatialGridManager.getSlabSupportForWall( + parent.id, + wall.start, + wall.end, + wall.curveOffset, + wall.thickness, + null, + options?.maxElevation, + ) + const candidateElevations = new Set() + for (const node of Object.values(nodes)) { + if (node.type !== 'slab' || node.parentId !== parent.id) continue + const candidate = node as SlabNode + const preferred = spatialGridManager.getSlabSupportForWall( + parent.id, + wall.start, + wall.end, + wall.curveOffset, + wall.thickness, + candidate.id, + ) + if (preferred.electedSlabId === candidate.id) { + candidateElevations.add(candidate.elevation) + } + } + + return { + supportSlabId: candidateElevations.size >= 2 ? (support.electedSlabId ?? undefined) : undefined, + } +} + +/** Fence-like shape the fence host election needs — plain segment, arc, or spline. */ +export type FenceSupportInput = Pick< + FenceNode, + 'start' | 'end' | 'curveOffset' | 'path' | 'thickness' | 'parentId' +> + +/** Sample count for a curved (sagitta) fence centerline, matching the wall band test. */ +const FENCE_CURVE_SUPPORT_SAMPLES = 16 +/** Fallback fence thickness (schema default) when the node carries none. */ +const DEFAULT_FENCE_THICKNESS = 0.08 +/** Minimum band depth so the footprint survives the election's polygon inset. */ +const MIN_FENCE_SUPPORT_BAND = 0.05 + +function fenceCenterlinePoints(fence: FenceSupportInput): Array<[number, number]> { + if (fence.path && fence.path.length >= 2) { + return fence.path.map((point) => [point[0], point[1]]) + } + const wallLike = { start: fence.start, end: fence.end, curveOffset: fence.curveOffset ?? 0 } + if ((fence.curveOffset ?? 0) !== 0 && isCurvedWall(wallLike)) { + const points: Array<[number, number]> = [] + for (let i = 0; i <= FENCE_CURVE_SUPPORT_SAMPLES; i++) { + const frame = getWallCurveFrameAt(wallLike, i / FENCE_CURVE_SUPPORT_SAMPLES) + points.push([frame.point.x, frame.point.y]) + } + return points + } + return [ + [fence.start[0], fence.start[1]], + [fence.end[0], fence.end[1]], + ] +} + +/** + * Support-host patch for a fence: elect the slab the fence line stands on + * and persist it as `supportSlabId` (the fence lift resolves absent = + * level floor — see `packages/nodes/src/fence/lift.ts`). + * + * The centerline (chord, sampled arc, or spline path) is turned into thin + * band footprints and run through the same candidate machinery items use. + * `options.maxElevation` is the pointer-decided cap: aiming at the floor + * under a deck elects the floor, aiming at the deck top elects the deck. + * + * Persist rule: the items ambiguity rule (stacked candidates disagree) + * PLUS the elevated-host case — a winner sitting meaningfully above the + * level floor must be persisted even when unambiguous (a balcony deck with + * nothing underneath), or the commit loses the election entirely since + * fences run no per-frame election. A single default ground slab (its top + * within `SUPPORT_ELEVATION_EPSILON` of the floor) stays unpersisted so + * plain fences keep sitting at the level base. A capped-out election (all + * overlapping slabs above the aimed-at ground) also resolves to the floor + * via the same absent-host default. Pure; exported for tests. + */ +export function resolveFenceSupportSlabPatch( + fence: FenceSupportInput, + nodes: Record, + options?: SupportSlabPatchOptions, +): SupportSlabPatch { + const parent = fence.parentId ? nodes[fence.parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + const maxElevation = options?.maxElevation + const band = Math.max(fence.thickness ?? DEFAULT_FENCE_THICKNESS, MIN_FENCE_SUPPORT_BAND) + const points = fenceCenterlinePoints(fence) + const candidateElevations = new Set() + let winner: { slabId: string; elevation: number } | null = null + + for (let i = 1; i < points.length; i++) { + const [ax, az] = points[i - 1]! + const [bx, bz] = points[i]! + const length = Math.hypot(bx - ax, bz - az) + if (length < 1e-6) continue + const position: [number, number, number] = [(ax + bx) / 2, 0, (az + bz) / 2] + const dimensions: [number, number, number] = [length, 1, band] + // getItemFootprint's rotation convention: local +X maps to + // (cos yRot, sin yRot) in XZ, so the segment angle aligns the band. + const rotation: [number, number, number] = [0, Math.atan2(bz - az, bx - ax), 0] + + const candidates = spatialGridManager.getSupportCandidatesForFootprint( + parent.id, + position, + dimensions, + rotation, + ) + for (const candidate of candidates) candidateElevations.add(candidate.elevation) + + const support = spatialGridManager.getSlabSupportForItem( + parent.id, + position, + dimensions, + rotation, + maxElevation, + ) + if (support.slabId && (!winner || support.elevation > winner.elevation)) { + winner = { slabId: support.slabId, elevation: support.elevation } + } + } + + if (winner === null) return { supportSlabId: undefined } + const persist = candidateElevations.size >= 2 || winner.elevation > SUPPORT_ELEVATION_EPSILON + return { supportSlabId: persist ? winner.slabId : undefined } +} diff --git a/packages/core/src/hooks/spatial-grid/support-host.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts new file mode 100644 index 0000000000..88ed945322 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts @@ -0,0 +1,628 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, AnyNodeId, SlabNode } from '../../schema' +import { WallNode } from '../../schema' +import useScene, { clearSceneHistory } from '../../store/use-scene' +import { resolveWallEffectiveHeight, resolveWallTop } from '../../systems/wall/wall-top' +import { getFloorPlacedElevation } from './floor-placed-elevation' +import { spatialGridManager } from './spatial-grid-manager' +import { initSpatialGridSync } from './spatial-grid-sync' +import { resolveSupportSlabPatch, resolveWallSupportSlabPatch } from './support-host-patch' + +const LEVEL_ID = 'level_test' + +const SQUARE: Array<[number, number]> = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], +] + +function makeDefinition( + kind: AnyNode['type'], + capabilities: AnyNodeDefinition['capabilities'] = {}, +): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + capabilities, + } +} + +function registerFloorPlacedItem() { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + }, + }), + ) +} + +function makeLevel(children: string[] = []): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children, + level: 0, + } as AnyNode +} + +function makeFloorNode(overrides: Partial = {}): AnyNode { + return { + id: 'item_test', + type: 'item', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + asset: { + id: 'asset_test', + category: 'test', + name: 'Test', + thumbnail: '', + src: 'asset:test', + dimensions: [1, 1, 1], + source: 'library', + }, + ...overrides, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +function addSlab(slab: SlabNode) { + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +describe('persisted support hosts (items)', () => { + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + useScene.setState({ nodes: {} }) + }) + + test('no-host election over stacked slabs keeps returning the highest elevation', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.8) + }) + + test('a persisted host wins over the election, whichever slab it names', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + const level = makeLevel() + const hostedLow = makeFloorNode({ supportSlabId: 'slab_low' } as Partial) + const hostedHigh = makeFloorNode({ supportSlabId: 'slab_high' } as Partial) + + expect( + getFloorPlacedElevation({ + node: hostedLow, + nodes: nodesFor(level, hostedLow), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.2) + expect( + getFloorPlacedElevation({ + node: hostedHigh, + nodes: nodesFor(level, hostedHigh), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.8) + }) + + test('a host reshaped away falls back without clearing the field, and resumes on return', () => { + registerFloorPlacedItem() + const host = makeSlab('slab_low', SQUARE, 0.2) + addSlab(host) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + const level = makeLevel() + const node = makeFloorNode({ supportSlabId: 'slab_low' } as Partial) + const args = { + node, + nodes: nodesFor(level, node), + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + } + + expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2) + + // Reshape the host away from the item's footprint. + const movedAway: Array<[number, number]> = [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ] + spatialGridManager.handleNodeUpdated(makeSlab('slab_low', movedAway, 0.2) as AnyNode, LEVEL_ID) + expect(getFloorPlacedElevation(args)).toBeCloseTo(0.8) + expect((node as { supportSlabId?: string }).supportSlabId).toBe('slab_low') + + // Reshape it back — the stale reference resumes hosting. + spatialGridManager.handleNodeUpdated(host as AnyNode, LEVEL_ID) + expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2) + }) + + test('getSlabSupportForItem surfaces the winning slab id', () => { + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]), + ).toEqual({ elevation: 0.8, slabId: 'slab_high' }) + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [20, 0, 20], [1, 1, 1], [0, 0, 0]), + ).toEqual({ elevation: 0, slabId: null }) + }) + + test('getSupportCandidatesForFootprint lists distinct overlapping slabs, highest first', () => { + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + addSlab( + makeSlab( + 'slab_far', + [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ], + 0.5, + ), + ) + + expect( + spatialGridManager.getSupportCandidatesForFootprint( + LEVEL_ID, + [0, 0, 0], + [1, 1, 1], + [0, 0, 0], + ), + ).toEqual([ + { slabId: 'slab_high', elevation: 0.8 }, + { slabId: 'slab_low', elevation: 0.2 }, + ]) + expect( + spatialGridManager.getSupportCandidatesForFootprint( + LEVEL_ID, + [20, 0, 20], + [1, 1, 1], + [0, 0, 0], + ), + ).toEqual([]) + }) + + test('resolveSupportSlabPatch persists only an ambiguous stacked-slab winner', () => { + registerFloorPlacedItem() + const low = makeSlab('slab_low', SQUARE, 0.2) + const high = makeSlab('slab_high', SQUARE, 0.8) + addSlab(low) + addSlab(high) + + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node, low as AnyNode, high as AnyNode) + expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_high' }) + + spatialGridManager.handleNodeDeleted(high.id, 'slab', LEVEL_ID) + expect(resolveSupportSlabPatch(node, nodesFor(level, node, low as AnyNode))).toEqual({ + supportSlabId: undefined, + }) + }) + + test('item support follows the RENDERED slab polygon (wall band adoption)', () => { + registerFloorPlacedItem() + + // Room slab drawn on the wall centerlines; the rendered polygon + // extends to the walls' outer faces (x/z ± 0.05 for 0.1-thick walls). + const roomPolygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + const walls = [ + WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1, parentId: LEVEL_ID }), + WallNode.parse({ start: [4, 0], end: [4, 3], thickness: 0.1, parentId: LEVEL_ID }), + WallNode.parse({ start: [4, 3], end: [0, 3], thickness: 0.1, parentId: LEVEL_ID }), + WallNode.parse({ start: [0, 3], end: [0, 0], thickness: 0.1, parentId: LEVEL_ID }), + ] + const level = makeLevel(walls.map((wall) => wall.id)) + const node = makeFloorNode() + useScene.setState({ nodes: nodesFor(level, node, ...(walls as AnyNode[])) }) + // Grounded raised floor (thickness = elevation): band adoption only + // applies to grounded slabs — a floating deck keeps its drawn polygon. + addSlab(makeSlab('slab_room', roomPolygon, 0.4, { thickness: 0.4 })) + + // Footprint fully outside the STORED polygon (x from 4.0 to 4.6 with a + // 0.01 overlap inset) but inside the rendered band edge at x = 4.05. + const elevation = spatialGridManager.getSlabElevationForItem( + LEVEL_ID, + [4.3, 0, 1.5], + [0.6, 1, 0.6], + [0, 0, 0], + ) + expect(elevation).toBeCloseTo(0.4) + + // The manager sees wall changes: removing the walls drops the adopted + // band, so the same footprint stops electing the slab. + for (const wall of walls) { + spatialGridManager.handleNodeDeleted(wall.id, 'wall', LEVEL_ID) + } + useScene.setState({ nodes: nodesFor(makeLevel(), node) }) + expect( + spatialGridManager.getSlabElevationForItem(LEVEL_ID, [4.3, 0, 1.5], [0.6, 1, 0.6], [0, 0, 0]), + ).toBe(0) + }) +}) + +describe('persisted support hosts (walls, via the manager)', () => { + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + useScene.setState({ nodes: {} }) + }) + + test('preferred slab pins the elected elevation; invalid preference falls back', () => { + const polygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + addSlab(makeSlab('slab_low', polygon, 0.1)) + addSlab(makeSlab('slab_high', polygon, 0.6)) + + const start: [number, number] = [0, 1.5] + const end: [number, number] = [4, 1.5] + + const elected = spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end) + expect(elected.elevation).toBeCloseTo(0.6) + expect(elected.electedSlabId).toBe('slab_high') + + const preferred = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + start, + end, + 0, + 0.1, + 'slab_low', + ) + expect(preferred.elevation).toBeCloseTo(0.1) + expect(preferred.electedSlabId).toBe('slab_low') + + const fallback = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + start, + end, + 0, + 0.1, + 'slab_missing', + ) + expect(fallback.elevation).toBeCloseTo(0.6) + expect(fallback.electedSlabId).toBe('slab_high') + }) + + test('resolveWallSupportSlabPatch persists the winner over two elevations', () => { + const low = makeSlab( + 'slab_low', + [ + [-2, -1], + [0, -1], + [0, 1], + [-2, 1], + ], + 0.2, + ) + const high = makeSlab( + 'slab_high', + [ + [0, -1], + [2, -1], + [2, 1], + [0, 1], + ], + 0.8, + ) + const wall = WallNode.parse({ + id: 'wall_test', + parentId: LEVEL_ID, + start: [-2, 0], + end: [2, 0], + thickness: 0.1, + }) + const level = makeLevel([low.id, high.id, wall.id]) + const nodes = nodesFor(level, low as AnyNode, high as AnyNode, wall as AnyNode) + useScene.setState({ nodes }) + addSlab(low) + addSlab(high) + + expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({ + supportSlabId: 'slab_high', + }) + }) + + // Elevated deck stacked over a ground floor slab — the "wall on a deck" + // fixture (both slabs cover the wall band; the deck sits above). + const DECK_ELEVATION = 0.9 + const FLOOR_ELEVATION = 0.05 + function makeDeckOverFloorFixture() { + const deck = makeSlab( + 'slab_deck', + [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + DECK_ELEVATION, + ) + const ground = makeSlab( + 'slab_ground', + [ + [-6, -6], + [6, -6], + [6, 6], + [-6, 6], + ], + FLOOR_ELEVATION, + ) + const wall = WallNode.parse({ + id: 'wall_on_deck', + parentId: LEVEL_ID, + start: [0.5, 1.5], + end: [3.5, 1.5], + thickness: 0.1, + }) + const level = makeLevel([deck.id, ground.id, wall.id]) + const nodes = nodesFor(level, deck as AnyNode, ground as AnyNode, wall as AnyNode) + useScene.setState({ nodes }) + addSlab(deck) + addSlab(ground) + return { wall, nodes } + } + + test('a wall whose band lies over an elevated deck bases on the deck with a plane-bound top', () => { + const { wall, nodes } = makeDeckOverFloorFixture() + + const support = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + wall.start, + wall.end, + 0, + wall.thickness, + ) + expect(support.electedSlabId).toBe('slab_deck') + expect(support.elevation).toBeCloseTo(DECK_ELEVATION) + + // Wall-top inversion: no stored height → the top stays at the storey + // plane, so the extruded body is the plane minus the deck base. + const storeyHeight = 2.7 + expect(resolveWallTop(wall, storeyHeight, support.elevation)).toBeCloseTo(storeyHeight) + expect(resolveWallEffectiveHeight(wall, storeyHeight, support.elevation)).toBeCloseTo( + storeyHeight - DECK_ELEVATION, + ) + + // Commit persists the deck deterministically (two candidate elevations). + expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('pointer cap: aiming at the floor under the deck elects and persists the floor', () => { + const { wall, nodes } = makeDeckOverFloorFixture() + + const capped = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + wall.start, + wall.end, + 0, + wall.thickness, + null, + FLOOR_ELEVATION, + ) + expect(capped.electedSlabId).toBe('slab_ground') + expect(capped.elevation).toBeCloseTo(FLOOR_ELEVATION) + + expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({ + supportSlabId: 'slab_ground', + }) + // Aiming at the deck top keeps the deck. + expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: DECK_ELEVATION })).toEqual({ + supportSlabId: 'slab_deck', + }) + }) +}) + +describe('deleteNodesAction strips supportSlabId references', () => { + let stopSync = () => {} + + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + registerFloorPlacedItem() + + const slabLow = makeSlab('slab_low', SQUARE, 0.2) + const slabHigh = makeSlab('slab_high', SQUARE, 0.8) + const item = makeFloorNode({ supportSlabId: 'slab_low' } as Partial) + const level = makeLevel(['slab_low', 'slab_high', item.id]) + + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + nodes: nodesFor(level, slabLow as AnyNode, slabHigh as AnyNode, item), + readOnly: false, + rootNodeIds: [LEVEL_ID as AnyNodeId], + } as never) + clearSceneHistory() + stopSync = initSpatialGridSync() + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + }) + + function itemElevation(): number { + const nodes = useScene.getState().nodes + const item = nodes['item_test' as AnyNodeId]! + return getFloorPlacedElevation({ + node: item, + nodes, + position: [0, 0, 0], + rotation: [0, 0, 0], + }) + } + + test('deleting the host slab clears the reference and re-elects; undo restores both', () => { + expect(itemElevation()).toBeCloseTo(0.2) + + useScene.getState().deleteNodes(['slab_low' as AnyNodeId]) + + const afterDelete = useScene.getState().nodes + expect(afterDelete['slab_low' as AnyNodeId]).toBeUndefined() + expect( + (afterDelete['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId, + ).toBeUndefined() + expect(itemElevation()).toBeCloseTo(0.8) + + useScene.temporal.getState().undo() + + const afterUndo = useScene.getState().nodes + expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined() + expect((afterUndo['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId).toBe( + 'slab_low', + ) + expect(itemElevation()).toBeCloseTo(0.2) + }) + + test('deleting a non-host slab leaves the reference alone', () => { + useScene.getState().deleteNodes(['slab_high' as AnyNodeId]) + + expect( + (useScene.getState().nodes['item_test' as AnyNodeId] as { supportSlabId?: string }) + .supportSlabId, + ).toBe('slab_low') + expect(itemElevation()).toBeCloseTo(0.2) + }) + + test('deleting the destination deck strips deckSlabId from stairs; undo restores it', () => { + const stair = { + id: 'stair_test', + type: 'stair', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + deckSlabId: 'slab_low', + } as unknown as AnyNode + + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + stair_test: stair, + [LEVEL_ID]: { + ...useScene.getState().nodes[LEVEL_ID as AnyNodeId]!, + children: ['slab_low', 'slab_high', 'item_test', 'stair_test'], + } as AnyNode, + } as never, + }) + clearSceneHistory() + + useScene.getState().deleteNodes(['slab_low' as AnyNodeId]) + + const afterDelete = useScene.getState().nodes + expect( + (afterDelete['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId, + ).toBeUndefined() + + useScene.temporal.getState().undo() + + const afterUndo = useScene.getState().nodes + expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined() + expect((afterUndo['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId).toBe( + 'slab_low', + ) + }) + + test('deleting a slab that is not the destination deck leaves deckSlabId alone', () => { + const stair = { + id: 'stair_test', + type: 'stair', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + deckSlabId: 'slab_low', + } as unknown as AnyNode + + useScene.setState({ + nodes: { ...useScene.getState().nodes, stair_test: stair } as never, + }) + + useScene.getState().deleteNodes(['slab_high' as AnyNodeId]) + + expect( + (useScene.getState().nodes['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId, + ).toBe('slab_low') + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts index b1da3b3354..3a1b79fe82 100644 --- a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts +++ b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts @@ -90,7 +90,7 @@ describe('computeWallSlabElevation', () => { parseWall([4, 4], [0, 4]), parseWall([0, 4], [0, 0]), ] - const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 }) + const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 }) const bottom = walls[0]! expect( @@ -102,6 +102,37 @@ describe('computeWallSlabElevation', () => { ).toBeCloseTo(0.1) }) + it('elects a floating deck for a wall standing on its drawn footprint', () => { + // Wall ON a deck: no band adoption needed — the wall body lies inside + // the deck's drawn polygon, which is exactly what a floating slab + // renders. + const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 }) + const wallOnDeck = parseWall([1, 2], [3, 2]) + + expect( + computeWallSlabElevation( + { start: [1, 2], end: [3, 2], thickness: 0.1 }, + [deck], + [wallOnDeck], + ), + ).toBeCloseTo(1.5) + }) + + it('a wall in the adoption band beside a floating deck does not stand on it', () => { + // Centerline 6cm below the deck's bottom edge — inside the adoption + // band (half-thickness + 0.06) but the body never reaches the drawn + // footprint. A grounded slab adopts the band and carries the wall; the + // deck keeps its drawn polygon and offers no support. + const bandWall = parseWall([0, -0.06], [4, -0.06]) + const wallLike = { start: bandWall.start, end: bandWall.end, thickness: bandWall.thickness } + + const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 }) + expect(computeWallSlabElevation(wallLike, [deck], [bandWall])).toBe(0) + + const grounded = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 }) + expect(computeWallSlabElevation(wallLike, [grounded], [bandWall])).toBeCloseTo(0.1) + }) + it('lifts a wall whose body a legacy stored polygon falls short of', () => { // Legacy hand-adjusted slab: edges 6cm inside the wall centerlines — // 1cm short of even the inner faces, so the STORED polygon never @@ -114,6 +145,8 @@ describe('computeWallSlabElevation', () => { parseWall([4, 4], [0, 4]), parseWall([0, 4], [0, 0]), ] + // Grounded (thickness = elevation): band adoption only applies to + // grounded room floors under the vertical model. const slab = SlabNode.parse({ polygon: [ [0.06, 0.06], @@ -122,6 +155,7 @@ describe('computeWallSlabElevation', () => { [0.06, 3.94], ], elevation: 0.1, + thickness: 0.1, }) const bottom = walls[0]! @@ -304,6 +338,7 @@ describe('computeWallSlabElevation', () => { computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []), ).toEqual({ elevation: 0.6, + electedSlabId: platform.id, baseElevation: 0.6, baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], }) @@ -329,6 +364,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: platform.id, baseElevation: 0.05, baseSegments: [ { start: 0, end: 2 / 3, elevation: 0.6 }, @@ -340,6 +376,8 @@ describe('computeWallSlabElevation', () => { it('keeps a shared wall on the higher slab that carries the full wall band', () => { const sharedWall = parseWall([4, 0], [4, 4]) const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 }) + // Raised room floor: grounded (thickness = elevation) so the band-carry + // rule applies — a floating deck would keep its drawn polygon instead. const high = SlabNode.parse({ polygon: [ [4, 0], @@ -348,6 +386,7 @@ describe('computeWallSlabElevation', () => { [4, 4], ], elevation: 0.6, + thickness: 0.6, }) expect( @@ -358,6 +397,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: high.id, baseElevation: 0.6, baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], }) @@ -374,6 +414,7 @@ describe('computeWallSlabElevation', () => { parseWall([8, 1.5], [8, 4.5]), parseWall([8, 4.5], [4, 4.5]), ] + // Grounded raised room floor (see the shared-wall test above). const high = SlabNode.parse({ polygon: [ [0, 0], @@ -382,6 +423,7 @@ describe('computeWallSlabElevation', () => { [0, 3], ], elevation: 0.6, + thickness: 0.6, }) const low = SlabNode.parse({ polygon: [ @@ -401,6 +443,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: high.id, baseElevation: 0.05, baseSegments: [ { start: 0, end: 3.05 / 4.5, elevation: 0.6 }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1147e04c1a..d48f43a9e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,12 +46,16 @@ export { } from './hooks/scene-registry/scene-registry' export { type FloorPlacedElevationArgs, + GROUND_SUPPORT_ID, getFloorPlacedElevation, getFloorPlacedFootprints, getFloorStackedPosition, } from './hooks/spatial-grid/floor-placed-elevation' export { + getWallEffectiveHeightForNodes, + type PointedSupportSurface, pointInPolygon, + SUPPORT_ELEVATION_EPSILON, spatialGridManager, type WallSlabSupportSegment, } from './hooks/spatial-grid/spatial-grid-manager' @@ -61,6 +65,14 @@ export { resolveBuildingForLevel, resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' +export { + type FenceSupportInput, + resolveFenceSupportSlabPatch, + resolveSupportSlabPatch, + resolveWallSupportSlabPatch, + type SupportSlabPatch, + type SupportSlabPatchOptions, +} from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' export { @@ -123,7 +135,6 @@ export { planAutoCeilingsForLevel, planAutoSlabsForLevel, planAutoZonesForLevel, - projectAutoSlabsForPlan, resolveAutoZonePolygon, resumeSpaceDetection, type Space, @@ -245,9 +256,7 @@ export { } from './systems/elevator/elevator-runtime' export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system' export { - DEFAULT_ELEVATOR_LEVEL_HEIGHT, type ElevatorLevelEntry, - getElevatorLevelHeight, resolveElevatorBuildingLevels, resolveElevatorLevels, resolveElevatorServiceLevelIds, @@ -266,10 +275,16 @@ export { isSplineFence, sampleFenceSpline, } from './systems/fence/fence-spline' +export { + clampSlabElevationForWalls, + getSlabElevationUpperBound, + type SlabElevationClamp, +} from './systems/slab/slab-support' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { StairOpeningSystem } from './systems/stair/stair-opening-system' +export { resolveStairTotalRise } from './systems/stair/stair-rise' export { getClampedWallCurveOffset, getMaxWallCurveOffset, @@ -310,6 +325,11 @@ export { type WallMoveLinkedWallTargetPlan, type WallPlanPoint, } from './systems/wall/wall-move' +export { + MIN_WALL_HEIGHT, + resolveWallEffectiveHeight, + resolveWallTop, +} from './systems/wall/wall-top' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/lib/slab-polygon.test.ts b/packages/core/src/lib/slab-polygon.test.ts index 45490c1ec4..ef89df43e4 100644 --- a/packages/core/src/lib/slab-polygon.test.ts +++ b/packages/core/src/lib/slab-polygon.test.ts @@ -7,10 +7,22 @@ function wallOf(start: [number, number], end: [number, number], thickness = 0.1) return WallNode.parse({ start, end, thickness }) } -function slabOf(polygon: Array<[number, number]>, autoFromWalls = true, elevation?: number) { - return SlabNode.parse( - elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation }, - ) +function slabOf( + polygon: Array<[number, number]>, + autoFromWalls = true, + elevation?: number, + thickness?: number, +) { + return SlabNode.parse({ + polygon, + autoFromWalls, + ...(elevation === undefined ? {} : { elevation }), + // Raised ROOM FLOOR fixtures pass thickness = elevation so the slab + // stays grounded (underside 0) — adoption/seam rules only apply to + // grounded slabs; the schema-default 0.05 thickness would make an + // elevated fixture a floating deck. + ...(thickness === undefined ? {} : { thickness }), + }) } function xs(polygon: Array<[number, number]>) { @@ -398,6 +410,7 @@ describe('getRenderableSlabPolygon', () => { ], false, 0.34, + 0.34, ) const low = slabOf( [ @@ -450,6 +463,7 @@ describe('getRenderableSlabPolygon', () => { ], false, 0.4, + 0.4, ) const legacyLow = slabOf( [ @@ -471,8 +485,11 @@ describe('getRenderableSlabPolygon', () => { }) test('stacked slabs are not mistaken for rooms across a wall', () => { + // The platform is a grounded raised floor (thickness = elevation); the + // floating-deck variant of this shape is covered by the adoption-gate + // tests below. const floor = slabOf(roomA, false, 0.05) - const platform = slabOf(roomA, false, 0.4) + const platform = slabOf(roomA, false, 0.4, 0.4) const walls = [ wallOf([0, 0], [4, 0]), wallOf([4, 0], [4, 3]), @@ -527,6 +544,7 @@ describe('getRenderableSlabPolygon', () => { ], false, 0.3, + 0.3, ) const stepLow = slabOf( [ @@ -635,6 +653,7 @@ describe('getRenderableSlabPolygon', () => { ], true, 0.4, + 0.4, ) const low = slabOf( [ @@ -784,6 +803,76 @@ describe('getRenderableSlabPolygon', () => { }) }) +describe('grounded adoption gate', () => { + // Owner rule: wall adoption / per-edge extension exists so ROOM FLOORS + // tile with the walls standing on them. It applies only to grounded + // slabs (underside ≈ 0) and recessed pools; a floating deck keeps its + // drawn polygon exactly. + + test('a floating deck near walls keeps its drawn polygon exactly', () => { + // Same footprint as roomA — every edge inside a wall adoption band — + // but floating at 1.5m: no edge may extend to a wall face. + const deck = slabOf(roomA, false, 1.5, 0.05) + + const poly = getRenderableSlabPolygon(deck, { walls: twoRoomWalls, siblingSlabs: [] }) + + expect(poly).toEqual(roomA) + }) + + test('boundary case: underside 0.005 still counts as grounded and adopts', () => { + const nearlyGrounded = slabOf(roomA, false, 0.055, 0.05) + + const poly = getRenderableSlabPolygon(nearlyGrounded, { + walls: [wallOf([0, 0], [4, 0])], + siblingSlabs: [], + }) + + expect(Math.min(...zs(poly))).toBeCloseTo(-0.05) + }) + + test('a slab floated just past the epsilon stops adopting', () => { + // Underside 0.02 > 0.01 epsilon — already a deck. + const justFloating = slabOf(roomA, false, 0.07, 0.05) + + const poly = getRenderableSlabPolygon(justFloating, { + walls: [wallOf([0, 0], [4, 0])], + siblingSlabs: [], + }) + + expect(Math.min(...zs(poly))).toBeCloseTo(0) + }) + + test('a recessed pool keeps band adoption (unchanged)', () => { + // Recessed slabs are sunk into the ground, never floating — their + // negative elevation encodes depth, so the gate must not strip the + // wall-face extension a sunken room floor relies on. + const pool = SlabNode.parse({ polygon: roomA, elevation: -0.15, recessed: true }) + + const poly = getRenderableSlabPolygon(pool, { + walls: [wallOf([0, 0], [4, 0])], + siblingSlabs: [], + }) + + expect(Math.min(...zs(poly))).toBeCloseTo(-0.05) + }) + + test('a grounded floor ignores a floating deck sibling as a seam target', () => { + // Deck butted across the x=4 wall band: were it a room floor, the + // grounded (lower) floor would terminate at its own wall face (3.95). + // As a deck it is no seam partner — the floor adopts the wall's outer + // face (4.05) as if alone, and the deck itself stays as drawn. + const floor = slabOf(roomA, false, 0.05) + const deck = slabOf(roomB, false, 1.5, 0.05) + const walls = [wallOf([4, 0], [4, 3])] + + const floorPoly = getRenderableSlabPolygon(floor, { walls, siblingSlabs: [deck] }) + const deckPoly = getRenderableSlabPolygon(deck, { walls, siblingSlabs: [floor] }) + + expect(Math.max(...xs(floorPoly))).toBeCloseTo(4.05) + expect(deckPoly).toEqual(roomB) + }) +}) + describe('snapSlabEdgeToWallBand', () => { test('an edge inside the band snaps onto the wall centerline', () => { const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])]) diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts index 5b121adbc1..e8fded0d15 100644 --- a/packages/core/src/lib/slab-polygon.ts +++ b/packages/core/src/lib/slab-polygon.ts @@ -38,6 +38,13 @@ import { getWallThickness } from '../systems/wall/wall-footprint' * render offsets. * - FREE — no neighbour, no wall. Rendered exactly as drawn. * + * The whole machinery exists to make ROOM FLOORS tile with the walls + * standing on them, so it only applies to GROUNDED slabs (underside on + * the level plane) and recessed pools. A floating deck keeps its drawn + * polygon exactly — it must not grow into a wall it happens to float + * beside — and is symmetrically ignored as a seam target by its + * grounded siblings. + * * Sub-edges of one edge with different projections are joined by a * perpendicular STEP connector at the breakpoint. Breakpoints sit on * candidate span boundaries — wall junctions — so the step's vertical @@ -74,9 +81,28 @@ const WALL_LATERAL_TIE_EPSILON = 0.02 const CURVED_WALL_SAMPLE_SEGMENTS = 32 const SLAB_SEAM_ELEVATION_EPSILON = 1e-4 const DEFAULT_SLAB_ELEVATION = 0.05 +const DEFAULT_SLAB_THICKNESS = 0.05 +/** + * A non-recessed slab whose underside (`elevation − thickness`) rises + * above the level plane by more than this is a floating deck: it keeps + * its drawn polygon (no wall adoption, no seam projection) and grounded + * siblings don't seam toward it. + */ +const GROUNDED_SLAB_UNDERSIDE_EPSILON = 0.01 /** Prevent near-parallel offset lines from producing unbounded corner spikes. */ const MAX_CORNER_MITER_RATIO = 10 +/** + * Floating deck test — see the module header. Recessed pools are never + * floating: their negative elevation encodes depth, not placement. + */ +function isFloatingSlab(slab: SlabNode): boolean { + if (slab.recessed) return false + const elevation = slab.elevation ?? DEFAULT_SLAB_ELEVATION + const thickness = slab.thickness ?? DEFAULT_SLAB_THICKNESS + return elevation - thickness > GROUNDED_SLAB_UNDERSIDE_EPSILON +} + export type SlabPolygonContext = { /** Walls on the slab's level. */ walls: WallNode[] @@ -138,7 +164,7 @@ export function getRenderableSlabPolygon( context: SlabPolygonContext, ): Array<[number, number]> { const polygon = slabNode.polygon - if (polygon.length < 3) { + if (polygon.length < 3 || isFloatingSlab(slabNode)) { return polygon.map(([x, z]) => [x, z] as [number, number]) } @@ -368,6 +394,11 @@ function computeEdgeSubSpans( const neighborSegments: NeighborSegment[] = [] for (const sibling of context.siblingSlabs) { + // A floating deck keeps its drawn polygon, so it can't be a seam + // partner: projecting toward it would move this slab's edge while the + // deck's stays put (asymmetric seam), and the higher/lower band rules + // only describe room floors meeting under a wall. + if (isFloatingSlab(sibling)) continue const siblingPolygon = sibling.polygon if (siblingPolygon.length < 2) continue const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 708f715380..c7eb3949a6 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'bun:test' -import { CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' +import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode, ZoneNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { resolveCeilingHeight } from '../services/level-height' +import { getCeilingClampBound } from '../services/storey' import { detectSpacesForLevel, + initSpaceDetectionSync, planAutoCeilingsForLevel, planAutoSlabsForLevel, planAutoZonesForLevel, @@ -38,34 +42,35 @@ function slab(elevation: number) { } describe('planAutoCeilingsForLevel', () => { - test('creates auto ceilings at the top of the room walls', () => { + test('creates auto ceilings height-less so they follow the level top', () => { const created = planAutoCeilingsForLevel([roomPolygon()], [], { - walls: squareWalls(), - slabs: [slab(0.05)], + storeyHeight: 2.7, }).create[0] - expect(created?.height).toBeCloseTo(2.55) + expect(created).toBeDefined() + // Follows-mode: no stored height — the effective height derives from + // the clamp bound at read time via resolveCeilingHeight. + expect('height' in created!).toBe(false) + expect(created?.autoFromWalls).toBe(true) }) - test('updates existing auto ceiling height when the slab elevation changes', () => { + test('never writes a height onto a matched auto ceiling', () => { const ceiling = CeilingNode.parse({ polygon: square, - height: 2.55, autoFromWalls: true, }) const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { - walls: squareWalls(), - slabs: [slab(0.4)], + storeyHeight: 3, }) - expect(plan.update).toHaveLength(1) - expect(plan.update[0]?.id).toBe(ceiling.id) - expect(plan.update[0]?.data.polygon).toBeUndefined() - expect(plan.update[0]?.data.height).toBeCloseTo(2.9) + // Same polygon, follows-mode height — nothing to update. + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(0) + expect(plan.delete).toHaveLength(0) }) - test('updates existing auto ceiling height when wall height changes', () => { + test('a leftover explicit height on a matched auto ceiling is not rewritten', () => { const ceiling = CeilingNode.parse({ polygon: square, height: 2.55, @@ -73,12 +78,12 @@ describe('planAutoCeilingsForLevel', () => { }) const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { - walls: squareWalls(3), - slabs: [slab(0.05)], + storeyHeight: 3, }) - expect(plan.update).toHaveLength(1) - expect(plan.update[0]?.data.height).toBeCloseTo(3.05) + // The sync no longer re-derives auto heights; a user-set explicit + // height survives (still under the bound, so no clamp either). + expect(plan.update).toHaveLength(0) }) test('does not replace a manual ceiling with an auto ceiling', () => { @@ -88,9 +93,10 @@ describe('planAutoCeilingsForLevel', () => { autoFromWalls: false, }) + // Storey plane above the stored 2.5 so the stage 3-B manual re-clamp + // stays out of this test's scope (suppression only). const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], { - walls: squareWalls(), - slabs: [slab(0.4)], + storeyHeight: 2.7, }) expect(plan.create).toHaveLength(0) @@ -160,9 +166,10 @@ describe('planAutoCeilingsForLevel', () => { const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data }) expect(demoted.autoFromWalls).toBe(false) + // Storey plane above the stored 2.55 so the stage 3-B manual re-clamp + // stays out of this test's scope (suppression only). const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], { - walls: squareWalls(), - slabs: [slab(0.05)], + storeyHeight: 2.7, }) expect(plan.create).toHaveLength(0) @@ -171,6 +178,219 @@ describe('planAutoCeilingsForLevel', () => { }) }) +// Two stacked levels; the deck slab (occupying [-0.3, 0] over the upper +// level's plane) covers the queried level below, so the clamp bound is +// 2.5 - 0.3 - 0.01 = 2.19 (scenario gate 11's flush deck). +function stackedDeckNodes(): Record { + const deck = SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_1', + polygon: square, + elevation: 0, + thickness: 0.3, + }) + const list: AnyNode[] = [ + BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }), + LevelNode.parse({ id: 'level_0', level: 0, height: 2.5, parentId: 'building_a' }), + LevelNode.parse({ + id: 'level_1', + level: 1, + height: 2.5, + parentId: 'building_a', + children: ['slab_deck'], + }), + deck, + ] + return Object.fromEntries(list.map((node) => [node.id, node])) as Record +} + +describe('stage 3-B ceiling clamp bound', () => { + test('height-less auto ceilings resolve under the covering-slab bound at read time', () => { + const nodes = stackedDeckNodes() + const created = planAutoCeilingsForLevel([roomPolygon()], [], { + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon), + }).create[0] + + expect(created).toBeDefined() + expect('height' in created!).toBe(false) + // Follows-mode: the effective height is the deck-limited bound. + expect(resolveCeilingHeight({ ...created!, parentId: 'level_0' }, nodes)).toBeCloseTo(2.19) + }) + + test('clamps a manual ceiling above the bound down to it (plane-only degradation)', () => { + const manual = CeilingNode.parse({ polygon: square, height: 2.6, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 }) + + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.id).toBe(manual.id) + expect(plan.update[0]?.data.polygon).toBeUndefined() + expect(plan.update[0]?.data.height).toBeCloseTo(2.49) + }) + + test('never raises a manual ceiling sitting below the bound', () => { + const manual = CeilingNode.parse({ polygon: square, height: 2.0, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 }) + + expect(plan.update).toHaveLength(0) + }) + + test('skips follows-mode manual ceilings (never converts them to explicit)', () => { + const nodes = stackedDeckNodes() + const manual = CeilingNode.parse({ polygon: square, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon), + }) + + expect(plan.update).toHaveLength(0) + }) + + test('a flush deck above clamps a manual ceiling at the plane margin to its underside', () => { + // Scenario gate 11: manual ceiling at storeyHeight - 0.01 (the no-deck + // bound) → deck occupying [-0.3, 0] above → clamps to 2.5 - 0.3 - 0.01. + const nodes = stackedDeckNodes() + const manual = CeilingNode.parse({ polygon: square, height: 2.49, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon), + }) + + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.id).toBe(manual.id) + expect(plan.update[0]?.data.height).toBeCloseTo(2.19) + }) +}) + +// Minimal store stand-ins for initSpaceDetectionSync: a zustand-shaped +// scene store (getState/subscribe/temporal) whose write methods mutate the +// nodes record and re-notify, and an editor store carrying `spaces`. +function createSceneStoreStub(initialNodes: Record) { + const listeners = new Set<(state: unknown) => void>() + const state: Record & { nodes: Record } = { + nodes: initialNodes, + } + const notify = () => { + for (const listener of [...listeners]) listener(state) + } + state.updateNodes = (updates: Array<{ id: string; data: Record }>) => { + const next: Record = { ...state.nodes } + for (const { id, data } of updates) { + const existing = next[id] + if (existing) next[id] = { ...existing, ...data } as AnyNode + } + state.nodes = next + notify() + } + state.deleteNodes = (ids: string[]) => { + const next: Record = { ...state.nodes } + for (const id of ids) delete next[id] + state.nodes = next + notify() + } + state.createNodes = (entries: Array<{ node: AnyNode; parentId: string }>) => { + const next: Record = { ...state.nodes } + for (const { node, parentId } of entries) { + next[node.id] = { ...node, parentId } as AnyNode + const parent = next[parentId] as (AnyNode & { children?: string[] }) | undefined + if (parent) { + next[parentId] = { ...parent, children: [...(parent.children ?? []), node.id] } as AnyNode + } + } + state.nodes = next + notify() + } + return { + getState: () => state, + subscribe: (listener: (state: unknown) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + temporal: { getState: () => ({ pause() {}, resume() {} }) }, + setNodes(next: Record) { + state.nodes = next + notify() + }, + } +} + +function createEditorStoreStub() { + const state = { + spaces: {} as Record, + setSpaces(next: Record) { + state.spaces = next + }, + } + return { getState: () => state } +} + +describe('reactive ceiling re-clamp through the detection sync', () => { + test('a flush deck created on the level above clamps the existing manual ceiling below', () => { + const walls = [ + WallNode.parse({ start: [0, 0], end: [4, 0], parentId: 'level_0' }), + WallNode.parse({ start: [4, 0], end: [4, 3], parentId: 'level_0' }), + WallNode.parse({ start: [4, 3], end: [0, 3], parentId: 'level_0' }), + WallNode.parse({ start: [0, 3], end: [0, 0], parentId: 'level_0' }), + ] + const manualCeiling = CeilingNode.parse({ + id: 'ceiling_main', + parentId: 'level_0', + polygon: square, + height: 2.49, + autoFromWalls: false, + }) + const initialNodes = Object.fromEntries( + [ + BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }), + LevelNode.parse({ + id: 'level_0', + level: 0, + height: 2.5, + parentId: 'building_a', + children: [...walls.map((wall) => wall.id), 'ceiling_main'], + }), + LevelNode.parse({ id: 'level_1', level: 1, height: 2.5, parentId: 'building_a' }), + ...walls, + manualCeiling, + ].map((node) => [node.id, node]), + ) as Record + + const sceneStore = createSceneStoreStub(initialNodes) + const editorStore = createEditorStoreStub() + const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore) + + try { + // Scenario gate 11's reactive half: the deck lands on the level + // ABOVE, so only the covering-underside part of level_0's structure + // snapshot changes — the sync must still re-run and clamp down. + const deck = SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_1', + polygon: square, + elevation: 0, + thickness: 0.3, + }) + const current = sceneStore.getState().nodes + const levelAbove = current.level_1 as AnyNode + sceneStore.setNodes({ + ...current, + slab_deck: deck, + level_1: { ...levelAbove, children: ['slab_deck'] } as AnyNode, + }) + + const ceiling = sceneStore.getState().nodes.ceiling_main as CeilingNode + expect(ceiling.height).toBeCloseTo(2.5 - 0.3 - 0.01) + } finally { + unsubscribe() + } + }) +}) + describe('detectSpacesForLevel', () => { const areaOf = (polygon: Array<{ x: number; y: number }>) => { let area = 0 diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index c125e275f1..41302a7422 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -2,12 +2,21 @@ import { type AnyNodeId, CeilingNode, type CeilingNode as CeilingNodeType, + type LevelNode, SlabNode, type SlabNode as SlabNodeType, type WallNode, ZoneNode, type ZoneNode as ZoneNodeType, } from '../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +import { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + getCeilingClampBound, + getLevelElevations, + getStoredLevelHeight, +} from '../services/storey' import { getSceneHistoryPauseDepth, pauseSceneHistory, @@ -56,10 +65,6 @@ type DetectedRoom = { bbox: ReturnType } -type DetectedCeilingRoom = DetectedRoom & { - ceilingHeight: number -} - export type AutoSlabSyncPlan = { create: SlabNodeType[] update: Array<{ id: SlabNodeType['id']; data: Partial }> @@ -77,7 +82,6 @@ export type AutoZoneSyncPlan = { } const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 -const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const CEILING_HEIGHT_EPSILON = 1e-6 const ROOM_CURVE_TOLERANCE = 0.04 const MAX_CURVE_SUBDIVISION_DEPTH = 6 @@ -94,9 +98,21 @@ const WALL_JUNCTION_TOLERANCE = 0.08 const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 const COVERAGE_SAMPLE_STEPS = 12 +// Auto ceilings are created height-less (follows-mode: they track the +// clamp bound live through `resolveCeilingHeight`), so the planner needs +// no wall/slab inputs anymore — only the bound for the explicit-height +// reactive re-clamp below. export type AutoCeilingPlanningContext = { - walls?: WallNode[] - slabs?: SlabNodeType[] + /** Stored storey height of the level being planned (floor-to-floor). */ + storeyHeight?: number + /** + * Stage 3-B clamp-bound resolver for a polygon on the planned level: + * `min(storey plane, lowest covering-slab underside from the level + * above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent + * (pure-planner callers without a nodes record), the bound degrades to + * the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`. + */ + ceilingClampBound?: (polygon: Array<[number, number]>) => number } function pointFromTuple(point: [number, number]): Point2D { @@ -306,61 +322,17 @@ function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) { return matchingPoints.length >= 2 } -function pointIsOnSlab(point: Point2D, slab: SlabNodeType) { - if (slab.polygon.length < 3) return false - const slabPolygon = slab.polygon.map(pointFromTuple) - if (!pointInPolygon(point, slabPolygon)) return false - - for (const hole of slab.holes ?? []) { - if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) { - return false - } - } - - return true -} - -function slabSupportsRoom(roomPolygon: Point2D[], slab: SlabNodeType) { - if (slab.polygon.length < 3) return false - if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) { - return true - } - return pointIsOnSlab(polygonCentroid(roomPolygon), slab) -} - -function resolveRoomSlabElevation(roomPolygon: Point2D[], slabs: SlabNodeType[] = []) { - let maxElevation = 0 - - for (const slab of slabs) { - if (!slabSupportsRoom(roomPolygon, slab)) continue - maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION) - } - - return maxElevation -} - -function resolveRoomWallHeight(roomPolygon: Point2D[], walls: WallNode[] = []) { - let maxHeight = 0 - - for (const wall of walls) { - if (!wallBoundsRoom(wall, roomPolygon)) continue - const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT - if (Number.isFinite(height)) { - maxHeight = Math.max(maxHeight, height) - } - } - - return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT -} - -function resolveAutoCeilingHeight( - roomPolygon: Point2D[], - context: AutoCeilingPlanningContext = {}, +/** + * The clamp bound for a ceiling polygon under this planning context — + * the context's cross-level resolver when provided, else the plane-only + * `storeyHeight - CEILING_CLAMP_MARGIN` degradation. + */ +function resolveCeilingClampBound( + polygon: Array<[number, number]>, + context: AutoCeilingPlanningContext, ) { - return ( - resolveRoomSlabElevation(roomPolygon, context.slabs) + - resolveRoomWallHeight(roomPolygon, context.walls) - ) + if (context.ceilingClampBound) return context.ceilingClampBound(polygon) + return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN } function getWallDirection(wall: Pick) { @@ -809,7 +781,10 @@ function wallGeometrySignature(wall: WallNode) { wall.end[0].toFixed(4), wall.end[1].toFixed(4), (wall.thickness ?? 0.2).toFixed(4), - (wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4), + // Plane-bound (no stored height) is a distinct state, not a default + // value: it resolves to the storey plane, so it must not alias an + // explicit height of the same magnitude in the trigger signature. + wall.height == null ? 'plane' : wall.height.toFixed(4), getClampedWallCurveOffset(wall).toFixed(4), ].join('|') } @@ -827,13 +802,24 @@ function zoneGeometrySignature(zone: ZoneNodeType) { ].join('|') } -// Slabs and ceilings stay out of the trigger signature: including generated -// surfaces caused delete/recreate feedback. Zones are included only so a newly -// traced room footprint can adopt its enclosing walls without waiting for the -// next remodel. +// Slab/ceiling POLYGONS stay out of the trigger signature: including +// generated footprints caused delete/recreate feedback. Zones are included +// only so a newly traced room footprint can adopt its enclosing walls +// without waiting for the next remodel. Slab ELEVATIONS and the level's +// stored storey height ARE included — both feed the explicit-ceiling +// re-clamp bound (the storey plane), and neither is rewritten by +// the sync, so regeneration triggers when they change without feedback. +// Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation − +// thickness, recessed pools excluded): a deck created, lowered, or +// thickened above must re-run the sync below so ceilings re-clamp under +// it. Same polygon exclusion applies — the level-above's own auto sync +// rewrites its slab footprints, and hashing them here would re-trigger +// this level on every remodel above. function levelStructureSnapshots(nodes: Record) { const wallsByLevel = new Map() const zonesByLevel = new Map() + const slabElevationsByLevel = new Map() + const coveringUndersidesByLevel = new Map() for (const node of Object.values(nodes)) { if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue @@ -846,17 +832,39 @@ function levelStructureSnapshots(nodes: Record) { const zones = zonesByLevel.get(levelId) ?? [] zones.push(ZoneNode.parse(node)) zonesByLevel.set(levelId, zones) + } else if ((node as any).type === 'slab') { + const elevations = slabElevationsByLevel.get(levelId) ?? [] + elevations.push( + `${(node as any).id}:${(((node as any).elevation as number | undefined) ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`, + ) + slabElevationsByLevel.set(levelId, elevations) + if ((node as any).recessed !== true) { + const undersides = coveringUndersidesByLevel.get(levelId) ?? [] + const elevation = ((node as any).elevation as number | undefined) ?? 0.05 + const thickness = ((node as any).thickness as number | undefined) ?? 0.05 + undersides.push(`${(node as any).id}:${(elevation - thickness).toFixed(4)}`) + coveringUndersidesByLevel.set(levelId, undersides) + } } } + const levelElevations = getLevelElevations(nodes as Record) const snapshots = new Map() const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]) for (const levelId of levelIds) { const walls = wallsByLevel.get(levelId) ?? [] const zones = zonesByLevel.get(levelId) ?? [] + const level = nodes[levelId] + const storeyKey = + level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : '' + const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';') + const aboveId = findLevelAboveId(levelId, levelElevations) + const aboveSlabKey = aboveId + ? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';') + : '' snapshots.set( levelId, - `${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`, + `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, ) } @@ -1111,29 +1119,6 @@ function syncAutoSlabsForLevel( return plan } -export function projectAutoSlabsForPlan( - existingSlabs: SlabNodeType[], - plan: AutoSlabSyncPlan, -): SlabNodeType[] { - const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab])) - - for (const id of plan.delete) { - slabsById.delete(id) - } - - for (const update of plan.update) { - const slab = slabsById.get(update.id) - if (!slab) continue - slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data })) - } - - for (const slab of plan.create) { - slabsById.set(slab.id, slab) - } - - return [...slabsById.values()] -} - export function planAutoCeilingsForLevel( roomPolygons: Point2D[][], existingCeilings: CeilingNodeType[], @@ -1145,7 +1130,7 @@ export function planAutoCeilingsForLevel( ) const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple)) - const detectedAll: DetectedCeilingRoom[] = roomPolygons + const detectedAll: DetectedRoom[] = roomPolygons .map((poly) => ({ poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( pointFromTuple, @@ -1161,7 +1146,6 @@ export function planAutoCeilingsForLevel( centroid: polygonCentroid(room.poly), area: Math.abs(polygonArea(room.poly)), bbox: bboxOf(room.poly), - ceilingHeight: resolveAutoCeilingHeight(room.poly, context), })) const detected = detectedAll.filter( @@ -1182,7 +1166,7 @@ export function planAutoCeilingsForLevel( const matchedCeilingIds = new Set() const matchedDetectedIdx = new Set() - const updatesById = new Map() + const updatesById = new Map() const autoBySignature = new Map>() for (const entry of existingAutoMeta) { @@ -1199,7 +1183,6 @@ export function planAutoCeilingsForLevel( matchedCeilingIds.add(existing.ceiling.id) updatesById.set(existing.ceiling.id, { polygon: room.poly.map(pointToTuple), - height: room.ceilingHeight, }) }) @@ -1237,7 +1220,6 @@ export function planAutoCeilingsForLevel( matchedCeilingIds.add(bestMatch.entry.ceiling.id) updatesById.set(bestMatch.entry.ceiling.id, { polygon: room.poly.map(pointToTuple), - height: room.ceilingHeight, }) } @@ -1255,27 +1237,36 @@ export function planAutoCeilingsForLevel( } } + // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab + // created, moved, or thickened on the level above can leave an EXISTING + // manual explicit-height ceiling poking into its solid. Clamp explicit + // heights down to the bound; never raise them — a user-lowered ceiling + // is intent, only an over-bound one is a conflict. Follows-mode + // ceilings (absent height) derive under the bound by construction and + // are skipped, so the clamp can never convert one to an explicit + // height. + const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => { + if (ceiling.height == null) return [] + const bound = resolveCeilingClampBound(ceiling.polygon, context) + if (!Number.isFinite(bound)) return [] + return ceiling.height > bound + CEILING_HEIGHT_EPSILON + ? [{ id: ceiling.id, data: { height: bound } }] + : [] + }) + const ceilingsToUpdate = [ + // Auto ceilings only track their room's POLYGON here — their height is + // follows-mode (absent) and derives from the level top at read time. ...existingAuto .filter((ceiling) => updatesById.has(ceiling.id)) .flatMap((ceiling) => { const update = updatesById.get(ceiling.id) if (!update) return [] - - const data: Partial = {} - if (!sameTuplePolygon(ceiling.polygon, update.polygon)) { - data.polygon = update.polygon - } - if ( - Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) > - CEILING_HEIGHT_EPSILON - ) { - data.height = update.height - } - - return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }] + if (sameTuplePolygon(ceiling.polygon, update.polygon)) return [] + return [{ id: ceiling.id, data: { polygon: update.polygon } }] }), ...ceilingDemotions, + ...manualClamps, ] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] @@ -1289,12 +1280,14 @@ export function planAutoCeilingsForLevel( const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling') plannedCeilingsForNaming.push({ name }) + // Height-less on purpose: auto ceilings follow the level top (the + // clamp bound) through `resolveCeilingHeight` instead of baking a + // derived height that would go stale on level-height edits. ceilingsToCreate.push( CeilingNode.parse({ name, polygon: room.poly.map(pointToTuple), holes: [], - height: room.ceilingHeight, autoFromWalls: true, }), ) @@ -1402,14 +1395,21 @@ function runSpaceDetection( } const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) - const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) - const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan) + syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) + const levelNode = nodes[levelId] + const storeyHeight = + levelNode?.type === 'level' + ? getStoredLevelHeight(levelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT syncAutoCeilingsForLevel( levelId, roomPolygons, ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), sceneStore, - { walls, slabs: projectedSlabs }, + { + storeyHeight, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), + }, ) const zonePlan = planAutoZonesForLevel( spaces, diff --git a/packages/core/src/lib/zone-quantities.test.ts b/packages/core/src/lib/zone-quantities.test.ts index c8e529ce1b..4bdeb23a93 100644 --- a/packages/core/src/lib/zone-quantities.test.ts +++ b/packages/core/src/lib/zone-quantities.test.ts @@ -17,7 +17,12 @@ function sceneRecord(nodes: AnyNode[]): Record { function roomNodes() { const zone = ZoneNode.parse({ id: 'zone_room', name: 'Studio', parentId: 'level_main', polygon }) const slab = SlabNode.parse({ id: 'slab_room', parentId: 'level_main', polygon }) - const ceiling = CeilingNode.parse({ id: 'ceiling_room', parentId: 'level_main', polygon }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_room', + parentId: 'level_main', + polygon, + height: 2.5, + }) const walls = polygon.map((start, index) => WallNode.parse({ id: `wall_${index}`, diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index f120ff02c6..a2ad983c72 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -1,6 +1,11 @@ import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' +import type { AnyNodeId } from '../schema/types' +import { DEFAULT_LEVEL_HEIGHT, resolveCeilingHeight } from '../services/level-height' +import { getWallPlaneTop } from '../services/storey' +import { computeWallSlabSupport } from '../systems/slab/slab-support' import { sampleWallCenterline } from '../systems/wall/wall-curve' -import { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' +import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' +import { resolveWallEffectiveHeight } from '../systems/wall/wall-top' import { detectSpacesForLevel, type Space } from './space-detection' type Point2D = readonly [number, number] @@ -473,6 +478,12 @@ export function deriveZoneQuantityReport( ? Object.values(sceneNodes).filter((node) => node.parentId === levelId) : [] const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall') + const slabs = levelNodes.filter((node): node is SlabNode => node.type === 'slab') + const wallEffectiveHeight = (wall: WallNode) => { + const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) + const planeTop = levelId ? getWallPlaneTop(wall, levelId, sceneNodes) : DEFAULT_LEVEL_HEIGHT + return resolveWallEffectiveHeight(wall, planeTop, support.elevation) + } const edgeLengths = zone.polygon.map((start, index) => { const end = zone.polygon[(index + 1) % zone.polygon.length] return end ? pointDistance(start, end) : 0 @@ -488,7 +499,7 @@ export function deriveZoneQuantityReport( const ceilingCoverage = proveSurfaceCoverage( zone, levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'), - (node) => node.height, + (node) => resolveCeilingHeight(node, sceneNodes as Record), { singular: 'ceiling', plural: 'Ceilings', datum: 'heights' }, ) @@ -517,7 +528,7 @@ export function deriveZoneQuantityReport( ? { status: 'available' as const, value: wallSpans!.reduce( - (sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT), + (sum, span) => sum + span.length * wallEffectiveHeight(span.wall), 0, ), note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.', diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 7727c9861c..e58db3159a 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -184,7 +184,7 @@ export { SkylightType, type SkylightTypePreset, } from './nodes/skylight' -export { SlabNode } from './nodes/slab' +export { MIN_SLAB_THICKNESS, SlabNode } from './nodes/slab' export { SolarPanelMaterialRole, SolarPanelNode, diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index a84e32487d..a0949a4967 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -80,6 +80,8 @@ export type CabinetCompartmentSchema = z.infer const cabinetBoxFields = { position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), width: z.number().min(0.05).max(3).default(0.5), depth: z.number().min(0.3).max(1.2).default(0.5), carcassHeight: z.number().min(0.4).max(2.4).default(0.72), diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index b94aaaa2b6..cb673d2cc9 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -18,7 +18,12 @@ export const CeilingNode = BaseNode.extend({ polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), - height: z.number().default(2.5), // Height in meters + // Height in meters. Absent = the ceiling follows the level top: its + // effective height is the same bound its write-clamp uses — + // min(storey plane, lowest covering-slab underside over the polygon) + // − CEILING_CLAMP_MARGIN (see `resolveCeilingHeight`). Present = an + // explicit custom height, still write-clamped under that bound. + height: z.number().optional(), autoFromWalls: z.boolean().default(false), }).describe( dedent` @@ -26,6 +31,7 @@ export const CeilingNode = BaseNode.extend({ - polygon: array of [x, z] points defining the ceiling boundary - holes: array of polygons representing holes in the ceiling - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts + - height: explicit height in meters; absent = follows the level top automatically - autoFromWalls: whether the ceiling is automatically generated from a closed wall loop `, ) diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index de4116045d..1dc704cff5 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -86,6 +86,8 @@ export const ColumnNode = BaseNode.extend({ type: nodeType('column'), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), style: ColumnStyle.default('plain'), crossSection: ColumnCrossSection.default('round'), height: z.number().positive().default(2.5), diff --git a/packages/core/src/schema/nodes/duct-terminal.ts b/packages/core/src/schema/nodes/duct-terminal.ts index 5eefff3148..6c5f058af6 100644 --- a/packages/core/src/schema/nodes/duct-terminal.ts +++ b/packages/core/src/schema/nodes/duct-terminal.ts @@ -21,6 +21,8 @@ export const DuctTerminalNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Yaw in radians. rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'), // Which surface the terminal mounts on. Drives face orientation and // which way the collar (and its port) points. diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index e8e7687421..26ebff7774 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -32,6 +32,9 @@ export const FenceNode = BaseNode.extend({ tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(), height: z.number().default(1.8), thickness: z.number().default(0.08), + // Persisted slab-support host — the fence sits on that slab's walking + // surface (see ItemNode.supportSlabId for the host rules). + supportSlabId: z.string().optional(), curveOffset: z.number().optional(), baseHeight: z.number().default(0.22), postSpacing: z.number().default(2), @@ -54,6 +57,7 @@ export const FenceNode = BaseNode.extend({ - path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them - tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent - height/thickness: overall fence dimensions in meters + - supportSlabId: optional slab host; the fence stands on that slab's walking surface (elevation) - curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set) - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - groundClearance/edgeInset/baseStyle: fence support and inset configuration diff --git a/packages/core/src/schema/nodes/hvac-equipment.ts b/packages/core/src/schema/nodes/hvac-equipment.ts index a0e00a1392..6bde1e2c15 100644 --- a/packages/core/src/schema/nodes/hvac-equipment.ts +++ b/packages/core/src/schema/nodes/hvac-equipment.ts @@ -23,6 +23,8 @@ export const HvacEquipmentNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Yaw in radians. rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'), // Cabinet dimensions in meters. Defaults match a typical upflow // furnace cabinet (~22" × 28" footprint, ~43" tall). diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index f8732482bf..00c5a998b1 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -143,6 +143,20 @@ export const ItemNode = BaseNode.extend({ roofSegmentId: z.string().optional(), roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), + // Persisted floor-support host (canonical doc — the same field on other + // floor-placed kinds and walls follows these rules). Written at + // placement/commit ONLY when overlapping slabs disagree on elevation + // (ambiguity); absent/null means "elect the support fresh on every + // read", which is the historical behavior. Read paths PREFER this slab + // while it still exists and still overlaps the node's footprint, and + // silently fall back to election otherwise. Deleting the host slab + // strips the field (deleteNodesAction); a host merely reshaped away is + // deliberately kept so hosting resumes if the slab's polygon returns. + // The sentinel value 'ground' (GROUND_SUPPORT_ID) pins the node to the + // level base — written when a pointer-capped commit elected the ground + // while a slab (e.g. an elevated deck) still overlapped the footprint. + supportSlabId: z.string().optional(), + // Denormalized references to collections this node belongs to collectionIds: z.array(z.custom()).optional(), diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts index 839be780da..ae44915deb 100644 --- a/packages/core/src/schema/nodes/level.test.ts +++ b/packages/core/src/schema/nodes/level.test.ts @@ -48,4 +48,9 @@ describe('LevelNode', () => { nodes.map((node) => node.id), ) }) + + test('does not materialize height on parse — absence marks unmigrated legacy data', () => { + expect('height' in LevelNode.parse({})).toBe(false) + expect(LevelNode.parse({ height: 3 }).height).toBe(3) + }) }) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index ef4de43aeb..a566f593fd 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -59,11 +59,18 @@ export const LevelNode = BaseNode.extend({ .default([]), // Specific props level: z.number().default(0), + /** + * Stored storey height in meters (floor-to-floor). No zod default on + * purpose: absence marks unmigrated legacy data and gates the load-time + * migration; a schema default would materialize silently through .parse(). + */ + height: z.number().optional(), }).describe( dedent` Level node - used to represent a level in the building - children: array of architectural, equipment, and MEP distribution nodes - level: level number + - height: storey height in meters (floor-to-floor); absent only on unmigrated legacy data `, ) diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts index 3e9b74b2a5..89faaeddb8 100644 --- a/packages/core/src/schema/nodes/shelf.ts +++ b/packages/core/src/schema/nodes/shelf.ts @@ -43,6 +43,8 @@ export const ShelfNode = BaseNode.extend({ children: z.array(ItemNode.shape.id).default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), // Dimensions (meters). Schema-level defaults intentionally reproduce // the v1 wall-shelf so existing v1 scenes that omit the v2-introduced diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index fe3c47c1d2..ec8e99b743 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -4,6 +4,11 @@ import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' import { SurfaceHoleMetadata } from './surface-hole-metadata' +// Edit-time floor for `thickness` — a thinner slab z-fights the ceiling's +// −0.01 underside offset. Applies to edits only; migration writes legacy +// intervals verbatim (including degenerate zero-thickness slabs). +export const MIN_SLAB_THICKNESS = 0.02 + export const SlabNode = BaseNode.extend({ id: objectId('slab'), type: nodeType('slab'), @@ -16,7 +21,9 @@ export const SlabNode = BaseNode.extend({ polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), - elevation: z.number().default(0.05), // Elevation in meters + elevation: z.number().default(0.05), // Walking surface (slab top), meters above the level plane + thickness: z.number().default(0.05), // Grows downward from the surface + recessed: z.boolean().default(false), autoFromWalls: z.boolean().default(false), }).describe( dedent` @@ -24,7 +31,9 @@ export const SlabNode = BaseNode.extend({ - polygon: array of [x, z] points defining the slab boundary - holes: array of [x, z] polygons representing cutouts in the slab - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - - elevation: elevation in meters + - elevation: the walking surface (slab top), in meters above the level plane + - thickness: grows downward from the surface; the solid occupies [elevation - thickness, elevation] + - recessed: open recess (pool) whose floor sits at elevation (< 0); the shell walls rise to the level plane - autoFromWalls: whether the slab is automatically generated from a closed wall loop `, ) diff --git a/packages/core/src/schema/nodes/spawn.ts b/packages/core/src/schema/nodes/spawn.ts index 521d3f8101..b3a620eb14 100644 --- a/packages/core/src/schema/nodes/spawn.ts +++ b/packages/core/src/schema/nodes/spawn.ts @@ -6,6 +6,8 @@ export const SpawnNode = BaseNode.extend({ type: nodeType('spawn'), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), }) export type SpawnNode = z.infer diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index abc1fe54a6..cf9d0664aa 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -37,13 +37,19 @@ export const StairNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), stairType: StairType.default('straight'), fromLevelId: z.string().nullable().default(null), toLevelId: z.string().nullable().default(null), + // Destination deck (a slab id). When set, the stair's rise follows that + // slab's elevation live. An explicit `totalRise` still wins when BOTH are + // set (edge case — the panel clears the custom rise when attaching). + deckSlabId: z.string().optional(), slabOpeningMode: StairSlabOpeningMode.default('none'), openingOffset: z.number().default(0), width: z.number().default(1.0), - totalRise: z.number().default(2.5), + totalRise: z.number().optional(), stepCount: z.number().default(10), thickness: z.number().default(0.25), fillToFloor: z.boolean().default(true), @@ -66,6 +72,7 @@ export const StairNode = BaseNode.extend({ - rotation: rotation around Y axis - stairType: straight (segment-based), curved (arc-based), or spiral - fromLevelId / toLevelId: source and destination levels used for auto slab cutouts + - deckSlabId: destination deck (slab) — the rise derives from its elevation while set - slabOpeningMode: whether a destination-level slab opening is generated for this stair - openingOffset: extra opening expansion applied after the cutout polygon is computed - width: stair width diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index abc2f5e85a..5364e083cc 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -41,16 +41,19 @@ describe('wall face bands', () => { }) expect( - getWallFaceBandConfig({ - height: 2.5, - faceBands: { - enabled: true, - count: 3, - lowerHeight: 0.84, - middleHeight: 0.61, - upperHeight: 0.61, + getWallFaceBandConfig( + { + height: 2.5, + faceBands: { + enabled: true, + count: 3, + lowerHeight: 0.84, + middleHeight: 0.61, + upperHeight: 0.61, + }, }, - }), + 2.5, + ), ).toMatchObject({ count: 3, lowerTop: 0.84, @@ -60,16 +63,19 @@ describe('wall face bands', () => { test('four bands adds an upper split below the final top band', () => { expect( - getWallFaceBandConfig({ - height: 2.5, - faceBands: { - enabled: true, - count: 4, - lowerHeight: 0.5, - middleHeight: 0.6, - upperHeight: 0.7, + getWallFaceBandConfig( + { + height: 2.5, + faceBands: { + enabled: true, + count: 4, + lowerHeight: 0.5, + middleHeight: 0.6, + upperHeight: 0.7, + }, }, - }), + 2.5, + ), ).toMatchObject({ count: 4, lowerTop: 0.5, diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 4bfaa0634f..a01a98ada4 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -151,6 +151,8 @@ export const WallNode = BaseNode.extend({ thickness: z.number().optional(), height: z.number().optional(), curveOffset: z.number().optional(), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), faceBands: WallFaceBandConfig.optional(), skirting: WallTrimConfig.optional(), crown: WallTrimConfig.optional(), @@ -198,8 +200,11 @@ export const WALL_SLOT_DEFAULT: Record = { exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior, } -export function getWallFaceBandConfig(wall: Pick) { - const wallHeight = wall.height ?? 2.5 +export function getWallFaceBandConfig( + wall: Pick, + effectiveWallHeight: number, +) { + const wallHeight = Math.max(0, effectiveWallHeight) const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) } const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1 const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0 @@ -223,8 +228,9 @@ export function getWallFaceBandConfig(wall: Pick, y: number, + effectiveWallHeight: number, ): WallFaceBand { - const bands = getWallFaceBandConfig(wall) + const bands = getWallFaceBandConfig(wall, effectiveWallHeight) if (!bands.enabled) return 'upper' if (y < bands.lowerTop) return 'lower' if (y < bands.middleTop) return 'middle' diff --git a/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts b/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts new file mode 100644 index 0000000000..75841f1ae8 --- /dev/null +++ b/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts @@ -0,0 +1,89 @@ +// Real-scene repro for the max-side boundary clamp miss (project_O1z9NLOylyb5kFX4). +// Auto slab polygon derives from wall centerlines, putting wall samples exactly on +// the polygon boundary — the shape that exposed ray-cast side dependence. +export const wallPlaneTopBoundaryRepro = { + building_rr4rx7weux2fpdbh: { + id: 'building_rr4rx7weux2fpdbh', + type: 'building', + object: 'node', + visible: true, + children: ['level_pomuk0sbwec15mf3', 'level_5msog1z8hy2lyvxr'], + metadata: {}, + parentId: null, + position: [0, 0, 0], + rotation: [0, 0, 0], + }, + level_pomuk0sbwec15mf3: { + id: 'level_pomuk0sbwec15mf3', + type: 'level', + level: 0, + height: 2.7, + object: 'node', + visible: true, + children: ['wall_39bnnq29h824ryy0', 'wall_on4rj410n69n3rzf'], + metadata: {}, + parentId: null, + }, + level_5msog1z8hy2lyvxr: { + id: 'level_5msog1z8hy2lyvxr', + type: 'level', + level: 1, + height: 2.5, + object: 'node', + visible: true, + children: ['slab_j3i4ebjg4nsu8xk7'], + metadata: {}, + parentId: 'building_rr4rx7weux2fpdbh', + }, + wall_39bnnq29h824ryy0: { + id: 'wall_39bnnq29h824ryy0', + end: [-1, 4], + name: 'Wall 1', + type: 'wall', + start: [3, 4], + object: 'node', + visible: true, + backSide: 'exterior', + children: [], + metadata: {}, + parentId: 'level_pomuk0sbwec15mf3', + frontSide: 'interior', + }, + wall_on4rj410n69n3rzf: { + id: 'wall_on4rj410n69n3rzf', + end: [-1, -1], + name: 'Wall 2', + type: 'wall', + start: [-1, 4], + object: 'node', + visible: true, + backSide: 'exterior', + children: [], + metadata: {}, + parentId: 'level_pomuk0sbwec15mf3', + frontSide: 'interior', + }, + slab_j3i4ebjg4nsu8xk7: { + id: 'slab_j3i4ebjg4nsu8xk7', + name: 'Room 1 Slab', + type: 'slab', + holes: [], + object: 'node', + polygon: [ + [-1, 4], + [-1, -1], + [4, -1], + [4, 1], + [3, 1], + [3, 4], + ], + visible: true, + metadata: {}, + parentId: 'level_5msog1z8hy2lyvxr', + recessed: false, + elevation: 0.19757210573188194, + thickness: 0.5, + holeMetadata: [], + autoFromWalls: true, + }, +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 85517d5cab..245793f628 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -46,7 +46,7 @@ export { DEFAULT_LEVEL_HEIGHT, getCeilingAt, getCeilingHeightAt, - getLevelHeight, + resolveCeilingHeight, } from './level-height' export { type AxisLock, @@ -102,6 +102,19 @@ export { snapVec3ToGrid, snapWorldXZToBuildingLocal, } from './snap' +export { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + findLevelBelowId, + getCeilingClampBound, + getCoveringSlabUndersideAt, + getLevelAbove, + getLevelBelow, + getLevelElevations, + getStoredLevelHeight, + getWallPlaneTop, + type LevelElevation, +} from './storey' export { buildPortComponents, type SystemSummary, diff --git a/packages/core/src/services/level-height.test.ts b/packages/core/src/services/level-height.test.ts new file mode 100644 index 0000000000..81698a26e6 --- /dev/null +++ b/packages/core/src/services/level-height.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'bun:test' +import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { deriveLegacyLevelHeight, getCeilingAt, resolveCeilingHeight } from './level-height' + +function createFixture(): Record { + const nodes: AnyNode[] = [ + LevelNode.parse({ id: 'level_empty', children: [] }), + LevelNode.parse({ id: 'level_no_slab', children: ['wall_no_slab'] }), + WallNode.parse({ + id: 'wall_no_slab', + parentId: 'level_no_slab', + start: [10, 0], + end: [12, 0], + }), + LevelNode.parse({ id: 'level_standard_slab', children: ['slab_standard', 'wall_standard'] }), + SlabNode.parse({ + id: 'slab_standard', + parentId: 'level_standard_slab', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + elevation: 0.05, + }), + WallNode.parse({ + id: 'wall_standard', + parentId: 'level_standard_slab', + start: [1, 2], + end: [3, 2], + }), + LevelNode.parse({ id: 'level_tall_wall', children: ['slab_raised', 'wall_tall'] }), + SlabNode.parse({ + id: 'slab_raised', + parentId: 'level_tall_wall', + polygon: [ + [20, 0], + [24, 0], + [24, 4], + [20, 4], + ], + elevation: 0.35, + }), + WallNode.parse({ + id: 'wall_tall', + parentId: 'level_tall_wall', + start: [21, 2], + end: [23, 2], + height: 3.2, + }), + LevelNode.parse({ id: 'level_ceiling', children: ['wall_below_ceiling', 'ceiling_tall'] }), + WallNode.parse({ + id: 'wall_below_ceiling', + parentId: 'level_ceiling', + start: [40, 0], + end: [42, 0], + }), + CeilingNode.parse({ + id: 'ceiling_tall', + parentId: 'level_ceiling', + polygon: [ + [40, 0], + [42, 0], + [42, 2], + [40, 2], + ], + height: 3.4, + }), + LevelNode.parse({ id: 'level_negative_slab', children: ['slab_negative', 'wall_negative'] }), + SlabNode.parse({ + id: 'slab_negative', + parentId: 'level_negative_slab', + polygon: [ + [30, 0], + [34, 0], + [34, 4], + [30, 4], + ], + elevation: -0.4, + }), + WallNode.parse({ + id: 'wall_negative', + parentId: 'level_negative_slab', + start: [31, 2], + end: [33, 2], + height: 2.8, + }), + ] + + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +describe('deriveLegacyLevelHeight', () => { + const nodes = createFixture() + const cases = [ + ['level_no_slab', 2.5], + ['level_standard_slab', 2.5], + ['level_tall_wall', 3.55], + ['level_ceiling', 3.4], + ['level_negative_slab', 2.8], + ['level_empty', 2.5], + ] as const + + for (const [levelId, expected] of cases) { + it(`derives ${expected} for ${levelId}`, () => { + expect(deriveLegacyLevelHeight(levelId, nodes)).toBeCloseTo(expected) + }) + } +}) + +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +// Post-migration stack: two stored-height levels; the upper level carries a +// deck slab occupying [-0.3, 0] over the lower level's plane, so the lower +// level's ceiling clamp bound is 2.5 − 0.3 − 0.01 = 2.19 under the deck. +function createResolverFixture(options: { deck?: boolean } = {}): Record { + const list: AnyNode[] = [ + BuildingNode.parse({ + id: 'building_a', + children: ['level_low', 'level_high'], + }), + LevelNode.parse({ id: 'level_low', level: 0, height: 2.5, parentId: 'building_a' }), + LevelNode.parse({ + id: 'level_high', + level: 1, + height: 2.5, + parentId: 'building_a', + children: options.deck ? ['slab_deck'] : [], + }), + ] + if (options.deck) { + list.push( + SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_high', + polygon: SQUARE, + elevation: 0, + thickness: 0.3, + }), + ) + } + return Object.fromEntries(list.map((node) => [node.id, node])) as Record +} + +describe('resolveCeilingHeight', () => { + it('returns the explicit height verbatim when stored', () => { + const nodes = createResolverFixture({ deck: true }) + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE, height: 2.0 }) + + expect(resolveCeilingHeight(ceiling, nodes)).toBe(2.0) + }) + + it('resolves an absent height to the level-top clamp bound', () => { + const nodes = createResolverFixture() + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE }) + + expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49) + }) + + it('tracks a level height change without any ceiling write', () => { + const nodes = createResolverFixture() + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE }) + expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49) + + const level = nodes['level_low' as AnyNodeId] as AnyNode & { height?: number } + const raised = { + ...nodes, + level_low: { ...level, height: 3.2 } as AnyNode, + } as Record + + expect(resolveCeilingHeight(ceiling, raised)).toBeCloseTo(3.19) + }) + + it('resolves under a covering deck from the level above', () => { + const nodes = createResolverFixture({ deck: true }) + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE }) + + expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.19) + }) + + it('falls back to the default plane when the level is unresolvable', () => { + const ceiling = CeilingNode.parse({ parentId: null, polygon: SQUARE }) + + expect(resolveCeilingHeight(ceiling, {} as Record)).toBeCloseTo(2.49) + }) +}) + +describe('getCeilingAt lowest-wins with mixed follows/explicit', () => { + it('picks the explicit low ceiling under a follows-mode one, and vice versa', () => { + const base = createResolverFixture() + const follows = CeilingNode.parse({ + id: 'ceiling_follows', + parentId: 'level_low', + polygon: SQUARE, + }) + const explicitLow = CeilingNode.parse({ + id: 'ceiling_low', + parentId: 'level_low', + polygon: SQUARE, + height: 2.0, + }) + const level = base['level_low' as AnyNodeId] as AnyNode & { children: string[] } + const nodes = { + ...base, + level_low: { ...level, children: ['ceiling_follows', 'ceiling_low'] } as AnyNode, + ceiling_follows: follows, + ceiling_low: explicitLow, + } as Record + + // Explicit 2.0 undercuts the 2.49 follows bound. + expect(getCeilingAt('level_low', nodes, 2, 2)?.id).toBe(explicitLow.id) + + // Raise the explicit one above the bound comparison: 2.6 stored — the + // follows ceiling (2.49) is now the lowest surface over the point. + const nodesHighExplicit = { + ...nodes, + ceiling_low: { ...explicitLow, height: 2.6 } as AnyNode, + } as Record + expect(getCeilingAt('level_low', nodesHighExplicit, 2, 2)?.id).toBe(follows.id) + }) +}) diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 016711ac4c..871303c41b 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,40 +1,68 @@ -import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager' -import type { CeilingNode, LevelNode, WallNode } from '../schema' +import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' +import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' +import { resolveWallTop } from '../systems/wall/wall-top' +// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: +// both sides only reference the other inside function bodies. +import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' export const DEFAULT_LEVEL_HEIGHT = 2.5 /** - * Optional resolver for a wall's rendered base Y (mesh elevation). - * - * `packages/core` is pure domain logic and must not read viewer/Three.js - * state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have - * registry access (viewer systems, node tools) may pass a resolver so the - * mesh elevation is factored in; pure/headless callers (MCP, tests, server) - * omit it and get a deterministic result from serialized node data alone. + * Effective ceiling height in level-local meters. An explicit stored + * `height` wins; absent height means the ceiling follows the level top — + * the same bound its write-clamp uses: min(storey plane, lowest + * covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see + * {@link getCeilingClampBound}). Falls back to the default plane minus + * the same margin when the owning level is unresolvable. */ -export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined +export function resolveCeilingHeight( + ceiling: Pick, + nodes: Record, +): number { + if (ceiling.height != null) return ceiling.height + const bound = + typeof ceiling.parentId === 'string' + ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) + : Number.POSITIVE_INFINITY + return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN +} -export function getLevelHeight( +export function deriveLegacyLevelHeight( levelId: string, nodes: Record, - resolveWallBaseY?: WallBaseYResolver, ): number { const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (!level) return DEFAULT_LEVEL_HEIGHT + const levelChildren = level.children + .map((childId) => nodes[childId as keyof typeof nodes]) + .filter((child): child is AnyNode => child !== undefined) + const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab') + const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall') + let maxTop = 0 - for (const childId of level.children) { - const child = nodes[childId as keyof typeof nodes] - if (!child) continue + for (const child of levelChildren) { if (child.type === 'ceiling') { - const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT - if (ch > maxTop) maxTop = ch + // Absence here is the PRE-migration legacy schema default (2.5), not + // follows-mode — this derivation runs before the level has a height + // for a follows-mode bound to track. + const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT + if (height > maxTop) maxTop = height } else if (child.type === 'wall') { - let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0 - if (baseY < 0) baseY = 0 - const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT) + const wall = child as WallNode + const electedElevation = computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation) if (top > maxTop) maxTop = top } } @@ -58,14 +86,18 @@ export function getCeilingAt( if (!level) return null let best: CeilingNode | null = null + let bestHeight = Number.POSITIVE_INFINITY for (const childId of level.children) { const child = nodes[childId as keyof typeof nodes] if (child?.type !== 'ceiling') continue const ceiling = child as CeilingNode if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue - const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT - if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling + const h = resolveCeilingHeight(ceiling, nodes) + if (best === null || h < bestHeight) { + best = ceiling + bestHeight = h + } } return best } @@ -82,5 +114,5 @@ export function getCeilingHeightAt( z: number, ): number | null { const ceiling = getCeilingAt(levelId, nodes, x, z) - return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null + return ceiling ? resolveCeilingHeight(ceiling, nodes) : null } diff --git a/packages/core/src/services/storey.test.ts b/packages/core/src/services/storey.test.ts new file mode 100644 index 0000000000..2cce58eff1 --- /dev/null +++ b/packages/core/src/services/storey.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, test } from 'bun:test' +import { BuildingNode, LevelNode, SlabNode, type WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { wallPlaneTopBoundaryRepro as reproFixture } from './__fixtures__/wall-plane-top-boundary-repro' +import { DEFAULT_LEVEL_HEIGHT } from './level-height' +import { + CEILING_CLAMP_MARGIN, + getCeilingClampBound, + getCoveringSlabUndersideAt, + getLevelAbove, + getLevelBelow, + getLevelElevations, + getStoredLevelHeight, + getWallPlaneTop, +} from './storey' + +const buildNodes = (list: AnyNode[]): Record => + Object.fromEntries(list.map((node) => [node.id, node])) as Record + +const level = ( + id: string, + ordinal: number, + opts: { height?: number; parentId?: string | null; children?: string[] } = {}, +): LevelNode => + LevelNode.parse({ + id, + level: ordinal, + parentId: opts.parentId ?? null, + children: opts.children ?? [], + ...(opts.height === undefined ? {} : { height: opts.height }), + }) + +const building = (id: string, children: string[]): BuildingNode => + BuildingNode.parse({ id, children }) + +const slabNode = ( + id: string, + opts: { + polygon?: Array<[number, number]> + holes?: Array> + elevation?: number + thickness?: number + recessed?: boolean + }, +): SlabNode => + SlabNode.parse({ + id, + polygon: + opts.polygon ?? + ([ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] as Array<[number, number]>), + holes: opts.holes ?? [], + ...(opts.elevation === undefined ? {} : { elevation: opts.elevation }), + ...(opts.thickness === undefined ? {} : { thickness: opts.thickness }), + ...(opts.recessed === undefined ? {} : { recessed: opts.recessed }), + }) + +describe('getStoredLevelHeight', () => { + test('returns the stored height when present', () => { + expect(getStoredLevelHeight(level('level_a', 0, { height: 3.25 }))).toBe(3.25) + }) + + test('falls back to the default for unmigrated legacy levels', () => { + expect(getStoredLevelHeight(level('level_a', 0))).toBe(DEFAULT_LEVEL_HEIGHT) + expect(getStoredLevelHeight(level('level_a', 0))).toBe(2.5) + }) +}) + +describe('getLevelElevations', () => { + test('single building matches a hand-computed prefix sum', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_1', 'level_2', 'level_3']), + level('level_0', 0, { height: 3, parentId: 'building_a' }), + level('level_1', 1, { height: 2.5, parentId: 'building_a' }), + level('level_2', 2, { height: 2.75, parentId: 'building_a' }), + level('level_3', 3, { height: 4, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_0')).toEqual({ + baseY: 0, + height: 3, + buildingId: 'building_a', + ordinal: 0, + }) + expect(elevations.get('level_1')?.baseY).toBe(3) + expect(elevations.get('level_2')?.baseY).toBe(5.5) + expect(elevations.get('level_3')?.baseY).toBe(8.25) + }) + + test('stacks two buildings independently with interleaved, unsorted ordinals', () => { + const nodes = buildNodes([ + level('level_b1', 1, { height: 2.5, parentId: 'building_b' }), + level('level_a2', 2, { height: 3, parentId: 'building_a' }), + building('building_a', ['level_a0', 'level_a1', 'level_a2']), + level('level_a0', 0, { height: 3.5, parentId: 'building_a' }), + building('building_b', ['level_b0', 'level_b1']), + level('level_b0', 0, { height: 4, parentId: 'building_b' }), + level('level_a1', 1, { height: 3.25, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_a0')?.baseY).toBe(0) + expect(elevations.get('level_a1')?.baseY).toBe(3.5) + expect(elevations.get('level_a2')?.baseY).toBe(6.75) + expect(elevations.get('level_b0')?.baseY).toBe(0) + expect(elevations.get('level_b1')?.baseY).toBe(4) + expect(elevations.get('level_a2')?.buildingId).toBe('building_a') + expect(elevations.get('level_b1')?.buildingId).toBe('building_b') + }) + + test('negative ordinals stack from the lowest level up', () => { + const nodes = buildNodes([ + building('building_a', ['level_basement', 'level_ground', 'level_upper']), + level('level_upper', 1, { height: 3, parentId: 'building_a' }), + level('level_basement', -1, { height: 2.25, parentId: 'building_a' }), + level('level_ground', 0, { height: 2.5, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_basement')?.baseY).toBe(0) + expect(elevations.get('level_ground')?.baseY).toBe(2.25) + expect(elevations.get('level_upper')?.baseY).toBe(4.75) + }) + + test('duplicate and fractional ordinals stack stably without NaN', () => { + const nodes = buildNodes([ + building('building_a', ['level_ground', 'level_mezz', 'level_dup_b', 'level_dup_a']), + level('level_dup_b', 1, { height: 3, parentId: 'building_a' }), + level('level_dup_a', 1, { height: 2.5, parentId: 'building_a' }), + level('level_mezz', 0.5, { height: 1.5, parentId: 'building_a' }), + level('level_ground', 0, { height: 2.5, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_ground')?.baseY).toBe(0) + expect(elevations.get('level_mezz')?.baseY).toBe(2.5) + // Stable sort: equal ordinals keep nodes-record insertion order. + expect(elevations.get('level_dup_b')?.baseY).toBe(4) + expect(elevations.get('level_dup_a')?.baseY).toBe(7) + for (const elevation of elevations.values()) { + expect(Number.isFinite(elevation.baseY)).toBe(true) + expect(Number.isFinite(elevation.height)).toBe(true) + } + }) + + test('levels missing height fall back to 2.5 for both height and stacking', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_1', 'level_2']), + level('level_0', 0, { parentId: 'building_a' }), + level('level_1', 1, { height: 3, parentId: 'building_a' }), + level('level_2', 2, { parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_0')?.height).toBe(2.5) + expect(elevations.get('level_1')?.baseY).toBe(2.5) + expect(elevations.get('level_2')?.baseY).toBe(5.5) + expect(elevations.get('level_2')?.height).toBe(2.5) + }) + + test('resolves buildings via parentId, legacy children membership, and non-building parents', () => { + const nodes = buildNodes([ + // level_direct is not in children; level_site has a non-building parentId. + building('building_x', ['level_legacy', 'level_site']), + level('level_direct', 0, { height: 3, parentId: 'building_x' }), + level('level_legacy', 1, { height: 2.5, parentId: null }), + level('level_site', 2, { height: 2.75, parentId: 'site_main' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_direct')?.buildingId).toBe('building_x') + expect(elevations.get('level_legacy')?.buildingId).toBe('building_x') + expect(elevations.get('level_site')?.buildingId).toBe('building_x') + expect(elevations.get('level_direct')?.baseY).toBe(0) + expect(elevations.get('level_legacy')?.baseY).toBe(3) + expect(elevations.get('level_site')?.baseY).toBe(5.5) + }) + + test('levels with no resolvable building share one legacy stack from 0', () => { + const nodes = buildNodes([ + level('level_orphan_1', 1, { height: 3 }), + level('level_orphan_0', 0, { height: 2.75 }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_orphan_0')).toEqual({ + baseY: 0, + height: 2.75, + buildingId: null, + ordinal: 0, + }) + expect(elevations.get('level_orphan_1')?.baseY).toBe(2.75) + }) +}) + +describe('getLevelAbove', () => { + test('returns the next-higher ordinal in the same building, skipping ordinal gaps', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_2', 'level_5']), + level('level_0', 0, { parentId: 'building_a' }), + level('level_5', 5, { parentId: 'building_a' }), + level('level_2', 2, { parentId: 'building_a' }), + ]) + + expect(getLevelAbove('level_0', nodes)?.id).toBe('level_2') + expect(getLevelAbove('level_2', nodes)?.id).toBe('level_5') + expect(getLevelAbove('level_5', nodes)).toBeNull() + }) + + test('never crosses into another building', () => { + const nodes = buildNodes([ + building('building_a', ['level_a0']), + building('building_b', ['level_b0', 'level_b1']), + level('level_a0', 0, { parentId: 'building_a' }), + level('level_b0', 0, { parentId: 'building_b' }), + level('level_b1', 1, { parentId: 'building_b' }), + ]) + + expect(getLevelAbove('level_a0', nodes)).toBeNull() + expect(getLevelAbove('level_b0', nodes)?.id).toBe('level_b1') + }) + + test('orphan levels resolve within the shared legacy stack', () => { + const nodes = buildNodes([ + level('level_orphan_0', 0, { height: 2.75 }), + level('level_orphan_1', 1, { height: 3 }), + ]) + + expect(getLevelAbove('level_orphan_0', nodes)?.id).toBe('level_orphan_1') + expect(getLevelAbove('level_orphan_1', nodes)).toBeNull() + }) + + test('returns null for an unknown level id', () => { + const nodes = buildNodes([level('level_0', 0)]) + expect(getLevelAbove('level_missing', nodes)).toBeNull() + }) +}) + +describe('getLevelBelow', () => { + test('returns the next-lower ordinal in the same building, skipping ordinal gaps', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_2', 'level_5']), + level('level_0', 0, { parentId: 'building_a' }), + level('level_5', 5, { parentId: 'building_a' }), + level('level_2', 2, { parentId: 'building_a' }), + ]) + + expect(getLevelBelow('level_5', nodes)?.id).toBe('level_2') + expect(getLevelBelow('level_2', nodes)?.id).toBe('level_0') + expect(getLevelBelow('level_0', nodes)).toBeNull() + }) + + test('never crosses into another building', () => { + const nodes = buildNodes([ + building('building_a', ['level_a0']), + building('building_b', ['level_b0', 'level_b1']), + level('level_a0', 0, { parentId: 'building_a' }), + level('level_b0', 0, { parentId: 'building_b' }), + level('level_b1', 1, { parentId: 'building_b' }), + ]) + + expect(getLevelBelow('level_a0', nodes)).toBeNull() + expect(getLevelBelow('level_b1', nodes)?.id).toBe('level_b0') + }) + + test('returns null for an unknown level id', () => { + const nodes = buildNodes([level('level_0', 0)]) + expect(getLevelBelow('level_missing', nodes)).toBeNull() + }) +}) + +// Two stacked levels in one building; `slabs` become children of the level +// above the queried one. +const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5) => + buildNodes([ + building('building_a', ['level_0', 'level_1']), + level('level_0', 0, { height: queriedHeight, parentId: 'building_a' }), + level('level_1', 1, { + height: 2.5, + parentId: 'building_a', + children: slabs.map((node) => node.id), + }), + ...slabs, + ]) + +describe('getCoveringSlabUndersideAt', () => { + test('expresses a flush deck underside in the queried level local Y', () => { + // Flush deck occupying [-0.3, 0] above the plane: underside sits at + // storeyHeight + (0 - 0.3) = 2.2 over the queried level's floor. + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2) + }) + + test('returns null outside the slab polygon', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 10, 10)).toBeNull() + }) + + test('a hole in the slab vetoes coverage', () => { + const nodes = stackedNodes([ + slabNode('slab_deck', { + elevation: 0, + thickness: 0.3, + holes: [ + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ], + ], + }), + ]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull() + expect(getCoveringSlabUndersideAt('level_0', nodes, 0.5, 0.5)).toBeCloseTo(2.2) + }) + + test('recessed pools never cover', () => { + const nodes = stackedNodes([ + slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }), + ]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull() + }) + + test('the lowest underside wins among overlapping covering slabs', () => { + const nodes = stackedNodes([ + // Default floor slab occupying [0, 0.05]: underside at the plane (2.5). + slabNode('slab_floor', {}), + slabNode('slab_deck', { elevation: 0, thickness: 0.3 }), + ]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2) + }) + + test('returns null when there is no level above', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCoveringSlabUndersideAt('level_1', nodes, 2, 2)).toBeNull() + }) +}) + +describe('getWallPlaneTop', () => { + const wallAt = ( + start: [number, number], + end: [number, number], + ): { start: [number, number]; end: [number, number] } => ({ start, end }) + + test('no covering slab → the stored level height', () => { + const nodes = stackedNodes([], 3) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(3) + }) + + test('a flush thick deck above clamps the plane to its underside', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBeCloseTo(2.2) + }) + + test('a slab covering only part of the span clamps via the min of the samples', () => { + // Deck over x ∈ [3.5, 6]: start (0,2) and chord midpoint (2,2) miss it, + // only the end sample (4,2) lands inside — the min still clamps. + const nodes = stackedNodes([ + slabNode('slab_deck', { + polygon: [ + [3.5, 0], + [6, 0], + [6, 4], + [3.5, 4], + ], + elevation: 0, + thickness: 0.3, + }), + ]) + expect(getWallPlaneTop(wallAt([0, 2], [4, 2]), 'level_0', nodes)).toBeCloseTo(2.2) + }) + + test('a recessed slab above is ignored', () => { + const nodes = stackedNodes([ + slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }), + ]) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(2.5) + }) + + test('falls back to the default height when the level does not resolve', () => { + const nodes = stackedNodes([]) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_missing', nodes)).toBe( + DEFAULT_LEVEL_HEIGHT, + ) + }) + + test('repro project: both boundary walls clamp to the covering slab underside', () => { + // Real scene subset (project_O1z9NLOylyb5kFX4): the level-1 auto slab's + // polygon derives from the level-0 wall CENTERLINES, so every perimeter + // wall's samples sit exactly ON the polygon boundary. Wall 2 (min-x edge) + // clamped while Wall 1 (max-z edge) ran full height — ray-cast + // pointInPolygon includes min-side boundaries and excludes max-side ones. + const nodes = reproFixture as unknown as Record + const levelId = 'level_pomuk0sbwec15mf3' + const wall1 = nodes['wall_39bnnq29h824ryy0' as AnyNodeId] as WallNode + const wall2 = nodes['wall_on4rj410n69n3rzf' as AnyNodeId] as WallNode + // storeyHeight 2.7 + (slab elevation 0.19757… - thickness 0.5) + const underside = 2.7 + (0.19757210573188194 - 0.5) + expect(getWallPlaneTop(wall1, levelId, nodes)).toBeCloseTo(underside) + expect(getWallPlaneTop(wall2, levelId, nodes)).toBeCloseTo(underside) + }) + + test('all four rectangle walls under a same-footprint covering slab clamp', () => { + // The repro shape distilled: wall centerlines lie exactly on the covering + // slab's polygon edges. Every orientation must clamp identically. + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + const walls: Array<[[number, number], [number, number]]> = [ + [ + [0, 0], + [4, 0], + ], + [ + [4, 0], + [4, 4], + ], + [ + [4, 4], + [0, 4], + ], + [ + [0, 4], + [0, 0], + ], + ] + for (const [start, end] of walls) { + expect(getWallPlaneTop(wallAt(start, end), 'level_0', nodes)).toBeCloseTo(2.2) + } + }) + + test('a diagonal wall under the covering slab clamps', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([0.5, 0.5], [3.5, 3.5]), 'level_0', nodes)).toBeCloseTo(2.2) + }) + + test('a wall fully outside the covering slab keeps the storey height', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([6, 0], [6, 4]), 'level_0', nodes)).toBe(2.5) + }) + + test('a wall partially overlapping the covering slab clamps', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([2, 2], [8, 2]), 'level_0', nodes)).toBeCloseTo(2.2) + }) +}) + +describe('getCeilingClampBound', () => { + const ceilingPolygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] + + test('with no covering slab the bound is the storey plane minus the margin', () => { + const nodes = stackedNodes([]) + expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo( + 2.5 - CEILING_CLAMP_MARGIN, + ) + }) + + test('a covering deck lowers the bound to its underside minus the margin', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + }) + + test('a slab covering only the interior is caught by the centroid sample', () => { + // Deck hovers over the middle of the ceiling — every vertex sample + // misses, only the centroid (2, 2) lands inside it. + const nodes = stackedNodes([ + slabNode('slab_deck', { + polygon: [ + [1.5, 1.5], + [2.5, 1.5], + [2.5, 2.5], + [1.5, 2.5], + ], + elevation: 0, + thickness: 0.3, + }), + ]) + expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + }) + + test('returns Infinity for an unresolvable level', () => { + const nodes = stackedNodes([]) + expect(getCeilingClampBound('level_missing', nodes, ceilingPolygon)).toBe( + Number.POSITIVE_INFINITY, + ) + }) + + test('vertices on the covering slab boundary clamp identically on every side', () => { + // Two mirrored strips share an edge with the 4x4 deck: one along its + // min-z edge, one along its max-z edge. Their interiors and centroids sit + // outside the deck, so only the shared-edge vertices can register — + // ray-cast pointInPolygon used to admit the min-side vertices and reject + // the max-side ones, giving orientation-dependent clamps. + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + const minSideStrip: Array<[number, number]> = [ + [0, -1], + [4, -1], + [4, 0], + [0, 0], + ] + const maxSideStrip: Array<[number, number]> = [ + [0, 4], + [4, 4], + [4, 5], + [0, 5], + ] + expect(getCeilingClampBound('level_0', nodes, minSideStrip)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + expect(getCeilingClampBound('level_0', nodes, maxSideStrip)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + }) +}) diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts new file mode 100644 index 0000000000..4fd6563fed --- /dev/null +++ b/packages/core/src/services/storey.ts @@ -0,0 +1,358 @@ +import type { BuildingNode, LevelNode, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { + pointInPolygon, + pointOnPolygonBoundary, + wallOverlapsSlabFootprint, +} from '../systems/slab/slab-support' +import { DEFAULT_LEVEL_HEIGHT } from './level-height' + +/** + * Gap kept between a ceiling's stored height and its clamp bound (storey + * plane or covering-slab underside), so the ceiling surface never + * coincides with the solid above it. + */ +export const CEILING_CLAMP_MARGIN = 0.01 + +/** + * Stored storey height in meters (floor-to-floor). Falls back to + * {@link DEFAULT_LEVEL_HEIGHT} for unmigrated legacy levels whose `height` + * field is absent. + */ +export function getStoredLevelHeight(level: Pick): number { + return level.height ?? DEFAULT_LEVEL_HEIGHT +} + +export type LevelElevation = { + /** World Y of the level's floor: prefix sum of the storey heights below it. */ + baseY: number + /** Stored storey height of this level (fallback applied). */ + height: number + buildingId: string | null + ordinal: number +} + +/** + * Resolves the owning building: explicit `parentId` pointing at a building + * wins; legacy levels that only appear in a building's `children` array + * resolve through that membership. + */ +function resolveLevelBuildingId( + levelId: LevelNode['id'], + parentId: string | null, + buildings: readonly BuildingNode[], +): string | null { + const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined + if (directParent) return directParent.id + + return buildings.find((building) => building.children.includes(levelId))?.id ?? null +} + +/** + * Per-building stacked elevations from stored storey heights: levels are + * sorted by ordinal ascending within each building, the lowest level's floor + * sits at 0, and each next floor sits on top of the previous storey height. + * Levels with no resolvable building share one legacy stack from 0. + * + * Pure — operates on the serialized nodes record only. + */ +export function getLevelElevations(nodes: Record): Map { + const buildings = Object.values(nodes).filter( + (node): node is BuildingNode => node?.type === 'building', + ) + + const entries: Array<{ levelId: string } & LevelElevation> = [] + for (const node of Object.values(nodes)) { + if (node?.type !== 'level') continue + const level = node as LevelNode + entries.push({ + levelId: level.id, + baseY: 0, + height: getStoredLevelHeight(level), + buildingId: resolveLevelBuildingId(level.id, level.parentId, buildings), + ordinal: level.level, + }) + } + + const elevations = new Map() + const cumulativeYByBuilding = new Map() + for (const entry of entries.sort((a, b) => a.ordinal - b.ordinal)) { + const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0 + elevations.set(entry.levelId, { + baseY, + height: entry.height, + buildingId: entry.buildingId, + ordinal: entry.ordinal, + }) + cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height) + } + + return elevations +} + +/** + * The id of the level directly above `levelId` in its own stack (same + * resolved building, or the shared legacy stack for building-less levels): + * the level with the lowest ordinal strictly greater than the queried + * level's. `null` when the level is topmost or unresolvable. + */ +export function findLevelAboveId( + levelId: string, + elevations: Map, +): string | null { + const entry = elevations.get(levelId) + if (!entry) return null + + let aboveId: string | null = null + let aboveOrdinal = Number.POSITIVE_INFINITY + for (const [candidateId, candidate] of elevations) { + if (candidateId === levelId) continue + if (candidate.buildingId !== entry.buildingId) continue + if (candidate.ordinal > entry.ordinal && candidate.ordinal < aboveOrdinal) { + aboveOrdinal = candidate.ordinal + aboveId = candidateId + } + } + return aboveId +} + +/** + * The level directly above `levelId` — see {@link findLevelAboveId}. + * `null` when topmost or unresolvable. Pure. + */ +export function getLevelAbove( + levelId: string, + nodes: Record, +): LevelNode | null { + const aboveId = findLevelAboveId(levelId, getLevelElevations(nodes)) + if (!aboveId) return null + const above = nodes[aboveId as LevelNode['id']] + return above?.type === 'level' ? (above as LevelNode) : null +} + +/** + * The id of the level directly below `levelId` in its own stack — mirror of + * {@link findLevelAboveId}: the level with the highest ordinal strictly less + * than the queried level's. `null` when the level is lowest or unresolvable. + */ +export function findLevelBelowId( + levelId: string, + elevations: Map, +): string | null { + const entry = elevations.get(levelId) + if (!entry) return null + + let belowId: string | null = null + let belowOrdinal = Number.NEGATIVE_INFINITY + for (const [candidateId, candidate] of elevations) { + if (candidateId === levelId) continue + if (candidate.buildingId !== entry.buildingId) continue + if (candidate.ordinal < entry.ordinal && candidate.ordinal > belowOrdinal) { + belowOrdinal = candidate.ordinal + belowId = candidateId + } + } + return belowId +} + +/** + * The level directly below `levelId` — see {@link findLevelBelowId}. + * `null` when lowest or unresolvable. Pure. + */ +export function getLevelBelow( + levelId: string, + nodes: Record, +): LevelNode | null { + const belowId = findLevelBelowId(levelId, getLevelElevations(nodes)) + if (!belowId) return null + const below = nodes[belowId as LevelNode['id']] + return below?.type === 'level' ? (below as LevelNode) : null +} + +type CoveringSlabContext = { + /** Stored storey height of the QUERIED level. */ + storeyHeight: number + /** Non-recessed slab children of the level above. */ + slabs: SlabNode[] +} + +/** + * Storey height of the queried level plus the level-above's covering + * (non-recessed) slabs. `null` when `levelId` doesn't resolve to a level. + * A missing level above yields an empty slab list, not `null` — the + * storey height is still meaningful for the clamp bound. + */ +function resolveCoveringSlabContext( + levelId: string, + nodes: Record, +): CoveringSlabContext | null { + const level = nodes[levelId as LevelNode['id']] + if (level?.type !== 'level') return null + + const above = getLevelAbove(levelId, nodes) + const slabs: SlabNode[] = [] + for (const childId of above?.children ?? []) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'slab') continue + const slab = child as SlabNode + // Recessed slabs (pools) are open shells, not covering solids. + if (slab.recessed === true) continue + if (slab.polygon.length < 3) continue + slabs.push(slab) + } + + return { storeyHeight: getStoredLevelHeight(level as LevelNode), slabs } +} + +/** + * Underside of `slab`'s solid in the QUERIED level's local Y. The solid + * occupies `[elevation - thickness, elevation]` in ITS level's local Y, + * which sits `storeyHeight` above the queried level's floor. + */ +function coveringUndersideY(storeyHeight: number, slab: SlabNode): number { + return storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05)) +} + +/** + * Whether `slab`'s stored footprint (polygon minus holes) covers `[x, z]`. + * Ray-cast pointInPolygon flips arbitrarily for points exactly ON the + * boundary (min-side edges read inside, max-side edges outside), so + * boundary contact counts as covered explicitly — the same convention as + * the slab-support interval classification. A point on a hole's rim keeps + * coverage (mirrors the support election's hole handling). + * + * Raw stored polygon + holes on purpose (mirrors getCeilingAt): the + * clamp bound doesn't need the rendered footprint's junction trims, + * and staying off the render path keeps this query cheap and pure. + */ +function slabCoversPoint(slab: SlabNode, x: number, z: number): boolean { + if (!pointInPolygon(x, z, slab.polygon) && !pointOnPolygonBoundary(x, z, slab.polygon)) { + return false + } + for (const hole of slab.holes ?? []) { + if (hole.length < 3) continue + if (pointInPolygon(x, z, hole) && !pointOnPolygonBoundary(x, z, hole)) return false + } + return true +} + +/** + * Lowest underside among `slabs` covering `[x, z]`, in the queried + * level's local Y, or `null` when none covers the point. + */ +function lowestCoveringUndersideAt( + context: CoveringSlabContext, + x: number, + z: number, +): number | null { + let lowest: number | null = null + for (const slab of context.slabs) { + if (!slabCoversPoint(slab, x, z)) continue + const underside = coveringUndersideY(context.storeyHeight, slab) + if (lowest === null || underside < lowest) lowest = underside + } + return lowest +} + +/** + * Underside of the LOWEST slab from the level above that covers + * level-local point `[x, z]`, expressed in the queried level's local Y: + * `storeyHeight + (slab.elevation - slab.thickness)`. `recessed` slabs + * (pools) never cover. `null` when no covering slab (or no level above). + * + * Coordinate spaces: levels stack in Y only (`LevelNode` carries no XZ + * transform and the viewer's LevelSystem writes only `position.y`), so a + * level-local `[x, z]` is valid in every level of the stack unchanged. + */ +export function getCoveringSlabUndersideAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const context = resolveCoveringSlabContext(levelId, nodes) + if (!context) return null + return lowestCoveringUndersideAt(context, x, z) +} + +/** + * Top plane for a plane-bound wall on `levelId`, in level-local Y: + * `min(stored storey height, lowest covering-slab underside over the wall's + * span)` — a thick or flush slab on the level above SHORTENS the walls below + * instead of colliding with them (Revit-style automatic attach). + * + * Coverage: the wall's thickness band (centerline + face lines, arc-aware) + * is clipped against each covering slab's stored polygon minus holes via + * {@link wallOverlapsSlabFootprint} — the same overlap machinery as the + * support election. Point sampling is deliberately avoided: auto-slab + * polygons derive from wall CENTERLINES, so perimeter walls sit exactly ON + * the polygon boundary, where ray-cast point-in-polygon flips with the + * edge's orientation (one wall clamped, its neighbor didn't). Boundary + * contact counts as covered on every side of the slab. + * + * This is THE plane for a plane-bound wall (`height` absent). Explicit-height + * walls ignore the value (`resolveWallTop` returns their stored height), so + * passing it wherever a raw storey height feeds `resolveWallTop` / + * `resolveWallEffectiveHeight` is always safe. Falls back to + * {@link DEFAULT_LEVEL_HEIGHT} when `levelId` doesn't resolve to a level. + */ +export function getWallPlaneTop( + wall: Pick & Partial>, + levelId: string, + nodes: Record, +): number { + const context = resolveCoveringSlabContext(levelId, nodes) + if (!context) return DEFAULT_LEVEL_HEIGHT + + let plane = context.storeyHeight + for (const slab of context.slabs) { + const underside = coveringUndersideY(context.storeyHeight, slab) + if (underside >= plane) continue + if (!wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) continue + plane = underside + } + return plane +} + +/** + * Upper bound for a ceiling's stored height over `polygon` on `levelId`: + * `min(storey plane, lowest covering-slab underside) - CEILING_CLAMP_MARGIN`. + * The covering underside is sampled at every polygon vertex plus the + * centroid — cheap, and a slab overlapping a convex-ish ceiling almost + * always covers one of those points; exact polygon-vs-polygon overlap is + * not worth its cost for a clamp bound. Ceiling outlines share footprint + * edges with the slabs above them the same way walls do, so vertices + * sitting exactly on a slab's boundary count as covered on every side + * (see `slabCoversPoint`) instead of flipping with the edge orientation. + * + * Returns `Infinity` when `levelId` doesn't resolve, so callers clamp + * against nothing rather than a garbage plane. + */ +export function getCeilingClampBound( + levelId: string, + nodes: Record, + polygon: ReadonlyArray<[number, number]>, +): number { + const context = resolveCoveringSlabContext(levelId, nodes) + if (!context) return Number.POSITIVE_INFINITY + + let bound = context.storeyHeight + if (polygon.length > 0) { + let cx = 0 + let cz = 0 + for (const [x, z] of polygon) { + cx += x + cz += z + } + const samples: Array<[number, number]> = [ + ...polygon, + [cx / polygon.length, cz / polygon.length], + ] + for (const [x, z] of samples) { + const underside = lowestCoveringUndersideAt(context, x, z) + if (underside !== null && underside < bound) bound = underside + } + } + + return bound - CEILING_CLAMP_MARGIN +} diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 3b0d09b509..e56623ec63 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -498,8 +498,21 @@ function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode { return sanitized.value as AnyNode } +// An explicit `key: undefined` in update data REMOVES the key: optional +// fields like wall.height encode a mode by their absence (absent = +// plane-bound top), and zod's safeParse echoes explicit-undefined keys, so +// a plain spread would leave a lingering own key that breaks `'height' in +// node` checks. +function mergeNodeUpdate(currentNode: AnyNode, patch: Partial): AnyNode { + const merged: Record = { ...currentNode, ...patch } + for (const key of Object.keys(patch)) { + if ((patch as Record)[key] === undefined) delete merged[key] + } + return merged as AnyNode +} + function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode { - const candidate = { ...currentNode, ...data } + const candidate = mergeNodeUpdate(currentNode, data) const parsed = AnyNodeSchema.safeParse(candidate) if (parsed.success) return parsed.data @@ -507,12 +520,12 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode const sanitized = sanitizeNumericValue(schema, data, currentNode, []) if (sanitized.issues.length === 0) { - return candidate as AnyNode + return candidate } warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues) - return { ...currentNode, ...(sanitized.value as Partial) } as AnyNode + return mergeNodeUpdate(currentNode, sanitized.value as Partial) } function shouldRefreshDefaultRidgeVents(data: Partial) { @@ -590,7 +603,10 @@ function areWallStylesCompatible(a: WallNode, b: WallNode) { (a.parentId ?? null) === (b.parentId ?? null) && Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && - Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && + // Absent height means plane-bound (follows the storey), which must never + // merge with an explicit height — even one that currently matches the plane. + (a.height == null) === (b.height == null) && + Math.abs((a.height ?? 0) - (b.height ?? 0)) <= 1e-6 && aInterior === bInterior && aExterior === bExterior && a.frontSide === b.frontSide && @@ -1129,6 +1145,31 @@ export const deleteNodesAction = ( } } + // Deleting a slab strips `supportSlabId` / `deckSlabId` references from + // surviving nodes in the same undo commit (mirrors the collectionIds + // cleanup below), so those nodes re-elect their support / re-derive + // their rise. Deletion is the ONLY writer — a host merely reshaped away + // keeps the field and the read path falls back, letting hosting resume + // if the slab returns. + const deletedSlabIds = new Set() + for (const id of allIds) { + if (nextNodes[id]?.type === 'slab') deletedSlabIds.add(id) + } + if (deletedSlabIds.size > 0) { + for (const [nodeId, node] of Object.entries(nextNodes)) { + if (allIds.has(nodeId as AnyNodeId)) continue + const patch: { supportSlabId?: undefined; deckSlabId?: undefined } = {} + const hostId = (node as { supportSlabId?: string }).supportSlabId + if (hostId && deletedSlabIds.has(hostId)) patch.supportSlabId = undefined + const deckId = (node as { deckSlabId?: string }).deckSlabId + if (deckId && deletedSlabIds.has(deckId)) patch.deckSlabId = undefined + if (Object.keys(patch).length > 0) { + nextNodes[nodeId as AnyNodeId] = { ...node, ...patch } as AnyNode + nodesToMarkDirty.add(nodeId as AnyNodeId) + } + } + } + for (const id of allIds) { const node = nextNodes[id] if (!node) continue diff --git a/packages/core/src/store/actions/node-mutation-sanitize.test.ts b/packages/core/src/store/actions/node-mutation-sanitize.test.ts index 1863b53857..3e54d6d497 100644 --- a/packages/core/src/store/actions/node-mutation-sanitize.test.ts +++ b/packages/core/src/store/actions/node-mutation-sanitize.test.ts @@ -14,6 +14,22 @@ type RafFn = (cb: (t: number) => void) => number const SHELF_ID = 'shelf_sanitize' as AnyNodeId const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId +const WALL_ID = 'wall_keyremoval' as AnyNodeId + +function makeWall(): AnyNode { + return { + id: WALL_ID, + type: 'wall', + parentId: null, + object: 'node', + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + height: 2.5, + } as unknown as AnyNode +} function makeShelf(overrides: Partial = {}): AnyNode { return { @@ -173,3 +189,45 @@ describe('node mutation numeric sanitization', () => { expect(Number.isFinite(created.thickness)).toBe(true) }) }) + +describe('node update explicit-undefined key removal', () => { + beforeEach(() => { + useScene.setState({ + nodes: { [WALL_ID]: makeWall() }, + rootNodeIds: [WALL_ID], + dirtyNodes: new Set(), + collections: {}, + readOnly: false, + } as never) + useScene.temporal.getState().clear() + }) + + test('an undefined value in update data removes the key from the stored node', () => { + useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial) + + const wall = useScene.getState().nodes[WALL_ID] as Record + expect('height' in wall).toBe(false) + }) + + test('undo restores a key removed via an undefined update value', () => { + useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial) + expect('height' in (useScene.getState().nodes[WALL_ID] as Record)).toBe(false) + + useScene.temporal.getState().undo() + + const wall = useScene.getState().nodes[WALL_ID] as { height?: number } + expect('height' in wall).toBe(true) + expect(wall.height).toBe(2.5) + }) + + test('other keys in the same patch still apply when one is removed', () => { + useScene.getState().updateNode(WALL_ID, { + height: undefined, + name: 'Plane-bound wall', + } as Partial) + + const wall = useScene.getState().nodes[WALL_ID] as Record + expect('height' in wall).toBe(false) + expect(wall.name).toBe('Plane-bound wall') + }) +}) diff --git a/packages/core/src/store/use-live-transforms.ts b/packages/core/src/store/use-live-transforms.ts index b2aef7dd0c..6c23558301 100644 --- a/packages/core/src/store/use-live-transforms.ts +++ b/packages/core/src/store/use-live-transforms.ts @@ -7,6 +7,14 @@ import { create } from 'zustand' export type LiveTransform = { position: [number, number, number] rotation: number // Y-axis rotation (plan-view rotation) + /** + * Pointer-decided support cap (level-local Y) published by 3D drags: + * the elevation of the surface the cursor ray actually points at. The + * floor-elevation system passes it to the slab-support election so a + * deck above the aimed-at floor never lifts the dragged node. Absent + * for 2D floorplan drags (no camera ray) — election stays uncapped. + */ + supportElevationCap?: number } type LiveTransformState = { diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index dd837e8cb6..1d35ed7d1c 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -342,7 +342,9 @@ describe('scene commit boundary', () => { const snapshot = currentSnapshot() snapshot.nodes = { ...snapshot.nodes, - [LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], level: 8 } as AnyNode, + // Marker must survive the load migration: level ordinals renumber on + // load, so the stored storey height marks the applied snapshot instead. + [LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], height: 8 } as AnyNode, } snapshot.installedPlugins = ['pascal:trees'] const commits: SceneCommit[] = [] @@ -350,7 +352,7 @@ describe('scene commit boundary', () => { useScene.getState().dirtyNodes.clear() expect(applySceneSnapshot(snapshot, { origin: 'host' })).toBe(true) - expect(levelNumber()).toBe(8) + expect((useScene.getState().nodes[LEVEL_ID] as { height?: number }).height).toBe(8) expect(useScene.getState().installedPlugins).toEqual(['pascal:trees']) expect(commits.map((commit) => commit.origin)).toEqual(['host']) expect(useScene.temporal.getState().pastStates).toHaveLength(0) diff --git a/packages/core/src/store/use-scene-vertical-migration.test.ts b/packages/core/src/store/use-scene-vertical-migration.test.ts new file mode 100644 index 0000000000..a95e7839b5 --- /dev/null +++ b/packages/core/src/store/use-scene-vertical-migration.test.ts @@ -0,0 +1,376 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +type RawNode = Record + +function baseNode(id: string, type: string, parentId: string | null, extra: RawNode = {}): RawNode { + return { object: 'node', id, type, parentId, visible: true, metadata: {}, ...extra } +} + +function site(children: string[]): RawNode { + return baseNode('site_test', 'site', null, { children }) +} + +function building(id: string, children: string[]): RawNode { + return baseNode(id, 'building', 'site_test', { children }) +} + +function level( + id: string, + buildingId: string, + ordinal: number, + children: string[], + extra: RawNode = {}, +): RawNode { + return baseNode(id, 'level', buildingId, { level: ordinal, children, ...extra }) +} + +function wall( + id: string, + levelId: string, + start: [number, number], + end: [number, number], + height?: number, +): RawNode { + return baseNode(id, 'wall', levelId, { + start, + end, + children: [], + ...(height !== undefined ? { height } : {}), + }) +} + +function slab( + id: string, + levelId: string, + polygon: Array<[number, number]>, + elevation = 0.05, +): RawNode { + return baseNode(id, 'slab', levelId, { polygon, holes: [], elevation }) +} + +function ceiling( + id: string, + levelId: string, + polygon: Array<[number, number]>, + height: number, + extra: RawNode = {}, +): RawNode { + return baseNode(id, 'ceiling', levelId, { polygon, holes: [], height, ...extra }) +} + +function stair(id: string, levelId: string, extra: RawNode = {}): RawNode { + return baseNode(id, 'stair', levelId, { position: [1, 0, 1], children: [], ...extra }) +} + +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +function loadScene(nodes: Record): Record { + useScene.getState().setScene(nodes as unknown as Record, ['site_test'] as never) + return useScene.getState().nodes as Record +} + +type LevelResult = Extract +type WallResult = Extract +type StairResult = Extract +type SlabResult = Extract +type CeilingResult = Extract + +describe('scene vertical model migration', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('default legacy storey derives height 2.5 and keeps walls plane-bound', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b']), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]), + wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4]), + }) + + expect((nodes.level_a as LevelResult).height).toBe(2.5) + expect('height' in (nodes.wall_a as WallResult)).toBe(false) + expect('height' in (nodes.wall_b as WallResult)).toBe(false) + }) + + test('hole pattern: walls within 0.20 of the plane become plane-bound', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_tall', 'wall_a', 'wall_b']), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65), + wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]), + wall_b: wall('wall_b', 'level_a', [0, 4], [4, 4]), + }) + + // Plane 0.05 + 2.65 = 2.7; absent walls top out at 2.55, 0.15 short. + expect((nodes.level_a as LevelResult).height).toBe(0.05 + 2.65) + expect('height' in (nodes.wall_tall as WallResult)).toBe(false) + expect('height' in (nodes.wall_a as WallResult)).toBe(false) + expect('height' in (nodes.wall_b as WallResult)).toBe(false) + }) + + test('intentional short walls at or beyond 0.20 keep their explicit height', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a', 'wall_b']), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0], 2.3), + wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.1), + }) + + expect((nodes.level_a as LevelResult).height).toBe(2.5) + expect((nodes.wall_a as WallResult).height).toBe(2.3) + expect((nodes.wall_b as WallResult).height).toBe(2.1) + }) + + test('absent-height wall well short of the plane materializes the 2.5 default', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a']), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 3.0), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]), + }) + + expect((nodes.level_a as LevelResult).height).toBe(3.0) + expect((nodes.wall_a as WallResult).height).toBe(2.5) + }) + + test('ordinal renumber compacts per building, anchored at zero', () => { + const nodes = loadScene({ + site_test: site(['building_a', 'building_b']), + building_a: building('building_a', ['level_a1', 'level_a2', 'level_a3']), + building_b: building('building_b', ['level_b1', 'level_b2', 'level_b3', 'level_b4']), + // Duplicate fractional ordinals (MCP wrote elevation params here). + level_a1: level('level_a1', 'building_a', 2.5, []), + level_a2: level('level_a2', 'building_a', 2.5, []), + level_a3: level('level_a3', 'building_a', 5, []), + // Basements compact upward toward -1, non-negatives down to 0. + level_b1: level('level_b1', 'building_b', -3, []), + level_b2: level('level_b2', 'building_b', -1, []), + level_b3: level('level_b3', 'building_b', 0, []), + level_b4: level('level_b4', 'building_b', 4, []), + }) + + expect((nodes.level_a1 as LevelResult).level).toBe(0) + expect((nodes.level_a2 as LevelResult).level).toBe(1) + expect((nodes.level_a3 as LevelResult).level).toBe(2) + + expect((nodes.level_b1 as LevelResult).level).toBe(-2) + expect((nodes.level_b2 as LevelResult).level).toBe(-1) + expect((nodes.level_b3 as LevelResult).level).toBe(0) + expect((nodes.level_b4 as LevelResult).level).toBe(1) + }) + + test('near-bound ceiling heights become follows-mode', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a', 'level_b']), + // Legacy default: ceiling 2.5 drives the derived level height 2.5, + // so the clamp bound is 2.49 and |2.5 − 2.49| < 0.20 → follows. + level_a: level('level_a', 'building_a', 0, ['ceiling_a']), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5), + // Already write-clamped default: 2.49 under a derived 2.49 level + // (bound 2.48) → follows too. + level_b: level('level_b', 'building_a', 1, ['ceiling_b']), + ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 2.49), + }) + + expect('height' in (nodes.ceiling_a as CeilingResult)).toBe(false) + expect('height' in (nodes.ceiling_b as CeilingResult)).toBe(false) + }) + + test('an intentional low ceiling keeps its explicit height', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + // The 3.0 wall drives the plane; the 2.0 ceiling sits 0.99 under + // the 2.99 bound — a deliberate dropped ceiling, kept explicit. + level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_low']), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0), + ceiling_low: ceiling('ceiling_low', 'level_a', SQUARE, 2.0), + }) + + expect((nodes.level_a as LevelResult).height).toBe(3.0) + expect((nodes.ceiling_low as CeilingResult).height).toBe(2.0) + }) + + test('autoFromWalls ceilings always convert to follows-mode', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + // 2.2 is far from the 2.99 bound, but auto heights were always + // derived by the sync — never user intent — so it drops anyway. + level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_auto']), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0), + ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.2, { autoFromWalls: true }), + }) + + expect('height' in (nodes.ceiling_auto as CeilingResult)).toBe(false) + }) + + test('migrated scene keeps a near-bound ceiling height (gate respected)', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + // Post-migration scene (level carries height): a stored 2.49 IS a + // deliberately typed value and must survive reloads. + level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'ceiling_auto'], { height: 2.5 }), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.49), + ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.49, { autoFromWalls: true }), + }) + + expect((nodes.ceiling_a as CeilingResult).height).toBe(2.49) + expect((nodes.ceiling_auto as CeilingResult).height).toBe(2.49) + }) + + test('legacy scene drops totalRise 2.5 but keeps other rises', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['stair_a', 'stair_b']), + stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }), + stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }), + }) + + expect('totalRise' in (nodes.stair_a as StairResult)).toBe(false) + expect((nodes.stair_b as StairResult).totalRise).toBe(3.1) + }) + + test('migrated scene keeps a deliberately typed totalRise 2.5', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['stair_a'], { height: 2.5 }), + stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }), + }) + + expect((nodes.stair_a as StairResult).totalRise).toBe(2.5) + }) + + test('already-migrated level and its walls are untouched', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b'], { height: 4.0 }), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]), + wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.5), + }) + + expect((nodes.level_a as LevelResult).height).toBe(4.0) + expect('height' in (nodes.wall_a as WallResult)).toBe(false) + expect((nodes.wall_b as WallResult).height).toBe(2.5) + }) + + test('slab split writes thickness = elevation exactly for legacy solids', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'slab_b']), + slab_a: slab('slab_a', 'level_a', SQUARE, 0.3), + slab_b: slab('slab_b', 'level_a', SQUARE, 0), + }) + + const raised = nodes.slab_a as SlabResult + expect(raised.elevation).toBe(0.3) + expect(raised.thickness).toBe(0.3) + expect(raised.recessed).not.toBe(true) + + // Degenerate zero-elevation slab keeps its zero occupied interval — + // migration never clamps to MIN_SLAB_THICKNESS. + const flush = nodes.slab_b as SlabResult + expect(flush.elevation).toBe(0) + expect(flush.thickness).toBe(0) + }) + + test('slab split defaults an absent elevation to the effective 0.05 thickness', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a']), + slab_a: baseNode('slab_a', 'slab', 'level_a', { polygon: SQUARE, holes: [] }), + }) + + expect((nodes.slab_a as SlabResult).thickness).toBe(0.05) + }) + + test('legacy pool becomes recessed with its elevation unchanged', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a']), + slab_a: slab('slab_a', 'level_a', SQUARE, -0.15), + }) + + const pool = nodes.slab_a as SlabResult + expect(pool.elevation).toBe(-0.15) + expect(pool.recessed).toBe(true) + expect(pool.thickness).toBe(0.05) + }) + + test('slab with thickness already present is untouched', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a']), + // A below-plane SOLID (already-split scene): the gate must not + // reinterpret its negative elevation as a pool. + slab_a: baseNode('slab_a', 'slab', 'level_a', { + polygon: SQUARE, + holes: [], + elevation: -0.15, + thickness: 0.3, + }), + }) + + const deck = nodes.slab_a as SlabResult + expect(deck.elevation).toBe(-0.15) + expect(deck.thickness).toBe(0.3) + expect('recessed' in deck).toBe(false) + }) + + test('migration is idempotent', () => { + const first = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a', 'level_b']), + level_a: level('level_a', 'building_a', 2.5, [ + 'slab_a', + 'wall_tall', + 'wall_a', + 'stair_a', + 'stair_b', + ]), + level_b: level('level_b', 'building_a', 5, ['ceiling_b', 'wall_b']), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65), + wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]), + stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }), + stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }), + ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 3.0), + wall_b: wall('wall_b', 'level_b', [0, 0], [4, 0]), + }) + + const second = loadScene(structuredClone(first) as unknown as Record) + + expect(second).toEqual(first) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 36848e84ec..be23e8e59d 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -32,6 +32,10 @@ import { type SceneMaterialId, } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' +import { deriveLegacyLevelHeight } from '../services/level-height' +import { getCeilingClampBound } from '../services/storey' +import { computeWallSlabSupport } from '../systems/slab/slab-support' +import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' import { healSceneNodes } from '../utils/heal-scene-graph' import * as nodeActions from './actions/node-actions' import { @@ -91,6 +95,7 @@ function getVector3(value: unknown, fallback: [number, number, number]): [number } function normalizeStairNode(node: Record) { + const hasTotalRise = 'totalRise' in node const sanitized = { ...node, position: getVector3(node.position, [0, 0, 0]), @@ -101,7 +106,7 @@ function normalizeStairNode(node: Record) { slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'), openingOffset: getFiniteNumber(node.openingOffset, 0), width: getFiniteNumber(node.width, 1), - totalRise: getFiniteNumber(node.totalRise, 2.5), + totalRise: hasTotalRise ? getFiniteNumber(node.totalRise, 2.5) : undefined, stepCount: getFiniteNumber(node.stepCount, 10), thickness: getFiniteNumber(node.thickness, 0.25), fillToFloor: getBoolean(node.fillToFloor, true), @@ -117,7 +122,13 @@ function normalizeStairNode(node: Record) { } const parsed = StairNodeSchema.safeParse(sanitized) - return parsed.success ? parsed.data : null + if (!parsed.success) return null + if (hasTotalRise) return parsed.data + // Absent `totalRise` means "rise derives from the storey height" and must + // survive the load: safeParse echoes the sanitized explicit-undefined key, + // which would flip `'totalRise' in node` checks — strip it back off. + const { totalRise: _totalRise, ...rest } = parsed.data + return rest } function normalizeStairSegmentNode(node: Record) { @@ -559,6 +570,15 @@ function migrateRoofSurfaceMaterials(node: Record) { return next } +// Walls whose top lands within this of the storey plane become plane-bound; +// ceilings whose stored height lands within this of their clamp bound become +// follows-mode (step 3f) — same census-backed threshold for both. +// From a prod census: the 0.15-short "hole pattern" (default 2.5 walls next to +// a taller wall) must snap to the plane, while intentional 0.20-short walls +// (2.5 under a 2.7 plane, 2.3 under a 2.5 plane) must keep their explicit +// height — hence 0.20 with a strictly-less-than comparison. +const PLANE_BOUND_EPSILON = 0.2 + function migrateNodes(nodes: Record): { nodes: Record mintedMaterials: Record @@ -886,6 +906,169 @@ function migrateNodes(nodes: Record): { } } + // Pass 3: vertical building model. + // A level without `height` marks a scene saved before the vertical model + // landed. Computed before this pass mutates anything: the stair-rise + // cleanup below must never run on already-migrated scenes. + const isLegacyScene = Object.values(patchedNodes).some( + (node) => node?.type === 'level' && !('height' in node), + ) + + // 3a. Ordinal renumber — always runs, per building (idempotent + // self-healing; MCP's create-level historically wrote its elevation PARAM + // into the ordinal, so fractional/duplicate ordinals exist in the wild). + const buildingNodes = Object.values(patchedNodes).filter((node) => node?.type === 'building') + const levelsByBuilding = new Map>() + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'level') continue + // Mirrors the building resolution in services/storey.ts: an explicit + // parentId pointing at a building wins, membership in a building's + // children array is the legacy fallback, and unresolvable levels share + // one orphan bucket. + const buildingId = + buildingNodes.find((building) => building.id === node.parentId)?.id ?? + buildingNodes.find((building) => getStringArray(building.children).includes(id))?.id ?? + null + const bucket = levelsByBuilding.get(buildingId) ?? [] + bucket.push({ id, ordinal: getFiniteNumber(node.level, 0) }) + levelsByBuilding.set(buildingId, bucket) + } + for (const bucket of levelsByBuilding.values()) { + // Anchored at zero on purpose: ordinals are semantic — `level < 0` + // renders "Basement N" and `level === 0` is the ground-floor default — + // so negatives compact upward toward −1 and non-negatives compact down + // to 0. A blind 0..n renumber would rename basements. + const sorted = [...bucket].sort((a, b) => a.ordinal - b.ordinal) + const negativeCount = sorted.filter((entry) => entry.ordinal < 0).length + sorted.forEach((entry, index) => { + const nextOrdinal = index - negativeCount + const current = patchedNodes[entry.id] + if (current.level !== nextOrdinal) { + patchedNodes[entry.id] = { ...current, level: nextOrdinal } + } + }) + } + + // 3b. Stored storey heights: materialize the legacy stacked height verbatim + // (never rounded or snapped — snapping would move existing buildings). + // All planes derive before any wall height below mutates. + const legacyLevelIds = Object.entries(patchedNodes) + .filter(([, node]) => node?.type === 'level' && !('height' in node)) + .map(([id]) => id) + const derivedHeights = new Map() + for (const levelId of legacyLevelIds) { + derivedHeights.set( + levelId, + deriveLegacyLevelHeight(levelId, patchedNodes as Record), + ) + } + + for (const levelId of legacyLevelIds) { + const plane = derivedHeights.get(levelId)! + const level = patchedNodes[levelId] + patchedNodes[levelId] = { ...level, height: plane } + + // 3c. Wall-top classification against the just-written plane, using the + // same slab-support election as deriveLegacyLevelHeight (call shape + // mirrored from services/level-height.ts). Walls whose top meets the + // plane drop their explicit height and follow the level from now on; + // walls ending short (or tall) keep an explicit height — materializing + // the 2.5 default onto absent-height walls that end short of the plane. + const children = getStringArray(level.children) + .map((childId) => patchedNodes[childId]) + .filter((child) => child !== undefined) + const slabs = children.filter((child) => child.type === 'slab') + const walls = children.filter((child) => child.type === 'wall') + for (const wall of walls) { + const electedBase = computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + const effectiveHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const top = Math.max(0, electedBase) + effectiveHeight + if (Math.abs(plane - top) < PLANE_BOUND_EPSILON) { + if ('height' in wall) { + const { height: _height, ...planeBound } = wall + patchedNodes[wall.id] = planeBound + } + } else { + patchedNodes[wall.id] = { ...wall, height: effectiveHeight } + } + } + } + + // 3d. Stair rise: on legacy scenes a totalRise of exactly 2.5 is the old + // schema default, not a user choice — drop it so the rise derives from the + // storey height. Gated on isLegacyScene because on a post-migration scene + // a stored 2.5 IS a deliberately typed value and must survive reloads. + if (isLegacyScene) { + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'stair') continue + if (node.totalRise !== 2.5) continue + const { totalRise: _totalRise, ...derivedRise } = node + patchedNodes[id] = derivedRise + } + } + + // 3e. Slab placement/thickness split. `elevation` stays the walking surface; + // the new `thickness` grows downward so the solid occupies + // [elevation − thickness, elevation]. Legacy solids extruded [0, elevation], + // so thickness = elevation EXACTLY (including degenerate 0 — MIN_SLAB_THICKNESS + // applies to edits only, never here) keeps the occupied interval identical. + // Legacy pools (elevation < 0) become explicit `recessed` intent with + // elevation unchanged. Gated per slab on a missing `thickness` — the + // migration output is cast, so schema defaults never materialize on load. + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'slab' || 'thickness' in node) continue + const elevation = getFiniteNumber(node.elevation, 0.05) + patchedNodes[id] = + elevation < 0 + ? { ...node, thickness: 0.05, recessed: true } + : { ...node, thickness: elevation } + } + + // 3f. Ceiling follows-mode classification (the ceiling mirror of 3c; runs + // after 3b/3e so the clamp bound sees stored level heights and split slab + // thicknesses). A stored ceiling height within PLANE_BOUND_EPSILON of its + // clamp bound (min(storey plane, covering-slab underside) − margin, via + // getCeilingClampBound) is the legacy default tracking the level top, not + // a choice — drop it so the ceiling follows the level from now on. + // autoFromWalls ceilings always convert: their height was derived by the + // space-detection sync, never user intent. Gated on isLegacyScene, which + // is exact — nothing shipped between the level-height migration and this + // one — and makes the step idempotent. Known accepted edge: a + // post-migration user typing a custom height exactly equal to the bound + // keeps it (the gate prevents re-classification on later loads). + if (isLegacyScene) { + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'ceiling' || !('height' in node)) continue + const dropHeight = () => { + const { height: _height, ...follows } = node + patchedNodes[id] = follows + } + if (node.autoFromWalls === true) { + dropHeight() + continue + } + if (typeof node.parentId !== 'string') continue + const bound = getCeilingClampBound( + node.parentId, + patchedNodes as Record, + Array.isArray(node.polygon) ? node.polygon : [], + ) + const stored = getFiniteNumber(node.height, Number.NaN) + if (Number.isFinite(bound) && Math.abs(stored - bound) < PLANE_BOUND_EPSILON) { + dropHeight() + } + } + } + return { nodes: patchedNodes as Record, mintedMaterials } } @@ -1163,6 +1346,7 @@ const useScene: UseSceneStore = create()( const level0 = LevelNode.parse({ level: 0, children: [], + height: 2.5, }) const building = BuildingNode.parse({ diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index 736ffe1dd3..124746e9f8 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -1,13 +1,5 @@ -import type { - AnyNode, - AnyNodeId, - CeilingNode, - ElevatorNode, - LevelNode, - WallNode, -} from '../../schema' - -export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 +import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema' +import { getStoredLevelHeight } from '../../services/storey' export type ElevatorLevelEntry = { id: LevelNode['id'] @@ -81,28 +73,6 @@ export function resolveElevatorServiceLevels( return levels.slice(minIndex, maxIndex + 1) } -export function getElevatorLevelHeight(levelId: string, nodes: Record): number { - const level = nodes[levelId as AnyNodeId] as LevelNode | undefined - if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT - - let maxTop = 0 - - for (const childId of level.children) { - const child = nodes[childId as AnyNodeId] - if (!child) continue - - if (child.type === 'ceiling') { - const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT - if (height > maxTop) maxTop = height - } else if (child.type === 'wall') { - const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT - if (height > maxTop) maxTop = height - } - } - - return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT -} - export function resolveElevatorLevels( elevator: ElevatorNode, nodes: Record, @@ -119,7 +89,7 @@ export function resolveElevatorLevels( let cumulativeY = 0 for (const level of allLevels) { baseYByLevelId.set(level.id, cumulativeY) - cumulativeY += getElevatorLevelHeight(level.id, nodes) + cumulativeY += getStoredLevelHeight(level) } const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) diff --git a/packages/core/src/systems/slab/slab-support.test.ts b/packages/core/src/systems/slab/slab-support.test.ts new file mode 100644 index 0000000000..758d31ab62 --- /dev/null +++ b/packages/core/src/systems/slab/slab-support.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'bun:test' +import { SlabNode, WallNode } from '../../schema' +import { MIN_WALL_HEIGHT } from '../wall/wall-top' +import { + clampSlabElevationForWalls, + computeWallSlabSupport, + getSlabElevationUpperBound, +} from './slab-support' + +// 4×3 room slab drawn on the wall centerlines, like an auto-slab. +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + +const STOREY_HEIGHT = 2.7 +const BOUND = STOREY_HEIGHT - MIN_WALL_HEIGHT + +function roomSlab(elevation: number) { + return SlabNode.parse({ polygon: SQUARE, elevation, autoFromWalls: true }) +} + +function roomWalls(height?: number) { + return [ + WallNode.parse({ start: [0, 0], end: [4, 0], height }), + WallNode.parse({ start: [4, 0], end: [4, 3], height }), + WallNode.parse({ start: [4, 3], end: [0, 3], height }), + WallNode.parse({ start: [0, 3], end: [0, 0], height }), + ] +} + +describe('clampSlabElevationForWalls', () => { + it('clamps a slab under plane-bound walls at the plane minus MIN_WALL_HEIGHT', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(2.5, slab, roomWalls(), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(true) + expect(result.elevation).toBeCloseTo(BOUND) + }) + + it('leaves proposals at or below the bound untouched', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(BOUND, slab, roomWalls(), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(BOUND) + }) + + it('passes negative (recessed-committing) proposals through untouched', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(-0.6, slab, roomWalls(), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(-0.6) + }) + + it('does not clamp when the walls all carry explicit heights', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(2.5, slab, roomWalls(2.5), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(2.5) + }) + + it('does not clamp a slab covering no walls', () => { + const island = SlabNode.parse({ + polygon: [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ], + elevation: 0.05, + }) + const result = clampSlabElevationForWalls(2.5, island, roomWalls(), [island], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(2.5) + }) +}) + +describe('getSlabElevationUpperBound', () => { + it('bounds a slab electable by plane-bound walls', () => { + const slab = roomSlab(0.05) + expect(getSlabElevationUpperBound(slab, roomWalls(), [slab], STOREY_HEIGHT)).toBeCloseTo(BOUND) + }) + + it('is unbounded under explicit-height walls', () => { + const slab = roomSlab(0.05) + expect(getSlabElevationUpperBound(slab, roomWalls(2.5), [slab], STOREY_HEIGHT)).toBe( + Number.POSITIVE_INFINITY, + ) + }) +}) + +describe('computeWallSlabSupport preferred host', () => { + const wallLike = { start: [0, 1.5] as [number, number], end: [4, 1.5] as [number, number] } + const low = SlabNode.parse({ + id: 'slab_low', + polygon: SQUARE, + elevation: 0.1, + autoFromWalls: true, + }) + const high = SlabNode.parse({ + id: 'slab_high', + polygon: SQUARE, + elevation: 0.6, + autoFromWalls: true, + }) + + it('elects the highest supporting elevation without a preference', () => { + const support = computeWallSlabSupport(wallLike, [low, high], []) + expect(support.elevation).toBeCloseTo(0.6) + }) + + it('pins the elected elevation to a still-supporting preferred slab', () => { + const support = computeWallSlabSupport(wallLike, [low, high], [], 'slab_low') + expect(support.elevation).toBeCloseTo(0.1) + // Fill-down machinery still derives from ALL supporting slabs. + expect(support.baseSegments).toHaveLength(1) + expect(support.baseSegments[0]!.elevation).toBeCloseTo(0.6) + }) + + it('ignores a preferred slab that no longer supports the wall', () => { + const island = SlabNode.parse({ + id: 'slab_island', + polygon: [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ], + elevation: 0.9, + }) + const support = computeWallSlabSupport(wallLike, [low, high, island], [], 'slab_island') + expect(support.elevation).toBeCloseTo(0.6) + }) +}) diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts new file mode 100644 index 0000000000..fb308d57e6 --- /dev/null +++ b/packages/core/src/systems/slab/slab-support.ts @@ -0,0 +1,688 @@ +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' +import type { SlabNode, WallNode } from '../../schema' +import { getWallCurveFrameAt, isCurvedWall } from '../wall/wall-curve' +import { DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint' +import { MIN_WALL_HEIGHT } from '../wall/wall-top' + +export type SlabElevationClamp = { + elevation: number + clamped: boolean +} + +/** + * Clamp-never-ask upper bound for a slab's elevation. A plane-bound wall + * (no stored `height`) keeps its top at the storey plane, so a slab that + * rises past `storeyHeight - MIN_WALL_HEIGHT` while electing as that + * wall's base would squeeze the wall body below its minimum (and at the + * plane, to nothing). Walls with explicit heights don't constrain — their + * top rides the elected base, not the plane. Negative proposals (the + * drag-through-zero path that commits the `recessed` intent) pass + * through untouched: this is a purely numeric upper bound. + * + * The election runs against `levelSlabs` with `proposedElevation` + * substituted into `slab`, so a slab that would only WIN the election at + * the proposed elevation still clamps, and a slab out-elected by a + * sibling doesn't. Pure. + */ +export function clampSlabElevationForWalls( + proposedElevation: number, + slab: SlabNode, + levelWalls: WallNode[], + levelSlabs: readonly SlabNode[], + storeyHeight: number, +): SlabElevationClamp { + const bound = storeyHeight - MIN_WALL_HEIGHT + if (proposedElevation <= bound) return { elevation: proposedElevation, clamped: false } + if (slab.polygon.length < 3) return { elevation: proposedElevation, clamped: false } + + const substituted = levelSlabs.some((candidate) => candidate.id === slab.id) + ? levelSlabs.map((candidate) => + candidate.id === slab.id ? { ...candidate, elevation: proposedElevation } : candidate, + ) + : [...levelSlabs, { ...slab, elevation: proposedElevation }] + + for (const wall of levelWalls) { + if (wall.height != null) continue + const wallLike: WallOverlapInput = { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + } + // Cheap pre-filter: a wall that never reaches the slab's footprint + // can't elect it, whatever the election says about sibling slabs. + if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue + const support = computeWallSlabSupport(wallLike, substituted, levelWalls) + if (Math.abs(support.elevation - proposedElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) { + return { elevation: bound, clamped: true } + } + } + + return { elevation: proposedElevation, clamped: false } +} + +/** + * Static upper bound for a slab-elevation drag: probe the election with + * the slab raised above every sibling and the storey plane. If any + * plane-bound wall would elect it there, the drag may not pass + * `storeyHeight - MIN_WALL_HEIGHT`; otherwise it is unbounded above. + */ +export function getSlabElevationUpperBound( + slab: SlabNode, + levelWalls: WallNode[], + levelSlabs: readonly SlabNode[], + storeyHeight: number, +): number { + const probe = + Math.max(storeyHeight, ...levelSlabs.map((candidate) => candidate.elevation ?? 0.05)) + 1 + return clampSlabElevationForWalls(probe, slab, levelWalls, levelSlabs, storeyHeight).clamped + ? storeyHeight - MIN_WALL_HEIGHT + : Number.POSITIVE_INFINITY +} + +/** + * Point-in-polygon test using ray casting algorithm. + */ +export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean { + let inside = false + const n = polygon.length + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = polygon[i]![0], + zi = polygon[i]![1] + const xj = polygon[j]![0], + zj = polygon[j]![1] + + if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) { + inside = !inside + } + } + return inside +} + +function pointSegmentDistance( + px: number, + pz: number, + ax: number, + az: number, + bx: number, + bz: number, +): number { + const dx = bx - ax + const dz = bz - az + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az) + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared)) + return Math.hypot(px - (ax + dx * t), pz - (az + dz * t)) +} + +// Ray-cast pointInPolygon is unreliable for points exactly on the polygon +// boundary: the answer flips depending on which side of the polygon the edge +// is on. Interval classification below therefore treats "within this distance +// of the boundary" as inside explicitly, so walls sitting exactly on a slab +// edge (the common case — auto-slab polygons derive from wall centerlines) +// classify identically on every side of the slab. +const ON_BOUNDARY_EPSILON = 1e-4 + +export function pointOnPolygonBoundary( + px: number, + pz: number, + polygon: Array<[number, number]>, +): boolean { + const n = polygon.length + for (let i = 0; i < n; i++) { + const [ax, az] = polygon[i]! + const [bx, bz] = polygon[(i + 1) % n]! + if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true + } + return false +} + +/** Sub-interval along a segment or polyline: [start, end] in length units. */ +type LengthInterval = [number, number] + +function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] { + if (intervals.length <= 1) return intervals + const sorted = [...intervals].sort((a, b) => a[0] - b[0]) + const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]] + for (let i = 1; i < sorted.length; i++) { + const [intervalStart, intervalEnd] = sorted[i]! + const last = merged[merged.length - 1]! + if (intervalStart <= last[1] + 1e-9) { + last[1] = Math.max(last[1], intervalEnd) + } else { + merged.push([intervalStart, intervalEnd]) + } + } + return merged +} + +/** Total length of a merged (sorted, disjoint) interval list. */ +function intervalsLength(intervals: readonly LengthInterval[]): number { + let total = 0 + for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart + return total +} + +/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */ +function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] { + if (base.length === 0 || cut.length === 0) return mergeIntervals(base) + const cuts = mergeIntervals(cut) + const result: LengthInterval[] = [] + for (const [baseStart, baseEnd] of mergeIntervals(base)) { + let cursor = baseStart + for (const [cutStart, cutEnd] of cuts) { + if (cutEnd <= cursor) continue + if (cutStart >= baseEnd) break + if (cutStart > cursor) result.push([cursor, cutStart]) + cursor = cutEnd + if (cursor >= baseEnd) break + } + if (cursor < baseEnd) result.push([cursor, baseEnd]) + } + return result +} + +/** + * Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and, + * when `includeBoundary`, on its boundary), as [t0, t1] fractions of the + * segment. The segment is split at every crossing with a polygon edge and + * each sub-interval is classified by its midpoint, so no test point ever + * sits on a crossing. + */ +function segmentInsideIntervals( + ax: number, + az: number, + bx: number, + bz: number, + polygon: Array<[number, number]>, + includeBoundary: boolean, +): LengthInterval[] { + const dx = bx - ax + const dz = bz - az + const length = Math.hypot(dx, dz) + if (length < 1e-9) return [] + + const ts = [0, 1] + const n = polygon.length + for (let i = 0; i < n; i++) { + const [px, pz] = polygon[i]! + const [qx, qz] = polygon[(i + 1) % n]! + const ex = qx - px + const ez = qz - pz + const denom = dx * ez - dz * ex + if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at + const t = ((px - ax) * ez - (pz - az) * ex) / denom + const s = ((px - ax) * dz - (pz - az) * dx) / denom + if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t) + } + ts.sort((a, b) => a - b) + + const inside: LengthInterval[] = [] + for (let i = 1; i < ts.length; i++) { + const t0 = ts[i - 1]! + const t1 = ts[i]! + if (t1 - t0 < 1e-9) continue + const tm = (t0 + t1) / 2 + const mx = ax + dx * tm + const mz = az + dz * tm + const midpointInside = pointOnPolygonBoundary(mx, mz, polygon) + ? includeBoundary + : pointInPolygon(mx, mz, polygon) + if (midpointInside) inside.push([t0, t1]) + } + return inside +} + +function polylineLength(points: Array<{ x: number; y: number }>): number { + let total = 0 + for (let i = 1; i < points.length; i++) { + total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y) + } + return total +} + +/** + * Inside sub-intervals of a polyline against a polygon, in cumulative + * arc-length units from the polyline start (merged, disjoint). Boundary + * contact counts as inside for slab support (walls sit exactly on slab + * edges — see ON_BOUNDARY_EPSILON above); hole callers pass + * `includeBoundary: false` so a wall running along a stairwell hole's + * rim keeps the rim's support. + */ +function polylineInsideIntervals( + points: Array<{ x: number; y: number }>, + polygon: Array<[number, number]>, + includeBoundary = true, +): LengthInterval[] { + const intervals: LengthInterval[] = [] + let offset = 0 + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]! + const b = points[i]! + const segmentLength = Math.hypot(b.x - a.x, b.y - a.y) + if (segmentLength < 1e-9) continue + for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) { + intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength]) + } + offset += segmentLength + } + return mergeIntervals(intervals) +} + +export type WallOverlapInput = { + start: [number, number] + end: [number, number] + curveOffset?: number + thickness?: number +} + +// Minimum length of wall that must lie on/inside a slab polygon before the +// wall counts as overlapping it. Point contact (a perpendicular wall butting +// into a room's edge) clips to ~zero length and never reaches this, so such +// walls don't follow the slab's elevation. +const WALL_SLAB_MIN_OVERLAP = 0.05 + +/** + * Centerline of the wall plus its two face lines (centerline offset by + * ±halfThickness). The face lines catch walls whose centerline sits on or + * just outside the slab boundary but whose body reaches onto the slab — + * e.g. slab polygons drawn to the room's interior faces. + */ +function wallTestPolylines( + start: [number, number], + end: [number, number], + curveOffset: number, + halfThickness: number, +): Array> { + const wallLike = { start, end, curveOffset } + if (curveOffset !== 0 && isCurvedWall(wallLike)) { + const count = 16 + const center: Array<{ x: number; y: number }> = [] + const left: Array<{ x: number; y: number }> = [] + const right: Array<{ x: number; y: number }> = [] + for (let i = 0; i <= count; i++) { + const frame = getWallCurveFrameAt(wallLike, i / count) + center.push(frame.point) + left.push({ + x: frame.point.x + frame.normal.x * halfThickness, + y: frame.point.y + frame.normal.y * halfThickness, + }) + right.push({ + x: frame.point.x - frame.normal.x * halfThickness, + y: frame.point.y - frame.normal.y * halfThickness, + }) + } + return halfThickness > 0 ? [center, left, right] : [center] + } + + const center = [ + { x: start[0], y: start[1] }, + { x: end[0], y: end[1] }, + ] + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const len = Math.hypot(dx, dz) + if (len < 1e-10 || halfThickness <= 0) return [center] + const nx = (-dz / len) * halfThickness + const nz = (dx / len) * halfThickness + return [ + center, + [ + { x: start[0] + nx, y: start[1] + nz }, + { x: end[0] + nx, y: end[1] + nz }, + ], + [ + { x: start[0] - nx, y: start[1] - nz }, + { x: end[0] - nx, y: end[1] - nz }, + ], + ] +} + +/** + * Test whether a wall overlaps a slab polygon along a segment of its length. + * + * The wall's centerline and both face lines are clipped against the polygon; + * the wall overlaps when the longest clipped inside-or-on-boundary length + * exceeds a threshold (5cm, halved for very short walls). Because interval + * midpoints classify "on the boundary" as inside explicitly (never by + * ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves + * identically on every side of the slab. + * + * A wall that only touches the polygon at a point — a perpendicular wall + * butting into a room's edge, or a corner-to-corner touch — clips to ~zero + * length and does NOT overlap. + */ +export function wallOverlapsPolygon( + startOrWall: [number, number] | WallOverlapInput, + endOrPolygon: [number, number] | Array<[number, number]>, + polygonArg?: Array<[number, number]>, +): boolean { + // Two call shapes: + // wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware + // wallOverlapsPolygon(start, end, polygon) — legacy chord-only + let start: [number, number] + let end: [number, number] + let polygon: Array<[number, number]> + let curveOffset = 0 + let thickness = DEFAULT_WALL_THICKNESS + if (Array.isArray(startOrWall)) { + start = startOrWall as [number, number] + end = endOrPolygon as [number, number] + polygon = polygonArg as Array<[number, number]> + } else { + start = startOrWall.start + end = startOrWall.end + curveOffset = startOrWall.curveOffset ?? 0 + thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS + polygon = endOrPolygon as Array<[number, number]> + } + return wallOverlapsSlabFootprint({ start, end, curveOffset, thickness }, polygon) +} + +/** + * {@link wallOverlapsPolygon} with the slab's stored holes subtracted from + * the covered length: a wall whose band only reaches the polygon inside a + * hole does not overlap. Hole boundaries keep coverage (rim convention — + * see {@link computeWallSlabSupport}). Polygon boundary contact counts as + * covered, so a wall sitting exactly on a slab edge resolves identically + * on every side of the slab. Pure. + */ +export function wallOverlapsSlabFootprint( + wallLike: WallOverlapInput, + polygon: Array<[number, number]>, + holes?: ReadonlyArray>, +): boolean { + const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike + const halfThickness = Math.max(thickness / 2, 0) + + const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) + const centerLength = polylineLength(polylines[0]!) + if (centerLength < 1e-9) return false + + let overlap = 0 + for (const line of polylines) { + let intervals = polylineInsideIntervals(line, polygon) + for (const hole of holes ?? []) { + if (intervals.length === 0) break + if (hole.length < 3) continue + intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) + } + overlap = Math.max(overlap, intervalsLength(intervals)) + } + const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5)) + return overlap >= threshold +} + +/** + * Tolerance for the pointer-decided support cap: a slab still counts as + * "the surface you're pointing at (or below)" when its walking surface is + * within this many meters ABOVE the pointed elevation. Absorbs elevation + * noise between the ray hit and slab tops without letting a deck hanging + * clearly above the hit point capture the election. Defined here (rather + * than in the spatial-grid manager, which re-exports it) so the wall + * election below can honour the same cap without an import cycle. + */ +export const SUPPORT_ELEVATION_EPSILON = 0.05 + +// A slab elevation must support at least this fraction of the wall's +// length before it can dictate the wall's base. Below majority, a raised +// slab reaching one endpoint would hoist the whole wall off the floor +// that actually carries it. +const WALL_SLAB_SUPPORT_MAJORITY = 0.5 + +// Slabs whose elevations differ by less than this pool their support: +// a wall shared between two rooms' slabs is covered roughly half by +// each, and must still follow their common elevation. +const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 + +/** + * Base elevation for a wall, decided by which slabs actually SUPPORT it. + * + * Support is measured as covered length: the wall's centerline and face + * lines are clipped against each slab's RENDERED footprint + * (`getRenderableSlabPolygon` with the level walls + siblings, not the + * stored polygon — legacy polygons stored at wall faces or with old + * baked offsets fall short of the wall body, but their band-adopted + * rendered edge reaches the wall's outer face) minus the slab's stored + * holes (holes are data, never render-offset). A slab supporting less + * than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point + * contact, endpoint grazes). + * + * Same-elevation slabs pool their coverage. `elevation` preserves the + * existing wall-relative origin: the highest elevation covering at + * least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered + * elevation when none reaches majority. `baseElevation` only fills down + * where a lower support remains exposed on a wall face after higher, + * overlapping support is accounted for. Coincident floor/platform slabs + * therefore keep the wall on the platform, while slabs on opposite wall + * sides bridge correctly. A slab touching only one endpoint never enters + * either result. Pure; + * exported for tests. + */ +export type WallSlabSupport = { + /** Existing wall-relative floor elevation used by hosted children and wall height. */ + elevation: number + /** Slab whose elevation won the election, or null when the wall has no support. */ + electedSlabId: string | null + /** Lowest exposed adjacent support; wall geometry fills down to this elevation. */ + baseElevation: number + /** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */ + baseSegments: WallSlabSupportSegment[] +} + +export type WallSlabSupportSegment = { + start: number + end: number + elevation: number +} + +/** + * `preferredSlabId` is a persisted support host (`wall.supportSlabId`): + * while that slab is still in the candidate set (still overlaps the wall + * band with enough covered length), the elected `elevation` is pinned to + * it instead of the majority/best-coverage election. `baseSegments` / + * `baseElevation` (fill-down) still derive from ALL supporting slabs + * unchanged. A preferred slab that no longer qualifies is silently + * ignored — deliberately never cleared here, so the host resumes if the + * slab's polygon returns (only slab deletion strips the stored field). + * + * `maxElevation` is the pointer-decided support cap (level-local Y, same + * semantics as the item election): when set, elevation groups whose + * walking surface sits above `maxElevation + SUPPORT_ELEVATION_EPSILON` + * are excluded from the majority/best election — a deck hanging above the + * surface the cursor ray actually hit never captures the elected base. + * `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and + * an explicit `preferredSlabId` still wins over the cap. + */ +export function computeWallSlabSupport( + wallLike: WallOverlapInput, + slabs: readonly SlabNode[], + levelWalls: WallNode[], + preferredSlabId?: string | null, + maxElevation?: number | null, +): WallSlabSupport { + const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike + const halfThickness = Math.max(thickness / 2, 0) + const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) + const polylineLengths = polylines.map(polylineLength) + const wallLength = polylineLengths[0]! + if (wallLength < 1e-9) { + return { elevation: 0, electedSlabId: null, baseElevation: 0, baseSegments: [] } + } + + const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5)) + + type ElevationGroup = { + elevation: number + slabIds: string[] + perPolyline: LengthInterval[][] + } + const groups: ElevationGroup[] = [] + let preferredElevation: number | null = null + let preferredElectedSlabId: string | null = null + + for (const slab of slabs) { + if (slab.polygon.length < 3) continue + const renderedPolygon = getRenderableSlabPolygon(slab, { + walls: levelWalls, + siblingSlabs: slabs.filter((other) => other.id !== slab.id), + }) + + let supported = 0 + const perPolyline = polylines.map((line) => { + let intervals = polylineInsideIntervals(line, renderedPolygon) + for (const hole of slab.holes || []) { + if (intervals.length === 0) break + if (hole.length < 3) continue + intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) + } + supported = Math.max(supported, intervalsLength(intervals)) + return intervals + }) + if (supported < minSupport) continue + + const elevation = slab.elevation ?? 0.05 + if (preferredSlabId != null && slab.id === preferredSlabId) { + preferredElevation = elevation + preferredElectedSlabId = slab.id + } + let group = groups.find( + (candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, + ) + if (!group) { + group = { elevation, slabIds: [], perPolyline: polylines.map(() => []) } + groups.push(group) + } + group.slabIds.push(slab.id) + for (let i = 0; i < perPolyline.length; i++) { + group.perPolyline[i]!.push(...perPolyline[i]!) + } + } + + type EvaluatedGroup = ElevationGroup & { + coverage: number + mergedPerPolyline: LengthInterval[][] + } + const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => { + let coverage = 0 + const mergedPerPolyline = group.perPolyline.map(mergeIntervals) + for (let i = 0; i < group.perPolyline.length; i++) { + const lineLength = polylineLengths[i]! + if (lineLength < 1e-9) continue + coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength) + } + return { ...group, coverage, mergedPerPolyline } + }) + + const electableGroups = + maxElevation == null + ? evaluatedGroups + : evaluatedGroups.filter( + (group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON, + ) + + let majorityElevation = Number.NEGATIVE_INFINITY + let bestElevation = Number.NEGATIVE_INFINITY + let bestCoverage = -1 + for (const group of electableGroups) { + if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { + majorityElevation = Math.max(majorityElevation, group.elevation) + } + if ( + group.coverage > bestCoverage + 1e-6 || + (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation) + ) { + bestCoverage = group.coverage + bestElevation = group.elevation + } + } + + const elevation = + preferredElevation !== null + ? preferredElevation + : majorityElevation !== Number.NEGATIVE_INFINITY + ? majorityElevation + : bestElevation === Number.NEGATIVE_INFINITY + ? 0 + : bestElevation + const electedSlabId = + preferredElectedSlabId ?? + electableGroups + .find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) + ?.slabIds.slice() + .sort()[0] ?? + null + const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => { + const lineLength = polylineLengths[polylineIndex]! + if (lineLength < 1e-9) return [] + return group.mergedPerPolyline[polylineIndex]!.map( + ([intervalStart, intervalEnd]) => + [intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval, + ) + } + + const normalizedByGroup = evaluatedGroups.map((group) => ({ + elevation: group.elevation, + perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)), + })) + const breakpoints = [0, 1] + for (const group of normalizedByGroup) { + for (const intervals of group.perPolyline) { + for (const [intervalStart, intervalEnd] of intervals) { + breakpoints.push(intervalStart, intervalEnd) + } + } + } + breakpoints.sort((left, right) => left - right) + const uniqueBreakpoints = breakpoints.filter( + (value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7, + ) + + const highestAt = (polylineIndex: number, t: number) => { + let highest = Number.NEGATIVE_INFINITY + for (const group of normalizedByGroup) { + if ( + group.perPolyline[polylineIndex]?.some( + ([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7, + ) + ) { + highest = Math.max(highest, group.elevation) + } + } + return highest + } + + const baseSegments: WallSlabSupportSegment[] = [] + for (let index = 1; index < uniqueBreakpoints.length; index++) { + const start = uniqueBreakpoints[index - 1]! + const end = uniqueBreakpoints[index]! + if (end - start < 1e-7) continue + const midpoint = (start + end) / 2 + const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY + const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY + const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) + const segmentElevation = + faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0) + const previous = baseSegments[baseSegments.length - 1] + if ( + previous && + Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON + ) { + previous.end = end + } else { + baseSegments.push({ start, end, elevation: segmentElevation }) + } + } + + if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) + const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) + return { elevation, electedSlabId, baseElevation, baseSegments } +} + +export function computeWallSlabElevation( + wallLike: WallOverlapInput, + slabs: readonly SlabNode[], + levelWalls: WallNode[], +): number { + return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation +} diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 36b1c2ffa4..0cd760cfb5 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -9,8 +9,10 @@ import type { StairSegmentNode, SurfaceHoleMetadata, } from '../../schema' -import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' +import { resolveCeilingHeight } from '../../services/level-height' +import { getLevelElevations } from '../../services/storey' import { computeSegmentTransforms, rotateXZ } from './stair-footprint' +import { resolveStairTotalRise } from './stair-rise' type SegmentTransform = { position: [number, number, number] @@ -463,7 +465,7 @@ function getStraightOpeningPolygonsForSurface( const layouts = getStraightStairLayouts(stair, nodes) if (layouts.length === 0) return [] - const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) + const riserHeight = resolveStairTotalRise(stair, nodes) / Math.max(stair.stepCount ?? 10, 1) const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) const openingRects: AxisAlignedRect[] = [] @@ -605,17 +607,16 @@ function getTargetSlabElevationForStair( nodes: Record, ) { const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) - const fromLevel = getLevelNumber(fromLevelId, nodes) - const slabLevel = getLevelNumber(slabLevelId, nodes) + const elevations = getLevelElevations(nodes as Record) + const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined + const slabElevation = elevations.get(slabLevelId) - if (fromLevel === undefined || slabLevel === undefined) { + if (!fromElevation || !slabElevation || fromElevation.buildingId !== slabElevation.buildingId) { return slab.elevation ?? 0.05 } return ( - (slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT + - (slab.elevation ?? 0.05) - - (stair.position[1] ?? 0) + slabElevation.baseY - fromElevation.baseY + (slab.elevation ?? 0.05) - (stair.position[1] ?? 0) ) } @@ -626,18 +627,21 @@ function getTargetCeilingElevationForStair( nodes: Record, ) { const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) - const fromLevel = getLevelNumber(fromLevelId, nodes) - const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) - - if (fromLevel === undefined || ceilingLevel === undefined) { - return ceiling.height ?? DEFAULT_WALL_HEIGHT + const elevations = getLevelElevations(nodes as Record) + const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined + const ceilingElevation = elevations.get(ceilingLevelId) + + const ceilingHeight = resolveCeilingHeight(ceiling, nodes as Record) + + if ( + !fromElevation || + !ceilingElevation || + fromElevation.buildingId !== ceilingElevation.buildingId + ) { + return ceilingHeight } - return ( - (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + - (ceiling.height ?? DEFAULT_WALL_HEIGHT) - - (stair.position[1] ?? 0) - ) + return ceilingElevation.baseY - fromElevation.baseY + ceilingHeight - (stair.position[1] ?? 0) } function shouldApplyStairToSlab( diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index b78224cf9a..357134e643 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useRef } from 'react' -import type { AnyNode } from '../../schema' +import type { AnyNode, AnyNodeId } from '../../schema' import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' import useLiveNodeOverrides from '../../store/use-live-node-overrides' import useLiveTransforms from '../../store/use-live-transforms' @@ -12,6 +12,7 @@ import { hasLiveStairOpeningInputs, } from './stair-opening-preview' import { syncAutoStairOpenings } from './stair-opening-sync' +import { syncStairRises } from './stair-rise' function isOpeningRelevantNode(node: AnyNode | undefined) { return ( @@ -47,7 +48,7 @@ export const StairOpeningSystem = () => { const previewControllerRef = useRef(createSurfaceOpeningPreviewController()) useEffect(() => { - const applyUpdates = (updates: ReturnType) => { + const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial }>) => { if (updates.length === 0) return syncingAutoOpeningsRef.current = true pauseSceneHistory(useScene) @@ -103,14 +104,40 @@ export const StairOpeningSystem = () => { ) } - applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) - refreshLivePreview() + const runAutoSync = () => { + // Rise first: straight stairs converge their flight heights to the + // resolved rise (level height or deck elevation), and the opening pass + // reads those segment heights — so it must run against the post-rise + // nodes. + applyUpdates(syncStairRises(useScene.getState().nodes)) + applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + } + + let disposed = false + let autoSyncQueued = false + const scheduleAutoSync = () => { + if (autoSyncQueued) return + autoSyncQueued = true + // One microtask later so every other scene-store listener for the + // triggering transition (and, at mount, the editor's spatial-grid + // init) runs first — the spatial-grid sync in particular. The + // deck-attached rise elects the stair's floor-stack base elevation + // through the spatial grid; syncing before the grid listener would + // rescale flights against the pre-transition slab state. + queueMicrotask(() => { + autoSyncQueued = false + if (disposed) return + runAutoSync() + refreshLivePreview() + }) + } + + scheduleAutoSync() const unsubscribeScene = useScene.subscribe((state, prevState) => { if (syncingAutoOpeningsRef.current) return if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return - applyUpdates(syncAutoStairOpenings(state.nodes)) - refreshLivePreview() + scheduleAutoSync() }) const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { @@ -122,6 +149,7 @@ export const StairOpeningSystem = () => { }) return () => { + disposed = true unsubscribeScene() unsubscribeLiveTransforms() unsubscribeLiveOverrides() diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts new file mode 100644 index 0000000000..00309cbfa1 --- /dev/null +++ b/packages/core/src/systems/stair/stair-rise.test.ts @@ -0,0 +1,465 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { z } from 'zod' +import { + GROUND_SUPPORT_ID, + getFloorPlacedElevation, +} from '../../hooks/spatial-grid/floor-placed-elevation' +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, StairNode as StairNodeType } from '../../schema' +import { LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { resolveStairTotalRise, syncStairRises } from './stair-rise' + +// The deck branch elects the stair's floor-stack base through the node +// registry + spatial grid singletons — reset them so tests are hermetic +// (base elects 0 unless a test registers a stair footprint and slabs). +beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() +}) + +function buildScene(levelHeight: number | undefined, totalRise: number | undefined) { + const stair = StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + ...(totalRise !== undefined ? { totalRise } : {}), + }) + const level = LevelNode.parse({ + id: 'level_1', + type: 'level', + level: 0, + children: ['stair_1'], + ...(levelHeight !== undefined ? { height: levelHeight } : {}), + }) + return { stair, nodes: { level_1: level, stair_1: stair } } +} + +function makeDeck(elevation: number, polygon?: Array<[number, number]>) { + return SlabNode.parse({ + id: 'slab_deck', + type: 'slab', + polygon: polygon ?? [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + elevation, + thickness: 0.05, + }) +} + +function buildDeckScene(options: { + deckElevation: number + deckPolygon?: Array<[number, number]> + totalRise?: number + deckSlabId?: string + segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> +}) { + const deck = makeDeck(options.deckElevation, options.deckPolygon) + const segments = (options.segments ?? []).map((segment) => + StairSegmentNode.parse({ + id: segment.id, + type: 'stair-segment', + segmentType: segment.segmentType, + width: 1, + length: 2, + height: segment.height, + stepCount: 8, + parentId: 'stair_1', + }), + ) + const stair = StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + deckSlabId: options.deckSlabId ?? deck.id, + children: segments.map((segment) => segment.id), + ...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}), + }) + const level = LevelNode.parse({ + id: 'level_1', + type: 'level', + level: 0, + height: 2.5, + children: ['stair_1', deck.id], + }) + const nodes: Record = { + level_1: level, + stair_1: stair, + [deck.id]: deck, + } + for (const segment of segments) nodes[segment.id] = segment + return { deck, stair, nodes } +} + +function buildLevelSceneWithSegments(options: { + levelHeight: number + totalRise?: number + segments: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> +}) { + const segments = options.segments.map((segment) => + StairSegmentNode.parse({ + id: segment.id, + type: 'stair-segment', + segmentType: segment.segmentType, + width: 1, + length: 2, + height: segment.height, + stepCount: 8, + parentId: 'stair_1', + }), + ) + const stair = StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + children: segments.map((segment) => segment.id), + ...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}), + }) + const level = LevelNode.parse({ + id: 'level_1', + type: 'level', + level: 0, + height: options.levelHeight, + children: ['stair_1'], + }) + const nodes: Record = { level_1: level, stair_1: stair } + for (const segment of segments) nodes[segment.id] = segment + return { level, stair, nodes } +} + +describe('resolveStairTotalRise', () => { + it('derives the rise from the containing level stored height when absent', () => { + const { stair, nodes } = buildScene(3.2, undefined) + expect(resolveStairTotalRise(stair, nodes)).toBe(3.2) + }) + + it('tracks a storey height change without any stair write', () => { + const { stair, nodes } = buildScene(2.55, undefined) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.55) + const level = nodes.level_1 + if (level.type !== 'level') throw new Error('expected level') + const updated = { ...nodes, level_1: { ...level, height: 3.0 } } + expect(resolveStairTotalRise(stair, updated)).toBe(3.0) + }) + + it('prefers an explicit totalRise over the storey height', () => { + const { stair, nodes } = buildScene(3.2, 2.5) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.5) + }) + + it('falls back to the default when the stair has no containing level', () => { + const { stair } = buildScene(3.2, undefined) + expect(resolveStairTotalRise(stair, {})).toBe(2.5) + }) + + it('derives the rise from the attached deck elevation', () => { + const { stair, nodes } = buildDeckScene({ deckElevation: 1.25 }) + expect(resolveStairTotalRise(stair, nodes)).toBe(1.25) + }) + + it('tracks a deck elevation change without any stair write', () => { + const { deck, stair, nodes } = buildDeckScene({ deckElevation: 1.25 }) + const updated = { ...nodes, [deck.id]: { ...deck, elevation: 1.6 } } + expect(resolveStairTotalRise(stair, updated)).toBe(1.6) + }) + + it('prefers an explicit totalRise over the attached deck', () => { + const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, totalRise: 2.0 }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.0) + }) + + it('falls through a stale deckSlabId to the storey height silently', () => { + const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, deckSlabId: 'slab_gone' }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.5) + }) +}) + +describe('syncStairRises', () => { + it('writes the deck elevation into a single flight segment', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.6, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 1.6 } }]) + }) + + it('is a no-op when the flights already match the deck elevation', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([]) + }) + + it('scales multiple flights proportionally and leaves landings alone', () => { + const { nodes } = buildDeckScene({ + deckElevation: 2.1, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.5 }, + { id: 'sseg_2', segmentType: 'landing', height: 0.1 }, + { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, + ], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(2) + expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } }) + expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } }) + }) + + it('distributes an explicit custom rise instead of the deck elevation', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.25, + totalRise: 2.0, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.0 } }]) + }) + + it('falls a stale deckSlabId back to the storey height', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.6, + deckSlabId: 'slab_gone', + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }]) + }) + + it('leaves a stale-deck stair with an explicit rise untouched', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.6, + deckSlabId: 'slab_gone', + totalRise: 2.0, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([]) + }) + + it('converges a level-following straight stair to the storey height', () => { + const { nodes } = buildLevelSceneWithSegments({ + levelHeight: 2.5, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.0 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }]) + }) + + it('converges a level-following stair after a storey height change', () => { + const scene = buildLevelSceneWithSegments({ + levelHeight: 2.5, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const nodes = { ...scene.nodes, level_1: { ...scene.level, height: 3.0 } as AnyNode } + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 3.0 } }]) + }) + + it('rescales level-following flights proportionally, landings untouched', () => { + const { nodes } = buildLevelSceneWithSegments({ + levelHeight: 2.1, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.5 }, + { id: 'sseg_2', segmentType: 'landing', height: 0.1 }, + { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, + ], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(2) + expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } }) + expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } }) + }) + + it('converges back to the storey height after a deck detach', () => { + const scene = buildDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const { deckSlabId: _deckSlabId, ...detached } = scene.stair + const nodes = { ...scene.nodes, stair_1: detached as AnyNode } + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }]) + }) + + it('leaves a detached explicit-rise stair with hand-set segments untouched', () => { + const { nodes } = buildLevelSceneWithSegments({ + levelHeight: 2.5, + totalRise: 2.0, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.9 }, + { id: 'sseg_2', segmentType: 'stair', height: 0.6 }, + ], + }) + expect(syncStairRises(nodes)).toEqual([]) + }) +}) + +// The stair stands on a floor slab (the default 0.05 one, or whatever the +// floor-stack elects) — the deck-derived rise must be measured from that +// lifted base so the last step lands flush with the deck's walking surface. +describe('deck-attached rise with a floor-lifted base', () => { + const FLOOR_POLYGON: Array<[number, number]> = [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ] + // Away from the stair footprint at the origin so the base election never + // sees the deck itself. + const AWAY_DECK_POLYGON: Array<[number, number]> = [ + [8, 8], + [10, 8], + [10, 10], + [8, 10], + ] + + beforeEach(() => { + registerNode({ + kind: 'stair', + schemaVersion: 1, + schema: z.object({ type: z.literal('stair') }) as never, + category: 'structure', + defaults: () => ({}) as never, + capabilities: { + floorPlaced: { + footprints: (node) => [ + { + position: (node as StairNodeType).position, + dimensions: [1, 1, 2] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + ], + }, + }, + } as AnyNodeDefinition) + }) + + function makeFloorSlab(elevation: number) { + return SlabNode.parse({ + id: 'slab_floor', + type: 'slab', + polygon: FLOOR_POLYGON, + elevation, + thickness: 0.05, + }) + } + + function buildLiftedDeckScene(options: { + deckElevation: number + floorElevation?: number + totalRise?: number + supportSlabId?: string + segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> + }) { + const floor = makeFloorSlab(options.floorElevation ?? 0.05) + const scene = buildDeckScene({ + deckElevation: options.deckElevation, + deckPolygon: AWAY_DECK_POLYGON, + totalRise: options.totalRise, + segments: options.segments, + }) + const stair = options.supportSlabId + ? ({ ...scene.stair, supportSlabId: options.supportSlabId } as typeof scene.stair) + : scene.stair + const nodes: Record = { + ...scene.nodes, + stair_1: stair, + [floor.id]: floor, + } + spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1') + spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1') + return { deck: scene.deck, floor, stair, nodes } + } + + it('lands the last step flush: rise = deck elevation − elected base', () => { + const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25 }) + const base = getFloorPlacedElevation({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: 'level_1', + }) + expect(base).toBeCloseTo(0.05) + const rise = resolveStairTotalRise(stair, nodes) + expect(rise).toBeCloseTo(1.2) + // Top surface = visual base + rise = the deck's walking surface, not 1.30. + expect(base + rise).toBeCloseTo(1.25) + }) + + it('rescales a flight converged under the old rule down to the flush rise', () => { + const { nodes } = buildLiftedDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect(updates[0]?.id).toBe('sseg_1' as never) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.2) + }) + + it('keeps the full deck elevation when the stair stands on bare ground', () => { + const scene = buildDeckScene({ deckElevation: 1.25, deckPolygon: AWAY_DECK_POLYGON }) + spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1') + expect(resolveStairTotalRise(scene.stair, scene.nodes)).toBeCloseTo(1.25) + }) + + it('lets an explicit totalRise win over the base-adjusted deck rise', () => { + const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25, totalRise: 2.0 }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.0) + }) + + it('re-converges to flush after a deck elevation change', () => { + const scene = buildLiftedDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const movedDeck = { ...scene.deck, elevation: 1.6 } + const nodes = { ...scene.nodes, [scene.deck.id]: movedDeck as AnyNode } + spatialGridManager.handleNodeUpdated(movedDeck as AnyNode, 'level_1') + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.55) + }) + + it('re-converges to flush after the base slab elevation changes', () => { + const scene = buildLiftedDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }], + }) + const movedFloor = { ...scene.floor, elevation: 0.3 } + const nodes = { ...scene.nodes, [scene.floor.id]: movedFloor as AnyNode } + spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1') + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(0.95) + }) + + it('rescales flights proportionally from the lifted base, landings untouched', () => { + const { nodes } = buildLiftedDeckScene({ + deckElevation: 2.15, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.5 }, + { id: 'sseg_2', segmentType: 'landing', height: 0.1 }, + { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, + ], + }) + // Target flight rise = 2.15 − 0.05 (base) − 0.1 (landing) = 2.0 → 1.0 each. + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(2) + expect(updates[0]?.id).toBe('sseg_1' as never) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.0) + expect(updates[1]?.id).toBe('sseg_3' as never) + expect((updates[1]?.data as { height?: number }).height).toBeCloseTo(1.0) + }) + + it('honors a persisted ground host over the floor slab election', () => { + const { stair, nodes } = buildLiftedDeckScene({ + deckElevation: 1.25, + supportSlabId: GROUND_SUPPORT_ID, + }) + expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25) + }) +}) diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts new file mode 100644 index 0000000000..12054831cd --- /dev/null +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -0,0 +1,90 @@ +import { getFloorStackedPosition } from '../../hooks/spatial-grid/floor-placed-elevation' +import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height' +import { getStoredLevelHeight } from '../../services/storey' + +export function resolveStairTotalRise(stair: StairNode, nodes: Record): number { + if (stair.totalRise !== undefined) return stair.totalRise + + const level = Object.values(nodes).find( + (node) => node.type === 'level' && node.children.includes(stair.id), + ) + + if (stair.deckSlabId) { + const deck = nodes[stair.deckSlabId] + // The deck's `elevation` IS its walking surface (level-local), but the + // stair's own base may be lifted onto a floor slab by the floor-stack + // (`FloorElevationSystem` / `syncStairGroupElevation` put the group at + // `position[1] + elected slab elevation`). The rise is measured from + // that base, so subtract it — electing the base exactly the way the + // visual systems do (persisted `supportSlabId` honored, uncapped + // election otherwise) keeps base + rise landing precisely on the deck's + // walking surface. A stale reference (deck gone) falls through to the + // level-derived rise. + if (deck?.type === 'slab') { + const baseElevation = getFloorStackedPosition({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: level?.id ?? null, + })[1] + return (deck.elevation ?? 0.05) - baseElevation + } + } + + return level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT +} + +const RISE_SYNC_EPSILON = 1e-4 + +/** + * Keeps straight stairs' flight segments in step with the resolved rise. + * Straight-stair geometry derives from per-segment heights (not from + * `resolveStairTotalRise`), so level-height and deck-elevation changes must + * write through to the flight segments — curved/spiral stairs read the + * resolved rise directly and need no sync. + * + * Scope: stairs whose total the system owns — follows-mode stairs (absent + * `totalRise`, tracking their level or their deck) and deck-attached stairs + * (an explicit rise converges to the typed value). A detached stair with an + * explicit `totalRise` is the one place hand-edited segment chains are + * legitimate, so it is never touched. Flight heights scale proportionally + * (landings keep theirs); returns `updateNodes` patches, empty when every + * stair is already in step. + */ +export function syncStairRises( + nodes: Record, +): Array<{ id: AnyNodeId; data: Partial }> { + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + + for (const node of Object.values(nodes)) { + if (node.type !== 'stair' || node.stairType !== 'straight') continue + const deck = node.deckSlabId ? nodes[node.deckSlabId] : undefined + if (node.totalRise !== undefined && deck?.type !== 'slab') continue + + const segments = (node.children ?? []) + .map((childId) => nodes[childId]) + .filter((child): child is StairSegmentNode => child?.type === 'stair-segment') + const flights = segments.filter((segment) => segment.segmentType === 'stair') + if (flights.length === 0) continue + + const landingRise = segments + .filter((segment) => segment.segmentType !== 'stair') + .reduce((sum, segment) => sum + segment.height, 0) + const flightRise = flights.reduce((sum, segment) => sum + segment.height, 0) + const targetFlightRise = resolveStairTotalRise(node, nodes) - landingRise + if (targetFlightRise <= 0) continue + if (Math.abs(flightRise - targetFlightRise) <= RISE_SYNC_EPSILON) continue + + for (const flight of flights) { + const height = + flightRise > RISE_SYNC_EPSILON + ? flight.height * (targetFlightRise / flightRise) + : targetFlightRise / flights.length + updates.push({ id: flight.id as AnyNodeId, data: { height } }) + } + } + + return updates +} diff --git a/packages/core/src/systems/wall/wall-top.test.ts b/packages/core/src/systems/wall/wall-top.test.ts new file mode 100644 index 0000000000..e452dbfe5d --- /dev/null +++ b/packages/core/src/systems/wall/wall-top.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import { resolveWallEffectiveHeight, resolveWallTop } from './wall-top' + +describe('resolveWallTop', () => { + test('explicit height on zero base keeps the stored top', () => { + expect(resolveWallTop({ height: 2.5 }, 3, 0)).toBe(2.5) + }) + + test('explicit height on raised base rides the base', () => { + expect(resolveWallTop({ height: 2.5 }, 3, 0.6)).toBeCloseTo(3.1) + }) + + test('explicit height on sunken base keeps the absolute top', () => { + expect(resolveWallTop({ height: 2.5 }, 3, -0.4)).toBe(2.5) + }) + + test('plane-bound wall tops out at the storey plane regardless of base', () => { + expect(resolveWallTop({}, 3, 0)).toBe(3) + expect(resolveWallTop({}, 3, 0.6)).toBe(3) + expect(resolveWallTop({}, 3, -0.4)).toBe(3) + }) +}) + +describe('resolveWallEffectiveHeight', () => { + test('explicit on raised base extrudes the stored height', () => { + expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0.6)).toBeCloseTo(2.5) + }) + + test('explicit on zero base extrudes the stored height', () => { + expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0)).toBe(2.5) + }) + + test('plane-bound on raised base gets shorter, never taller', () => { + expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeCloseTo(2.4) + expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeLessThan(3) + }) + + test('plane-bound on zero base spans the full storey', () => { + expect(resolveWallEffectiveHeight({}, 3, 0)).toBe(3) + }) + + test('plane-bound on sunken base fills down while the top stays at the plane', () => { + expect(resolveWallEffectiveHeight({}, 3, -0.4)).toBeCloseTo(3.4) + }) +}) diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts new file mode 100644 index 0000000000..208ed4bd8d --- /dev/null +++ b/packages/core/src/systems/wall/wall-top.ts @@ -0,0 +1,53 @@ +import type { WallNode } from '../../schema/nodes/wall' + +/** + * Minimum wall body height in meters. Governs both the wall height + * arrow's lower drag bound and the slab-elevation clamp: a slab may not + * rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall + * elects it as its base, or the wall's extrusion (plane minus base) + * would collapse below this minimum. + */ +export const MIN_WALL_HEIGHT = 0.5 + +/** + * Wall-top inversion (vertical building model): a wall with no stored + * `height` is plane-bound — its top sits at the storey plane (level-local + * Y = the level's stored height), so a slab lifting the wall's base makes + * the wall shorter, never taller, and no gap can open at the top of a + * level. A wall WITH `height` is an explicit exception (half wall, + * parapet) and keeps the legacy semantics: the top rides a raised elected + * base (`electedBase + height`), while a zero or sunken base leaves the + * top at `height` (the legacy negative-slab constraint). + * + * Returns the top in level-local Y (same frame as `electedBase`). + */ +export function resolveWallTop( + wall: Pick, + storeyHeight: number, + electedBase: number, +): number { + if (wall.height == null) return storeyHeight + return electedBase > 0 ? electedBase + wall.height : wall.height +} + +/** + * Extruded height of the wall body: {@link resolveWallTop} minus the + * elected base. Base convention: the elected slab-support elevation itself + * — the viewer computes `effectiveBaseElevation = min(baseElevation, + * slabElevation)` and defaults `baseElevation` to the elected elevation, + * so with only the election in hand the two coincide. Fill-down below the + * elected base (`baseSegments`) is a geometry detail the extruder handles + * separately and never changes where the top sits. + * + * Equivalently: the wall-local Y of the wall's top, measured from the wall + * mesh origin (which sits at `electedBase`). May be non-positive when a + * slab reaches the storey plane; callers own the degenerate-geometry + * policy. + */ +export function resolveWallEffectiveHeight( + wall: Pick, + storeyHeight: number, + electedBase: number, +): number { + return resolveWallTop(wall, storeyHeight, electedBase) - electedBase +} diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index 8b0b04a913..93fd3c69fd 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from 'bun:test' import type { CollectionId } from '../schema/collections' import type { AnyNode, AnyNodeId } from '../schema/types' -import { forkSceneGraph, type SceneGraph } from './clone-scene-graph' +import { + cloneLevelSubtree, + cloneSceneGraph, + forkSceneGraph, + type SceneGraph, +} from './clone-scene-graph' function makeNode(id: string, type: string, extra: Record = {}): AnyNode { return { @@ -71,3 +76,50 @@ describe('forkSceneGraph', () => { expect(forked.installedPlugins).toEqual(['pascal:trees']) }) }) + +describe('supportSlabId remap', () => { + test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => { + const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] }) + const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' }) + const item = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' }) + + const cloned = cloneSceneGraph({ + nodes: { + ['level_1' as AnyNodeId]: level, + ['slab_1' as AnyNodeId]: slab, + ['item_1' as AnyNodeId]: item, + }, + rootNodeIds: ['level_1' as AnyNodeId], + }) + + const clonedSlab = Object.values(cloned.nodes).find((node) => node.type === 'slab')! + const clonedItem = Object.values(cloned.nodes).find((node) => node.type === 'item')! + expect(clonedSlab.id).not.toBe('slab_1') + expect((clonedItem as { supportSlabId?: string }).supportSlabId).toBe(clonedSlab.id) + }) + + test('cloneLevelSubtree remaps in-subtree hosts and preserves external references', () => { + const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1', 'item_2'] }) + const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' }) + const hosted = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' }) + const external = makeNode('item_2', 'item', { + parentId: 'level_1', + supportSlabId: 'slab_external', + }) + + const { clonedNodes, idMap } = cloneLevelSubtree( + { + ['level_1' as AnyNodeId]: level, + ['slab_1' as AnyNodeId]: slab, + ['item_1' as AnyNodeId]: hosted, + ['item_2' as AnyNodeId]: external, + }, + 'level_1' as AnyNodeId, + ) + + const clonedHosted = clonedNodes.find((node) => node.id === idMap.get('item_1'))! + const clonedExternal = clonedNodes.find((node) => node.id === idMap.get('item_2'))! + expect((clonedHosted as { supportSlabId?: string }).supportSlabId).toBe(idMap.get('slab_1')!) + expect((clonedExternal as { supportSlabId?: string }).supportSlabId).toBe('slab_external') + }) +}) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index ad100cbed7..8235c012e7 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -1,3 +1,4 @@ +import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation' import { remapMeasurementReferences } from '../lib/measurement-geometry' import type { AnyNode, AnyNodeId } from '../schema' import { generateId } from '../schema/base' @@ -85,6 +86,24 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { ) as string | undefined } + // Remap supportSlabId (persisted slab-support hosts). The 'ground' + // sentinel is not a node id — keep it as-is. + if ( + 'supportSlabId' in clonedNode && + typeof clonedNode.supportSlabId === 'string' && + clonedNode.supportSlabId !== GROUND_SUPPORT_ID + ) { + ;(clonedNode as Record).supportSlabId = idMap.get( + clonedNode.supportSlabId, + ) as string | undefined + } + + if ('deckSlabId' in clonedNode && typeof clonedNode.deckSlabId === 'string') { + ;(clonedNode as Record).deckSlabId = idMap.get(clonedNode.deckSlabId) as + | string + | undefined + } + if (clonedNode.type === 'measurement') { clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap) } @@ -240,6 +259,18 @@ export function cloneLevelSubtree( idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId } + // Remap supportSlabId when the host slab is inside the cloned subtree; + // preserve it otherwise (like wallId, the reference may point outside). + if ('supportSlabId' in cloned && typeof cloned.supportSlabId === 'string') { + ;(cloned as Record).supportSlabId = + idMap.get(cloned.supportSlabId) ?? cloned.supportSlabId + } + + if ('deckSlabId' in cloned && typeof cloned.deckSlabId === 'string') { + ;(cloned as Record).deckSlabId = + idMap.get(cloned.deckSlabId) ?? cloned.deckSlabId + } + if (cloned.type === 'measurement') { cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) } diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 88820d37a3..d57968c2d3 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -15,6 +15,7 @@ import { getElevatorShaftDepth, getElevatorShaftWallThickness, getElevatorShaftWidth, + getLevelElevations, getResolvedElevatorDoorStyle, openElevatorDoor, requestElevatorLevel, @@ -76,7 +77,6 @@ const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14 const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 -const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 const VOID_FALL_RESPAWN_DEPTH = 12 type MovementKeyName = Exclude @@ -281,37 +281,17 @@ function isInsideElevatorCab( ) } -function getFirstPersonLevelHeight(levelId: string, nodes: Record) { - const level = nodes[levelId as AnyNodeId] - if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT - - let maxTop = 0 - for (const childId of level.children) { - const child = nodes[childId as AnyNodeId] - if (!child) continue - - if (child.type === 'ceiling') { - maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT) - continue - } - - if (child.type === 'wall') { - const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0) - maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)) - } - } - - return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT -} - function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record) { const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + const levelElevations = getLevelElevations(nodes as Record) const baseYByLevelId = new Map() let cumulativeY = 0 for (const level of allLevels) { - baseYByLevelId.set(level.id, cumulativeY) - cumulativeY += getFirstPersonLevelHeight(level.id, nodes) + const elevation = levelElevations.get(level.id) + const baseY = elevation?.baseY ?? 0 + baseYByLevelId.set(level.id, baseY) + cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0)) } const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index fa526574c4..5d2f9a6276 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -6,7 +6,6 @@ import { type CeilingNode, ColumnNode, createSceneApi, - DEFAULT_WALL_HEIGHT, DoorNode, ElevatorNode, emitter, @@ -15,6 +14,7 @@ import { getActiveRoofHeight, getEffectiveNode, getWallCurveLength, + getWallEffectiveHeightForNodes, getWallThickness, ItemNode, isCurvedWall, @@ -271,7 +271,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): { } { if (node.type === 'wall') { return { - height: node.height ?? DEFAULT_WALL_HEIGHT, + height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes), length: getWallCurveLength(node), thickness: getWallThickness(node), } @@ -413,7 +413,10 @@ export function FloatingActionMenu() { const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as | { height?: number } | undefined - const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT + const fallbackHeight = + node.type === 'wall' + ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes) + : FENCE_DEFAULT_HEIGHT const liveHeight = override?.height ?? node.height ?? fallbackHeight pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}` } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 531f9f82b7..78c0f25d68 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -153,6 +153,12 @@ export interface EditorProps { * only while a node is selected. */ inspectorFooter?: ReactNode + /** + * Docked below the multi-selection panel (v2). Hosts mount whole-selection + * affordances here (e.g. "Save to my catalog"); shows only while more than + * one node is selected. + */ + multiSelectionFooter?: ReactNode /** Host-owned content mounted inside the editor's React Three Fiber scene. */ viewerSceneSlot?: ReactNode @@ -1108,6 +1114,7 @@ export default function Editor({ viewerToolbarRight, stageOverlay, inspectorFooter, + multiSelectionFooter, viewerSceneSlot, floorplanSceneSlot, projectId, @@ -1412,7 +1419,10 @@ export default function Editor({ )} {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
- +
)} {!isCaptureMode && ( diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index 285b962933..92851a0909 100644 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -1,10 +1,11 @@ 'use client' import { + type AnyNode, type AnyNodeId, calculateLevelMiters, - DEFAULT_WALL_HEIGHT, getWallCurveLength, + getWallEffectiveHeightForNodes, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, @@ -91,10 +92,7 @@ export function WallMeasurementLabel() { return createPortal(, selectedObject) } -function getLevelWalls( - wall: WallNode, - nodes: Record, -): WallNode[] { +function getLevelWalls(wall: WallNode, nodes: Record): WallNode[] { if (!wall.parentId) return [wall] const levelNode = nodes[wall.parentId as AnyNodeId] @@ -308,7 +306,7 @@ function getCurvedWallMeasurementPath( function buildMeasurementGuide( wall: WallNode, - nodes: Record, + nodes: Record, ): MeasurementGuide | null { const levelWalls = getLevelWalls(wall, nodes) const miterData = calculateLevelMiters(levelWalls) @@ -317,7 +315,7 @@ function buildMeasurementGuide( const measurementPoints = measurementLine ?? fallbackMiddlePoints if (!measurementPoints) return null - const height = wall.height ?? DEFAULT_WALL_HEIGHT + const height = getWallEffectiveHeightForNodes(wall, nodes) const startLocal = worldPointToWallLocal(wall, measurementPoints.start) const endLocal = worldPointToWallLocal(wall, measurementPoints.end) const curvedMeasurementPath = isCurvedWall(wall) @@ -516,14 +514,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { const color = isNight ? '#ffffff' : '#111111' const shadowColor = isNight ? '#111111' : '#ffffff' - const guide = useMemo( - () => - buildMeasurementGuide( - wall, - nodes as Record, - ), - [nodes, wall], - ) + const guide = useMemo(() => buildMeasurementGuide(wall, nodes), [nodes, wall]) const length = useMemo(() => { if (!guide?.guidePath?.length || guide.guidePath.length < 2) { return getWallCurveLength(wall) @@ -538,7 +529,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { return total }, [guide, wall]) const label = formatLinearMeasurement(length, unit) - const heightLabel = `H ${formatLinearMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}` + const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall]) + const heightLabel = `H ${formatLinearMeasurement(height, unit)}` if (!(guide && Number.isFinite(length) && length >= 0.01)) return null diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 735f1763cf..3ae9b8a109 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -2,11 +2,12 @@ import { type AnyNodeId, - DEFAULT_WALL_HEIGHT, type FenceNode, getWallCurveFrameAt, + getWallEffectiveHeightForNodes, getWallThickness, isCurvedWall, + MIN_WALL_HEIGHT, sceneRegistry, useLiveNodeOverrides, useScene, @@ -55,7 +56,6 @@ const HANDLE_MIN_OFFSET = 0.33 const HANDLE_MIN_HEIGHT = 0.4 const HANDLE_TOP_INSET = 0.08 const HEIGHT_HANDLE_OFFSET = 0.26 -const MIN_WALL_HEIGHT = 0.5 const ARROW_COLOR = '#8381ed' const ARROW_HOVER_COLOR = '#a5b4fc' // Match the door arrows: scale the rendered chevron down to ~two-thirds @@ -244,7 +244,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: const corner = endpoint === 'start' ? wall.start : wall.end const x = corner[0] const z = corner[1] - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) @@ -433,7 +433,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const wallAngle = Math.atan2(-dirZ, dirX) // `wall` is the override-merged effective wall (see // WallMoveSideHandlesForWall), so this height is already live during a drag. - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const handleY = wallHeight + HEIGHT_HANDLE_OFFSET const activateHeightResize = (event: ThreeEvent) => { @@ -466,7 +466,9 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const hit = new Vector3() if (!raycaster.ray.intersectPlane(plane, hit)) return - const initialHeight = wall.height ?? DEFAULT_WALL_HEIGHT + // Dragging the top makes the wall custom-height; seed from the resolved + // effective height so a plane-bound wall's drag starts at its real top. + const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const initialY = hit.y const wallId = wall.id as AnyNodeId let pendingHeight = initialHeight @@ -774,7 +776,7 @@ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { const midpoint: [number, number] = frame ? [frame.point.x, frame.point.y] : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index e3fa0855b9..7ef0ee9b1d 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -3,12 +3,13 @@ import { type AnyNode, type AnyNodeId, - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallCurveLength, + getWallPlaneTop, getWallThickness, isCurvedWall, resolveLevelId, + resolveWallTop, sceneRegistry, spatialGridManager, useScene, @@ -147,15 +148,16 @@ type WallTopHighlightSegment = { function getWallTopY(wall: WallNode, nodes: Readonly>) { const levelId = resolveLevelId(wall, nodes as Record) - const slabElevation = spatialGridManager.getSlabElevationForWall( + const support = spatialGridManager.getSlabSupportForWall( levelId, wall.start, wall.end, wall.curveOffset ?? 0, wall.thickness, + wall.supportSlabId, ) - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT - return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT + const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) + return resolveWallTop(wall, planeTop, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT } function buildHighlightSegment(start: [number, number], end: [number, number]) { diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 71e787ab65..e60c88e9d8 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -3,6 +3,7 @@ import { type CeilingNode, emitter, + resolveCeilingHeight, resolveLevelId, sceneRegistry, snapPointToGrid, @@ -151,6 +152,9 @@ const CeilingSelectionAffordance = ({ () => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), [ceiling, liveOverride], ) + // Explicit height when stored, else the live level-top bound the ceiling + // follows (primitive selector — re-render-safe). + const resolvedHeight = useScene((s) => resolveCeilingHeight(effectiveCeiling, s.nodes)) const [levelObject, setLevelObject] = useState( () => sceneRegistry.nodes.get(levelId) ?? null, ) @@ -221,7 +225,7 @@ const CeilingSelectionAffordance = ({ ) raycasterRef.current.setFromCamera(ndcRef.current, camera) - planePointRef.current.set(0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0) + planePointRef.current.set(0, resolvedHeight + BRACKET_Y_OFFSET, 0) levelObject.localToWorld(planePointRef.current) planeOriginRef.current.set(0, 0, 0) @@ -238,7 +242,7 @@ const CeilingSelectionAffordance = ({ levelObject.worldToLocal(localIntersectionRef.current) return [localIntersectionRef.current.x, localIntersectionRef.current.z] }, - [camera, effectiveCeiling.height, gl.domElement, levelObject], + [camera, resolvedHeight, gl.domElement, levelObject], ) const handleCornerPointerDown = useCallback( @@ -438,10 +442,7 @@ const CeilingSelectionAffordance = ({ if (!levelObject || corners.length === 0) return null return createPortal( - + {corners.map((corner, index) => ( e.stopPropagation(), viaHandle: true, }) diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index 0d76ef27b6..fec5ab6afe 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -5,6 +5,7 @@ import { getWallCurveFrameAt, getWallCurveLength, isCurvedWall, + resolveFenceSupportSlabPatch, snapPointAlongAngleRay, useScene, type WallNode, @@ -187,9 +188,22 @@ export function snapFenceDraftPoint(args: { return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint } +export type FenceCommitOptions = { + /** + * Pointer-decided support cap (level-local Y) from + * `resolvePointerSupportSurface` — the 3D tool passes the elevation of + * the surface the commit click actually aimed at, so a fence drawn on a + * deck top persists the deck as its lift host while one drawn at the + * floor underneath stays grounded. Omitted by 2D floor-plan commits (no + * camera ray): those keep the uncapped max election. + */ + supportCap?: number | null +} + export function createFenceOnCurrentLevel( start: FencePlanPoint, end: FencePlanPoint, + options?: FenceCommitOptions, ): FenceNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, nodes } = useScene.getState() @@ -209,6 +223,13 @@ export function createFenceOnCurrentLevel( start, end, }) + // Fences run no per-frame support election — the persisted host IS the + // lift (absent = level floor), so elect it at commit, pointer-capped. + fence.supportSlabId = resolveFenceSupportSlabPatch( + { ...fence, parentId: currentLevelId }, + nodes, + { maxElevation: options?.supportCap ?? null }, + ).supportSlabId createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') @@ -225,6 +246,7 @@ export function createFenceOnCurrentLevel( export function createSplineFenceOnCurrentLevel( path: FencePlanPoint[], tangents = getTwoPointFenceCurveTangents(path), + options?: FenceCommitOptions, ): FenceNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, nodes } = useScene.getState() @@ -250,6 +272,11 @@ export function createSplineFenceOnCurrentLevel( path, tangents, }) + fence.supportSlabId = resolveFenceSupportSlabPatch( + { ...fence, parentId: currentLevelId }, + nodes, + { maxElevation: options?.supportCap ?? null }, + ).supportSlabId createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index f402148cfa..297d6ebf74 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -2,6 +2,7 @@ import { type AnyNodeId, type AssetInput, ItemNode, + resolveSupportSlabPatch, sceneRegistry, useScene, } from '@pascal-app/core' @@ -39,8 +40,14 @@ export interface DraftNodeHandle { ) => ItemNode | null /** Take ownership of an existing scene node as the draft (for move mode). */ adopt: (node: ItemNode) => void - /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */ - commit: (finalUpdate: Partial) => string | null + /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. + * `supportElevationCap` (floor commits) is the pointer-decided surface + * elevation — it caps the persisted `supportSlabId` election so the + * commit lands on the surface the cursor pointed at. */ + commit: ( + finalUpdate: Partial, + options?: { supportElevationCap?: number | null }, + ) => string | null /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */ destroy: () => void } @@ -124,100 +131,128 @@ export function useDraftNode(): DraftNodeHandle { ) }, []) - const commit = useCallback((finalUpdate: Partial): string | null => { - const draft = draftRef.current - if (!draft) return null + const commit = useCallback( + ( + finalUpdate: Partial, + options?: { supportElevationCap?: number | null }, + ): string | null => { + const draft = draftRef.current + if (!draft) return null + + if (adoptedRef.current) { + // Move mode: update in place (single undoable action) + const { parentId: newParentId, ...updateProps } = finalUpdate + const parentId = + newParentId ?? + originalStateRef.current?.parentId ?? + useViewer.getState().selection.levelId + const original = originalStateRef.current! + + // Restore original state while paused — so the undo baseline is clean + useScene.getState().updateNode(draft.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, + metadata: original.metadata, + }) + + // Resume → tracked update (undo reverts to original) + useScene.temporal.getState().resume() + + const effectiveNode = ItemNode.parse({ + ...draft, + ...updateProps, + parentId, + metadata: updateProps.metadata ?? stripTransient(draft.metadata), + }) + + useScene.getState().updateNode(draft.id, { + position: updateProps.position ?? draft.position, + rotation: updateProps.rotation ?? draft.rotation, + side: updateProps.side ?? draft.side, + metadata: updateProps.metadata ?? stripTransient(draft.metadata), + parentId: parentId as string, + // Forward the roof host explicitly: strategies set it on every + // commit (segment id on a roof face, undefined elsewhere), and + // dropping it here strands the item in the roof frame without + // the segment transform. + roofSegmentId: updateProps.roofSegmentId, + roofFace: updateProps.roofFace, + // Only when the strategy decided about wallId (roof commits clear + // it) — floor/ceiling commits never managed the field. + ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), + ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, { + maxElevation: options?.supportElevationCap, + }), + }) + + useScene.temporal.getState().pause() + + const id = draft.id + if (usePlacementPreview.getState().node?.id === id) { + usePlacementPreview.getState().clear() + } + draftRef.current = null + adoptedRef.current = false + originalStateRef.current = null + return id + } - if (adoptedRef.current) { - // Move mode: update in place (single undoable action) + // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause const { parentId: newParentId, ...updateProps } = finalUpdate - const parentId = - newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId - const original = originalStateRef.current! + const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId + if (!parentId) return null - // Restore original state while paused — so the undo baseline is clean - useScene.getState().updateNode(draft.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, - metadata: original.metadata, - }) + // Delete draft while paused (invisible to undo) + useScene.getState().deleteNode(draft.id) + draftRef.current = null - // Resume → tracked update (undo reverts to original) + // Briefly resume → create fresh node (the single undoable action) useScene.temporal.getState().resume() - useScene.getState().updateNode(draft.id, { + const finalNode = ItemNode.parse({ + name: draft.name, + asset: draft.asset, position: updateProps.position ?? draft.position, rotation: updateProps.rotation ?? draft.rotation, + scale: updateProps.scale ?? draft.scale, side: updateProps.side ?? draft.side, - metadata: updateProps.metadata ?? stripTransient(draft.metadata), - parentId: parentId as string, - // Forward the roof host explicitly: strategies set it on every - // commit (segment id on a roof face, undefined elsewhere), and - // dropping it here strands the item in the roof frame without - // the segment transform. + // Carry painted slot overrides so a duplicated item keeps its materials. + ...(draft.slots ? { slots: draft.slots } : {}), + // Roof host — see the move-mode commit above for why this must be + // forwarded explicitly. roofSegmentId: updateProps.roofSegmentId, roofFace: updateProps.roofFace, - // Only when the strategy decided about wallId (roof commits clear - // it) — floor/ceiling commits never managed the field. ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), + metadata: updateProps.metadata ?? stripTransient(draft.metadata), + parentId, + }) + const nodes = useScene.getState().nodes + const committedNode = ItemNode.parse({ + ...finalNode, + ...resolveSupportSlabPatch( + finalNode, + { ...nodes, [finalNode.id]: finalNode }, + { maxElevation: options?.supportElevationCap }, + ), }) + useScene.getState().createNode(committedNode, parentId) + if (usePlacementPreview.getState().node?.id === draft.id) { + usePlacementPreview.getState().clear() + } + // Re-pause for next draft cycle useScene.temporal.getState().pause() - const id = draft.id - if (usePlacementPreview.getState().node?.id === id) { - usePlacementPreview.getState().clear() - } - draftRef.current = null adoptedRef.current = false originalStateRef.current = null - return id - } - - // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause - const { parentId: newParentId, ...updateProps } = finalUpdate - const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId - if (!parentId) return null - - // Delete draft while paused (invisible to undo) - useScene.getState().deleteNode(draft.id) - draftRef.current = null - - // Briefly resume → create fresh node (the single undoable action) - useScene.temporal.getState().resume() - - const finalNode = ItemNode.parse({ - name: draft.name, - asset: draft.asset, - position: updateProps.position ?? draft.position, - rotation: updateProps.rotation ?? draft.rotation, - scale: updateProps.scale ?? draft.scale, - side: updateProps.side ?? draft.side, - // Carry painted slot overrides so a duplicated item keeps its materials. - ...(draft.slots ? { slots: draft.slots } : {}), - // Roof host — see the move-mode commit above for why this must be - // forwarded explicitly. - roofSegmentId: updateProps.roofSegmentId, - roofFace: updateProps.roofFace, - ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), - metadata: updateProps.metadata ?? stripTransient(draft.metadata), - }) - useScene.getState().createNode(finalNode, parentId) - if (usePlacementPreview.getState().node?.id === draft.id) { - usePlacementPreview.getState().clear() - } - - // Re-pause for next draft cycle - useScene.temporal.getState().pause() - - adoptedRef.current = false - originalStateRef.current = null - return finalNode.id - }, []) + return committedNode.id + }, + [], + ) const destroy = useCallback(() => { if (!draftRef.current) return diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 8e54234070..26624958a8 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -62,6 +62,10 @@ import { type PreviewBounds, updateLineGeometry, } from '../shared/placement-box-geometry' +import { + resolvePointerSupportElevation, + resolvePointerSupportSurface, +} from '../shared/pointer-support-cap' import { getDetachedAttachmentPreviewLift, getGridAlignedDimensions, @@ -269,6 +273,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const raycastDisabledMeshRef = useRef(null) const restoreRaycastsRef = useRef void>>([]) const raycastDisabledChildrenRef = useRef(new WeakSet()) + // Level-local elevation of the surface the pointer ray actually points + // at (deck top, floor slab, or ground), refreshed on every grid move. + // Threaded into the floor-support election as its cap so the POINTER + // decides the target surface — without it the election lifts the ghost + // by the MAX overlapping slab and a deck above the aimed-at floor + // captures the item (and the grid-plane feedback makes it blink). + const pointerSupportCapRef = useRef(null) const [dimensionBounds, setDimensionBounds] = useState(null) // Live camera ref — the shelf-stickiness test reconstructs the cursor world @@ -420,6 +431,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea node: previewNode, position, rotation: previewNode.rotation, + maxElevation: pointerSupportCapRef.current, }) }, [asset?.attachTo, draftNode], @@ -449,6 +461,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, } + // No pointer surface known yet — fall back to the uncapped election + // (an adopted move draft keeps its persisted host until the first move). + pointerSupportCapRef.current = null if (!asset.attachTo && placementState.current.surface === 'floor') { gridPosition.current.y = 0 if (cursorGroupRef.current) { @@ -848,6 +863,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea has3DPointerDrivenMoveRef.current = true + // The pointer decides the target surface AND the floor point: cap + // the floor-support election at the elevation of the surface the + // camera ray actually hits, and re-aim the event at the ray's + // crossing of that surface's plane. The grid event's own hit can't + // be used directly — its plane rides at the ghost's last height, so + // its Y is a feedback loop (the under-deck blink) and its XZ is + // perspective-skewed along the ray whenever the plane sits on a + // different storey than the pointed surface (the skew is what made + // a drag over a deck-above-a-floor hop between the two surfaces). + const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) + pointerSupportCapRef.current = pointed?.elevation ?? null + const surfaceEvent: GridEvent = + pointed?.worldPoint && pointed.localPoint + ? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint } + : event + // Shelf stickiness: while hosting on a shelf, ignore floor events while // the cursor ray still points at the shelf volume (the ray merely slipped // off a board / through a gap and hit the floor behind). Detach to the @@ -855,10 +886,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // item oscillates between the shelf row and the floor on every micro-move. if (placementState.current.surface === 'shelf-surface') { if (cursorRayIntersectsActiveShelf(event.position)) return - detachItemSurfaceToFloor(event as unknown as ItemEvent) + // Land at the pointed surface's plan point — the raw grid hit is + // still skewed by the plane riding at the shelf-surface height. + detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent) } - const floorEvent = applyFloorGrabOffset(event) + const floorEvent = applyFloorGrabOffset(surfaceEvent) lastRawPos.current.set( floorEvent.localPosition[0], @@ -941,11 +974,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (draft) draft.position = gridPos - // Publish live transform for 2D floorplan + // Publish live transform for 2D floorplan (and the pointer surface + // cap, so FloorElevationSystem's per-frame Y agrees with the ghost). if (draft) { useLiveTransforms.getState().set(draft.id, { position: gridPos, rotation: cursorGroupRef.current.rotation.y, + supportElevationCap: pointerSupportCapRef.current ?? undefined, }) } @@ -973,7 +1008,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const committedId = draftNode.current?.id ?? null const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) + // Carry the pointer surface cap into the commit so the persisted + // supportSlabId reproduces the capped election (elects the aimed-at + // lower slab — or the ground — instead of a deck hanging above). + const finalId = draftNode.commit(result.nodeUpdate, { + supportElevationCap: pointerSupportCapRef.current, + }) finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { draftNode.create( gridPosition.current, @@ -1392,6 +1432,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const detachItemSurfaceToFloor = (event: ItemEvent) => { hostSurfaceDragAnchor = null + // Landing back on the floor: refresh the pointer surface cap from + // this event's world hit so the first floor position already targets + // the aimed-at surface (not a deck above it). + pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [ + event.position[0], + event.position[1], + event.position[2], + ]) // Coming back from a host: forget the floor grab too, so the item // centers under the cursor instead of restoring the pre-drag offset — // and landing on the floor is "anchoring elsewhere", so a later return diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 0d0360f98d..ccd37b3587 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -23,6 +23,7 @@ import { resolveAlignment, resolveConnectivityUpdates, resolveFacingIndicator, + resolveSupportSlabPatch, sceneRegistry, spatialGridManager, useLiveNodeOverrides, @@ -30,6 +31,7 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' @@ -54,6 +56,7 @@ import { DragBoundingBox } from '../shared/drag-bounding-box' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility' import { PlacementBox } from '../shared/placement-box' +import { resolvePointerSupportSurface } from '../shared/pointer-support-cap' /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */ @@ -231,6 +234,15 @@ const CLICK_TRIGGER_KINDS = [ ] as const export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { + // Live camera ref — the pointer-surface cap reconstructs the cursor world + // ray (camera → grid hit) to find which walking surface is aimed at. + const camera = useThree((s) => s.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + // Level-local elevation of the surface the pointer ray points at, + // refreshed per grid move. Caps the floor-support election so a deck + // hanging above the aimed-at floor never lifts the dragged node. + const supportCapRef = useRef(null) // Kinds whose `position` lives in a host parent's local frame declare // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the // plan-frame cursor through the capability's hooks and previews via @@ -356,6 +368,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0] : rotationY })(), + maxElevation: supportCapRef.current, }) }, [parentFrame, frameParent, node], @@ -404,6 +417,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { rotationRef.current = originalRotationY altRef.current = false validRef.current = true + // No pointer surface known yet — uncapped election (the node keeps its + // persisted host / committed elevation until the first grid move). + supportCapRef.current = null // Re-sync the box transform to the (possibly new) node. `node` changes // without this component remounting whenever a positioned preset re-arms a // fresh clone after a drop, or the user picks a different catalog tile — @@ -591,8 +607,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } const onGridMove = (event: GridEvent) => { - const rawX = event.localPosition[0] - const rawZ = event.localPosition[2] + // The pointer decides the target surface AND the cursor plan point, + // both resolved from the true camera ray in one place. The event's + // own hit can't be used directly: its plane rides at the ghost's + // last height, so whenever that plane sits on a different storey + // than the aimed-at surface the hit XZ is perspective-skewed along + // the ray — electing at that skewed point is what made a drag over + // a deck-above-a-floor hop between the two surfaces (each hop moved + // the plane, which re-skewed the next hit, which flipped the + // election back). Cap and XZ from the same ray ∩ pointed-surface + // test are plane-height independent, so the elected surface is a + // single fixed point per pointer ray. + const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) + supportCapRef.current = pointed?.elevation ?? null + const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0] + const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2] revealFreshPlacement() const resolved = resolvePlanarCursorPosition({ @@ -721,9 +750,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // the absolute world plan position here. Polygon-based kinds // (slab / ceiling / fence) follow a different delta contract — // their floor-plan move-targets handle the override themselves. + // The pointer surface cap rides along so FloorElevationSystem's + // per-frame Y agrees with this tool's preview. useLiveTransforms.getState().set(node.id, { position, rotation: rotationRef.current, + supportElevationCap: supportCapRef.current ?? undefined, }) syncParentFramePreview(position) markMovedNodeDirty() @@ -781,9 +813,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { let committedId = node.id as AnyNodeId if (useScene.getState().nodes[node.id]) { + const effectiveNode = { + ...(node as Record), + position, + rotation, + } as AnyNode const data = { position, rotation, + // The pointer cap makes the persisted host reproduce the capped + // election — a drop under a deck stores the aimed-at lower slab + // (or the ground), not the deck hanging above. + ...resolveSupportSlabPatch( + effectiveNode, + { + ...useScene.getState().nodes, + [node.id]: effectiveNode, + }, + { maxElevation: supportCapRef.current }, + ), ...(isNew ? { metadata: stripPlacementMetadataFlags(node.metadata), @@ -819,6 +867,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId] if (liveNode && liveParent) { parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene)) + const committedNodes = useScene.getState().nodes + const committedParent = committedNodes[frameParent.id as AnyNodeId] + if (committedParent) { + useScene + .getState() + .updateNode( + committedParent.id, + resolveSupportSlabPatch(committedParent, committedNodes), + ) + } } } useScene.temporal.getState().pause() @@ -834,9 +892,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { metadata: {}, position, rotation, - }) + }) as AnyNode + const committedNode = def.schema.parse({ + ...reparsed, + parentId: node.parentId, + ...resolveSupportSlabPatch( + { ...reparsed, parentId: node.parentId } as AnyNode, + { + ...useScene.getState().nodes, + [reparsed.id]: reparsed, + }, + { maxElevation: supportCapRef.current }, + ), + }) as AnyNode useScene.temporal.getState().resume() - useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId) + useScene.getState().createNode(committedNode, node.parentId as AnyNodeId) useScene.temporal.getState().pause() committed = true } @@ -901,6 +971,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useLiveTransforms.getState().set(node.id, { position, rotation: rotationRef.current, + supportElevationCap: supportCapRef.current ?? undefined, }) syncParentFramePreview(position) markMovedNodeDirty() diff --git a/packages/editor/src/components/tools/shared/floor-stack-preview.ts b/packages/editor/src/components/tools/shared/floor-stack-preview.ts index f29b608be5..08ecfb6d2e 100644 --- a/packages/editor/src/components/tools/shared/floor-stack-preview.ts +++ b/packages/editor/src/components/tools/shared/floor-stack-preview.ts @@ -6,6 +6,8 @@ type FloorStackPreviewArgs = { rotation?: unknown levelId?: string | null nodes?: Record + /** Pointer-decided support cap — see `FloorPlacedElevationArgs.maxElevation`. */ + maxElevation?: number | null } export function getFloorStackPreviewPosition({ @@ -14,6 +16,7 @@ export function getFloorStackPreviewPosition({ rotation, levelId, nodes, + maxElevation, }: FloorStackPreviewArgs): [number, number, number] { return getFloorStackedPosition({ node, @@ -21,5 +24,6 @@ export function getFloorStackPreviewPosition({ position, rotation, levelId, + maxElevation, }) } diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts new file mode 100644 index 0000000000..b0bc8398e1 --- /dev/null +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -0,0 +1,99 @@ +import { type AnyNodeId, sceneRegistry, spatialGridManager } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { type Camera, Vector3 } from 'three' + +const originScratch = new Vector3() +const hitScratch = new Vector3() +const worldScratch = new Vector3() +const pointScratch = new Vector3() + +export type PointerSupportSurface = { + /** Level-local elevation of the pointed surface — the election cap. */ + elevation: number + /** World-space Y of the same surface, for grid-plane / preview placement. */ + worldY: number + /** + * World-space point where the pointer ray meets the pointed surface's + * plane, or null when the ray never reaches it. Unlike the grid event's + * own hit — whose XZ is perspective-skewed whenever the event plane + * rides at a different storey than the aimed-at surface — this point + * depends only on the ray and the pointed surface, so a preview / + * election fed from it cannot flip with the event plane's height. + */ + worldPoint: [number, number, number] | null + /** {@link PointerSupportSurface.worldPoint} in the grid event's + * `localPosition` (building-local) frame — a drop-in replacement for + * the event's plane-hit XZ. */ + localPoint: [number, number, number] | null +} + +/** + * The walking surface the pointer actually points at: its level-local + * elevation (for use as the slab-support election cap, `maxElevation`), + * its world-space Y (for riding the grid event plane / draw preview on + * it), and the ray's crossing of that surface's plane (the plan point the + * cursor indicates). + * + * The grid event plane rides at the ghost's last height, so its hit point + * alone can't be trusted (that feedback loop is what made a ghost under an + * elevated deck blink between the deck top and the ground). But camera → + * hit reconstructs the true pointer ray regardless of the plane height, + * and the nearest slab plane that ray crosses inside its rendered polygon + * IS the surface under the cursor — the deck top when aiming at the deck, + * the floor/ground when aiming underneath it. The same reasoning applies + * to the cursor XZ: the event plane's hit is skewed along the ray whenever + * the plane sits on a different storey than the pointed surface, so + * callers should place at `localPoint` / `worldPoint`, not the event hit. + * + * Returns null when no level is active (callers fall back to the + * uncapped max election and the raw event hit). + */ +export function resolvePointerSupportSurface( + camera: Camera, + worldHit: readonly [number, number, number], +): PointerSupportSurface | null { + const levelId = useViewer.getState().selection.levelId + if (!levelId) return null + + camera.getWorldPosition(originScratch) + hitScratch.set(worldHit[0], worldHit[1], worldHit[2]) + // Slab polygons/elevations live in the level frame; the level mesh + // carries the storey Y offset and any building rotation. + const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId) + if (levelMesh) { + levelMesh.worldToLocal(originScratch) + levelMesh.worldToLocal(hitScratch) + } + hitScratch.sub(originScratch) + if (hitScratch.lengthSq() < 1e-12) return null + + const { elevation, point } = spatialGridManager.getPointedSupportSurface( + levelId, + [originScratch.x, originScratch.y, originScratch.z], + [hitScratch.x, hitScratch.y, hitScratch.z], + ) + const worldY = levelMesh ? levelMesh.localToWorld(worldScratch.set(0, elevation, 0)).y : elevation + + let worldPoint: [number, number, number] | null = null + let localPoint: [number, number, number] | null = null + if (point) { + pointScratch.set(point[0], elevation, point[1]) + if (levelMesh) levelMesh.localToWorld(pointScratch) + worldPoint = [pointScratch.x, pointScratch.y, pointScratch.z] + // Same frame the grid events report `localPosition` in (use-grid-events). + const buildingId = useViewer.getState().selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + if (buildingMesh) buildingMesh.worldToLocal(pointScratch) + localPoint = [pointScratch.x, pointScratch.y, pointScratch.z] + } + + return { elevation, worldY, worldPoint, localPoint } +} + +/** {@link resolvePointerSupportSurface}, elevation only — the election cap. */ +export function resolvePointerSupportElevation( + camera: Camera, + worldHit: readonly [number, number, number], +): number | null { + return resolvePointerSupportSurface(camera, worldHit)?.elevation ?? null +} diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.test.ts b/packages/editor/src/components/tools/stair/stair-click-guard.test.ts new file mode 100644 index 0000000000..9339860f71 --- /dev/null +++ b/packages/editor/src/components/tools/stair/stair-click-guard.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' +import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' + +describe('createStairCommitGate', () => { + test('allows commits until the session exits', () => { + const gate = createStairCommitGate() + expect(gate.shouldCommit()).toBe(true) + // Repeat continuation: consecutive commits stay allowed. + expect(gate.shouldCommit()).toBe(true) + }) + + test('refuses every trigger after a single-continuation exit', () => { + const gate = createStairCommitGate() + expect(gate.shouldCommit()).toBe(true) + gate.markExited() + // The native follow-up click / stray node click of the same gesture. + expect(gate.shouldCommit()).toBe(false) + expect(gate.shouldCommit()).toBe(false) + }) +}) + +describe('swallowFollowUpBrowserClick', () => { + test('stops exactly one follow-up click', () => { + const target = new EventTarget() + swallowFollowUpBrowserClick(target) + + let firstStopped = 0 + const first = new Event('click', { cancelable: true }) + Object.defineProperty(first, 'stopPropagation', { value: () => firstStopped++ }) + target.dispatchEvent(first) + expect(firstStopped).toBe(1) + expect(first.defaultPrevented).toBe(true) + + // `once: true` — the next click of the next gesture passes through. + let secondStopped = 0 + const second = new Event('click', { cancelable: true }) + Object.defineProperty(second, 'stopPropagation', { value: () => secondStopped++ }) + target.dispatchEvent(second) + expect(secondStopped).toBe(0) + expect(second.defaultPrevented).toBe(false) + }) + + test('disarms after the timeout when no click follows', async () => { + const target = new EventTarget() + swallowFollowUpBrowserClick(target, 5) + await new Promise((resolve) => setTimeout(resolve, 15)) + + let stopped = 0 + const late = new Event('click', { cancelable: true }) + Object.defineProperty(late, 'stopPropagation', { value: () => stopped++ }) + target.dispatchEvent(late) + expect(stopped).toBe(0) + }) + + test('no-ops without a window-like target', () => { + expect(() => swallowFollowUpBrowserClick(undefined)).not.toThrow() + }) +}) diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.ts b/packages/editor/src/components/tools/stair/stair-click-guard.ts new file mode 100644 index 0000000000..59100848b9 --- /dev/null +++ b/packages/editor/src/components/tools/stair/stair-click-guard.ts @@ -0,0 +1,72 @@ +/** + * Guards for the stair tool's commit triggers. + * + * One physical validation click can reach the stair tool's commit handler + * twice: node-surface clicks (`slab:click`, `wall:click`, …) are synthesized + * on *pointerup* by the viewer (`use-node-events`), while `grid:click` rides + * the browser's native *click* event from a canvas-level DOM listener + * (`use-grid-events`) that deliberately ignores R3F stopPropagation — and + * after a single-continuation commit the tool's emitter subscriptions survive + * until React unmounts it, which lands only after that native click. Without + * these guards a validation click over any node surface (a deck or floor + * slab, a wall, another stair) created TWO stairs from one click. + * + * Same hazard and same countermeasures as + * `packages/nodes/src/shared/floor-placement.ts` (`stopPlacementCommitPropagation`) + * and the `committed` flag in `move-registry-node-tool.tsx` — reimplemented + * here because `@pascal-app/editor` cannot depend on `@pascal-app/nodes`. + */ + +export type StairCommitGate = { + /** True while the armed session may still commit. */ + shouldCommit: () => boolean + /** + * Mark the session exited (single continuation): every further click + * trigger reaching the still-subscribed handler — the native follow-up + * click, a stray second node click — is refused. + */ + markExited: () => void +} + +export function createStairCommitGate(): StairCommitGate { + let exited = false + return { + shouldCommit: () => !exited, + markExited: () => { + exited = true + }, + } +} + +type ClickSwallowTarget = { + addEventListener: ( + type: string, + listener: (event: Event) => void, + options?: AddEventListenerOptions, + ) => void + removeEventListener: ( + type: string, + listener: (event: Event) => void, + options?: EventListenerOptions, + ) => void +} + +/** + * Eat the one native browser `click` that follows a pointerup-synthesized + * node click, before it reaches the canvas `grid:click` listener (capture + * phase on window runs first). Needed in repeat continuation too, where the + * tool stays armed and the gate above must keep allowing one commit per + * gesture. Self-disarms after the click or `timeoutMs`, whichever first. + */ +export function swallowFollowUpBrowserClick( + target: ClickSwallowTarget | undefined = typeof window === 'undefined' ? undefined : window, + timeoutMs = 300, +): void { + if (!target) return + const swallow = (event: Event) => { + event.stopPropagation() + event.preventDefault() + } + target.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => target.removeEventListener('click', swallow, { capture: true }), timeoutMs) +} diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 8711909e37..3b1b8068f0 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -34,6 +34,7 @@ import useFacingPose from '../../../store/use-facing-pose' import { useStairBuildPreview } from '../../../store/use-stair-build-preview' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' +import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' import { DEFAULT_CURVED_STAIR_INNER_RADIUS, DEFAULT_CURVED_STAIR_SWEEP_ANGLE, @@ -150,7 +151,6 @@ function createDefaultStairNode({ slabOpeningMode: 'destination', openingOffset: DEFAULT_STAIR_OPENING_OFFSET, width: DEFAULT_STAIR_WIDTH, - totalRise: DEFAULT_STAIR_HEIGHT, stepCount: DEFAULT_STAIR_STEP_COUNT, thickness: DEFAULT_STAIR_THICKNESS, fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, @@ -231,6 +231,9 @@ export const StairTool: React.FC = () => { if (!currentLevelId) return const openingPreview = createSurfaceOpeningPreviewController() + // Refuses the duplicate commit triggers a single physical click produces + // — see `stair-click-guard.ts`. Fresh per armed session. + const commitGate = createStairCommitGate() // Reset rotation when tool activates rotationRef.current = 0 @@ -441,10 +444,20 @@ export const StairTool: React.FC = () => { const commitAtCursor = (event: ClickTriggerEvent) => { if (!currentLevelId) return + // One physical click can reach here twice (node click synthesized on + // pointerup + the native browser click driving `grid:click`) — see + // `stair-click-guard.ts`. The gate refuses anything after a single- + // continuation commit; the swallow below eats the same gesture's + // follow-up click while the tool stays armed (repeat continuation). + if (!commitGate.shouldCommit()) return const nodeEvent = 'node' in event ? (event as NodeEvent) : null if (nodeEvent) { nodeEvent.stopPropagation() nodeEvent.nativeEvent.stopPropagation() + // The canvas-level `grid:click` listener is out of stopPropagation's + // reach — without this, the browser click that follows this + // pointerup-synthesized node click commits a second stair. + swallowFollowUpBrowserClick() } const position = nodeEvent @@ -465,8 +478,14 @@ export const StairTool: React.FC = () => { if (useEditor.getState().getContinuation('point') === 'repeat') { alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) } else { + commitGate.markExited() useFacingPose.getState().clear() useEditor.getState().setTool(null) + // Return to select mode explicitly (matches the spawn tool's exit). + // The selection managers route node clicks only while + // `mode === 'select'`; exiting with `mode: 'build'` + a null tool + // left every click dead until the user pressed Escape. + useEditor.getState().setMode('select') } } diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 20edaeed36..e588ce5a8e 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -5,6 +5,7 @@ import { type DoorNode, getScaledDimensions, type ItemNode, + resolveWallSupportSlabPatch, runAsSingleSceneHistoryStep, snapPointAlongAngleRay, useScene, @@ -456,6 +457,17 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b export function createWallOnCurrentLevel( start: WallPlanPoint, end: WallPlanPoint, + options?: { + /** + * Pointer-decided support cap (level-local Y) from + * `resolvePointerSupportSurface` — the 3D tool passes the elevation of + * the surface the commit click actually aimed at, so the persisted + * host reproduces what the preview showed (floor under a deck vs deck + * top). Omitted by 2D floor-plan commits (no camera ray): those keep + * the uncapped max election. + */ + supportCap?: number | null + }, ): WallNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, createNodes, deleteNode, nodes } = useScene.getState() @@ -541,8 +553,18 @@ export function createWallOnCurrentLevel( }) createNode(wall, currentLevelId) + const createdWall = useScene.getState().nodes[wall.id] + if (createdWall?.type === 'wall') { + useScene.getState().updateNode( + createdWall.id, + resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, { + maxElevation: options?.supportCap ?? null, + }), + ) + } sfxEmitter.emit('sfx:structure-build') - return wall + const committedWall = useScene.getState().nodes[wall.id] + return committedWall?.type === 'wall' ? committedWall : wall }) } diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 967602bf1d..300cb4298f 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -1,7 +1,7 @@ 'use client' import type { AnyNodeId } from '@pascal-app/core' -import { LevelNode, useScene } from '@pascal-app/core' +import { DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { AppWindow, @@ -196,6 +196,7 @@ export function EditorCommands() { ).length const newLevel = LevelNode.parse({ level: levelCount, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: building.id, }) diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index ef922dfaae..591902c4ab 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -22,6 +22,8 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + DEFAULT_LEVEL_HEIGHT, + getStoredLevelHeight, LevelNode, useScene, } from '@pascal-app/core' @@ -49,7 +51,10 @@ import { subscribeEditorClipboard, } from '../../lib/scene-clipboard' import { sfxEmitter } from '../../lib/sfx-bus' +import { useLinearDisplay } from '../../lib/use-linear-display' import { cn } from '../../lib/utils' +import { ActionButton } from './controls/action-button' +import { SliderControl } from './controls/slider-control' import { LevelDuplicateDialog } from './level-duplicate-dialog' import { Dialog, @@ -145,6 +150,26 @@ function LevelRow({ }) { const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) + const updateNode = useScene((s) => s.updateNode) + const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2) + + const storeyHeight = getStoredLevelHeight(level) + // toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays. + const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}` + + // Clean preset values per display system; imperial stores exact meters + // for whole-foot storey heights. + const heightPresets = isImperial + ? [ + { label: '8 ft', height: 2.4384 }, + { label: '9 ft', height: 2.7432 }, + { label: '10 ft', height: 3.048 }, + ] + : [ + { label: '2.5 m', height: 2.5 }, + { label: '3.0 m', height: 3.0 }, + { label: '3.5 m', height: 3.5 }, + ] return (
@@ -195,6 +220,48 @@ function LevelRow({ {getLevelDisplayName(level)} + {/* Storey height badge — opens the height popover */} + + + + + e.stopPropagation()} + side="right" + sideOffset={8} + > + updateNode(level.id, { height: v })} + precision={3} + step={0.1} + unit="m" + value={Math.round(storeyHeight * 1000) / 1000} + /> +
+ {heightPresets.map((preset) => ( + updateNode(level.id, { height: preset.height })} + /> + ))} +
+
+
+ {/* Vertical three-dot menu — inside the pill */} @@ -371,6 +438,7 @@ export function FloatingLevelSelector() { const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1 const newLevel = LevelNode.parse({ level: maxLevel + 1, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) @@ -383,6 +451,7 @@ export function FloatingLevelSelector() { const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1 const newLevel = LevelNode.parse({ level: minLevel - 1, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) @@ -409,6 +478,7 @@ export function FloatingLevelSelector() { const newLevel = LevelNode.parse({ level: newLevelNumber, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) diff --git a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx new file mode 100644 index 0000000000..09cac7ff4f --- /dev/null +++ b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx @@ -0,0 +1,57 @@ +'use client' + +import { type AnyNodeId, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Copy, Trash2 } from 'lucide-react' +import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions' +import { ActionButton, ActionGroup } from '../controls/action-button' +import { PanelWrapper } from './panel-wrapper' +import { formatSelectionBreakdown } from './selection-breakdown' + +/** + * Docked right-side panel for a MULTI-selection — the compact sibling of the + * single-node inspector, rendered by `PanelManager` when more than one node + * is selected. Same collapsed-by-default `PanelWrapper` shell (header always + * visible; the shared desktop collapse state carries across single ↔ multi + * swaps). Actions mirror the floating group pill: Duplicate clones the + * selection and picks the clones up, Delete removes the whole selection + * (including its bulk-delete confirm). Unlike the pill, the docked panel + * stays visible during interactions. `footer` is the host-injected slot + * (e.g. community's "Save to my catalog"). + */ +export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const setSelection = useViewer((s) => s.setSelection) + // String selector — recomputed on scene ticks, but the === compare keeps + // unrelated mutations from re-rendering the panel. + const breakdown = useScene((s) => + formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type)), + ) + + return ( + setSelection({ selectedIds: [] })} + title={`${selectedIds.length} selected`} + width={320} + > + {breakdown &&
{breakdown}
} +
+ + } + label="Duplicate" + onClick={() => duplicateSelectionAndPickUp()} + /> + } + label="Delete" + onClick={() => deleteSelection()} + /> + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 9752022a3c..34d3671871 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -28,6 +28,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { MobilePanelSheet } from './mobile-panel-sheet' import { MobileSelectionBar } from './mobile-selection-bar' +import { MultiSelectionPanel } from './multi-selection-panel' import { getNodeDisplay } from './node-display' import { resetDesktopInspectorCollapsed } from './panel-wrapper' import { ParametricInspector } from './parametric-inspector' @@ -168,7 +169,13 @@ function MobilePanelLayer({ ) } -export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) { +export function PanelManager({ + inspectorFooter, + multiSelectionFooter, +}: { + inspectorFooter?: React.ReactNode + multiSelectionFooter?: React.ReactNode +}) { const isMobile = useIsMobile() const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedZoneId = useViewer((s) => s.selection.zoneId) @@ -237,5 +244,11 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac ) } + // Multi-selection: compact docked panel (desktop only — the mobile branch + // above keeps today's behavior and renders nothing for multi-selections). + if (selectedIds.length > 1) { + return + } + return panelForType(selectedNodeType, inspectorFooter) } diff --git a/packages/editor/src/components/ui/panels/selection-breakdown.test.ts b/packages/editor/src/components/ui/panels/selection-breakdown.test.ts new file mode 100644 index 0000000000..5a3df38f0a --- /dev/null +++ b/packages/editor/src/components/ui/panels/selection-breakdown.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { formatSelectionBreakdown } from './selection-breakdown' + +describe('formatSelectionBreakdown', () => { + test('counts per type in first-appearance order, pluralizing with +s', () => { + expect(formatSelectionBreakdown(['slab', 'stair', 'fence', 'fence'])).toBe( + '1 slab · 1 stair · 2 fences', + ) + }) + + test('humanizes hyphenated kinds', () => { + expect(formatSelectionBreakdown(['roof-segment', 'roof-segment', 'wall'])).toBe( + '2 roof segments · 1 wall', + ) + }) + + test('skips missing nodes', () => { + expect(formatSelectionBreakdown(['wall', undefined, null])).toBe('1 wall') + }) + + test('empty selection formats to an empty string', () => { + expect(formatSelectionBreakdown([])).toBe('') + }) +}) diff --git a/packages/editor/src/components/ui/panels/selection-breakdown.ts b/packages/editor/src/components/ui/panels/selection-breakdown.ts new file mode 100644 index 0000000000..b8c5c91367 --- /dev/null +++ b/packages/editor/src/components/ui/panels/selection-breakdown.ts @@ -0,0 +1,20 @@ +/** + * "1 slab · 1 stair · 2 fences" — one entry per node type in first-appearance + * order so the line stays stable while shift-clicking. Labels derive from the + * type id ('roof-segment' → 'roof segment'); pluralization is a simple +s + * (the codebase has no pluralize helper and no current kind needs one). + * Missing nodes (stale ids) are skipped. + */ +export function formatSelectionBreakdown(types: Array): string { + const counts = new Map() + for (const type of types) { + if (!type) continue + counts.set(type, (counts.get(type) ?? 0) + 1) + } + const parts: string[] = [] + for (const [type, count] of counts) { + const label = type.replace(/-/g, ' ') + parts.push(`${count} ${count === 1 ? label : `${label}s`}`) + } + return parts.join(' · ') +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index 5407b9299e..f0303eecf0 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -1,4 +1,4 @@ -import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core' +import { type BuildingNode, DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Building2, Plus } from 'lucide-react' import { memo, useState } from 'react' @@ -43,6 +43,7 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({ const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length const newLevel = LevelNode.parse({ level: levelCount, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: nodeId, }) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index af7fe82dec..87a6352b4e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -2,6 +2,7 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + DEFAULT_LEVEL_HEIGHT, emitter, type GuideNode, LevelNode, @@ -926,6 +927,7 @@ const LevelsSection = memo(function LevelsSection({ const handleAddLevel = () => { const newLevel = LevelNode.parse({ level: levels.length, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: building.id, }) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5db96e10c8..ba2dfedbb6 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -132,6 +132,13 @@ export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility' export { PlacementBox } from './components/tools/shared/placement-box' +// Pointer-decided support surface (deck top vs floor underneath) — the +// draw tools (wall / fence) ride their grid plane and commit cap on it. +export { + type PointerSupportSurface, + resolvePointerSupportElevation, + resolvePointerSupportSurface, +} from './components/tools/shared/pointer-support-cap' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, diff --git a/packages/editor/src/lib/level-duplication.test.ts b/packages/editor/src/lib/level-duplication.test.ts index 5f8500710d..b4f2434c17 100644 --- a/packages/editor/src/lib/level-duplication.test.ts +++ b/packages/editor/src/lib/level-duplication.test.ts @@ -11,7 +11,7 @@ import { buildLevelDuplicateCreateOps } from './level-duplication' describe('buildLevelDuplicateCreateOps', () => { test('parents a duplicated bootstrap level back to its building', () => { - const level = LevelNode.parse({ level: 0, children: [] }) + const level = LevelNode.parse({ level: 0, height: 3.25, children: [] }) const building = BuildingNode.parse({ children: [level.id] }) const wall = WallNode.parse({ parentId: level.id, @@ -36,6 +36,7 @@ describe('buildLevelDuplicateCreateOps', () => { expect(sourceLevel.parentId).toBeNull() expect(levelCreateOp?.parentId).toBe(building.id) + expect(levelCreateOp?.node.type === 'level' ? levelCreateOp.node.height : undefined).toBe(3.25) }) test('does not copy spawn points from the source level', () => { diff --git a/packages/editor/src/lib/stair-levels.ts b/packages/editor/src/lib/stair-levels.ts index d0f1841a83..e14101346a 100644 --- a/packages/editor/src/lib/stair-levels.ts +++ b/packages/editor/src/lib/stair-levels.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + DEFAULT_LEVEL_HEIGHT, LevelNode, type LevelNode as LevelNodeType, resolveBuildingForLevel, @@ -154,6 +155,7 @@ export function resolveStairDestinationLevel({ if (createMissing && buildingId) { const createdLevel = LevelNode.parse({ children: [], + height: DEFAULT_LEVEL_HEIGHT, level: fromLevel.level + 1, parentId: buildingId, }) diff --git a/packages/mcp/src/templates/empty-studio.ts b/packages/mcp/src/templates/empty-studio.ts index 10a4b7c0b6..75c9de4cdf 100644 --- a/packages/mcp/src/templates/empty-studio.ts +++ b/packages/mcp/src/templates/empty-studio.ts @@ -1,3 +1,4 @@ +import { DEFAULT_LEVEL_HEIGHT } from '@pascal-app/core' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' @@ -137,7 +138,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: [], thickness: 0.1, - height: 2.5, start: [-W, -D], end: [W, -D], frontSide: 'unknown', @@ -153,7 +153,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: [], thickness: 0.1, - height: 2.5, start: [W, -D], end: [W, D], frontSide: 'unknown', @@ -169,7 +168,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: ['door_front'], thickness: 0.1, - height: 2.5, start: [W, D], end: [-W, D], frontSide: 'unknown', @@ -185,7 +183,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: ['window_w'], thickness: 0.1, - height: 2.5, start: [-W, D], end: [-W, -D], frontSide: 'unknown', @@ -217,6 +214,7 @@ function buildNodes(): StudioNodes { visible: true, metadata: {}, level: 0, + height: DEFAULT_LEVEL_HEIGHT, children: [...wallIds, 'zone_living'] as AnyNodeId[], } as unknown as AnyNode diff --git a/packages/mcp/src/templates/garden-house.ts b/packages/mcp/src/templates/garden-house.ts index 5dd8dcd733..4e695ac385 100644 --- a/packages/mcp/src/templates/garden-house.ts +++ b/packages/mcp/src/templates/garden-house.ts @@ -41,7 +41,6 @@ function wall( metadata: {}, children, thickness: WALL_THICKNESS, - height: WALL_HEIGHT, start, end, frontSide: 'unknown', @@ -250,6 +249,7 @@ function buildTemplate(): SceneGraph { visible: true, metadata: {}, level: 0, + height: WALL_HEIGHT, children: [ 'wall_n', 'wall_e', diff --git a/packages/mcp/src/templates/two-bedroom.ts b/packages/mcp/src/templates/two-bedroom.ts index d41a14b1d1..dcd8dd1eb5 100644 --- a/packages/mcp/src/templates/two-bedroom.ts +++ b/packages/mcp/src/templates/two-bedroom.ts @@ -45,7 +45,6 @@ function wall( metadata: {}, children, thickness: WALL_THICKNESS, - height: WALL_HEIGHT, start, end, frontSide: 'unknown', @@ -274,6 +273,7 @@ function buildTemplate(): SceneGraph { visible: true, metadata: {}, level: 0, + height: WALL_HEIGHT, children: [ 'wall_n', 'wall_e', diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index a8e43e9297..307d6f881c 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -1,4 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { resolveStairTotalRise } from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { CeilingNode, @@ -23,9 +24,10 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const export const createStoryShellInput = { levelId: NodeIdSchema, footprint: z.array(Vec2Schema).min(3), - wallHeight: measurement('length', 'm', { positive: true, description: 'Wall height.' }).default( - 2.8, - ), + wallHeight: measurement('length', 'm', { + positive: true, + description: 'Explicit wall height override. Omit for level-plane-bound walls.', + }).optional(), wallThickness: measurement('length', 'm', { positive: true, description: 'Wall thickness.', @@ -100,7 +102,7 @@ export const createStairBetweenLevelsInput = { totalRise: measurement('length', 'm', { positive: true, description: 'Total vertical rise.', - }).default(2.8), + }).optional(), stepCount: z.number().int().positive().default(14), railingMode: z.enum(RAILING_MODES).default('both'), destinationSlabId: NodeIdSchema.optional(), @@ -276,7 +278,7 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat start: points[i], end: points[(i + 1) % points.length], thickness: wallThickness, - height: wallHeight, + ...(wallHeight !== undefined ? { height: wallHeight } : {}), frontSide: 'exterior', backSide: 'interior', ...(wallMaterialPreset ? { materialPreset: wallMaterialPreset } : {}), @@ -292,6 +294,11 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat name: namePrefix ? `${namePrefix} Slab` : undefined, polygon: points, elevation: slabElevation, + // Grounded solid: underside on the level plane, so the created + // story slab occupies [0, slabElevation] like the legacy + // extrude-from-zero model. + thickness: Math.max(slabElevation, 0), + recessed: slabElevation < 0, ...(slabMaterialPreset ? { materialPreset: slabMaterialPreset } : {}), metadata: { role: 'story-slab' }, }) @@ -301,10 +308,13 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat let ceilingId: string | null = null if (createCeiling) { + // Height-less unless the caller pinned one: a new story ceiling + // follows the level top automatically. + const explicitCeilingHeight = ceilingHeight ?? wallHeight const ceiling = CeilingNode.parse({ name: namePrefix ? `${namePrefix} Ceiling` : undefined, polygon: points, - height: ceilingHeight ?? wallHeight, + ...(explicitCeilingHeight !== undefined ? { height: explicitCeilingHeight } : {}), ...(ceilingMaterialPreset ? { materialPreset: ceilingMaterialPreset } : {}), metadata: { role: 'story-ceiling' }, }) @@ -372,12 +382,12 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat const roofLevel = LevelNode.parse({ name: roofLevelLabel, level: roofLevelElevation ?? nextLevelIndex(bridge, buildingId, referenceLevel), + height: roofLevelHeight ?? Math.max(wallHeight + peakHeight, 0.2), children: [], metadata: { role: 'roof', label: roofLevelLabel, referenceLevelId: levelId, - height: roofLevelHeight ?? Math.max(wallHeight + peakHeight, 0.2), }, }) targetRoofLevelId = roofLevel.id as AnyNodeId @@ -460,15 +470,7 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat ) } - const segment = StairSegmentNode.parse({ - segmentType: 'stair', - width, - length: runLength, - height: totalRise, - stepCount, - ...(materialPreset ? { materialPreset } : {}), - }) - const stair = StairNode.parse({ + const stairDraft = StairNode.parse({ name: name ?? 'Stair', position: position as [number, number, number], rotation, @@ -478,15 +480,33 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat slabOpeningMode: 'none', openingOffset, width, - totalRise, + ...(totalRise !== undefined ? { totalRise } : {}), stepCount, railingMode, - children: [segment.id], + children: [], ...(materialPreset ? { materialPreset } : {}), metadata: { openingManaged: 'manual-rectangular', }, }) + const riseNodes = { + ...bridge.getNodes(), + [fromLevel.id]: { + ...fromLevel, + children: [...(fromLevel as Extract).children, stairDraft.id], + }, + [stairDraft.id]: stairDraft, + } as Record + const resolvedTotalRise = resolveStairTotalRise(stairDraft, riseNodes) + const segment = StairSegmentNode.parse({ + segmentType: 'stair', + width, + length: runLength, + height: resolvedTotalRise, + stepCount, + ...(materialPreset ? { materialPreset } : {}), + }) + const stair = { ...stairDraft, children: [segment.id] } const openingPolygon = rectangularOpening({ position: position as [number, number, number], diff --git a/packages/mcp/src/tools/create-level.test.ts b/packages/mcp/src/tools/create-level.test.ts index cf548daa1c..268876b618 100644 --- a/packages/mcp/src/tools/create-level.test.ts +++ b/packages/mcp/src/tools/create-level.test.ts @@ -20,11 +20,11 @@ describe('create_level', () => { await Promise.all([server.connect(srvT), client.connect(cliT)]) }) - test('creates a level on a building', async () => { + test('appends a level on a building with stored height', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const result = await client.callTool({ name: 'create_level', - arguments: { buildingId: building.id, elevation: 3, label: 'Second' }, + arguments: { buildingId: building.id, elevation: 99, height: 3.1, label: 'Second' }, }) expect(result.isError).toBeFalsy() const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) @@ -32,7 +32,9 @@ describe('create_level', () => { const created = bridge.getNode(parsed.levelId) expect(created).not.toBeNull() expect(created!.type).toBe('level') - expect((created as { level: number }).level).toBe(3) + expect((created as { height: number; level: number }).level).toBe(1) + expect((created as { height: number; level: number }).height).toBe(3.1) + expect(created?.metadata.height).toBeUndefined() }) test('rejects unknown building id', async () => { diff --git a/packages/mcp/src/tools/create-level.ts b/packages/mcp/src/tools/create-level.ts index 784297c422..06ce61bd78 100644 --- a/packages/mcp/src/tools/create-level.ts +++ b/packages/mcp/src/tools/create-level.ts @@ -1,4 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { DEFAULT_LEVEL_HEIGHT } from '@pascal-app/core' import type { AnyNodeId } from '@pascal-app/core/schema' import { LevelNode } from '@pascal-app/core/schema' import { z } from 'zod' @@ -10,10 +11,13 @@ import { NodeIdSchema } from './schemas' export const createLevelInput = { buildingId: NodeIdSchema, - elevation: z.number().optional(), + elevation: z + .number() + .optional() + .describe("Legacy parameter; new levels are appended above the building's current top level."), height: measurement('length', 'm', { min: 0, - description: 'Level height (stored in metadata).', + description: 'Stored floor-to-floor storey height.', }).optional(), label: z.string().optional(), } @@ -28,11 +32,11 @@ export function registerCreateLevel(server: McpServer, bridge: SceneOperations): { title: 'Create level', description: - 'Create a new level node attached to the given building. height and label are stored in metadata.', + "Append a new level above the given building's current top level. height is stored as the level's floor-to-floor storey height.", inputSchema: createLevelInput, outputSchema: createLevelOutput, }, - async ({ buildingId, elevation, height, label }) => { + async ({ buildingId, height, label }) => { const parent = bridge.getNode(buildingId as AnyNodeId) if (!parent) { throwMcpError(ErrorCode.InvalidParams, `Building not found: ${buildingId}`) @@ -45,11 +49,16 @@ export function registerCreateLevel(server: McpServer, bridge: SceneOperations): } const metadata: Record = {} - if (height !== undefined) metadata.height = height if (label !== undefined) metadata.label = label + const existingOrdinals = bridge + .getChildren(buildingId as AnyNodeId) + .filter((node) => node.type === 'level') + .map((node) => node.level) + const nextOrdinal = Math.max(-1, ...existingOrdinals) + 1 const levelNode = LevelNode.parse({ - level: elevation ?? 0, + level: nextOrdinal, + height: height ?? DEFAULT_LEVEL_HEIGHT, children: [], ...(Object.keys(metadata).length > 0 ? { metadata } : {}), ...(label !== undefined ? { name: label } : {}), diff --git a/packages/mcp/src/tools/describe-node.ts b/packages/mcp/src/tools/describe-node.ts index 8be93177df..628323513f 100644 --- a/packages/mcp/src/tools/describe-node.ts +++ b/packages/mcp/src/tools/describe-node.ts @@ -1,8 +1,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { resolveCeilingHeight } from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' +import { resolveReportedWallHeight } from './scene-query' import { NodeIdSchema } from './schemas' export const describeNodeInput = { @@ -23,13 +25,13 @@ export const describeNodeOutput = { * Build a short, human-readable one-liner describing the node. * Covers the common shapes; falls back to a generic sentence otherwise. */ -function describe(node: AnyNode): string { +function describe(node: AnyNode, bridge: SceneOperations): string { switch (node.type) { case 'wall': { const [x1, z1] = node.start const [x2, z2] = node.end const t = node.thickness ?? 0.1 - const h = node.height ?? 2.5 + const h = resolveReportedWallHeight(bridge, node) return `Wall from (${x1},${z1}) to (${x2},${z2}), thickness ${t.toFixed(2)}m, height ${h.toFixed(2)}m` } case 'level': @@ -45,7 +47,7 @@ function describe(node: AnyNode): string { case 'slab': return `Slab with ${node.polygon.length} vertices` case 'ceiling': - return `Ceiling with ${node.polygon.length} vertices, height ${node.height.toFixed(2)}m` + return `Ceiling with ${node.polygon.length} vertices, height ${resolveCeilingHeight(node, bridge.getNodes()).toFixed(2)}m` case 'door': return `Door (${node.width.toFixed(2)}m x ${node.height.toFixed(2)}m)` case 'window': @@ -83,14 +85,22 @@ export function registerDescribeNode(server: McpServer, bridge: SceneOperations) const childrenIds = children.map((n) => n.id as string) const n = node as AnyNode + const properties = + n.type === 'wall' + ? { + ...n, + resolvedHeight: resolveReportedWallHeight(bridge, n), + heightIsExplicit: n.height !== undefined, + } + : n const payload = { id: n.id as string, type: n.type as string, parentId: (n.parentId ?? null) as string | null, ancestryIds, childrenIds, - properties: n as unknown as Record, - description: describe(n), + properties: properties as unknown as Record, + description: describe(n, bridge), } return { diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index 96d2d2b1f9..3679a38416 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -55,6 +55,7 @@ const VisionResponseSchema = z.object({ start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), thickness: z.number().optional(), + height: z.number().positive().optional(), }), ), rooms: z.array( @@ -82,13 +83,14 @@ const SYSTEM_PROMPT = `You are a vision assistant that extracts structured floor Your ONLY job: return a JSON object that exactly matches this schema — no prose, no markdown fences. { - "walls": [{ "start": [x, z], "end": [x, z], "thickness": number? }, ...], + "walls": [{ "start": [x, z], "end": [x, z], "thickness": number?, "height": number? }, ...], "rooms": [{ "name": string, "polygon": [[x,z], ...], "approximateAreaSqM": number? }, ...], "approximateDimensions": { "widthM": number, "depthM": number }, "confidence": number 0..1 } Coordinates are in metres. Origin can be the floor plan's centre or bottom-left — be consistent. +Only include a wall height when it is visibly measured or annotated in the image. If the image is unclear, lower the confidence score but still produce your best attempt. DO NOT wrap the JSON in markdown. DO NOT explain. Just output the raw JSON.` @@ -235,7 +237,7 @@ function buildSceneGraphFromVision( // Build the skeleton: site → building → level. const building = BuildingNode.parse({}) - const level = LevelNode.parse({ level: 0 }) + const level = LevelNode.parse({ level: 0, height: defaultWallHeight }) const site = SiteNode.parse({ children: [building.id] }) // Link parent ids so downstream traversal works. @@ -283,7 +285,7 @@ function buildSceneGraphFromVision( start: w.start, end: w.end, thickness: w.thickness ?? defaultWallThickness, - height: defaultWallHeight, + ...(w.height !== undefined ? { height: w.height } : {}), }) const linkedWall: AnyNodeT = { ...(wall as AnyNodeT), diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index 22f7cc2d0e..b3eac2f4a3 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -1,5 +1,13 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + DEFAULT_LEVEL_HEIGHT, + getStoredLevelHeight, + getWallPlaneTop, + resolveStairTotalRise, + resolveWallEffectiveHeight, +} from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' +import { computeWallSlabSupport } from '@pascal-app/core/spatial-grid' import { z } from 'zod' import type { SceneOperations } from '../operations' import { @@ -111,6 +119,27 @@ function nodesOnLevel(bridge: SceneOperations, levelId: AnyNodeId): AnyNode[] { ) } +export function resolveReportedWallHeight( + bridge: SceneOperations, + wall: Extract, +): number { + const levelId = bridge.resolveLevelId(wall.id as AnyNodeId) + // Covering-clamped plane for plane-bound walls; explicit heights pass + // through resolveWallEffectiveHeight untouched. + const planeTop = levelId + ? getWallPlaneTop(wall, levelId, bridge.getNodes()) + : DEFAULT_LEVEL_HEIGHT + const levelNodes = levelId ? nodesOnLevel(bridge, levelId) : [] + const slabs = levelNodes.filter( + (node): node is Extract => node.type === 'slab', + ) + const walls = levelNodes.filter( + (node): node is Extract => node.type === 'wall', + ) + const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) + return resolveWallEffectiveHeight(wall, planeTop, support.elevation) +} + function metadataRecord(node: AnyNode): Record | null { return typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) @@ -159,6 +188,7 @@ function openingSummaries(bridge: SceneOperations, wallId: AnyNodeId) { function wallSummary(bridge: SceneOperations, wall: AnyNode) { if (wall.type !== 'wall') return null const length = distance2D(wall.start, wall.end) + const resolvedHeight = resolveReportedWallHeight(bridge, wall) return { id: wall.id, name: wall.name, @@ -166,6 +196,8 @@ function wallSummary(bridge: SceneOperations, wall: AnyNode) { end: wall.end, length: Math.round(length * 100) / 100, height: wall.height, + resolvedHeight, + heightIsExplicit: wall.height !== undefined, thickness: wall.thickness, openings: openingSummaries(bridge, wall.id as AnyNodeId), } @@ -308,7 +340,7 @@ function stairFootprintPolygons( { width: stair.width ?? 1, length: 3, - height: stair.totalRise ?? 2.5, + height: resolveStairTotalRise(stair, nodes), stepCount: stair.stepCount ?? 10, attachmentSide: 'front' as const, }, @@ -657,17 +689,11 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations): if (level.type !== 'level') continue const summary = levels.find((entry) => entry.levelId === level.id) if (!summary?.isOccupiedStory) continue - const expectedHeight = - typeof level.metadata === 'object' && - level.metadata !== null && - 'height' in level.metadata && - typeof level.metadata.height === 'number' - ? level.metadata.height - : 3.2 + const expectedHeight = getStoredLevelHeight(level) for (const wall of nodesOnLevel(bridge, level.id as AnyNodeId).filter( (node): node is AnyNode & { type: 'wall' } => node.type === 'wall', )) { - const wallHeight = wall.height ?? 2.5 + const wallHeight = resolveReportedWallHeight(bridge, wall) if (wallHeight > expectedHeight + 0.25) { issues.push( `Wall ${wall.name ?? wall.id} on ${level.name ?? level.id} is ${wallHeight}m high; multi-story exterior walls should be split into level-owned story walls`, @@ -699,7 +725,7 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations): if (localX - width / 2 < -0.01 || localX + width / 2 > length + 0.01) { issues.push(`${node.type} ${node.id} extends outside wall ${parent.id}`) } - const wallHeight = parent.height ?? 2.5 + const wallHeight = resolveReportedWallHeight(bridge, parent) const bottom = node.position[1] - height / 2 const top = node.position[1] + height / 2 if (bottom < -0.01 || top > wallHeight + 0.01) { diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index a07a4b59bf..56672a1893 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -15,6 +15,7 @@ import { movingFootprintAnchors, nodeRegistry, resolveAlignment, + resolveSupportSlabPatch, spatialGridManager, useScene, type WallEvent, @@ -797,6 +798,7 @@ const CabinetTool = () => { name: island ? 'Kitchen Island' : 'Modular Cabinet', position, rotation: yaw, + parentId: activeLevelId, depth: patch.depth ?? cabinetDefinition.defaults().depth, carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight, ...(island && { @@ -841,11 +843,16 @@ const CabinetTool = () => { buildModule(m.x, m.width, index), ) for (const module of modules) sceneApi.upsert(module, cabinet.id as AnyNodeId) + const liveRun = sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet + sceneApi.update( + liveRun.id as AnyNodeId, + resolveSupportSlabPatch(liveRun, sceneApi.nodes()), + ) bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) sceneApi.resumeHistory() return { endModule: modules[modules.length - 1]!, - run: sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet, + run: sceneApi.get(cabinet.id as AnyNodeId) ?? liveRun, } } @@ -889,6 +896,19 @@ const CabinetTool = () => { } bumpCabinetRunsNearNewRun(nextRun.id as AnyNodeId) + const liveNextRun = sceneApi.get(nextRun.id as AnyNodeId) ?? nextRun + sceneApi.update( + liveNextRun.id as AnyNodeId, + resolveSupportSlabPatch(liveNextRun, sceneApi.nodes()), + ) + const rootRun = chainRootRunRef.current + if (rootRun) { + const liveRoot = sceneApi.get(rootRun.id as AnyNodeId) ?? rootRun + sceneApi.update( + liveRoot.id as AnyNodeId, + resolveSupportSlabPatch(liveRoot, sceneApi.nodes()), + ) + } sceneApi.resumeHistory() return { endModule: anchorModule, @@ -996,11 +1016,16 @@ const CabinetTool = () => { } const { cabinet, buildModule } = buildRunNodes(next.position, next.yaw) const module = buildModule(0, previewNode.width, 0) + const nodes = { ...useScene.getState().nodes, [cabinet.id]: cabinet, [module.id]: module } + const committedCabinet = CabinetNode.parse({ + ...cabinet, + ...resolveSupportSlabPatch(cabinet, nodes), + }) useScene.getState().createNodes([ - { node: cabinet, parentId: activeLevelId }, - { node: module, parentId: cabinet.id }, + { node: committedCabinet, parentId: activeLevelId }, + { node: module, parentId: committedCabinet.id }, ]) - bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) + bumpCabinetRunsNearNewRun(committedCabinet.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [module.id] }) useEditor.getState().setMode('select') triggerSFX('sfx:item-place') diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index f0e963177e..3ed66c0cd0 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -1,6 +1,12 @@ 'use client' -import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { + type CeilingNode, + resolveCeilingHeight, + resolveLevelId, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' import { boundaryReshapeScope, clearCeilingSnapFeedback, @@ -173,7 +179,7 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = onVertexHoverChange={handleHandleHoverChange} polygon={effectiveCeiling.polygon} resolvePlanPoint={resolvePolygonEditorPlanPoint} - surfaceHeight={effectiveCeiling.height ?? 2.5} + surfaceHeight={resolveCeilingHeight(effectiveCeiling, useScene.getState().nodes)} /> ) } diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index 6dbc39ebea..fc8b863595 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -1,7 +1,12 @@ -import type { - CeilingNode as CeilingNodeType, - HandleDescriptor, - NodeDefinition, +import { + type AnyNodeId, + type CeilingNode as CeilingNodeType, + getCeilingClampBound, + type HandleDescriptor, + type NodeDefinition, + resolveCeilingHeight, + type SceneApi, + useScene, } from '@pascal-app/core' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' import { buildCeilingFloorplan } from './floorplan' @@ -20,6 +25,18 @@ import { ceilingSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_CEILING_HEIGHT = 0.5 +// Ceilings no longer drive the storey height; the stored level height +// does. Height writes clamp under min(storey plane, lowest underside of +// any covering slab from the level above) − CEILING_CLAMP_MARGIN, so a +// ceiling can poke into neither the level above nor a deck hanging from +// it (clamp, never ask). +function ceilingHeightBound(n: CeilingNodeType, sceneApi: SceneApi): number { + const parent = n.parentId ? sceneApi.get(n.parentId as AnyNodeId) : undefined + return parent?.type === 'level' + ? getCeilingClampBound(parent.id, sceneApi.nodes(), n.polygon ?? []) + : Number.POSITIVE_INFINITY +} + function ceilingPolygonCenter(n: CeilingNodeType): [number, number] { const polygon = n.polygon ?? [] if (polygon.length === 0) return [0, 0] @@ -38,8 +55,12 @@ function ceilingPolygonCenter(n: CeilingNodeType): [number, number] { // the cursor upward grows the value directly. Live override + commit // flow comes from the shared registry arrow pipeline. // +// `currentValue` resolves through `resolveCeilingHeight`, and `apply` +// always writes `height` — so dragging a follows-mode ceiling converts +// it to an explicit custom height, mirroring the wall top-drag. +// // The placement Y is in *mesh-local* coords. CeilingSystem already -// parks `mesh.position.y = ceiling.height - 0.01`, so the local Y is +// parks `mesh.position.y = resolved height - 0.01`, so the local Y is // just the offset above that plane (NOT `height + offset` — that // would double-add the height and push the arrow off-screen). function ceilingHeightHandle(): HandleDescriptor { @@ -48,7 +69,8 @@ function ceilingHeightHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_CEILING_HEIGHT, - currentValue: (n) => n.height ?? 2.5, + max: ceilingHeightBound, + currentValue: (n) => resolveCeilingHeight(n, useScene.getState().nodes), apply: (_n, newValue) => ({ height: newValue }), placement: { position: (n) => { @@ -87,6 +109,8 @@ export const ceilingDefinition: NodeDefinition = { category: 'structure', surfaceRole: 'ceiling', + // Height-less on purpose: a new ceiling follows the level top until the + // user gives it an explicit custom height. defaults: () => ({ object: 'node', parentId: null, @@ -96,14 +120,15 @@ export const ceilingDefinition: NodeDefinition = { polygon: [], holes: [], holeMetadata: [], - height: 2.5, autoFromWalls: false, }), capabilities: { selectable: { hitVolume: 'bbox' }, surfaces: { - top: { height: (n) => (n as CeilingNode).height }, + top: { + height: (n) => resolveCeilingHeight(n as CeilingNodeType, useScene.getState().nodes), + }, }, duplicable: true, deletable: true, @@ -124,7 +149,7 @@ export const ceilingDefinition: NodeDefinition = { features: (node) => polygonMeasurementFeatures({ featurePrefix: 'ceiling', - height: node.height, + height: resolveCeilingHeight(node, useScene.getState().nodes), label: 'Ceiling', polygon: node.polygon, }), diff --git a/packages/nodes/src/ceiling/floorplan-move.ts b/packages/nodes/src/ceiling/floorplan-move.ts index 42c0e2efac..6eb715e389 100644 --- a/packages/nodes/src/ceiling/floorplan-move.ts +++ b/packages/nodes/src/ceiling/floorplan-move.ts @@ -1,4 +1,4 @@ -import type { CeilingNode, FloorplanMoveTarget } from '@pascal-app/core' +import { type CeilingNode, type FloorplanMoveTarget, resolveCeilingHeight } from '@pascal-app/core' import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move' /** @@ -6,14 +6,14 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move * centroid-pivot mover (same pivot semantics as slab / items). See * `shared/polygon-centroid-move.ts` for the rationale. * - * `meshY = height − 0.01`: `CeilingSystem` parks the ceiling group at that Y - * on rebuild, so mirroring it during the drag avoids a vertical teleport in - * split view. + * `meshY = resolved height − 0.01`: `CeilingSystem` parks the ceiling group + * at that Y on rebuild, so mirroring it during the drag avoids a vertical + * teleport in split view. */ export const ceilingFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => createPolygonCentroidMoveTarget({ node, nodes, - meshY: (node.height ?? 2.5) - 0.01, + meshY: resolveCeilingHeight(node, nodes) - 0.01, extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined, }) diff --git a/packages/nodes/src/ceiling/hole-editor.tsx b/packages/nodes/src/ceiling/hole-editor.tsx index 1b0ac28ca1..b418112d57 100644 --- a/packages/nodes/src/ceiling/hole-editor.tsx +++ b/packages/nodes/src/ceiling/hole-editor.tsx @@ -1,6 +1,12 @@ 'use client' -import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { + type CeilingNode, + resolveCeilingHeight, + resolveLevelId, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' import { PolygonEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect } from 'react' @@ -64,7 +70,7 @@ export const CeilingHoleEditor: React.FC<{ onPolygonChange={handlePolygonChange} onPolygonPreview={handlePolygonPreview} polygon={hole} - surfaceHeight={ceiling.height ?? 2.5} + surfaceHeight={resolveCeilingHeight(ceiling, useScene.getState().nodes)} /> ) } diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index ac52e65e64..43387a804c 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -8,6 +8,7 @@ import { type GridEvent, polygonAnchors, resolveAlignment, + resolveCeilingHeight, sceneRegistry, snapScalar, useLiveTransforms, @@ -99,7 +100,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), ) const originalCenterRef = useRef(getPolygonCenter(originalPolygonRef.current)) - const heightRef = useRef(node.height ?? 2.5) + // Resolved once at drag start — the ceiling plane can't change mid-move. + const heightRef = useRef(resolveCeilingHeight(node, useScene.getState().nodes)) const dragAnchorRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) const deltaRef = useRef<[number, number]>([0, 0]) @@ -254,7 +256,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { diff --git a/packages/nodes/src/ceiling/panel.tsx b/packages/nodes/src/ceiling/panel.tsx index 219b695f12..07754c031c 100644 --- a/packages/nodes/src/ceiling/panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -1,12 +1,20 @@ 'use client' -import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' +import { + type AnyNode, + type CeilingNode, + getCeilingClampBound, + resolveCeilingHeight, + useScene, +} from '@pascal-app/core' import { ActionButton, ActionGroup, + formatLinearMeasurement, holeEditScope, PanelSection, PanelWrapper, + SegmentedControl, SliderControl, triggerSFX, useEditingHole, @@ -27,6 +35,7 @@ import { useCallback, useEffect, useRef } from 'react' */ export function CeilingPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) const editingHole = useEditingHole() const setMovingNode = useEditor((s) => s.setMovingNode) @@ -35,11 +44,40 @@ export function CeilingPanel() { selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined, ) + // Ceilings no longer drive the storey height — the stored level height + // does — so height writes clamp under min(storey plane, lowest covering + // slab underside from the level above) − margin instead of poking into + // the level above or a deck hanging from it (clamp, never ask). + // Selector returns a primitive, so recomputing per store update is + // re-render-safe. + const maxHeight = useScene((s) => { + const parent = node?.parentId ? s.nodes[node.parentId as AnyNode['id']] : undefined + return parent?.type === 'level' + ? getCeilingClampBound(parent.id, s.nodes, node?.polygon ?? []) + : 6 + }) + + // Effective height: the stored custom height, or — for follows-mode + // ceilings (absent `height`) — the live level-top bound. Primitive + // selector so it tracks level-height / covering-slab edits. + const resolvedHeight = useScene((s) => { + const ceiling = selectedId + ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) + : undefined + return ceiling?.type === 'ceiling' ? resolveCeilingHeight(ceiling, s.nodes) : 2.5 + }) + // Panel slider-drag fix recipe (plans/editor-node-registry.md): stable // handler refs so slider drags don't trigger Maximum update depth. const nodeRef = useRef(node) nodeRef.current = node + const maxHeightRef = useRef(maxHeight) + maxHeightRef.current = maxHeight + + const resolvedHeightRef = useRef(resolvedHeight) + resolvedHeightRef.current = resolvedHeight + const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return @@ -48,6 +86,31 @@ export function CeilingPanel() { [selectedId], ) + const handleHeightChange = useCallback( + (proposed: number) => { + handleUpdate({ height: Math.min(proposed, maxHeightRef.current) }) + }, + [handleUpdate], + ) + + const handleTopModeChange = useCallback( + (mode: 'storey' | 'custom') => { + const n = nodeRef.current + if (!n) return + const isCustom = n.height != null + if (mode === 'custom' && !isCustom) { + // Seed from the current resolved height so the surface doesn't + // jump at the moment of detaching from the level top. + handleUpdate({ height: Math.min(resolvedHeightRef.current, maxHeightRef.current) }) + } else if (mode === 'storey' && isCustom) { + // Absent `height` = follows the level top; the store strips + // undefined keys. + handleUpdate({ height: undefined }) + } + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) useInteractionScope @@ -156,6 +219,22 @@ export function CeilingPanel() { } const area = calculateArea(node.polygon) + const isFollows = node.height == null + + // Clean preset values per display system; imperial stores exact meters + // for 8'0" / 8'6" / 9'0" ceilings. + const heightPresets = + unit === 'imperial' + ? [ + { label: 'Low (8\'0")', height: 2.4384 }, + { label: 'Standard (8\'6")', height: 2.5908 }, + { label: 'High (9\'0")', height: 2.7432 }, + ] + : [ + { label: 'Low (2.4m)', height: 2.4 }, + { label: 'Standard (2.5m)', height: 2.5 }, + { label: 'High (3.0m)', height: 3.0 }, + ] return ( - handleUpdate({ height: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.height * 1000) / 1000} + + {isFollows ? ( +
+ Currently {formatLinearMeasurement(resolvedHeight, unit)} +
+ ) : ( + + )} + {/* Presets write an explicit height (clamped to the bound), so + clicking one on a follows-mode ceiling switches it to custom. */}
- handleUpdate({ height: 2.4 })} /> - handleUpdate({ height: 2.5 })} /> - handleUpdate({ height: 3.0 })} /> + {heightPresets.map((preset) => ( + handleHeightChange(preset.height)} + /> + ))}
diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 9f49086c87..781c4c5a79 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -3,6 +3,7 @@ import { type CeilingNode, getMaterialPresetByRef, + resolveCeilingHeight, resolveMaterial, useLiveTransforms, useRegistry, @@ -46,7 +47,11 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { // ceiling slot references re-tints it live. const sceneMaterials = useScene((s) => s.materials) const liveTransform = useLiveTransforms((s) => s.get(node.id)) - const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) + // Resolved height: explicit when stored, else the live level-top bound + // (primitive selector, so follows-mode ceilings track level-height edits + // and covering-slab changes without a node write). + const resolvedHeight = useScene((s) => resolveCeilingHeight(node, s.nodes)) + const ceilingY = resolvedHeight - 0.01 + (liveTransform?.position[1] ?? 0) const position: [number, number, number] = [ liveTransform?.position[0] ?? 0, ceilingY, diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 31befe79e4..c8af70c59c 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -7,6 +7,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveSupportSlabPatch, useScene, } from '@pascal-app/core' import { @@ -142,9 +143,16 @@ const ColumnTool = () => { !isGridSnapActive(), ) - const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) - useScene.getState().createNode(column, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [column.id] }) + const column = ColumnNode.parse({ + ...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position), + parentId: activeLevelId, + }) + const committedColumn = ColumnNode.parse({ + ...column, + ...resolveSupportSlabPatch(column, useScene.getState().nodes), + }) + useScene.getState().createNode(committedColumn, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedColumn.id] }) triggerSFX('sfx:structure-build') useAlignmentGuides.getState().clear() usePlacementPreview.getState().clear() @@ -155,7 +163,10 @@ const ColumnTool = () => { cursorVisibleRef.current = false setCursorVisible(false) useFacingPose.getState().clear() + // Restore select mode with the tool — `mode: 'build'` with no tool is + // a dead state where the selection manager ignores every click. useEditor.getState().setTool(null) + useEditor.getState().setMode('select') } stopPlacementCommitPropagation(event) } diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 4a6f6cc6da..195c141b14 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -9,6 +9,7 @@ import type { import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' +import { readHostWallCeiling } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { scaleHandleHeight } from './door-math' import { buildDoorFloorplan } from './floorplan' @@ -34,12 +35,6 @@ function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unk return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } -function readWallHeight(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!door.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined - return wall?.height ?? Number.POSITIVE_INFINITY -} - // Width arrow on the door-local +X (right) or -X (left) side. Drag grows // the door from the anchored OPPOSITE edge; the door's wall-local center // re-centers so the anchored edge stays put. @@ -100,7 +95,7 @@ function doorHeightHandle(): HandleDescriptor { const roofMax = readRoofFaceHeightMax(n, scene, 1) if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax) const bottom = n.position[1] - n.height / 2 - return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom) + return Math.max(MIN_DOOR_HEIGHT, readHostWallCeiling(n.wallId, scene) - bottom) }, currentValue: (n) => n.height, onDrag: (node) => publishOpeningResizeGuides(node, false), diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index e176fa7e06..92d523d12b 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -9,6 +9,7 @@ import { type GridEvent, getCeilingAt, getCeilingHeightAt, + resolveCeilingHeight, useScene, } from '@pascal-app/core' import { @@ -1132,7 +1133,7 @@ function CeilingHighlight({ ceiling }: { ceiling: CeilingNode }) { return pts }, [ceiling.polygon]) if (!geometry) return null - const y = ceiling.height ?? 2.5 + const y = resolveCeilingHeight(ceiling, useScene.getState().nodes) return ( diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index b1b9cafcbc..c8ef306391 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -2,10 +2,13 @@ import { type AnyNodeId, + type CeilingNode, DuctTerminalNode, emitter, pointInPolygon, + resolveCeilingHeight, resolveLevelId, + resolveSupportSlabPatch, sceneRegistry, useScene, type WallEvent, @@ -33,8 +36,6 @@ import { COLLAR_LENGTH, mountQuaternion } from './ports' const PREVIEW_OPACITY = 0.55 /** R/T yaw step — 45°. */ const ROTATE_STEP_RAD = Math.PI / 4 -/** Fallback height (meters) for a ceiling node that carries no `height`. */ -const DEFAULT_CEILING_HEIGHT = 2.5 /** Snap radius (meters) for mating the collar onto a nearby duct port. */ const PORT_SNAP_RADIUS_M = 0.5 @@ -227,12 +228,8 @@ const DuctTerminalTool = () => { for (const node of Object.values(nodes)) { if (node?.type !== 'ceiling') continue if (resolveLevelId(node, nodes) !== activeLevelId) continue - const ceiling = node as { - height?: number - polygon: Array<[number, number]> - holes?: Array> - } - const height = ceiling.height ?? DEFAULT_CEILING_HEIGHT + const ceiling = node as CeilingNode + const height = resolveCeilingHeight(ceiling, nodes) const hit = hitLocalPlane(nativeEvent, height) if (!hit) continue if (!pointInPolygon(hit.x, hit.z, ceiling.polygon)) continue @@ -289,9 +286,14 @@ const DuctTerminalTool = () => { mount: p.mount, position: p.position, rotation: p.yaw, + parentId: activeLevelId, + }) + const committedTerminal = DuctTerminalNode.parse({ + ...terminal, + ...resolveSupportSlabPatch(terminal, useScene.getState().nodes), }) - useScene.getState().createNode(terminal, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [terminal.id] }) + useScene.getState().createNode(committedTerminal, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedTerminal.id] }) triggerSFX('sfx:item-place') } diff --git a/packages/nodes/src/fence/__tests__/lift.test.ts b/packages/nodes/src/fence/__tests__/lift.test.ts new file mode 100644 index 0000000000..53d6f935f0 --- /dev/null +++ b/packages/nodes/src/fence/__tests__/lift.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, FenceNode, SlabNode } from '@pascal-app/core' +import { resolveFenceLiftElevation } from '../lift' + +const LEVEL_ID = 'level-1' + +function makeDeck(elevation: number, parentId: string | null = LEVEL_ID): SlabNode { + return SlabNode.parse({ + parentId, + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + elevation, + thickness: 0.05, + }) +} + +function makeRailing(supportSlabId: string | undefined, parentId: string | null = LEVEL_ID) { + return FenceNode.parse({ + parentId, + start: [0, 0], + end: [4, 0], + supportSlabId, + }) +} + +function resolverFor(...nodes: AnyNode[]) { + const byId = new Map(nodes.map((node) => [node.id as string, node])) + return (id: string) => byId.get(id) +} + +describe('resolveFenceLiftElevation', () => { + test('lifts onto the host slab walking surface', () => { + const deck = makeDeck(1.25) + const railing = makeRailing(deck.id) + expect(resolveFenceLiftElevation(railing, resolverFor(deck))).toBe(1.25) + }) + + test('unhosted fence stays on the level floor', () => { + const railing = makeRailing(undefined) + expect(resolveFenceLiftElevation(railing, resolverFor())).toBe(0) + }) + + test('stale host (slab gone) falls back to the floor', () => { + const deck = makeDeck(1.25) + const railing = makeRailing(deck.id) + expect(resolveFenceLiftElevation(railing, resolverFor())).toBe(0) + }) + + test('host on another level does not lift the fence', () => { + const deck = makeDeck(1.25, 'level-2') + const railing = makeRailing(deck.id) + expect(resolveFenceLiftElevation(railing, resolverFor(deck))).toBe(0) + }) + + test('host id resolving to a non-slab node is ignored', () => { + const deck = makeDeck(1.25) + const impostor = makeRailing(undefined) + const railing = makeRailing(impostor.id) + expect(resolveFenceLiftElevation(railing, resolverFor(deck, impostor))).toBe(0) + }) +}) diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index d7d8a9f138..12a6bcdeab 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -6,6 +6,7 @@ import { type DragAction, type FenceNode, resolveAlignment, + resolveFenceSupportSlabPatch, useScene, type WallNode, } from '@pascal-app/core' @@ -111,6 +112,26 @@ function snapshotLinked( return out } +/** + * Re-elect the slab lift host for the given fences from their CURRENT + * store state (call after the endpoint writes). Only writes when the host + * actually changes, so unaffected drags stay patch-free. + */ +function applyFenceSupportPatches( + ids: readonly AnyNodeId[], + scene: { update(id: AnyNodeId, data: Partial): void }, +) { + const nodes = useScene.getState().nodes + for (const id of ids) { + const fence = nodes[id] + if (fence?.type !== 'fence') continue + const patch = resolveFenceSupportSlabPatch(fence as FenceNode, nodes) + if (patch.supportSlabId !== (fence as FenceNode).supportSlabId) { + scene.update(id, patch as Partial) + } + } +} + function linkedCascade( linked: LinkedFenceSnapshot[], origin: FencePlanPoint, @@ -230,6 +251,10 @@ export const moveFenceEndpointDragAction: DragAction) + const patched: AnyNodeId[] = [ctx.fenceId] if (!draft.detached) { for (const linked of draft.linkedUpdates) { scene.update( @@ -259,8 +285,14 @@ export const moveFenceEndpointDragAction: DragAction, ) + patched.push(linked.id as AnyNodeId) } } + // The restoreAll above reverted any live host patch — re-run the + // election against the final endpoints so the committed fence stands + // on (or leaves) its deck. Uncapped: an endpoint drag has no commit + // pointer ray worth trusting, matching the wall move commits. + applyFenceSupportPatches(patched, scene) return true }, diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index b283a2a0fa..f75baeabb0 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -261,8 +261,13 @@ export const fenceDefinition: NodeDefinition = { // Stage B: pure geometry function. Generic rebuilds // on dirtyNodes; mounts the empty group. - // `renderer` + `system` fields dropped along with their files. geometry: buildFenceGeometry, + // Dependency tracker only — a hosted railing (`supportSlabId`) renders at + // its slab's elevation, so host elevation edits must re-dirty the fence. + system: { + module: () => import('./system'), + priority: 4, + }, // Stage C: floor-plan rendering. FloorplanRegistryLayer iterates kinds // with `floorplan` set and renders via FloorplanGeometryRenderer. // Legacy `floorplanFenceEntries` short-circuits to [] when fence is diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index bc0b33dc60..4482dcc4a6 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -7,6 +7,7 @@ import { getMaxWallCurveOffset, getWallChordFrame, normalizeWallCurveOffset, + resolveFenceSupportSlabPatch, useLiveNodeOverrides, useScene, type WallNode, @@ -320,6 +321,21 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance = { data: { start: u.start, end: u.end }, })), ]) + // Re-elect the slab lift host as the endpoint drags (uncapped max + // election — 2D has no camera ray). This legacy write path commits + // via the dispatcher's snapshot diff, so patching per tick both + // previews the lift and lands it in the committed diff. Fences run + // no per-frame election: `supportSlabId` IS the lift. + const patchedNodes = useScene.getState().nodes + const supportPatches = [node.id, ...linkedUpdates.map((u) => u.id)].flatMap((id) => { + const fence = patchedNodes[id] + if (fence?.type !== 'fence') return [] + const patch = resolveFenceSupportSlabPatch(fence as FenceNode, patchedNodes) + return patch.supportSlabId === (fence as FenceNode).supportSlabId + ? [] + : [{ id, data: patch }] + }) + if (supportPatches.length > 0) useScene.getState().updateNodes(supportPatches) }, canCommit() { // Pointer-up always runs canCommit — drop the alignment guide here diff --git a/packages/nodes/src/fence/floorplan-move.ts b/packages/nodes/src/fence/floorplan-move.ts index 5a3db01529..9dabf89adb 100644 --- a/packages/nodes/src/fence/floorplan-move.ts +++ b/packages/nodes/src/fence/floorplan-move.ts @@ -3,6 +3,7 @@ import { type FenceNode, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + resolveFenceSupportSlabPatch, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -179,6 +180,35 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node // tracked change. Drop the override AFTER the scene write so // mid-commit reads still see the new position (override wins until // cleared; scene wins after). + // The re-elected slab lift host rides in the same write (uncapped max + // election — 2D has no camera ray): a fence moved onto / off an + // elevated deck must land on the right surface, since fences run no + // per-frame election (`supportSlabId` IS the lift). One updateNodes + // keeps the whole move a single tracked change. Election runs on the + // committed endpoints against the pre-write store (the patch only + // reads the parent level + the slab grid). + const baselineNodes = useScene.getState().nodes + const supportFor = ( + start: PlanPoint, + end: PlanPoint, + path: PlanPoint[] | undefined, + id: AnyNodeId, + ) => { + const fence = baselineNodes[id] + return fence?.type === 'fence' + ? resolveFenceSupportSlabPatch( + { + start, + end, + path, + curveOffset: (fence as FenceNode).curveOffset, + thickness: (fence as FenceNode).thickness, + parentId: fence.parentId, + }, + baselineNodes, + ) + : {} + } const fenceUpdate: { id: AnyNodeId; data: Partial } = isNew ? { id: fenceId, @@ -187,9 +217,18 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node end: lastNextEnd, path: lastNextPath, metadata: { ...originalMetadata, isNew: false }, + ...supportFor(lastNextStart, lastNextEnd, lastNextPath, fenceId), } as Partial, } - : { id: fenceId, data: { start: lastNextStart, end: lastNextEnd, path: lastNextPath } } + : { + id: fenceId, + data: { + start: lastNextStart, + end: lastNextEnd, + path: lastNextPath, + ...supportFor(lastNextStart, lastNextEnd, lastNextPath, fenceId), + }, + } const linkedUpdates = linkedOriginals.map((l) => ({ id: l.id, ...projectLinked(l, lastNextStart, lastNextEnd, lastDelta[0], lastDelta[1]), @@ -198,7 +237,12 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node fenceUpdate, ...linkedUpdates.map((u) => ({ id: u.id, - data: { start: u.start, end: u.end, path: u.path }, + data: { + start: u.start, + end: u.end, + path: u.path, + ...supportFor(u.start, u.end, u.path, u.id), + }, })), ]) const overrides = useLiveNodeOverrides.getState() diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts index e48f63550a..b8bebb8ba8 100644 --- a/packages/nodes/src/fence/geometry.ts +++ b/packages/nodes/src/fence/geometry.ts @@ -1,4 +1,4 @@ -import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' +import { type AnyNodeId, type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' import { applyMaterialPresetToMaterials, type ColorPreset, @@ -11,6 +11,7 @@ import { resolveSlotDefaultMaterial, } from '@pascal-app/viewer' import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' +import { resolveFenceLiftElevation } from './lift' import type { FenceNode } from './schema' import { FENCE_SLOT_DEFAULTS, type FenceSlotId } from './slots' @@ -110,6 +111,14 @@ export function buildFenceGeometry( const group = new Group() const geometries = generateFenceSlotGeometries(node) + // A hosted railing (`supportSlabId`) stands on its slab's walking surface. + // The builder emits local-space children, so the lift lives on an inner + // group rather than the registered (React-transformed) root. + const lift = ctx ? resolveFenceLiftElevation(node, (id) => ctx.resolve(id as AnyNodeId)) : 0 + const meshParent = new Group() + meshParent.position.y = lift + group.add(meshParent) + for (const slotId of FENCE_SLOT_ORDER) { const geometry = geometries[slotId] if (geometry.getAttribute('position') === undefined) continue @@ -126,7 +135,7 @@ export function buildFenceGeometry( mesh.castShadow = true mesh.receiveShadow = true mesh.userData.slotId = slotId - group.add(mesh) + meshParent.add(mesh) } return group diff --git a/packages/nodes/src/fence/lift.ts b/packages/nodes/src/fence/lift.ts new file mode 100644 index 0000000000..01397d324c --- /dev/null +++ b/packages/nodes/src/fence/lift.ts @@ -0,0 +1,25 @@ +import type { AnyNode, SlabNode } from '@pascal-app/core' +import type { FenceNode } from './schema' + +/** + * Elevation (meters above the level plane) a hosted fence stands at. + * + * A fence carrying `supportSlabId` is a railing on that slab's walking + * surface (drawn onto a deck, or placed by a deck preset). The + * host pins the lift only while it still exists as a slab on the fence's + * own level — a stale host (deleted slab, reparented fence) silently + * falls back to the level floor, mirroring the read-path rules of the + * other `supportSlabId` carriers. Pure so it is unit-testable and + * callable from the geometry builder with `ctx.resolve`. + */ +export function resolveFenceLiftElevation( + node: Pick, + resolve: (id: string) => AnyNode | undefined, +): number { + if (!node.supportSlabId) return 0 + const host = resolve(node.supportSlabId) + if (host?.type !== 'slab') return 0 + if ((host.parentId ?? null) !== (node.parentId ?? null)) return 0 + const elevation = (host as SlabNode).elevation + return Number.isFinite(elevation) ? elevation : 0 +} diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index fcc1f2790f..4be27eced0 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -10,6 +10,7 @@ import { isCurvedWall, isSplineFence, type LevelNode, + resolveFenceSupportSlabPatch, useLiveNodeOverrides, useScene, type WallMoveAxis, @@ -224,11 +225,35 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { path?: [number, number][] }>, ) => { + // Fold the re-elected slab lift host into the same write — a fence + // moved onto / off an elevated deck must land on the right surface + // (fences run no per-frame election; `supportSlabId` IS the lift), + // and one updateNodes keeps the whole move a single undo step. + // Election runs on the committed endpoints against the pre-write + // store (the patch only reads the parent level + the slab grid). + const baselineNodes = useScene.getState().nodes useScene.getState().updateNodes( - updates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end, path: entry.path }, - })), + updates.map((entry) => { + const fence = baselineNodes[entry.id as AnyNodeId] + const support = + fence?.type === 'fence' + ? resolveFenceSupportSlabPatch( + { + start: entry.start, + end: entry.end, + path: entry.path, + curveOffset: (fence as FenceNode).curveOffset, + thickness: (fence as FenceNode).thickness, + parentId: fence.parentId, + }, + baselineNodes, + ) + : {} + return { + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end, path: entry.path, ...support }, + } + }), ) for (const entry of updates) { useScene.getState().markDirty(entry.id as AnyNodeId) diff --git a/packages/nodes/src/fence/system.tsx b/packages/nodes/src/fence/system.tsx new file mode 100644 index 0000000000..dedfc96e2c --- /dev/null +++ b/packages/nodes/src/fence/system.tsx @@ -0,0 +1,50 @@ +'use client' + +import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' +import { useEffect } from 'react' +import { resolveFenceLiftElevation } from './lift' +import type { FenceNode } from './schema' + +/** + * Hosted-railing dependency tracker. A fence with `supportSlabId` renders at + * its host slab's elevation, but a store update only dirties the node that + * changed — editing the slab's elevation (or restoring a deleted host) would + * leave the railing floating at the stale height. Watch each hosted fence's + * resolved lift and dirty the fence when it moves; `GeometrySystem` rebuilds + * through `def.geometry` as usual. (Deleting the host needs no help here: + * `deleteNodesAction` strips `supportSlabId` and dirties the fence itself.) + */ + +function fenceLiftSignatures(nodes: Record): Map { + const signatures = new Map() + for (const node of Object.values(nodes)) { + if (node.type !== 'fence') continue + const fence = node as FenceNode + if (!fence.supportSlabId) continue + signatures.set( + fence.id, + resolveFenceLiftElevation(fence, (id) => nodes[id]), + ) + } + return signatures +} + +const FenceSystems = () => { + useEffect(() => { + let previous = fenceLiftSignatures(useScene.getState().nodes) + + return useScene.subscribe((state) => { + const current = fenceLiftSignatures(state.nodes) + for (const [fenceId, lift] of current.entries()) { + if (previous.get(fenceId) !== lift) { + state.markDirty(fenceId as AnyNodeId) + } + } + previous = current + }) + }, []) + + return null +} + +export default FenceSystems diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index c8f3d9e469..682b9cd30c 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -18,6 +18,7 @@ import { } from '@pascal-app/core' import { CursorSphere, + clearPlacementSurface, createFenceOnCurrentLevel, createSplineFenceOnCurrentLevel, EDITOR_LAYER, @@ -33,6 +34,8 @@ import { isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, + publishPlacementSurface, + resolvePointerSupportSurface, type SegmentAngleReference, snapFenceDraftPoint, snapScalarToGrid, @@ -46,11 +49,34 @@ import { import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' +import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' +import { + BoxGeometry, + BufferGeometry, + type Camera, + DoubleSide, + type Group, + type Mesh, + Vector3, +} from 'three' const FENCE_PREVIEW_HEIGHT = 1.8 const FENCE_PREVIEW_THICKNESS = 0.08 +// Grid-plane surface publish (pointer-decided): scratch + constant normal so +// per-move publishes don't allocate. +const SURFACE_UP = new Vector3(0, 1, 0) +const surfacePointScratch = new Vector3() + +// The walking surface the pointer actually aims at (deck top when over the +// deck, floor/ground underneath it) — only for genuine 3D pointer events. +// The 2D floor plan emits synthetic grid events with no camera ray behind +// them; those keep the uncapped max election and leave the grid plane alone. +function pointedSurfaceFor(camera: Camera, event: GridEvent) { + return event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(camera, event.position) + : null +} /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 // HUD label heights are measured from the top of the preview bar, so they @@ -460,6 +486,11 @@ const StraightFenceTool: React.FC = () => { previewHeightRef.current = previewHeight const previewThicknessRef = useRef(previewThickness) previewThicknessRef.current = previewThickness + // Camera for the pointer-support resolution (deck top vs floor) — read + // through a ref so the live event handlers see the current camera. + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera const cursorRef = useRef(null) const previewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) @@ -521,6 +552,18 @@ const StraightFenceTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && previewRef.current)) return + // Ride the grid event plane on the pointed surface: aiming at an + // elevated deck lifts the plane to the deck top, so the draft's XZ + // lands where the cursor points and the preview/cursor Y + // (`event.localPosition[1]`) sits at the lift the committed fence + // will get. Aiming past the deck edge drops it back to the floor. + const pointed = pointedSurfaceFor(cameraRef.current, event) + if (pointed) { + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + } const { walls, fences } = getCurrentLevelElements() const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] // While drafting, the segment locks to 15° rays from its start. @@ -630,9 +673,11 @@ const StraightFenceTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return + const pointed = pointedSurfaceFor(cameraRef.current, event) const createdFence = createFenceOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, + { supportCap: pointed ? pointed.elevation : null }, ) if (!createdFence) return @@ -681,6 +726,7 @@ const StraightFenceTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + clearPlacementSurface() useSegmentDraftChain.getState().clear('fence') useAlignmentGuides.getState().clear() const draftPreview = useFloorplanDraftPreview.getState() @@ -738,6 +784,16 @@ const SplineFenceDraft: React.FC = () => { : FENCE_PREVIEW_HEIGHT const [draftPoints, setDraftPoints] = useState([]) const [cursor, setCursor] = useState(null) + // Building-local Y of the grid plane (rides the pointed surface — see + // `pointedSurfaceFor`), so the spline preview draws on the deck top when + // the curve is being laid out on one. + const [liftY, setLiftY] = useState(0) + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + // Pointer cap for the commit (Enter / double-click carry no useful grid + // event of their own) — last resolved on move/click. + const supportCapRef = useRef(null) const draftRef = useRef(draftPoints) draftRef.current = draftPoints @@ -761,7 +817,9 @@ const SplineFenceDraft: React.FC = () => { const commit = () => { const points = draftRef.current if (points.length >= 2) { - const created = createSplineFenceOnCurrentLevel(points) + const created = createSplineFenceOnCurrentLevel(points, undefined, { + supportCap: supportCapRef.current, + }) if (created) { triggerSFX('sfx:item-place') // Once the new curve fence is selected for direct editing, leave @@ -775,11 +833,24 @@ const SplineFenceDraft: React.FC = () => { setCursor(null) } + const trackPointedSurface = (event: GridEvent) => { + const pointed = pointedSurfaceFor(cameraRef.current, event) + if (!pointed) return + supportCapRef.current = pointed.elevation + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + setLiftY(event.localPosition[1]) + } + const onMove = (event: GridEvent) => { + trackPointedSurface(event) setCursor(snapPoint([event.localPosition[0], event.localPosition[2]])) } const onClick = (event: GridEvent) => { + trackPointedSurface(event) if (event.nativeEvent.detail >= 2) { commit() return @@ -807,6 +878,7 @@ const SplineFenceDraft: React.FC = () => { emitter.off('grid:move', onMove) emitter.off('grid:click', onClick) emitter.off('tool:cancel', onCancel) + clearPlacementSurface() window.removeEventListener('keydown', onKeyDown) } }, []) @@ -820,18 +892,18 @@ const SplineFenceDraft: React.FC = () => { SPLINE_PREVIEW_SEGMENTS, ) return new BufferGeometry().setFromPoints( - sampled.map((point) => new Vector3(point.x, previewHeight, point.y)), + sampled.map((point) => new Vector3(point.x, liftY + previewHeight, point.y)), ) - }, [previewHeight, previewPoints]) + }, [liftY, previewHeight, previewPoints]) return ( - {cursor && } + {cursor && } {draftPoints.map((point, index) => ( diff --git a/packages/nodes/src/hvac-equipment/tool.tsx b/packages/nodes/src/hvac-equipment/tool.tsx index 4aa874dbaf..678c09a467 100644 --- a/packages/nodes/src/hvac-equipment/tool.tsx +++ b/packages/nodes/src/hvac-equipment/tool.tsx @@ -1,6 +1,12 @@ 'use client' -import { emitter, type GridEvent, HvacEquipmentNode, useScene } from '@pascal-app/core' +import { + emitter, + type GridEvent, + HvacEquipmentNode, + resolveSupportSlabPatch, + useScene, +} from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -76,9 +82,14 @@ const HvacEquipmentTool = () => { name: 'Furnace', position, rotation: yawRef.current, + parentId: activeLevelId, }) - useScene.getState().createNode(unit, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [unit.id] }) + const committedUnit = HvacEquipmentNode.parse({ + ...unit, + ...resolveSupportSlabPatch(unit, useScene.getState().nodes), + }) + useScene.getState().createNode(committedUnit, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedUnit.id] }) triggerSFX('sfx:item-place') } diff --git a/packages/nodes/src/level/definition.ts b/packages/nodes/src/level/definition.ts index 3dc516c3da..c3e6fcead2 100644 --- a/packages/nodes/src/level/definition.ts +++ b/packages/nodes/src/level/definition.ts @@ -1,4 +1,8 @@ -import { LevelNode as LevelNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { + DEFAULT_LEVEL_HEIGHT, + LevelNode as LevelNodeSchema, + type NodeDefinition, +} from '@pascal-app/core' import { levelParametrics } from './parametrics' import { LevelNode } from './schema' @@ -14,7 +18,11 @@ export const levelDefinition: NodeDefinition = { category: 'site', defaults: () => { - const stub = LevelNodeSchema.parse({ id: 'level_default' as never, type: 'level' }) + const stub = LevelNodeSchema.parse({ + id: 'level_default' as never, + type: 'level', + height: DEFAULT_LEVEL_HEIGHT, + }) const { id: _id, type: _type, ...rest } = stub return rest }, diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 388cc85daf..c03f17c2f1 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -10,6 +10,7 @@ import { type RoofNode, type RoofSegmentNode, resolveAlignment, + resolveSupportSlabPatch, type StairNode, type StairSegmentNode, sceneRegistry, @@ -404,23 +405,38 @@ export const MoveRoofTool: React.FC<{ useAlignmentGuides.getState().clear() wasCommitted = true + const position: [number, number, number] = [localX, movingNode.position[1], localZ] + const effectiveNode = { + ...movingNode, + position, + rotation: pendingRotation, + } as typeof movingNode + const supportPatch = isFloorPlaced + ? resolveSupportSlabPatch(effectiveNode, { + ...useScene.getState().nodes, + [movingNode.id]: effectiveNode, + }) + : {} + let committedId = movingNode.id as AnyNodeId if (isNew) { committedId = commitFreshPlacementSubtree(movingNode.id as AnyNodeId, { - position: [localX, movingNode.position[1], localZ], + position, rotation: pendingRotation, metadata: committedMeta, visible: true, + ...supportPatch, }) ?? committedId } else { // The store still holds the original values (we didn't update during drag). // Resume temporal and apply the final state as a single undoable step. useScene.temporal.getState().resume() useScene.getState().updateNode(movingNode.id, { - position: [localX, movingNode.position[1], localZ], + position, rotation: pendingRotation, metadata: committedMeta, + ...supportPatch, }) useScene.temporal.getState().pause() } diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index 1729e92352..8d73eca7ef 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -15,6 +15,7 @@ import { type WallNode, } from '@pascal-app/core' import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor' +import { resolveWallOpeningCeiling } from './wall-opening-ceiling' // Parity with `snapLocalXToNeighbors`' along-wall threshold. const SILL_SNAP_THRESHOLD_M = 0.08 @@ -94,7 +95,7 @@ export function publishOpeningGuides3D(args: { }): void { const { wall, centerS, centerY, width, toWorld } = args const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) - const wallHeight = wall.height ?? 2.5 + const wallHeight = resolveWallOpeningCeiling(wall, args.nodes) const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes) const guides = computeOpeningGuides({ moving: { id: args.movingId, centerS, width, centerY, height: args.height }, diff --git a/packages/nodes/src/shared/opening-placement-dimensions.ts b/packages/nodes/src/shared/opening-placement-dimensions.ts index 08fedd6202..032ca2d07a 100644 --- a/packages/nodes/src/shared/opening-placement-dimensions.ts +++ b/packages/nodes/src/shared/opening-placement-dimensions.ts @@ -8,9 +8,11 @@ import { type GeometryContext, isCurvedWall, type OpeningSpan, + useScene, type WallNode, type WindowNode, } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from './wall-opening-ceiling' /** * Build placement-measurement dimension lines for a door / window @@ -94,7 +96,10 @@ export function buildOpeningPlacementDimensions( height: opening.height, }, siblings, - wall: { length: wallLength, height: wall.height ?? 2.5 }, + wall: { + length: wallLength, + height: resolveWallOpeningCeiling(wall, useScene.getState().nodes), + }, // The 2D plan is top-down: sill/head height and vertical alignment aren't // representable here — those belong to the 3D viewport. includeVertical: false, diff --git a/packages/nodes/src/shared/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts new file mode 100644 index 0000000000..e6465b9655 --- /dev/null +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -0,0 +1,64 @@ +import { + type AnyNode, + type AnyNodeId, + getWallPlaneTop, + resolveWallEffectiveHeight, + spatialGridManager, + type WallNode, +} from '@pascal-app/core' + +/** + * Structural subset of `SceneApi` the opening-cap readers need — matches + * both handle-descriptor callbacks (which receive the full SceneApi) and + * tools holding a nodes snapshot. + */ +export type WallCeilingSceneReader = { + get: (id: AnyNodeId) => unknown + nodes: () => Readonly> +} + +/** + * Available wall-local Y span for an opening hosted on `wall`: the wall's + * resolved top (storey plane for plane-bound walls, stored height for + * explicit ones) minus the wall's elected slab base. Wall-local Y = 0 sits + * at the elected base (where the viewer positions the wall mesh), so this + * is the ceiling an opening's top edge must stay under. + * + * Uses the same slab election as the viewer's WallSystem + * (`spatialGridManager.getSlabSupportForWall`) so the cap agrees with the + * rendered wall; headless callers with an empty spatial grid elect base 0 + * and fall back to the full storey height. + */ +export function resolveWallOpeningCeiling( + wall: WallNode, + nodes: Readonly>, +): number { + const levelId = wall.parentId ?? 'default' + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, + ) + // Covering-clamped plane: openings cap under a flush/thick slab from the + // level above, matching the shortened wall body. + const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) + return resolveWallEffectiveHeight(wall, planeTop, support.elevation) +} + +/** + * Height cap for a wall-hosted opening's resize handles. Infinity only when + * the opening is unhosted (no wallId, or the wall is gone) — roof-hosted + * openings clamp elsewhere. + */ +export function readHostWallCeiling( + wallId: string | null | undefined, + scene: WallCeilingSceneReader, +): number { + if (!wallId) return Number.POSITIVE_INFINITY + const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined + if (!wall) return Number.POSITIVE_INFINITY + return resolveWallOpeningCeiling(wall, scene.nodes()) +} diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index de4dc94dcd..1130072991 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -4,6 +4,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveSupportSlabPatch, ShelfNode, useScene, } from '@pascal-app/core' @@ -132,9 +133,14 @@ const ShelfTool = () => { name: 'Shelf', position, rotation: [0, 0, 0], + parentId: activeLevelId, }) - useScene.getState().createNode(shelf, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [shelf.id] }) + const committedShelf = ShelfNode.parse({ + ...shelf, + ...resolveSupportSlabPatch(shelf, useScene.getState().nodes), + }) + useScene.getState().createNode(committedShelf, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedShelf.id] }) triggerSFX('sfx:item-place') useAlignmentGuides.getState().clear() if (useEditor.getState().getContinuation('point') === 'repeat') { diff --git a/packages/nodes/src/site/recessed-slab-ground-holes.test.ts b/packages/nodes/src/site/recessed-slab-ground-holes.test.ts index 92f28a7d4e..92bff7975b 100644 --- a/packages/nodes/src/site/recessed-slab-ground-holes.test.ts +++ b/packages/nodes/src/site/recessed-slab-ground-holes.test.ts @@ -9,6 +9,7 @@ describe('getRecessedSlabGroundHoles', () => { id: 'slab_ground-holes', parentId, elevation: -0.15, + recessed: true, polygon: [ [0, 0], [2, 0], @@ -51,4 +52,21 @@ describe('getRecessedSlabGroundHoles', () => { expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([]) }) + + test('keys on the recessed flag, not the elevation sign', () => { + // A below-plane SOLID (deck underside) must not punch a ground hole. + const slab = SlabNode.parse({ + id: 'slab_ground-holes-below-plane', + elevation: -0.15, + thickness: 0.3, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + + expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([]) + }) }) diff --git a/packages/nodes/src/site/recessed-slab-ground-holes.ts b/packages/nodes/src/site/recessed-slab-ground-holes.ts index e14a38f5de..77f5e6c3a0 100644 --- a/packages/nodes/src/site/recessed-slab-ground-holes.ts +++ b/packages/nodes/src/site/recessed-slab-ground-holes.ts @@ -36,10 +36,7 @@ export function getRecessedSlabGroundHoles( return nodeList .filter( (node): node is SlabNode => - node.type === 'slab' && - node.visible && - node.polygon.length >= 3 && - (node.elevation ?? 0.05) < 0, + node.type === 'slab' && node.visible && node.polygon.length >= 3 && node.recessed === true, ) .filter((slab) => { if (!Number.isFinite(lowestLevelIndex)) return true diff --git a/packages/nodes/src/slab/__tests__/definition.test.ts b/packages/nodes/src/slab/__tests__/definition.test.ts index e66434f04b..b625c187ef 100644 --- a/packages/nodes/src/slab/__tests__/definition.test.ts +++ b/packages/nodes/src/slab/__tests__/definition.test.ts @@ -45,7 +45,7 @@ describe('slabDefinition handles', () => { expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false) }) - test('allows the elevation arrow to cross zero into a recessed slab', () => { + test('routes the elevation arrow through adaptive slab top changes', () => { const slab = SlabNode.parse({ elevation: 0.05, polygon: [ @@ -58,6 +58,24 @@ describe('slabDefinition handles', () => { const heightHandle = getHeightHandle(slab) expect(heightHandle.min).toBe(-1) - expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ elevation: -0.15 }) + // Crossing zero flips the recessed intent in the same patch; coming back + // above the plane clears it. + expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ + elevation: -0.15, + recessed: true, + }) + expect(heightHandle.apply(slab, 0.1, {} as never)).toEqual({ + elevation: 0.1, + thickness: 0.1, + recessed: false, + }) + // The arrow is the drag surface: past SLAB_UNSTICK_THRESHOLD a + // grounded slab pops to the default deck thickness instead of + // stretching further. + expect(heightHandle.apply(slab, 0.6, {} as never)).toEqual({ + elevation: 0.6, + thickness: 0.05, + recessed: false, + }) }) }) diff --git a/packages/nodes/src/slab/__tests__/elevation-limit.test.ts b/packages/nodes/src/slab/__tests__/elevation-limit.test.ts new file mode 100644 index 0000000000..7a829488d0 --- /dev/null +++ b/packages/nodes/src/slab/__tests__/elevation-limit.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'bun:test' +import { SlabNode } from '@pascal-app/core' +import { + applySlabElevationPreset, + applySlabTopChange, + SLAB_UNSTICK_THRESHOLD, +} from '../elevation-limit' + +function slab(overrides: Partial = {}): SlabNode { + return SlabNode.parse({ polygon: [], ...overrides }) +} + +const drag = (node: SlabNode, newTop: number) => applySlabTopChange(node, newTop, { mode: 'drag' }) +const panel = (node: SlabNode, newTop: number) => + applySlabTopChange(node, newTop, { mode: 'panel' }) + +describe('applySlabTopChange — drag (viewport arrow)', () => { + test('stretches a grounded slab up to the unstick threshold', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + + expect(drag(grounded, 0.25)).toEqual({ + elevation: 0.25, + thickness: 0.25, + recessed: false, + }) + expect(drag(grounded, 0.04)).toEqual({ + elevation: 0.04, + thickness: 0.04, + recessed: false, + }) + // The threshold itself still stretches — unstick starts strictly past it. + expect(drag(grounded, SLAB_UNSTICK_THRESHOLD)).toEqual({ + elevation: SLAB_UNSTICK_THRESHOLD, + thickness: SLAB_UNSTICK_THRESHOLD, + recessed: false, + }) + }) + + test('unsticks past the threshold: pops to the default deck thickness', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + + expect(drag(grounded, 0.55)).toEqual({ + elevation: 0.55, + thickness: 0.05, + recessed: false, + }) + }) + + test('crosses a grounded slab into a pool and back out', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + const intoPool = drag(grounded, -0.15) + + expect(intoPool).toEqual({ elevation: -0.15, recessed: true }) + + const pool = { ...grounded, ...intoPool } + expect(drag(pool, 0.08)).toEqual({ + elevation: 0.08, + recessed: false, + }) + }) + + test('moves a floating deck and clamps its underside to ground', () => { + const floating = slab({ elevation: 0.5, thickness: 0.2 }) + + expect(drag(floating, 0.4)).toEqual({ + elevation: 0.4, + recessed: false, + }) + + const landedChange = drag(floating, 0.1) + expect(landedChange).toEqual({ elevation: 0.2, recessed: false }) + + // Landed (underside 0) → grounded again: below the threshold the way + // back up stretches, past it the slab unsticks to the default deck. + const landed = { ...floating, ...landedChange } + expect(drag(landed, 0.3)).toEqual({ + elevation: 0.3, + thickness: 0.3, + recessed: false, + }) + expect(drag(landed, 0.5)).toEqual({ + elevation: 0.5, + thickness: 0.05, + recessed: false, + }) + }) + + test('keeps a recessed pool thickness unchanged', () => { + const pool = slab({ elevation: -0.15, thickness: 0.08, recessed: true }) + + expect(drag(pool, -0.3)).toEqual({ + elevation: -0.3, + recessed: true, + }) + }) + + test('allows a grounded stretch below the edit-time minimum thickness', () => { + const grounded = slab({ elevation: 0.01, thickness: 0.01 }) + + expect(drag(grounded, 0.015)).toEqual({ + elevation: 0.015, + thickness: 0.015, + recessed: false, + }) + }) +}) + +describe('applySlabTopChange — panel (pure placement)', () => { + test('moves a grounded slab without coupling thickness', () => { + // The panel never stretches: raising a grounded slab lifts the body + // (thickness preserved by omission) instead of thickening it. + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + + expect(panel(grounded, 0.3)).toEqual({ elevation: 0.3, recessed: false }) + expect(panel(grounded, 0.55)).toEqual({ elevation: 0.55, recessed: false }) + }) + + test('clamps a grounded slab at underside 0 instead of shrinking it', () => { + const grounded = slab({ elevation: 0.2, thickness: 0.2 }) + + expect(panel(grounded, 0.1)).toEqual({ elevation: 0.2, recessed: false }) + }) + + test('moves a floating deck preserving thickness and clamps its underside', () => { + const floating = slab({ elevation: 0.5, thickness: 0.2 }) + + expect(panel(floating, 0.4)).toEqual({ elevation: 0.4, recessed: false }) + expect(panel(floating, 0.1)).toEqual({ elevation: 0.2, recessed: false }) + }) + + test('keeps the pool cross-zero gesture', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + const intoPool = panel(grounded, -0.15) + + expect(intoPool).toEqual({ elevation: -0.15, recessed: true }) + + const pool = { ...grounded, ...intoPool } + expect(panel(pool, -0.3)).toEqual({ elevation: -0.3, recessed: true }) + expect(panel(pool, 0.08)).toEqual({ elevation: 0.08, recessed: false }) + }) +}) + +test('slab elevation presets keep their explicit writes', () => { + expect(applySlabElevationPreset(-0.15)).toEqual({ elevation: -0.15, recessed: true }) + expect(applySlabElevationPreset(0)).toEqual({ + elevation: 0, + thickness: 0, + recessed: false, + }) + expect(applySlabElevationPreset(0.05)).toEqual({ + elevation: 0.05, + thickness: 0.05, + recessed: false, + }) + expect(applySlabElevationPreset(0.15)).toEqual({ + elevation: 0.15, + thickness: 0.15, + recessed: false, + }) +}) diff --git a/packages/nodes/src/slab/__tests__/geometry.test.ts b/packages/nodes/src/slab/__tests__/geometry.test.ts index 411d051fc8..82ca0814cd 100644 --- a/packages/nodes/src/slab/__tests__/geometry.test.ts +++ b/packages/nodes/src/slab/__tests__/geometry.test.ts @@ -28,4 +28,31 @@ describe('buildSlabGeometry', () => { expect(Array.from(uv2.array)).toEqual(Array.from(uv.array)) } }) + + test('solid slab meshes stay at the level plane; recessed meshes sink to the elevation', () => { + const polygon: Array<[number, number]> = [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ] + + const solid = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon }) + const solidGroup = buildSlabGeometry(solid, undefined, 'solid', false) + for (const mesh of solidGroup.children.filter( + (child): child is Mesh => child instanceof Mesh, + )) { + expect(mesh.position.y).toBe(0) + } + + const recessed = SlabNode.parse({ elevation: -0.2, recessed: true, polygon }) + const recessedGroup = buildSlabGeometry(recessed, undefined, 'solid', false) + const recessedMeshes = recessedGroup.children.filter( + (child): child is Mesh => child instanceof Mesh, + ) + expect(recessedMeshes.length).toBeGreaterThan(0) + for (const mesh of recessedMeshes) { + expect(mesh.position.y).toBeCloseTo(-0.2) + } + }) }) diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 7368466437..d44d2b07bd 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -5,6 +5,7 @@ import { type SlabNode as SlabNodeType, } from '@pascal-app/core' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' +import { applySlabTopChange, slabElevationUpperBound } from './elevation-limit' import { buildSlabFloorplan } from './floorplan' import { slabAddVertexAffordance, @@ -91,19 +92,23 @@ function slabHandleAnchor(slab: SlabNodeType): [number, number] { return best ?? fallback } -// Slab height arrow — vertical chevron on solid slab surface near the -// polygon center. Drags elevation through zero: positive values extrude -// upward from ground while negative values create a recessed floor whose -// depth follows the pointer. Same registry-handle pipeline as the column -// height arrow, so live override + commit-on-release come for free. +// Slab elevation arrow — vertical chevron on solid slab surface near the +// polygon center. The shared top-change policy stretches grounded slabs up +// to SLAB_UNSTICK_THRESHOLD (past it the slab pops to a thin floating +// deck), moves floating slabs, and preserves the drag-through-zero pool +// gesture. Same registry-handle pipeline as the column height arrow, so +// live override + commit-on-release come for free. `max` clamps the drag +// under the storey plane while plane-bound walls elect this slab as their +// base. function slabHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', axis: 'y', anchor: 'min', min: MIN_SLAB_ELEVATION, + max: (n, sceneApi) => slabElevationUpperBound(sceneApi.nodes(), n), currentValue: (n) => n.elevation ?? 0.05, - apply: (_n, newValue) => ({ elevation: newValue }), + apply: (n, newValue) => applySlabTopChange(n, newValue, { mode: 'drag' }), placement: { position: (n) => { const [cx, cz] = slabHandleAnchor(n) @@ -151,6 +156,8 @@ export const slabDefinition: NodeDefinition = { holes: [], holeMetadata: [], elevation: 0.05, + thickness: 0.05, + recessed: false, autoFromWalls: false, }), diff --git a/packages/nodes/src/slab/elevation-limit.ts b/packages/nodes/src/slab/elevation-limit.ts new file mode 100644 index 0000000000..bc335b68ae --- /dev/null +++ b/packages/nodes/src/slab/elevation-limit.ts @@ -0,0 +1,119 @@ +import { + type AnyNode, + type AnyNodeId, + clampSlabElevationForWalls, + getSlabElevationUpperBound, + getStoredLevelHeight, + type LevelNode, + type SlabElevationClamp, + type SlabNode, + type WallNode, +} from '@pascal-app/core' + +type SlabLevelContext = { + storeyHeight: number + walls: WallNode[] + slabs: SlabNode[] +} + +const GROUNDED_SLAB_EPSILON = 1e-3 +/** Deck thickness an unsticking slab pops to — the schema default. */ +const UNSTUCK_DECK_THICKNESS = 0.05 +/** + * Grounded-stretch ceiling for the 3D elevation arrow (m) — above any + * plausible step/platform height. While grounded, dragging the top up to + * here stretches the body; dragging past it unsticks the slab into a + * thin floating deck and the drag continues as pure placement. + */ +export const SLAB_UNSTICK_THRESHOLD = 0.4 + +export type SlabTopChangeMode = 'drag' | 'panel' + +/** + * The one owner of the slab vertical-editing rules. Both edit surfaces + * route through it: the viewport arrow as `mode: 'drag'`, the panel + * elevation input as `mode: 'panel'`. + * + * Hysteresis-free state machine (pure in current state + newTop): + * - recessed → move the pool floor; rising to ≥ 0 un-recesses. + * - grounded, newTop ≤ 0 → pool gesture (both modes). + * - grounded drag, newTop ≤ {@link SLAB_UNSTICK_THRESHOLD} → stretch + * (elevation and thickness move together, underside stays at 0). + * - grounded drag past the threshold → unstick: pop to the default deck + * thickness and continue as placement. + * - otherwise (floating, or any panel edit) → placement: move the body + * preserving thickness, clamping the underside to the level plane — + * landing re-grounds the slab, so the way back up stretches again + * below the threshold. + */ +export function applySlabTopChange( + slab: SlabNode, + newTop: number, + options: { mode: SlabTopChangeMode }, +): Partial { + if (slab.recessed) return { elevation: newTop, recessed: newTop < 0 } + + const grounded = Math.abs(slab.elevation - slab.thickness) < GROUNDED_SLAB_EPSILON + if (grounded && newTop <= 0) return { elevation: newTop, recessed: true } + + if (grounded && options.mode === 'drag') { + return newTop <= SLAB_UNSTICK_THRESHOLD + ? { elevation: newTop, thickness: newTop, recessed: false } + : { elevation: newTop, thickness: UNSTUCK_DECK_THICKNESS, recessed: false } + } + + return { elevation: Math.max(newTop, slab.thickness), recessed: false } +} + +export function applySlabElevationPreset(newTop: number): Partial { + return newTop < 0 + ? { elevation: newTop, recessed: true } + : { elevation: newTop, thickness: Math.max(newTop, 0), recessed: false } +} + +function resolveSlabLevelContext( + nodes: Readonly>, + slab: SlabNode, +): SlabLevelContext | null { + const parent = slab.parentId ? nodes[slab.parentId as AnyNodeId] : undefined + if (parent?.type !== 'level') return null + const level = parent as LevelNode + const children = level.children.map((childId) => nodes[childId as AnyNodeId]) + return { + storeyHeight: getStoredLevelHeight(level), + walls: children.filter((child): child is WallNode => child?.type === 'wall'), + slabs: children.filter((child): child is SlabNode => child?.type === 'slab'), + } +} + +/** + * Level-context wrapper over the pure core clamp: a slab under + * plane-bound walls may not rise past the storey plane minus the + * minimum wall height. Slabs outside a level (no parent) are + * unconstrained. + */ +export function clampSlabElevation( + nodes: Readonly>, + slab: SlabNode, + proposedElevation: number, +): SlabElevationClamp { + const context = resolveSlabLevelContext(nodes, slab) + if (!context) return { elevation: proposedElevation, clamped: false } + return clampSlabElevationForWalls( + proposedElevation, + slab, + context.walls, + context.slabs, + context.storeyHeight, + ) +} + +/** Drag-time upper bound for the slab height arrow; +Infinity when unconstrained. */ +export function slabElevationUpperBound( + nodes: Readonly>, + slab: SlabNode, +): number { + const context = resolveSlabLevelContext(nodes, slab) + if (!context) return Number.POSITIVE_INFINITY + return getSlabElevationUpperBound(slab, context.walls, context.slabs, context.storeyHeight) +} diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 69d8e1fa8d..5919578892 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -209,7 +209,10 @@ export function buildSlabGeometry( mesh.castShadow = true mesh.receiveShadow = true mesh.userData.slotId = slotId - if (elevation < 0) mesh.position.y = elevation + // Solid slabs bake [elevation − thickness, elevation] into the geometry; + // recessed shells are authored at local Y=0 and sink so the recess floor + // sits at `elevation` (< 0) with the shell rim on the level plane. + if (node.recessed) mesh.position.y = elevation group.add(mesh) } return group diff --git a/packages/nodes/src/slab/panel.tsx b/packages/nodes/src/slab/panel.tsx index 6587a589c3..cb73a8be67 100644 --- a/packages/nodes/src/slab/panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' +import { type AnyNode, MIN_SLAB_THICKNESS, type SlabNode, useScene } from '@pascal-app/core' import { ActionButton, ActionGroup, @@ -16,6 +16,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect, useRef } from 'react' +import { applySlabElevationPreset, applySlabTopChange, clampSlabElevation } from './elevation-limit' /** * Phase 5 Stage E — slab inspector (kind-owned). @@ -30,6 +31,7 @@ import { useCallback, useEffect, useRef } from 'react' */ export function SlabPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) const editingHole = useEditingHole() const setMovingNode = useEditor((s) => s.setMovingNode) @@ -52,6 +54,33 @@ export function SlabPanel() { [selectedId], ) + const handleElevationChange = useCallback( + (proposed: number) => { + const current = nodeRef.current + if (!current) return + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) + handleUpdate(applySlabTopChange(current, elevation, { mode: 'panel' })) + }, + [handleUpdate], + ) + + const handleThicknessChange = useCallback( + (proposed: number) => { + handleUpdate({ thickness: Math.max(MIN_SLAB_THICKNESS, proposed) }) + }, + [handleUpdate], + ) + + const handleElevationPreset = useCallback( + (proposed: number) => { + const current = nodeRef.current + if (!current) return + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) + handleUpdate(applySlabElevationPreset(elevation)) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) useInteractionScope @@ -162,6 +191,23 @@ export function SlabPanel() { const area = calculateArea(node.polygon) + // Clean preset values per display system; imperial stores exact meters + // for whole-inch offsets. + const elevationPresets = + unit === 'imperial' + ? [ + { label: 'Sunken (-6")', elevation: -0.1524 }, + { label: 'Ground (0")', elevation: 0 }, + { label: 'Raised (+2")', elevation: 0.0508 }, + { label: 'Step (+6")', elevation: 0.1524 }, + ] + : [ + { label: 'Sunken (-15cm)', elevation: -0.15 }, + { label: 'Ground (0m)', elevation: 0 }, + { label: 'Raised (+5cm)', elevation: 0.05 }, + { label: 'Step (+15cm)', elevation: 0.15 }, + ] + return ( handleUpdate({ elevation: v })} + onChange={handleElevationChange} precision={3} step={0.01} unit="m" value={Math.round(node.elevation * 1000) / 1000} /> + {!node.recessed && ( + + )} +
- handleUpdate({ elevation: -0.15 })} /> - handleUpdate({ elevation: 0 })} /> - handleUpdate({ elevation: 0.05 })} /> - handleUpdate({ elevation: 0.15 })} /> + {elevationPresets.map((preset) => ( + handleElevationPreset(preset.elevation)} + /> + ))}
diff --git a/packages/nodes/src/slab/parametrics.ts b/packages/nodes/src/slab/parametrics.ts index 292e0073bb..622a7098b3 100644 --- a/packages/nodes/src/slab/parametrics.ts +++ b/packages/nodes/src/slab/parametrics.ts @@ -1,4 +1,4 @@ -import type { ParametricDescriptor } from '@pascal-app/core' +import { MIN_SLAB_THICKNESS, type ParametricDescriptor } from '@pascal-app/core' import type { SlabNode } from './schema' /** @@ -15,7 +15,18 @@ export const slabParametrics: ParametricDescriptor = { groups: [ { label: 'Elevation', - fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }], + fields: [ + { key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }, + { + key: 'thickness', + kind: 'number', + unit: 'm', + min: MIN_SLAB_THICKNESS, + max: 0.5, + step: 0.01, + visibleIf: (n) => !n.recessed, + }, + ], }, ], customPanel: () => import('./panel'), diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index c68a5dbcd9..ce196c29d0 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -4,6 +4,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveSupportSlabPatch, SpawnNode, useScene, } from '@pascal-app/core' @@ -105,10 +106,18 @@ const SpawnTool = () => { let placedId: SpawnNode['id'] if (existingSpawnId) { + const live = useScene.getState().nodes[existingSpawnId] + const effectiveSpawn = SpawnNode.parse({ + ...live, + parentId: activeLevelId, + position: next, + rotation: 0, + }) useScene.getState().updateNode(existingSpawnId, { parentId: activeLevelId, position: next, rotation: 0, + ...resolveSupportSlabPatch(effectiveSpawn, useScene.getState().nodes), }) if (duplicates.length > 0) { useScene.getState().deleteNodes(duplicates) @@ -119,9 +128,14 @@ const SpawnTool = () => { name: 'Spawn Point', position: next, rotation: 0, + parentId: activeLevelId, + }) + const committedSpawn = SpawnNode.parse({ + ...spawn, + ...resolveSupportSlabPatch(spawn, useScene.getState().nodes), }) - useScene.getState().createNode(spawn, activeLevelId) - placedId = spawn.id + useScene.getState().createNode(committedSpawn, activeLevelId) + placedId = committedSpawn.id } useViewer.getState().setSelection({ selectedIds: [placedId] }) diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index d5431ceb7f..11510ac635 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -1,11 +1,13 @@ import { type HandleDescriptor, type NodeDefinition, + resolveStairTotalRise, type SceneApi, StairNode as StairNodeSchema, type StairNode as StairNodeType, type StairSegmentNode, stairFootprintAABB, + useScene, } from '@pascal-app/core' const MIN_CURVED_RISE = 0.3 @@ -53,10 +55,14 @@ type StairMoveBounds = { height: number } +function readTotalRise(node: StairNodeType): number { + return Math.max(resolveStairTotalRise(node, useScene.getState().nodes), 0.1) +} + function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom { const isSpiral = node.stairType === 'spiral' const stepCount = Math.max(2, Math.round(node.stepCount ?? 10)) - const totalRise = Math.max(node.totalRise ?? 2.5, 0.1) + const totalRise = readTotalRise(node) const width = Math.max(node.width ?? 1, MIN_CURVED_WIDTH) const minInnerRadius = isSpiral ? MIN_CURVED_INNER_RADIUS_SPIRAL : MIN_CURVED_INNER_RADIUS_CURVED const innerRadius = Math.max(minInnerRadius, node.innerRadius ?? 0.9) @@ -96,7 +102,7 @@ function fallbackStraightStairMoveBounds(node: StairNodeType): StairMoveBounds { maxX: width / 2, minZ: 0, maxZ: depth, - height: Math.max(node.totalRise ?? 2.5, 0.1), + height: readTotalRise(node), } } @@ -161,7 +167,7 @@ function curvedRiseHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_CURVED_RISE, - currentValue: (n) => Math.max(n.totalRise ?? 2.5, 0.1), + currentValue: readTotalRise, apply: (_n, newRise) => ({ totalRise: newRise }), placement: { position: (n) => { @@ -307,7 +313,7 @@ function stairRotateGizmoPosition(n: StairNodeType): [number, number, number] { return [radius * Math.cos(angle), g.totalRise / 2, radius * Math.sin(angle)] } const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH) - const yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2 + const yMid = readTotalRise(n) / 2 return [width / 2 + STAIR_ROTATE_CORNER_OFFSET, yMid, -STAIR_ROTATE_CORNER_OFFSET] } @@ -345,7 +351,7 @@ function stairRotateHandle(): HandleDescriptor { STAIR_ROTATE_RING_OFFSET ) }, - y: (n) => Math.max(n.totalRise ?? 2.5, 0.1) / 2, + y: (n) => readTotalRise(n) / 2, }, } } diff --git a/packages/nodes/src/stair/destination.test.ts b/packages/nodes/src/stair/destination.test.ts new file mode 100644 index 0000000000..331a7daf2f --- /dev/null +++ b/packages/nodes/src/stair/destination.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'bun:test' +import { LevelNode, SlabNode, StairNode } from '@pascal-app/core' +import { getStairDestinationUpdates } from './destination' + +function makeStair(overrides: Record = {}) { + return StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + slabOpeningMode: 'destination', + ...overrides, + }) +} + +const deck = SlabNode.parse({ + id: 'slab_deck', + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + elevation: 1.25, + thickness: 0.05, +}) + +const level = LevelNode.parse({ + id: 'level_2', + type: 'level', + level: 1, + children: [], +}) + +describe('getStairDestinationUpdates', () => { + it('attaching to a deck disables the auto cutout and clears the custom rise', () => { + const stair = makeStair({ totalRise: 2.0 }) + const updates = getStairDestinationUpdates(stair, deck, deck.id) + expect(updates.deckSlabId).toBe(deck.id) + expect(updates.slabOpeningMode).toBe('none') + // The key must be PRESENT with an undefined value so the store merge + // clears the field. + expect('totalRise' in updates && updates.totalRise === undefined).toBe(true) + }) + + it('detaching back to a level restores the placement-default cutout and follows mode', () => { + const stair = makeStair({ deckSlabId: deck.id, slabOpeningMode: 'none' }) + const updates = getStairDestinationUpdates(stair, level, level.id) + expect(updates.toLevelId).toBe(level.id) + expect(updates.slabOpeningMode).toBe('destination') + expect('deckSlabId' in updates && updates.deckSlabId === undefined).toBe(true) + expect('totalRise' in updates && updates.totalRise === undefined).toBe(true) + }) + + it('a plain level-to-level switch leaves rise and opening mode alone', () => { + const stair = makeStair({ totalRise: 2.0 }) + const updates = getStairDestinationUpdates(stair, level, level.id) + expect(updates.toLevelId).toBe(level.id) + expect('deckSlabId' in updates && updates.deckSlabId === undefined).toBe(true) + expect('totalRise' in updates).toBe(false) + expect('slabOpeningMode' in updates).toBe(false) + }) + + it('detaching a stale deck reference still resets to follows mode', () => { + const stair = makeStair({ deckSlabId: 'slab_gone', totalRise: 1.4 }) + const updates = getStairDestinationUpdates(stair, level, level.id) + expect(updates.slabOpeningMode).toBe('destination') + expect('totalRise' in updates && updates.totalRise === undefined).toBe(true) + }) +}) diff --git a/packages/nodes/src/stair/destination.ts b/packages/nodes/src/stair/destination.ts new file mode 100644 index 0000000000..41b6148e71 --- /dev/null +++ b/packages/nodes/src/stair/destination.ts @@ -0,0 +1,30 @@ +import type { AnyNode, StairNode } from '@pascal-app/core' + +/** + * Computes the stair patch for a destination ("To") switch. + * + * Attaching to a deck clears any explicit custom rise (the rise follows the + * deck's elevation from now on) and disables the auto cutout — a deck stair + * lands ON its destination slab, not through it. Switching a deck-attached + * stair back to a level clears both again so the rise re-derives from the + * storey height, and restores `slabOpeningMode: 'destination'` — the + * placement default (the schema default is 'none', but the stair tool places + * ordinary stairs with 'destination', so that is what a level-destination + * stair regains). Plain level-to-level switches leave rise and opening mode + * alone. + */ +export function getStairDestinationUpdates( + stair: StairNode, + target: AnyNode | undefined, + targetId: string, +): Partial { + if (target?.type === 'slab') { + return { deckSlabId: targetId, totalRise: undefined, slabOpeningMode: 'none' } + } + const updates: Partial = { toLevelId: targetId, deckSlabId: undefined } + if (stair.deckSlabId) { + updates.totalRise = undefined + updates.slabOpeningMode = 'destination' + } + return updates +} diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 00297893af..fae2b5ac5c 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -4,6 +4,8 @@ import { type AnyNode, type AnyNodeId, type LevelNode, + resolveStairTotalRise, + type SlabNode, type StairNode, type StairRailingMode, type StairSegmentNode, @@ -35,6 +37,7 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' +import { getStairDestinationUpdates } from './destination' const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ { label: 'None', value: 'none' }, @@ -59,6 +62,10 @@ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode } { label: 'Destination', value: 'destination' }, ] +// Slabs at least this high off the storey floor read as decks (mezzanines) — +// lower slabs are floor coverings, never useful stair destinations. +const DECK_DESTINATION_MIN_ELEVATION = 0.5 + export default function StairPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) @@ -75,6 +82,20 @@ export default function StairPanel() { () => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []), [node, nodes], ) + const candidateDecks = useMemo(() => { + if (node?.type !== 'stair') return [] + const level = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (level?.type !== 'level') return [] + const decks: SlabNode[] = [] + for (const childId of level.children) { + const child = nodes[childId as AnyNodeId] + if (child?.type !== 'slab') continue + if (child.id === node.deckSlabId || child.elevation >= DECK_DESTINATION_MIN_ELEVATION) { + decks.push(child) + } + } + return decks + }, [node, nodes]) const segments = useScene( useShallow((s) => { if (!selectedId) return [] @@ -133,6 +154,15 @@ export default function StairPanel() { [handleUpdate], ) + const handleDestinationChange = useCallback( + (value: string) => { + if (!node) return + const target = useScene.getState().nodes[value as AnyNodeId] + handleUpdate(getStairDestinationUpdates(node, target, value)) + }, + [node, handleUpdate], + ) + const getLastSegmentFillDefaults = useCallback(() => { if (!node) return { fillToFloor: true } const children = node.children ?? [] @@ -223,6 +253,9 @@ export default function StairPanel() { const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels) const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels) + const deckNode = node.deckSlabId ? nodes[node.deckSlabId as AnyNodeId] : undefined + const attachedDeck = deckNode?.type === 'slab' ? deckNode : undefined + const resolvedRise = Math.round(resolveStairTotalRise(node, nodes) * 100) / 100 return (
- + {attachedDeck ? null : ( + + )}
@@ -276,40 +311,85 @@ export default function StairPanel() {
- To Level + To
- handleAutoCutoutChange(value === 'destination')} - options={STAIR_SLAB_OPENING_OPTIONS} - value={node.slabOpeningMode ?? 'none'} - /> - - {(node.slabOpeningMode ?? 'none') === 'destination' ? ( - handleUpdate({ openingOffset: value })} - precision={2} - step={0.01} - unit="m" - value={Math.round((node.openingOffset ?? 0) * 100) / 100} - /> + {attachedDeck ? ( +
+
+ Rise +
+ + handleUpdate( + value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined }, + ) + } + options={[ + { label: 'Follows deck', value: 'follows' }, + { label: 'Custom rise', value: 'custom' }, + ]} + value={node.totalRise == null ? 'follows' : 'custom'} + /> + {node.totalRise == null ? ( +
+ Currently {resolvedRise} m +
+ ) : ( + handleUpdate({ totalRise: value })} + precision={2} + step={0.05} + unit="m" + value={resolvedRise} + /> + )} +
) : null} + {attachedDeck ? null : ( + <> + handleAutoCutoutChange(value === 'destination')} + options={STAIR_SLAB_OPENING_OPTIONS} + value={node.slabOpeningMode ?? 'none'} + /> + + {(node.slabOpeningMode ?? 'none') === 'destination' ? ( + handleUpdate({ openingOffset: value })} + precision={2} + step={0.01} + unit="m" + value={Math.round((node.openingOffset ?? 0) * 100) / 100} + /> + ) : null} + + )} + {node.stairType === 'spiral' && ( <>
@@ -389,7 +469,7 @@ export default function StairPanel() { precision={2} step={0.05} unit="m" - value={Math.round((node.totalRise ?? 2.5) * 100) / 100} + value={Math.round(resolveStairTotalRise(node, nodes) * 100) / 100} /> state.nodes) const sideMaterial = bodyMaterials[1] const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) - const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1) + const totalRise = Math.max(resolveStairTotalRise(stair, nodes), 0.1) const stepHeight = totalRise / stepCount const isSpiral = stair.stairType === 'spiral' const innerRadius = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? 0.9) diff --git a/packages/nodes/src/wall/measurement.ts b/packages/nodes/src/wall/measurement.ts index d9d9645f48..ac06b43d35 100644 --- a/packages/nodes/src/wall/measurement.ts +++ b/packages/nodes/src/wall/measurement.ts @@ -1,18 +1,19 @@ import { - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallThickness, type MeasurementFeature, type MeasurementFeatureBinding, type MeasurementFeatureReference, sampleWallCenterline, + useScene, type WallNode, } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' const point = (x: number, y: number, z: number) => [x, y, z] as [number, number, number] export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] { - const height = wall.height ?? DEFAULT_WALL_HEIGHT + const height = resolveWallOpeningCeiling(wall, useScene.getState().nodes) const centerline = sampleWallCenterline(wall).map(({ x, y }) => point(x, 0, y)) const midpoint = getWallCurveFrameAt(wall, 0.5).point const halfThickness = getWallThickness(wall) / 2 @@ -168,7 +169,10 @@ export function matchWallMeasurementFeature( const faceDistance = Math.hypot(hit[0] - faceX, hit[2] - faceZ) const threshold = Math.max(maxDistance, halfThickness + 0.03) if (faceDistance <= threshold && (!best || faceDistance < best.distance)) { - const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, hit[1])) + const height = Math.max( + 0, + Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), hit[1]), + ) best = { featureId: side > 0 ? 'wall:face:left' : 'wall:face:right', point: point(faceX, height, faceZ), @@ -204,7 +208,10 @@ export function resolveWallMeasurementFeature( if (typeof heightValue !== 'number' || feature.geometry.kind !== 'path') { return normal ? { ...feature, normal } : feature } - const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, heightValue)) + const height = Math.max( + 0, + Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), heightValue), + ) return { ...feature, ...(normal ? { normal } : {}), diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index fcb7bfd45d..f8bcc86b4b 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -3,13 +3,13 @@ import { type AnyNodeId, collectAlignmentAnchors, - DEFAULT_WALL_HEIGHT, emitter, type GridEvent, getWallCurveLength, getWallThickness, pauseSceneHistory, resolveAlignment, + resolveWallSupportSlabPatch, resumeSceneHistory, runAsSingleSceneHistoryStep, useLiveNodeOverrides, @@ -39,6 +39,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useCallback, useEffect, useRef, useState } from 'react' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** * Wall endpoint move tool (kind-owned). @@ -521,6 +522,16 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ }, })), ]) + const affectedIds = [nodeId as AnyNodeId, ...linkedUpdates.map((u) => u.id as AnyNodeId)] + const committedNodes = useScene.getState().nodes + useScene.getState().updateNodes( + affectedIds.flatMap((id) => { + const wall = committedNodes[id] + return wall?.type === 'wall' + ? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }] + : [] + }), + ) useScene.getState().markDirty(nodeId as AnyNodeId) for (const u of linkedUpdates) { useScene.getState().markDirty(u.id as AnyNodeId) @@ -613,7 +624,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ end: previewEnd, curveOffset: target.wall.curveOffset, }) - const wallHeight = target.wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = resolveWallOpeningCeiling(target.wall, useScene.getState().nodes) const dimMidX = (previewStart[0] + previewEnd[0]) / 2 const dimMidZ = (previewStart[1] + previewEnd[1]) / 2 diff --git a/packages/nodes/src/wall/move-shared.ts b/packages/nodes/src/wall/move-shared.ts index aebea59feb..3ae07a32f6 100644 --- a/packages/nodes/src/wall/move-shared.ts +++ b/packages/nodes/src/wall/move-shared.ts @@ -1,6 +1,5 @@ import { type AnyNodeId, - DEFAULT_WALL_HEIGHT, getMaterialPresetByRef, parseMaterialRef, resolveMaterial, @@ -12,6 +11,7 @@ import { WallNode as WallSchema, } from '@pascal-app/core' import { isSegmentLongEnough } from '@pascal-app/editor' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** * Pure helpers shared by the 3D `MoveWallTool` and the 2D @@ -241,7 +241,7 @@ export function buildBridgeWallPreviews(args: { start: [...plan.originalPoint] as WallPlanPoint, end: [...nextPoint] as WallPlanPoint, color: getWallGhostColor(plan.wall), - height: plan.wall.height ?? DEFAULT_WALL_HEIGHT, + height: resolveWallOpeningCeiling(plan.wall, useScene.getState().nodes), } previews.push({ ghost, wall }) wallsForDuplicateCheck.push(wall) diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 0a3b71eca1..95744fa7ff 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -8,13 +8,16 @@ import { detectSpacesForLevel, emitter, type GridEvent, + getCeilingClampBound, getPerpendicularWallMoveAxis, getPlannedLinkedWallUpdates, + getStoredLevelHeight, + type LevelNode, pauseSceneHistory, planAutoCeilingsForLevel, planAutoSlabsForLevel, planWallMoveJunctions, - projectAutoSlabsForPlan, + resolveWallSupportSlabPatch, resumeSceneHistory, type SlabNode, useLiveNodeOverrides, @@ -284,12 +287,14 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // re-flowed through the planner. const existingSlabs = getLevelSlabs(levelId, sceneState.nodes) const slabPlan = planAutoSlabsForLevel(roomPolygons, existingSlabs) + const levelNode = sceneState.nodes[levelId as AnyNodeId] const ceilingPlan = planAutoCeilingsForLevel( roomPolygons, getLevelCeilings(levelId, sceneState.nodes), { - walls: levelWalls, - slabs: projectAutoSlabsForPlan(existingSlabs, slabPlan), + storeyHeight: + levelNode?.type === 'level' ? getStoredLevelHeight(levelNode as LevelNode) : undefined, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, sceneState.nodes, polygon), }, ) @@ -594,6 +599,19 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // undoable step. Then drop the live overrides — the renderer // now reads the committed walls + polygons directly. commitSurfacesToStore() + const affectedWallIds = [ + ...commitUpdates.map((entry) => entry.id), + ...bridgeCreates.map((entry) => entry.node.id as AnyNodeId), + ] + const committedNodes = useScene.getState().nodes + useScene.getState().updateNodes( + affectedWallIds.flatMap((id) => { + const wall = committedNodes[id] + return wall?.type === 'wall' + ? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }] + : [] + }), + ) clearSurfaceOverrides() clearWallOverrides() diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index b914561fb4..9c41e857e2 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -23,6 +23,7 @@ import { createSlotPaintCapability, previewSlotByUserData, } from '../shared/slot-paint' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' const WALL_SLOT_IDS = new Set(Object.keys(WALL_SURFACE_SLOT_DEFAULTS)) const WALL_ARRAY_SLOT_INDEX: Partial> = { @@ -104,9 +105,13 @@ export function resolveWallRole(args: { } if (sideFromIndex && localPosition) { - const bands = getWallFaceBandConfig(node) + const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const bands = getWallFaceBandConfig(node, effectiveWallHeight) if (!bands.enabled) return sideFromIndex - return getWallBandSlotId(sideFromIndex, getWallFaceBandForHeight(node, localPosition[1])) + return getWallBandSlotId( + sideFromIndex, + getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight), + ) } if (sideFromIndex) return sideFromIndex @@ -128,15 +133,23 @@ export function resolveWallRole(args: { const semantic = hitFace === 'front' ? node.frontSide : node.backSide if (semantic === 'interior' || semantic === 'exterior') { - const bands = getWallFaceBandConfig(node) + const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const bands = getWallFaceBandConfig(node, effectiveWallHeight) if (!bands.enabled) return semantic - return getWallBandSlotId(semantic, getWallFaceBandForHeight(node, localPosition[1])) + return getWallBandSlotId( + semantic, + getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight), + ) } const side = hitFace === 'front' ? 'interior' : 'exterior' - const bands = getWallFaceBandConfig(node) + const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const bands = getWallFaceBandConfig(node, effectiveWallHeight) if (!bands.enabled) return side - return getWallBandSlotId(side, getWallFaceBandForHeight(node, localPosition[1])) + return getWallBandSlotId( + side, + getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight), + ) } /** diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 7234265719..2fd4729eb2 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -22,6 +22,7 @@ import { ActionButton, ActionGroup, curveReshapeScope, + formatLinearMeasurement, getLinearUnitLabel, linearControlValueToMeters, metersToLinearUnit, @@ -35,6 +36,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Spline } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' type WallTrimKey = 'skirting' | 'crown' | 'chairRail' @@ -104,6 +106,15 @@ export default function WallPanel() { }) }) + // Effective height while the wall is plane-bound (`height` absent): the + // storey plane minus the elected slab base — what the wall currently + // renders at. `undefined` for walls with an explicit custom height. + const planeBoundHeightMeters = useScene((s) => { + const wall = selectedId ? (s.nodes[selectedId as AnyNodeId] as WallNode | undefined) : undefined + if (!wall || wall.type !== 'wall' || wall.height != null) return undefined + return resolveWallOpeningCeiling(wall, s.nodes) + }) + // Mirror the latest node into a ref so the slider handlers below have // stable identities across re-renders. Without this, every store tick // (one per pointermove during a slider drag) rebuilt the handler @@ -145,6 +156,24 @@ export default function WallPanel() { [handleUpdate], ) + const handleTopModeChange = useCallback( + (mode: 'storey' | 'custom') => { + const n = nodeRef.current + if (!n) return + const isCustom = n.height != null + if (mode === 'custom' && !isCustom) { + // Seed from the current effective height so the geometry doesn't + // jump at the moment of detaching from the storey plane. + const seeded = resolveWallOpeningCeiling(n, useScene.getState().nodes) + handleUpdate({ height: Math.max(0.1, seeded) }) + } else if (mode === 'storey' && isCustom) { + // Absent `height` = plane-bound; the store strips undefined keys. + handleUpdate({ height: undefined }) + } + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -160,7 +189,8 @@ export default function WallPanel() { const length = getWallCurveLength(node) - const height = node.height ?? 2.5 + const isPlaneBound = node.height == null + const height = node.height ?? planeBoundHeightMeters ?? 2.5 const thickness = node.thickness ?? 0.1 const curveOffset = getClampedWallCurveOffset(node) const maxCurveOffset = getMaxWallCurveOffset(node) @@ -171,7 +201,7 @@ export default function WallPanel() { const displayCurveOffset = metersToLinearUnit(curveOffset, unit) const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit) const curveOffsetLimit = Math.max(0.01, maxCurveOffset) - const wallHeightMeters = node.height ?? 2.5 + const wallHeightMeters = height const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) } const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) } @@ -199,20 +229,37 @@ export default function WallPanel() { unit={unitLabel} value={displayLength} /> - - handleUpdate({ - height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), - }) - } - precision={2} - step={0.1} - unit={unitLabel} - value={Math.round(displayHeight * 100) / 100} +
+ Top +
+ + {isPlaneBound ? ( +
+ Currently {formatLinearMeasurement(height, unit)} +
+ ) : ( + + handleUpdate({ + height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), + }) + } + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayHeight * 100) / 100} + /> + )} = { label: 'Dimensions', fields: [ { key: 'thickness', kind: 'number', unit: 'm', min: 0.05, max: 0.6, step: 0.01 }, + // `height` may be absent (plane-bound top); the custom panel owns the + // Follows storey / Custom height mode switch, so this is metadata only. { key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 }, { key: 'curveOffset', kind: 'number', unit: 'm', min: -3, max: 3, step: 0.05 }, ], diff --git a/packages/nodes/src/wall/quick-measurement.ts b/packages/nodes/src/wall/quick-measurement.ts index cbe9b1ee57..c7f97f03c9 100644 --- a/packages/nodes/src/wall/quick-measurement.ts +++ b/packages/nodes/src/wall/quick-measurement.ts @@ -1,15 +1,16 @@ import { - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallCurveLength, getWallThickness, type QuickMeasurementReport, + useScene, type WallNode, } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' export function wallQuickMeasurement(node: WallNode): QuickMeasurementReport { const length = getWallCurveLength(node) - const height = node.height ?? DEFAULT_WALL_HEIGHT + const height = resolveWallOpeningCeiling(node, useScene.getState().nodes) const frame = getWallCurveFrameAt(node, 0.5) return { diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx index d5ce824317..7f880f3400 100644 --- a/packages/nodes/src/wall/system.tsx +++ b/packages/nodes/src/wall/system.tsx @@ -44,7 +44,7 @@ const WallTreatmentMiterSystem = () => { * * - **`WallSystem`** — reads `dirtyNodes`, batches by level, runs * `calculateLevelMiters(levelWalls)`, rebuilds geometry via - * `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments)`, + * `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments, storeyHeight)`, * and cascades to adjacent walls that share a junction. This is the * bulk of the wall runtime (~820 lines in viewer). * - **`WallCutout`** — cutaway-mode hide/show logic based on camera diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index f5acb490df..8f7b9cbcb4 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -2,6 +2,7 @@ import { type AnyNode, calculateLevelMiters, collectAlignmentAnchors, + DEFAULT_LEVEL_HEIGHT, emitter, type GridEvent, getWallMiterBoundaryPoints, @@ -17,6 +18,7 @@ import { import { CursorSphere, chainEndJoinsExistingWall, + clearPlacementSurface, createWallOnCurrentLevel, EDITOR_LAYER, formatAngleRadians, @@ -28,6 +30,8 @@ import { isAngleSnapActive, isMagneticSnapActive, markToolCancelConsumed, + publishPlacementSurface, + resolvePointerSupportSurface, type SegmentAngleReference, snapWallDraftPointDetailed, triggerSFX, @@ -42,6 +46,7 @@ import { } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' +import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' @@ -58,7 +63,6 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 * * Mounted via `def.tool` from `wall/definition.ts`. */ -const WALL_HEIGHT = 2.5 const DRAFT_WALL_THICKNESS = 0.1 /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 @@ -83,6 +87,11 @@ const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [ { vector: [0, 1], orientation: 'axis' }, ] +// Grid-plane surface publish (pointer-decided): scratch + constant normal so +// per-move publishes don't allocate. +const SURFACE_UP = new Vector3(0, 1, 0) +const surfacePointScratch = new Vector3() + type DraftAngleLabel = { id: string label: string @@ -513,13 +522,24 @@ function getBelowLevelWalls(): WallNode[] { export const WallTool: React.FC = () => { const unit = useViewer((state) => state.unit) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const activeLevelId = useViewer((state) => state.selection.levelId) + const activeLevelHeight = useScene((state) => { + const level = activeLevelId ? state.nodes[activeLevelId] : undefined + return level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + }) // A placed wall preset seeds `toolDefaults.wall` (height / thickness …) // before the tool mounts, so the draft preview is drawn at the preset's // dimensions rather than the generic fallbacks — matching the wall that // will be created. Read through refs so the live event handlers below see // the latest values without re-subscribing. const wallDefaults = useEditor((s) => s.toolDefaults.wall) - const previewHeight = typeof wallDefaults?.height === 'number' ? wallDefaults.height : WALL_HEIGHT + // Camera for the pointer-support resolution (deck top vs floor) — read + // through a ref so the event handlers below see the live camera. + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + const previewHeight = + typeof wallDefaults?.height === 'number' ? wallDefaults.height : activeLevelHeight const previewThickness = typeof wallDefaults?.thickness === 'number' ? wallDefaults.thickness : DRAFT_WALL_THICKNESS const previewHeightRef = useRef(previewHeight) @@ -591,6 +611,16 @@ export const WallTool: React.FC = () => { : point } + // The walking surface the pointer actually aims at (deck top when over + // the deck, floor/ground underneath it) — only for genuine 3D pointer + // events. The 2D floor plan emits synthetic grid events with no camera + // ray behind them; those keep the uncapped max election and leave the + // grid plane alone. + const pointedSurfaceFor = (event: GridEvent) => + event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(cameraRef.current, event.position) + : null + const stopDrafting = () => { buildingState.current = 0 chainFirstVertex.current = null @@ -611,6 +641,19 @@ export const WallTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && wallPreviewRef.current)) return + // Ride the grid event plane on the pointed surface: aiming at an + // elevated deck lifts the plane to the deck top, so the draft's XZ + // lands where the cursor points and the preview/cursor Y + // (`event.localPosition[1]`) sits at the base the committed wall + // will elect. Aiming past the deck edge drops it back to the floor. + const pointed = pointedSurfaceFor(event) + if (pointed) { + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + } + const walls = getCurrentLevelWalls() // Add walls on the floor below as extra snap references so the new wall // can align with the level beneath it. Kept separate from `walls` so the @@ -745,10 +788,12 @@ export const WallTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return + const pointed = pointedSurfaceFor(event) // Both start and end are building-local ✓ const createdWall = createWallOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, + { supportCap: pointed ? pointed.elevation : null }, ) if (!createdWall) return chainWallIds.current.push(createdWall.id) @@ -831,6 +876,7 @@ export const WallTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + clearPlacementSurface() useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() useSegmentDraftChain.getState().clear('wall') diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index c092875bca..6fe6856bd9 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -7,6 +7,7 @@ import { isCurvedWall, type SceneMaterial, type SceneMaterialId, + useScene, WALL_CHAIR_RAIL_DEFAULT, WALL_CROWN_DEFAULT, WALL_SKIRTING_DEFAULT, @@ -25,6 +26,7 @@ import { import { memo, useEffect, useMemo } from 'react' import * as THREE from 'three' import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' import { treatmentMiterDataForProud, type WallTreatmentLevelData } from './treatment-level-data' const CURVE_SEGMENTS = 24 @@ -502,7 +504,7 @@ export function buildTrimGeometry( childrenNodes: OpeningLike[], levelData: WallTreatmentLevelData, ) { - const wallHeight = node.height ?? 2.5 + const wallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) const height = trim.height const yBottom = kind === 'crown' diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index a61ea3cb7f..d2fa44679c 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -9,6 +9,7 @@ import type { import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' +import { readHostWallCeiling } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildWindowFloorplan } from './floorplan' import { windowWidthAffordance } from './floorplan-affordances' @@ -33,12 +34,6 @@ function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unkn return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } -function readWallHeight(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!w.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(w.wallId as AnyNodeId) as WallNode | undefined - return wall?.height ?? Number.POSITIVE_INFINITY -} - function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor { const sign = side === 'right' ? 1 : -1 return { @@ -96,9 +91,9 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor = ({ nod startLocalY, node.width, node.height, + nodes, ) // One click per real position step, keyed on the SNAPPED along-wall value diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 885560a1af..475a5c447d 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -356,6 +356,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode targetLocalY, movingWindowNode.width, movingWindowNode.height, + useScene.getState().nodes, ) const valid = !hasWallChildOverlap( diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index ede4907b28..d7d63a7678 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -339,7 +339,14 @@ const WindowTool: React.FC = () => { width, height, }) - const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height) + const { clampedX, clampedY } = clampToWall( + wall, + localX, + localY, + width, + height, + useScene.getState().nodes, + ) const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) return { clampedX, clampedY, valid } } diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index eb3547df6b..08ff4cb329 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,4 +1,5 @@ -import type { WallNode } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** * Default sill height (metres from the floor to the BOTTOM of a window) for a @@ -35,7 +36,11 @@ export function wallLocalToWorld( } /** - * Clamps window center position so it stays fully within wall bounds. + * Clamps window center position so it stays fully within wall bounds. The Y + * ceiling is the wall's RESOLVED top (storey plane for plane-bound walls, + * stored height for explicit ones, minus the elected slab base) — `nodes` is + * required because a plane-bound wall's top lives on its level, not on the + * wall record. */ export function clampToWall( wallNode: WallNode, @@ -43,11 +48,12 @@ export function clampToWall( localY: number, width: number, height: number, + nodes: Readonly>, ): { clampedX: number; clampedY: number } { const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] const wallLength = Math.sqrt(dx * dx + dz * dz) - const wallHeight = wallNode.height ?? 2.5 + const wallHeight = resolveWallOpeningCeiling(wallNode, nodes) const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 9a7e716574..8408cd88d8 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -161,10 +161,9 @@ export { type SurfaceFrame, } from './systems/roof/roof-system' export { ScanSystem } from './systems/scan/scan-system' -// Slab system follows the wall + fence re-export pattern — composed into -// the registry-driven slab definition's `def.system`. Removed in Phase 6 -// alongside the legacy slab mount point. -export { generateSlabGeometry, SlabSystem } from './systems/slab/slab-system' +// Pure slab geometry generator — composed into the registry-driven slab +// definition's `def.geometry` in `@pascal-app/nodes`. +export { generateSlabGeometry } from './systems/slab/slab-system' export { getStairBodyMaterials, getStairRailingMaterial, diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index b1fc6623c0..e490ecf697 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -3,6 +3,7 @@ import { type CeilingNode, getEffectiveNode, nodeRegistry, + resolveCeilingHeight, sceneRegistry, useLiveTransforms, useScene, @@ -44,7 +45,7 @@ export const CeilingSystem = () => { // the final value on commit. Mirrors WallSystem / GeometrySystem. const effective = getEffectiveNode(node as CeilingNode) const itemHoles = collectCeilingHoles(effective, nodes) - updateCeilingGeometry(effective, mesh, itemHoles) + updateCeilingGeometry(effective, mesh, itemHoles, nodes) clearDirty(id as AnyNodeId) } // If mesh not found, keep it dirty for next frame @@ -88,6 +89,7 @@ function updateCeilingGeometry( node: CeilingNode, mesh: THREE.Mesh, extraHoles: Array> = [], + nodes: SceneNodes = useScene.getState().nodes, ) { const newGeo = generateCeilingGeometry(node, extraHoles) @@ -108,7 +110,11 @@ function updateCeilingGeometry( const liveTransform = useLiveTransforms.getState().get(node.id) mesh.position.x = liveTransform?.position[0] ?? 0 mesh.position.z = liveTransform?.position[2] ?? 0 - mesh.position.y = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) // Slight offset to avoid z-fighting with upper-level slabs + // Resolved height: explicit when stored, else the level-top bound — so a + // follows-mode ceiling re-parks under the current plane on every rebuild + // (level-height edits / covering-slab changes dirty-mark ceilings). + // Slight offset to avoid z-fighting with upper-level slabs. + mesh.position.y = resolveCeilingHeight(node, nodes) - 0.01 + (liveTransform?.position[1] ?? 0) } /** diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx index 2098875e7f..33976fae72 100644 --- a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, getEffectiveNode, getFloorStackedPosition, + type LiveTransform, nodeRegistry, sceneRegistry, useLiveTransforms, @@ -16,8 +17,7 @@ type PositionedNode = AnyNode & { rotation?: [number, number, number] | number } -function withLiveTransform(node: AnyNode, id: string): AnyNode { - const liveTransform = useLiveTransforms.getState().get(id) +function withLiveTransform(node: AnyNode, liveTransform: LiveTransform | undefined): AnyNode { if (!liveTransform) return node const currentRotation = (node as PositionedNode).rotation @@ -75,7 +75,8 @@ export const FloorElevationSystem = () => { const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined if (!mesh) return - const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), id) + const liveTransform = useLiveTransforms.getState().get(id) + const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), liveTransform) const position = (effectiveNode as PositionedNode).position if (!position) return @@ -91,6 +92,10 @@ export const FloorElevationSystem = () => { node: effectiveNode, nodes: resolverNodes, position, + // 3D drags publish the pointer-decided surface cap with their live + // transform; honoring it here keeps this system's per-frame Y in + // agreement with the tool's preview (no deck/floor flicker). + maxElevation: liveTransform?.supportElevationCap, }) mesh.position.y = visualPosition[1] diff --git a/packages/viewer/src/systems/level/level-stacking.test.ts b/packages/viewer/src/systems/level/level-stacking.test.ts deleted file mode 100644 index c71d7668ab..0000000000 --- a/packages/viewer/src/systems/level/level-stacking.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { getLevelBuildingId, getLevelStackPositions, type LevelStackEntry } from './level-stacking' - -describe('getLevelBuildingId', () => { - const buildings = [ - { id: 'building_a', children: ['level_a0'] }, - { id: 'building_b', children: ['level_b0'] }, - ] - - test('uses an explicit building parent', () => { - expect(getLevelBuildingId('level_a0', 'building_a', buildings)).toBe('building_a') - }) - - test('falls back to building children for legacy levels without a parentId', () => { - expect(getLevelBuildingId('level_b0', null, buildings)).toBe('building_b') - }) - - test('ignores a non-building parent before checking building children', () => { - expect(getLevelBuildingId('level_a0', 'site_main', buildings)).toBe('building_a') - }) -}) - -describe('getLevelStackPositions', () => { - test('stacks levels within one building by level index', () => { - const entries: LevelStackEntry[] = [ - { levelId: 'level_second', buildingId: 'building_a', index: 2, height: 3.4 }, - { levelId: 'level_ground', buildingId: 'building_a', index: 0, height: 2.5 }, - { levelId: 'level_first', buildingId: 'building_a', index: 1, height: 3.1 }, - ] - - expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ - level_ground: 0, - level_first: 2.5, - level_second: 5.6, - }) - }) - - test('starts each building on its own ground plane', () => { - const entries: LevelStackEntry[] = [ - { levelId: 'level_a0', buildingId: 'building_a', index: 0, height: 2.5 }, - { levelId: 'level_b0', buildingId: 'building_b', index: 0, height: 3 }, - { levelId: 'level_a1', buildingId: 'building_a', index: 1, height: 2.8 }, - { levelId: 'level_b1', buildingId: 'building_b', index: 1, height: 3.2 }, - ] - - expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ - level_a0: 0, - level_b0: 0, - level_a1: 2.5, - level_b1: 3, - }) - }) - - test('keeps orphan levels in one legacy stack', () => { - const entries: LevelStackEntry[] = [ - { levelId: 'level_0', buildingId: null, index: 0, height: 2.7 }, - { levelId: 'level_1', buildingId: null, index: 1, height: 3 }, - ] - - expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ - level_0: 0, - level_1: 2.7, - }) - }) -}) diff --git a/packages/viewer/src/systems/level/level-stacking.ts b/packages/viewer/src/systems/level/level-stacking.ts deleted file mode 100644 index b966e38358..0000000000 --- a/packages/viewer/src/systems/level/level-stacking.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type LevelStackEntry = { - levelId: string - buildingId: string | null - index: number - height: number -} - -type BuildingOwnership = { id: string; children: readonly string[] } - -export function getLevelBuildingId( - levelId: string, - parentId: string | null, - buildings: readonly BuildingOwnership[], -): string | null { - const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined - if (directParent) return directParent.id - - return buildings.find((building) => building.children.includes(levelId))?.id ?? null -} - -export function getLevelStackPositions(entries: readonly LevelStackEntry[]): Map { - const positions = new Map() - const cumulativeYByBuilding = new Map() - - for (const entry of [...entries].sort((a, b) => a.index - b.index)) { - const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0 - positions.set(entry.levelId, baseY) - cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height) - } - - return positions -} diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index 80af5af360..3a20f6cb82 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -1,16 +1,9 @@ -import { - type BuildingNode, - getLevelHeight, - type LevelNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' +import { getLevelElevations, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import type { Object3D } from 'three' import { lerp } from 'three/src/math/MathUtils.js' import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' -import { getLevelBuildingId, getLevelStackPositions } from './level-stacking' const EXPLODED_GAP = 5 @@ -26,44 +19,31 @@ export const LevelSystem = () => { const levelMode = useViewer.getState().levelMode const selectedLevel = useViewer.getState().selection.levelId - // Collect level heights so each building can compute its own cumulative offsets. - // Level 0 → Y=0, Level 1 → Y=height(0), Level 2 → Y=height(0)+height(1), etc. + const levelElevations = getLevelElevations(nodes) type LevelEntry = { levelId: string - buildingId: string | null index: number - height: number obj: NonNullable> } const entries: LevelEntry[] = [] - const buildings = Object.values(nodes).filter( - (node): node is BuildingNode => node?.type === 'building', - ) sceneRegistry.byType.level!.forEach((levelId) => { const obj = sceneRegistry.nodes.get(levelId) const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (obj && level) { entries.push({ levelId, - buildingId: getLevelBuildingId(levelId, level.parentId, buildings), index: level.level, - height: getLevelHeight( - levelId, - nodes, - (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, - ), obj, }) } }) - const stackPositions = getLevelStackPositions(entries) const selectedIndex = selectedLevel ? entries.find((e) => e.levelId === selectedLevel)?.index : undefined for (const { levelId, index, obj } of entries) { const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined - const baseY = stackPositions.get(levelId) ?? 0 + const baseY = levelElevations.get(levelId)?.baseY ?? 0 const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0 const targetY = baseY + explodedExtra diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts index 25c03ba94c..cfa29b3bcb 100644 --- a/packages/viewer/src/systems/level/level-utils.ts +++ b/packages/viewer/src/systems/level/level-utils.ts @@ -1,11 +1,4 @@ -import { - type BuildingNode, - getLevelHeight, - type LevelNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' -import { getLevelBuildingId, getLevelStackPositions } from './level-stacking' +import { getLevelElevations, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' /** * Instantly snaps all level Objects3D to their true stacked Y positions @@ -25,33 +18,20 @@ export function snapLevelsToTruePositions(): () => void { type LevelEntry = { obj: NonNullable> levelId: string - buildingId: string | null - index: number - height: number } const entries: LevelEntry[] = [] - const buildings = Object.values(nodes).filter( - (node): node is BuildingNode => node?.type === 'building', - ) sceneRegistry.byType.level!.forEach((levelId) => { const obj = sceneRegistry.nodes.get(levelId) const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (obj && level) { entries.push({ levelId, - buildingId: getLevelBuildingId(levelId, level.parentId, buildings), - index: level.level, - height: getLevelHeight( - levelId, - nodes, - (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, - ), obj, }) } }) - const stackPositions = getLevelStackPositions(entries) + const levelElevations = getLevelElevations(nodes) // Snapshot current Y and visibility so we can restore them after the render const snapshot = new Map( @@ -60,7 +40,7 @@ export function snapLevelsToTruePositions(): () => void { // Snap to true stacked positions and make all levels visible for (const { levelId, obj } of entries) { - obj.position.y = stackPositions.get(levelId) ?? 0 + obj.position.y = levelElevations.get(levelId)?.baseY ?? 0 obj.visible = true } diff --git a/packages/viewer/src/systems/slab/slab-system.test.ts b/packages/viewer/src/systems/slab/slab-system.test.ts index a6e58aa21e..910c1dcc30 100644 --- a/packages/viewer/src/systems/slab/slab-system.test.ts +++ b/packages/viewer/src/systems/slab/slab-system.test.ts @@ -7,6 +7,13 @@ import { generateSlabGeometry } from './slab-system' const EMPTY_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] } +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) { const positions = geometry.getAttribute('position') for (let index = 0; index < positions.count; index += 1) { @@ -17,16 +24,20 @@ function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) { return false } +function uniqueSortedYs(geometry: THREE.BufferGeometry): number[] { + const positions = geometry.getAttribute('position') + const ys = new Set() + for (let index = 0; index < positions.count; index += 1) { + ys.add(Math.round(positions.getY(index) * 1e4) / 1e4) + } + return [...ys].sort((a, b) => a - b) +} + describe('generateSlabGeometry', () => { test('renders a boundary-overlapping hole as an open indentation', () => { const slab = SlabNode.parse({ elevation: 0.05, - polygon: [ - [0, 0], - [4, 0], - [4, 3], - [0, 3], - ], + polygon: SQUARE, holes: [ [ [1, -0.5], @@ -47,12 +58,8 @@ describe('generateSlabGeometry', () => { test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => { const slab = SlabNode.parse({ elevation: -0.2, - polygon: [ - [0, 0], - [4, 0], - [4, 3], - [0, 3], - ], + recessed: true, + polygon: SQUARE, holes: [ [ [1, -0.5], @@ -69,4 +76,47 @@ describe('generateSlabGeometry', () => { expect(hasVertexAt(geometry, 1, 1)).toBe(true) expect(hasVertexAt(geometry, 3, 1)).toBe(true) }) + + test('solid slab occupies [elevation − thickness, elevation]', () => { + const slab = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon: SQUARE }) + + const ys = uniqueSortedYs(generateSlabGeometry(slab, EMPTY_CONTEXT)) + + expect(ys).toEqual([0.2, 0.3]) + }) + + test('migrated legacy slab (thickness = elevation) reproduces the [0, elevation] extrusion', () => { + const slab = SlabNode.parse({ elevation: 0.05, thickness: 0.05, polygon: SQUARE }) + + const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT) + + // Old-style expectations: extrude-from-zero put the bottom cap at 0 and + // the top cap at `elevation`, with 8 cap verts + 4 side quads (16 verts) + // and 12 triangles for a plain quad slab. + expect(uniqueSortedYs(geometry)).toEqual([0, 0.05]) + expect(geometry.getAttribute('position').count).toBe(24) + expect((geometry.index?.count ?? 0) / 3).toBe(12) + for (const [x, z] of SQUARE) { + expect(hasVertexAt(geometry, x, z)).toBe(true) + } + }) + + test('recess is keyed by the flag, not the elevation sign', () => { + const belowPlaneSolid = SlabNode.parse({ elevation: -0.2, thickness: 0.05, polygon: SQUARE }) + + // Without `recessed`, a negative elevation is just a solid placed below + // the level plane: closed body at [-0.25, -0.2], world-space Y baked in. + expect(uniqueSortedYs(generateSlabGeometry(belowPlaneSolid, EMPTY_CONTEXT))).toEqual([ + -0.25, -0.2, + ]) + + // The recessed shell is authored at local Y=0 (floor) up to |elevation| + // (rim), with no top cap: 4 floor verts + 4 wall quads = 20 verts, + // 2 floor + 8 wall triangles. + const pool = SlabNode.parse({ elevation: -0.2, recessed: true, polygon: SQUARE }) + const poolGeometry = generateSlabGeometry(pool, EMPTY_CONTEXT) + expect(uniqueSortedYs(poolGeometry)).toEqual([0, 0.2]) + expect(poolGeometry.getAttribute('position').count).toBe(20) + expect((poolGeometry.index?.count ?? 0) / 3).toBe(10) + }) }) diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index f1d220e2dd..00ba8c88e5 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -1,129 +1,33 @@ import { - type AnyNode, - type AnyNodeId, - getEffectiveNode, getRenderableSlabPolygon, type PolygonPoint2D, pointInPolygon2D, polygonsIntersect, type SlabNode, type SlabPolygonContext, - sceneRegistry, - useScene, - type WallNode, } from '@pascal-app/core' -import { useFrame } from '@react-three/fiber' -import { useEffect } from 'react' import * as THREE from 'three' import { subtractPolygonsFromPolygon } from '../../lib/polygon-union' import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' -function ensureUv2Attribute(geometry: THREE.BufferGeometry) { - const uv = geometry.getAttribute('uv') - if (!uv) return - - geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) -} - // ============================================================================ -// SLAB SYSTEM +// SLAB GEOMETRY GENERATORS // ============================================================================ -export const SlabSystem = () => { - const dirtyNodes = useScene((state) => state.dirtyNodes) - const clearDirty = useScene((state) => state.clearDirty) - const markDirty = useScene((state) => state.markDirty) - - useEffect(() => { - const nodes = useScene.getState().nodes - for (const node of Object.values(nodes)) { - if (node.type === 'slab') { - markDirty(node.id) - } - } - }, [markDirty]) - - useFrame(() => { - if (dirtyNodes.size === 0) return - - const nodes = useScene.getState().nodes - const contextByLevel = new Map() - - // Process dirty slabs - dirtyNodes.forEach((id) => { - const node = nodes[id] - if (node?.type !== 'slab') return - - const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh - if (mesh) { - const slab = node as SlabNode - const levelContext = - contextByLevel.get(slab.parentId) ?? buildLevelSlabContext(slab.parentId, nodes) - contextByLevel.set(slab.parentId, levelContext) - updateSlabGeometry( - getEffectiveNode(slab), - excludeSlabFromContext(levelContext, slab.id), - mesh, - ) - clearDirty(id as AnyNodeId) - } - // If mesh not found, keep it dirty for next frame - }) - }, 1) - - return null -} - -function buildLevelSlabContext( - levelId: string | null, - nodes: Record, -): SlabPolygonContext { - const walls: WallNode[] = [] - const siblingSlabs: SlabNode[] = [] - for (const node of Object.values(nodes)) { - if (node.parentId !== levelId) continue - if (node.type === 'wall') walls.push(node as WallNode) - else if (node.type === 'slab') siblingSlabs.push(node as SlabNode) - } - return { walls, siblingSlabs } -} - -function excludeSlabFromContext(context: SlabPolygonContext, slabId: string): SlabPolygonContext { - return { - walls: context.walls, - siblingSlabs: context.siblingSlabs.filter((slab) => slab.id !== slabId), - } -} - /** - * Updates the geometry for a single slab - */ -function updateSlabGeometry(node: SlabNode, context: SlabPolygonContext, mesh: THREE.Mesh) { - const newGeo = generateSlabGeometry(node, context) - ensureUv2Attribute(newGeo) - - mesh.geometry.dispose() - mesh.geometry = newGeo - - // For negative elevation, shift the mesh down so the top face sits at Y=elevation - // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level). - const elevation = node.elevation ?? 0.05 - mesh.position.y = elevation < 0 ? elevation : 0 -} - -/** - * Generates extruded slab geometry from polygon. `context` carries the - * slab's level neighbourhood (walls + sibling slabs) driving the per-edge - * render offsets — see `getRenderableSlabPolygon`. + * Generates slab geometry from polygon. `context` carries the slab's level + * neighbourhood (walls + sibling slabs) driving the per-edge render offsets — + * see `getRenderableSlabPolygon`. Branches on the explicit `recessed` intent: + * a recessed slab is an open shell (pool), everything else a solid occupying + * `[elevation − thickness, elevation]`. */ export function generateSlabGeometry( slabNode: SlabNode, context: SlabPolygonContext, ): THREE.BufferGeometry { - const elevation = slabNode.elevation ?? 0.05 - return elevation < 0 + return slabNode.recessed ? generatePoolGeometry(slabNode, context) - : generatePositiveSlabGeometry(slabNode, context) + : generateSolidSlabGeometry(slabNode, context) } // Earcut normalizes cap triangulation regardless of input winding, but the side @@ -175,7 +79,8 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) } /** - * Standard slab: flat extrusion upward from Y=0 by elevation thickness. + * Solid slab occupying `[elevation − thickness, elevation]`: the top cap is + * the walking surface at `elevation`, the body grows downward by `thickness`. * * Built directly in 3D (Y-up) rather than via ExtrudeGeometry so the hole side * walls can be emitted double-sided. The slab material is forced to FrontSide @@ -186,12 +91,14 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) * thickness visible from any angle: the two coincident triangles never z-fight * because exactly one faces the camera under FrontSide culling. */ -function generatePositiveSlabGeometry( +function generateSolidSlabGeometry( slabNode: SlabNode, context: SlabPolygonContext, ): THREE.BufferGeometry { const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context)) const elevation = slabNode.elevation ?? 0.05 + const thickness = slabNode.thickness ?? 0.05 + const bottom = elevation - thickness const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) if (polygon.length < 3) return new THREE.BufferGeometry() @@ -207,14 +114,14 @@ function generatePositiveSlabGeometry( const addWall = (a: THREE.Vector2, b: THREE.Vector2, flipped: boolean) => { const base = positions.length / 3 const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001) - positions.push(a.x, 0, a.y) + positions.push(a.x, bottom, a.y) uvs.push(0, 0) - positions.push(b.x, 0, b.y) + positions.push(b.x, bottom, b.y) uvs.push(len, 0) positions.push(b.x, elevation, b.y) - uvs.push(len, elevation) + uvs.push(len, thickness) positions.push(a.x, elevation, a.y) - uvs.push(0, elevation) + uvs.push(0, thickness) // Standard winding on a CCW polygon gives inward-facing normals (see pool // path), so the unflipped quad faces outward; flipped is its back face. if (!flipped) { @@ -244,7 +151,7 @@ function generatePositiveSlabGeometry( } const bottomBase = positions.length / 3 for (const p of capPoints) { - positions.push(p.x, 0, p.y) + positions.push(p.x, bottom, p.y) uvs.push(p.x, -p.y) } @@ -303,7 +210,7 @@ function generatePoolGeometry( const pushFloorVertex = (x: number, y: number, z: number) => { positions.push(x, y, z) - // Floor UVs in metres (shape-space x, -z), matching generatePositiveSlabGeometry's + // Floor UVs in metres (shape-space x, -z), matching generateSolidSlabGeometry's // cap mapping so a finish tiles at the same world scale on every surface. uvs.push(x, -z) } diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index ebee5f3b02..929accfe51 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -2,7 +2,11 @@ import { type AnyNodeId, emitter, getWallFaceBandConfig, + getWallPlaneTop, + resolveLevelId, + resolveWallEffectiveHeight, sceneRegistry, + spatialGridManager, useScene, type WallNode, } from '@pascal-app/core' @@ -130,8 +134,22 @@ export const WallCutout = () => { const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const isDeleteHighlighted = deleteHoveredWallId === wallId const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) + const levelId = resolveLevelId(wallNode, sceneState.nodes) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wallNode.start, + wallNode.end, + wallNode.curveOffset ?? 0, + wallNode.thickness, + wallNode.supportSlabId, + ) + const effectiveWallHeight = resolveWallEffectiveHeight( + wallNode, + getWallPlaneTop(wallNode, levelId, sceneState.nodes), + support.elevation, + ) const shouldSelectionHighlight = - isSelectionHighlighted && !getWallFaceBandConfig(wallNode).enabled + isSelectionHighlighted && !getWallFaceBandConfig(wallNode, effectiveWallHeight).enabled const materials = getMaterialsForWall( wallNode, shading, diff --git a/packages/viewer/src/systems/wall/wall-support-extension.test.ts b/packages/viewer/src/systems/wall/wall-support-extension.test.ts index 881fb29e6c..3c8516b065 100644 --- a/packages/viewer/src/systems/wall/wall-support-extension.test.ts +++ b/packages/viewer/src/systems/wall/wall-support-extension.test.ts @@ -1,7 +1,13 @@ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' -import { calculateLevelMiters, WallNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + calculateLevelMiters, + getWallPlaneTop, + WallNode, +} from '@pascal-app/core' import { generateExtrudedWall } from './wall-system' describe('wall support extension', () => { @@ -31,6 +37,96 @@ describe('wall support extension', () => { geometry.dispose() }) + test('plane-bound wall tops out at the storey plane regardless of slab elevation', () => { + const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1 }) + + const flat = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0, 0, undefined, 3) + flat.computeBoundingBox() + expect(flat.boundingBox?.max.y).toBeCloseTo(3) + expect(flat.boundingBox?.min.y).toBeCloseTo(0) + flat.dispose() + + const raised = generateExtrudedWall( + wall, + [], + calculateLevelMiters([wall]), + 0.6, + 0.6, + undefined, + 3, + ) + raised.computeBoundingBox() + // Mesh sits at Y=0.6, so a 2.4 local top keeps the world top at the 3m + // plane — the raised slab shortens the wall instead of lifting its top. + expect(raised.boundingBox?.max.y).toBeCloseTo(2.4) + expect(raised.boundingBox?.min.y).toBeCloseTo(0) + raised.dispose() + }) + + test('plane-bound wall under a flush thick deck tops out at the deck underside', () => { + // level_1 carries a flush deck occupying [-0.3, 0] above the storey + // plane: the covering-clamped plane for level_0 is 2.5 − 0.3 = 2.2. + const wall = WallNode.parse({ + start: [0.5, 2], + end: [3.5, 2], + thickness: 0.1, + parentId: 'level_0', + }) + const base = { object: 'node', parentId: null, visible: true, metadata: {}, children: [] } + const nodes = { + level_0: { + ...base, + id: 'level_0', + type: 'level', + level: 0, + height: 2.5, + children: [wall.id], + }, + level_1: { + ...base, + id: 'level_1', + type: 'level', + level: 1, + height: 2.5, + children: ['slab_deck'], + }, + slab_deck: { + ...base, + id: 'slab_deck', + type: 'slab', + parentId: 'level_1', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + holes: [], + elevation: 0, + thickness: 0.3, + }, + } as unknown as Record + + const planeTop = getWallPlaneTop(wall, 'level_0', nodes) + expect(planeTop).toBeCloseTo(2.2) + + const geometry = generateExtrudedWall( + wall, + [], + calculateLevelMiters([wall]), + 0, + 0, + undefined, + planeTop, + ) + geometry.computeBoundingBox() + // Mesh sits at Y=0, so the world top lands at the deck underside instead + // of colliding with the slab solid above. + expect(geometry.boundingBox?.max.y).toBeCloseTo(2.2) + expect(geometry.boundingBox?.min.y).toBeCloseTo(0) + geometry.dispose() + }) + test('raises only the high-supported part of a mixed wall run', () => { const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 }) const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.6, 0.05, [ diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 20b0eaa8bd..754b02efbb 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -2,7 +2,7 @@ import { type AnyNode, type AnyNodeId, calculateLevelMiters, - DEFAULT_WALL_HEIGHT, + DEFAULT_LEVEL_HEIGHT, type DoorNode, getAdjacentWallIds, getEffectiveNode, @@ -11,6 +11,7 @@ import { getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, + getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, @@ -18,6 +19,7 @@ import { type Point2D, pointToKey, resolveLevelId, + resolveWallTop, sceneRegistry, spatialGridManager, useLiveNodeOverrides, @@ -222,15 +224,16 @@ function getWallFaceMaterialIndex( wall: Pick, face: 'front' | 'back', y: number, + effectiveWallHeight: number, ): number { const semantic = face === 'front' ? wall.frontSide : wall.backSide const fallback: WallSurfaceSide = face === 'front' ? 'interior' : 'exterior' const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback - const bands = getWallFaceBandConfig(wall) + const bands = getWallFaceBandConfig(wall, effectiveWallHeight) if (!bands.enabled) return WALL_BAND_SLOT_MATERIAL_INDEX[side] - const band = getWallFaceBandForHeight(wall, y) + const band = getWallFaceBandForHeight(wall, y, effectiveWallHeight) return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)] } @@ -238,6 +241,7 @@ function assignWallMaterialGroups( geometry: THREE.BufferGeometry, wall: WallNode, boundaryEdges: TaggedWallBoundaryEdge[], + effectiveWallHeight: number, ) { const position = geometry.getAttribute('position') if (!position) return @@ -317,7 +321,12 @@ function assignWallMaterialGroups( continue } - triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y) + triangleMaterials[triangleIndex] = getWallFaceMaterialIndex( + wall, + nearestTag, + centroid.y, + effectiveWallHeight, + ) } geometry.clearGroups() @@ -445,15 +454,15 @@ function splitGeometryAtHorizontalPlanes( return split } -function getWallBandSplitPlanes(wall: WallNode): number[] { - const bands = getWallFaceBandConfig(wall) +function getWallBandSplitPlanes(wall: WallNode, effectiveWallHeight: number): number[] { + const bands = getWallFaceBandConfig(wall, effectiveWallHeight) if (!bands.enabled) return [] const planes = [bands.lowerTop] if (bands.count >= 3) planes.push(bands.middleTop) if (bands.count >= 4) planes.push(bands.upperTop) return planes.filter( (plane) => - plane > WALL_BAND_SPLIT_EPSILON && plane < (wall.height ?? 2.5) - WALL_BAND_SPLIT_EPSILON, + plane > WALL_BAND_SPLIT_EPSILON && plane < effectiveWallHeight - WALL_BAND_SPLIT_EPSILON, ) } @@ -695,12 +704,16 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { if (!mesh) return const levelId = resolveLevelId(node, nodes) + // Covering-clamped plane: a flush/thick slab on the level above shortens + // the plane-bound walls below it (explicit-height walls ignore the value). + const planeTop = getWallPlaneTop(node, levelId, nodes) const slabSupport = spatialGridManager.getSlabSupportForWall( levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, + node.supportSlabId, ) const slabElevation = slabSupport.elevation @@ -731,6 +744,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, + planeTop, ) const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) // World transform the render mesh will apply (position + Y-rotation below). @@ -755,6 +769,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, + planeTop, ) collisionMesh.geometry.dispose() collisionMesh.geometry = collisionGeo @@ -845,14 +860,20 @@ export function generateExtrudedWall( baseSegments: readonly WallSlabSupportSegment[] = [ { start: 0, end: 1, elevation: baseElevation }, ], + storeyHeight = DEFAULT_LEVEL_HEIGHT, ): THREE.BufferGeometry { const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } - const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT - const topElevation = slabElevation > 0 ? slabElevation + wallHeight : wallHeight + const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation) + const effectiveWallHeight = topElevation - slabElevation const effectiveBaseElevation = Math.min(baseElevation, slabElevation) const localBottom = effectiveBaseElevation - slabElevation const height = topElevation - effectiveBaseElevation + // A slab at or above the storey plane leaves a plane-bound wall with no + // body — bail before ExtrudeGeometry sees a non-positive depth. + if (height <= 1e-9) { + return new THREE.BufferGeometry() + } const thickness = getWallThickness(wallNode) @@ -913,7 +934,7 @@ export function generateExtrudedWall( geometry.rotateX(-Math.PI / 2) if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) geometry.computeVertexNormals() - assignWallMaterialGroups(geometry, wallNode, boundaryEdges) + assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry) // Start with the lowest required wall prism, then remove the volume below @@ -1015,10 +1036,10 @@ export function generateExtrudedWall( if (cutoutBrushes.length === 0) { const splitGeometry = splitGeometryAtHorizontalPlanes( geometry, - getWallBandSplitPlanes(wallNode), + getWallBandSplitPlanes(wallNode, effectiveWallHeight), ) splitGeometry.computeVertexNormals() - assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges) + assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitGeometry) return splitGeometry } @@ -1052,10 +1073,10 @@ export function generateExtrudedWall( const resultGeometry = csgGeometry(resultBrush) const splitResultGeometry = splitGeometryAtHorizontalPlanes( resultGeometry, - getWallBandSplitPlanes(wallNode), + getWallBandSplitPlanes(wallNode, effectiveWallHeight), ) splitResultGeometry.computeVertexNormals() - assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges) + assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitResultGeometry) return splitResultGeometry