From cecd7e89caa8f8094e6495f49414c62b20d2a853 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 11:17:57 -0400 Subject: [PATCH 01/24] =?UTF-8?q?feat(core):=20stored=20storey=20heights?= =?UTF-8?q?=20groundwork=20=E2=80=94=20pure=20slab-support=20module=20+=20?= =?UTF-8?q?level=20height=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract pointInPolygon/computeWallSlabSupport and friends into a cycle-free packages/core/src/systems/slab/slab-support.ts (severs level-height -> spatial-grid-manager -> use-scene), add deriveLegacyLevelHeight as the pure mesh-free equivalent of the viewer's stacked level height, and add the optional LevelNode.height field plus the storey service (getStoredLevelHeight, getLevelElevations per-building prefix sums). No behavior change. Co-Authored-By: Claude Fable 5 --- .../spatial-grid/spatial-grid-manager.ts | 544 +----------------- packages/core/src/schema/nodes/level.test.ts | 5 + packages/core/src/schema/nodes/level.ts | 7 + packages/core/src/services/index.ts | 5 + .../core/src/services/level-height.test.ts | 145 +++++ packages/core/src/services/level-height.ts | 44 +- packages/core/src/services/storey.test.ts | 162 ++++++ packages/core/src/services/storey.ts | 79 +++ .../core/src/systems/slab/slab-support.ts | 531 +++++++++++++++++ 9 files changed, 991 insertions(+), 531 deletions(-) create mode 100644 packages/core/src/services/level-height.test.ts create mode 100644 packages/core/src/services/storey.test.ts create mode 100644 packages/core/src/services/storey.ts create mode 100644 packages/core/src/systems/slab/slab-support.ts 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..3a720a1c53 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,37 +1,31 @@ -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 useScene from '../../store/use-scene' -import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' +import { + computeWallSlabSupport, + pointInPolygon, + type WallSlabSupport, +} from '../../systems/slab/slab-support' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' +export { + computeWallSlabElevation, + computeWallSlabSupport, + pointInPolygon, + 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,514 +289,6 @@ 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. */ - 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 - elevation: number -} - -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 class SpatialGridManager { private readonly floorGrids = new Map() // levelId -> grid private readonly wallGrids = new Map() // levelId -> wall grid 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/services/index.ts b/packages/core/src/services/index.ts index 85517d5cab..7eec953716 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -102,6 +102,11 @@ export { snapVec3ToGrid, snapWorldXZToBuildingLocal, } from './snap' +export { + getLevelElevations, + getStoredLevelHeight, + 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..84386598b4 --- /dev/null +++ b/packages/core/src/services/level-height.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'bun:test' +import { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { computeWallSlabSupport } from '../systems/slab/slab-support' +import { deriveLegacyLevelHeight, getLevelHeight, type WallBaseYResolver } 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 +} + +function createWallBaseResolver( + levelId: string, + nodes: Record, +): WallBaseYResolver { + const level = nodes[levelId as AnyNodeId] + if (level?.type !== 'level') return () => undefined + + const children = level.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((child): child is AnyNode => child !== undefined) + const slabs = children.filter((child): child is SlabNode => child.type === 'slab') + const walls = children.filter((child): child is WallNode => child.type === 'wall') + + return (wallId) => { + const wall = nodes[wallId] + if (wall?.type !== 'wall') return undefined + return computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + } +} + +describe('deriveLegacyLevelHeight', () => { + const nodes = createFixture() + const cases = [ + ['level_no_slab', 2.5], + ['level_standard_slab', 2.55], + ['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(`matches getLevelHeight for ${levelId}`, () => { + const derived = deriveLegacyLevelHeight(levelId, nodes) + const resolved = getLevelHeight(levelId, nodes, createWallBaseResolver(levelId, nodes)) + + expect(derived).toBe(resolved) + expect(derived).toBeCloseTo(expected) + }) + } +}) diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 016711ac4c..30087bdc81 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,6 +1,7 @@ -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 { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' export const DEFAULT_LEVEL_HEIGHT = 2.5 @@ -42,6 +43,45 @@ export function getLevelHeight( return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT } +export function deriveLegacyLevelHeight( + levelId: string, + nodes: Record, +): 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 child of levelChildren) { + if (child.type === 'ceiling') { + const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT + if (height > maxTop) maxTop = height + } else if (child.type === 'wall') { + 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 = Math.max(0, electedElevation) + (wall.height ?? DEFAULT_WALL_HEIGHT) + if (top > maxTop) maxTop = top + } + } + + return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT +} + /** * The ceiling covering level-local point `[x, z]`, or `null` when none * sits over it. Points inside a ceiling's hole are treated as uncovered. diff --git a/packages/core/src/services/storey.test.ts b/packages/core/src/services/storey.test.ts new file mode 100644 index 0000000000..c9f2058f58 --- /dev/null +++ b/packages/core/src/services/storey.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from 'bun:test' +import { BuildingNode, LevelNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { DEFAULT_LEVEL_HEIGHT } from './level-height' +import { getLevelElevations, getStoredLevelHeight } 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 } = {}, +): LevelNode => + LevelNode.parse({ + id, + level: ordinal, + parentId: opts.parentId ?? null, + ...(opts.height === undefined ? {} : { height: opts.height }), + }) + +const building = (id: string, children: string[]): BuildingNode => + BuildingNode.parse({ id, children }) + +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) + }) +}) diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts new file mode 100644 index 0000000000..b853dd2d07 --- /dev/null +++ b/packages/core/src/services/storey.ts @@ -0,0 +1,79 @@ +import type { BuildingNode, LevelNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { DEFAULT_LEVEL_HEIGHT } from './level-height' + +/** + * 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 the same way viewer level stacking does: + * 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 +} 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..5095671bf0 --- /dev/null +++ b/packages/core/src/systems/slab/slab-support.ts @@ -0,0 +1,531 @@ +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' + +/** + * 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 + +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. */ + 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 + elevation: number +} + +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 +} From 437cea2620ba3d7ec2e43a0dbaa1ac53fe108702 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 11:17:57 -0400 Subject: [PATCH 02/24] feat(editor): storey height badge + edit popover on level rows Each floating-level-selector row shows its storey height; clicking opens a popover with 2.5/3.0/3.5 presets and a free slider writing level.height. Co-Authored-By: Claude Fable 5 --- .../components/ui/floating-level-selector.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index ef922dfaae..1a3ad65457 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -22,6 +22,7 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + getStoredLevelHeight, LevelNode, useScene, } from '@pascal-app/core' @@ -49,7 +50,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 +149,12 @@ function LevelRow({ }) { const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) + const updateNode = useScene((s) => s.updateNode) + const { 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}` return (
@@ -195,6 +205,55 @@ 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} + /> +
+ updateNode(level.id, { height: 2.5 })} + /> + updateNode(level.id, { height: 3.0 })} + /> + updateNode(level.id, { height: 3.5 })} + /> +
+
+
+ {/* Vertical three-dot menu — inside the pill */} From eb81863903742db227dd75deb7b3b04ff0493b56 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 11:30:56 -0400 Subject: [PATCH 03/24] =?UTF-8?q?feat(core):=20vertical-model=20load=20mig?= =?UTF-8?q?ration=20=E2=80=94=20stored=20heights,=20ordinal=20compaction,?= =?UTF-8?q?=20wall-top=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass 3 in migrateNodes: derive and store each legacy level's exact stacked height (never snapped), compact ordinals per building anchored at zero so basements stay basements, classify wall tops against the derived plane (|plane - top| < 0.20 strictly -> plane-bound, else explicit height materialized), and drop the blind totalRise 2.5 stair default on legacy scenes only. Epsilon and strictness validated by a prod census. Co-Authored-By: Claude Fable 5 --- .../core/src/store/use-scene-commits.test.ts | 6 +- .../use-scene-vertical-migration.test.ts | 245 ++++++++++++++++++ packages/core/src/store/use-scene.ts | 132 +++++++++- 3 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/store/use-scene-vertical-migration.test.ts 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..fc9e908572 --- /dev/null +++ b/packages/core/src/store/use-scene-vertical-migration.test.ts @@ -0,0 +1,245 @@ +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, +): RawNode { + return baseNode(id, 'ceiling', levelId, { polygon, holes: [], height }) +} + +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 + +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.55 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]), + }) + + // Exactly as derived: 0.05 slab support + 2.5 default wall — never snapped. + expect((nodes.level_a as LevelResult).height).toBe(0.05 + 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('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('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..e550a6eab1 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -32,6 +32,9 @@ import { type SceneMaterialId, } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' +import { deriveLegacyLevelHeight } from '../services/level-height' +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 +94,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 +105,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 +121,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 +569,13 @@ function migrateRoofSurfaceMaterials(node: Record) { return next } +// Walls whose top lands within this of the storey plane become plane-bound. +// 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 WALL_PLANE_BOUND_EPSILON = 0.2 + function migrateNodes(nodes: Record): { nodes: Record mintedMaterials: Record @@ -886,6 +903,116 @@ 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) < WALL_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 + } + } + return { nodes: patchedNodes as Record, mintedMaterials } } @@ -1163,6 +1290,7 @@ const useScene: UseSceneStore = create()( const level0 = LevelNode.parse({ level: 0, children: [], + height: 2.5, }) const building = BuildingNode.parse({ From 7fb50f9105ed265194745b559f41b1bcdebd98ec Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 11:38:25 -0400 Subject: [PATCH 04/24] feat: pin wall tops to the storey plane; stored heights become the only vertical truth Wall-top inversion: a wall without an explicit height now tops out at its storey plane (resolveWallTop); slabs lift only the base. Window/door caps resolve the real top through the same slab election instead of Infinity. All level stacking (viewer, elevator, first-person, stair openings, MCP scene queries) reads stored LevelNode.height; the four divergent live derivations and level.metadata.height are deleted. Stair totalRise becomes optional and derives from the storey height when absent. MCP create-level stops writing its elevation param into the ordinal. Level creation sites write explicit heights; templates carry their true derived heights. Co-Authored-By: Claude Fable 5 --- packages/core/src/index.ts | 4 +- packages/core/src/schema/nodes/stair.ts | 2 +- packages/core/src/services/index.ts | 1 - .../core/src/services/level-height.test.ts | 40 +---------- packages/core/src/services/level-height.ts | 38 ----------- packages/core/src/services/storey.ts | 6 +- .../src/systems/elevator/elevator-service.ts | 36 +--------- .../src/systems/stair/stair-opening-sync.ts | 31 +++++---- packages/core/src/systems/stair/stair-rise.ts | 12 ++++ .../core/src/systems/wall/wall-top.test.ts | 45 +++++++++++++ packages/core/src/systems/wall/wall-top.ts | 44 ++++++++++++ .../editor/first-person-controls.tsx | 32 ++------- .../src/components/tools/stair/stair-tool.tsx | 1 - .../ui/command-palette/editor-commands.tsx | 3 +- .../components/ui/floating-level-selector.tsx | 4 ++ .../panels/site-panel/building-tree-node.tsx | 3 +- .../ui/sidebar/panels/site-panel/index.tsx | 2 + .../editor/src/lib/level-duplication.test.ts | 3 +- packages/editor/src/lib/stair-levels.ts | 2 + packages/mcp/src/templates/empty-studio.ts | 2 + packages/mcp/src/templates/garden-house.ts | 1 + packages/mcp/src/templates/two-bedroom.ts | 1 + packages/mcp/src/tools/construction-tools.ts | 37 ++++++---- packages/mcp/src/tools/create-level.test.ts | 8 ++- packages/mcp/src/tools/create-level.ts | 21 ++++-- .../tools/photo-to-scene/photo-to-scene.ts | 2 +- packages/mcp/src/tools/scene-query.ts | 11 +-- packages/nodes/src/door/definition.ts | 9 +-- packages/nodes/src/level/definition.ts | 12 +++- .../nodes/src/shared/wall-opening-ceiling.ts | 62 +++++++++++++++++ packages/nodes/src/stair/definition.ts | 16 +++-- packages/nodes/src/stair/panel.tsx | 3 +- packages/nodes/src/stair/renderer.tsx | 6 +- packages/nodes/src/wall/system.tsx | 2 +- packages/nodes/src/window/definition.ts | 13 ++-- packages/nodes/src/window/floorplan-move.ts | 1 + packages/nodes/src/window/move-tool.tsx | 1 + packages/nodes/src/window/tool.tsx | 9 ++- packages/nodes/src/window/window-math.ts | 12 +++- .../src/systems/level/level-stacking.test.ts | 67 ------------------- .../src/systems/level/level-stacking.ts | 32 --------- .../viewer/src/systems/level/level-system.tsx | 26 +------ .../viewer/src/systems/level/level-utils.ts | 26 +------ .../wall/wall-support-extension.test.ts | 26 +++++++ .../viewer/src/systems/wall/wall-system.tsx | 26 ++++--- 45 files changed, 369 insertions(+), 372 deletions(-) create mode 100644 packages/core/src/systems/stair/stair-rise.ts create mode 100644 packages/core/src/systems/wall/wall-top.test.ts create mode 100644 packages/core/src/systems/wall/wall-top.ts create mode 100644 packages/nodes/src/shared/wall-opening-ceiling.ts delete mode 100644 packages/viewer/src/systems/level/level-stacking.test.ts delete mode 100644 packages/viewer/src/systems/level/level-stacking.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1147e04c1a..db32e3653f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -245,9 +245,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, @@ -270,6 +268,7 @@ export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/sta 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 +309,7 @@ export { type WallMoveLinkedWallTargetPlan, type WallPlanPoint, } from './systems/wall/wall-move' +export { 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/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index abc1fe54a6..5b71186348 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -43,7 +43,7 @@ export const StairNode = BaseNode.extend({ 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), diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 7eec953716..58ad461461 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -46,7 +46,6 @@ export { DEFAULT_LEVEL_HEIGHT, getCeilingAt, getCeilingHeightAt, - getLevelHeight, } from './level-height' export { type AxisLock, diff --git a/packages/core/src/services/level-height.test.ts b/packages/core/src/services/level-height.test.ts index 84386598b4..0316412679 100644 --- a/packages/core/src/services/level-height.test.ts +++ b/packages/core/src/services/level-height.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'bun:test' import { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' -import { computeWallSlabSupport } from '../systems/slab/slab-support' -import { deriveLegacyLevelHeight, getLevelHeight, type WallBaseYResolver } from './level-height' +import { deriveLegacyLevelHeight } from './level-height' function createFixture(): Record { const nodes: AnyNode[] = [ @@ -93,35 +92,6 @@ function createFixture(): Record { return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record } -function createWallBaseResolver( - levelId: string, - nodes: Record, -): WallBaseYResolver { - const level = nodes[levelId as AnyNodeId] - if (level?.type !== 'level') return () => undefined - - const children = level.children - .map((childId) => nodes[childId as AnyNodeId]) - .filter((child): child is AnyNode => child !== undefined) - const slabs = children.filter((child): child is SlabNode => child.type === 'slab') - const walls = children.filter((child): child is WallNode => child.type === 'wall') - - return (wallId) => { - const wall = nodes[wallId] - if (wall?.type !== 'wall') return undefined - return computeWallSlabSupport( - { - start: wall.start, - end: wall.end, - curveOffset: wall.curveOffset, - thickness: wall.thickness, - }, - slabs, - walls, - ).elevation - } -} - describe('deriveLegacyLevelHeight', () => { const nodes = createFixture() const cases = [ @@ -134,12 +104,8 @@ describe('deriveLegacyLevelHeight', () => { ] as const for (const [levelId, expected] of cases) { - it(`matches getLevelHeight for ${levelId}`, () => { - const derived = deriveLegacyLevelHeight(levelId, nodes) - const resolved = getLevelHeight(levelId, nodes, createWallBaseResolver(levelId, nodes)) - - expect(derived).toBe(resolved) - expect(derived).toBeCloseTo(expected) + it(`derives ${expected} for ${levelId}`, () => { + expect(deriveLegacyLevelHeight(levelId, nodes)).toBeCloseTo(expected) }) } }) diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 30087bdc81..95cb3b3b99 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -5,44 +5,6 @@ import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' 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. - */ -export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined - -export function getLevelHeight( - levelId: string, - nodes: Record, - resolveWallBaseY?: WallBaseYResolver, -): number { - const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined - if (!level) return DEFAULT_LEVEL_HEIGHT - - let maxTop = 0 - - for (const childId of level.children) { - const child = nodes[childId as keyof typeof nodes] - if (!child) continue - if (child.type === 'ceiling') { - const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT - if (ch > maxTop) maxTop = ch - } 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) - if (top > maxTop) maxTop = top - } - } - - return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT -} - export function deriveLegacyLevelHeight( levelId: string, nodes: Record, diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts index b853dd2d07..74f54279cf 100644 --- a/packages/core/src/services/storey.ts +++ b/packages/core/src/services/storey.ts @@ -21,9 +21,9 @@ export type LevelElevation = { } /** - * Resolves the owning building the same way viewer level stacking does: - * explicit `parentId` pointing at a building wins; legacy levels that only - * appear in a building's `children` array resolve through that membership. + * 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'], 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/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 36b1c2ffa4..8747ed9bec 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 { getLevelElevations } from '../../services/storey' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' 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,15 +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) { + const elevations = getLevelElevations(nodes as Record) + const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined + const ceilingElevation = elevations.get(ceilingLevelId) + + if ( + !fromElevation || + !ceilingElevation || + fromElevation.buildingId !== ceilingElevation.buildingId + ) { return ceiling.height ?? DEFAULT_WALL_HEIGHT } return ( - (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + + ceilingElevation.baseY - + fromElevation.baseY + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - (stair.position[1] ?? 0) ) 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..105a68d442 --- /dev/null +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -0,0 +1,12 @@ +import type { AnyNode, StairNode } 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), + ) + return level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT +} 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..142d73fc4e --- /dev/null +++ b/packages/core/src/systems/wall/wall-top.ts @@ -0,0 +1,44 @@ +import type { WallNode } from '../../schema/nodes/wall' + +/** + * 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/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/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 8711909e37..02178f1a77 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -150,7 +150,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, 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 1a3ad65457..0f0786901b 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -22,6 +22,7 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + DEFAULT_LEVEL_HEIGHT, getStoredLevelHeight, LevelNode, useScene, @@ -430,6 +431,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, }) @@ -442,6 +444,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, }) @@ -468,6 +471,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/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/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..40b4663c74 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' @@ -217,6 +218,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..050565345a 100644 --- a/packages/mcp/src/templates/garden-house.ts +++ b/packages/mcp/src/templates/garden-house.ts @@ -250,6 +250,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..91012d58b5 100644 --- a/packages/mcp/src/templates/two-bedroom.ts +++ b/packages/mcp/src/templates/two-bedroom.ts @@ -274,6 +274,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..15ff6fff17 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, @@ -100,7 +101,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(), @@ -372,12 +373,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 +461,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 +471,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/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index 96d2d2b1f9..fd590ce978 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 @@ -235,7 +235,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. diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index 22f7cc2d0e..89e661f428 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -1,4 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { getStoredLevelHeight, resolveStairTotalRise } from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneOperations } from '../operations' @@ -308,7 +309,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,13 +658,7 @@ 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', )) { diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 8b335cca9d..b4f45ffc0a 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/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/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts new file mode 100644 index 0000000000..a43640f72a --- /dev/null +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -0,0 +1,62 @@ +import { + type AnyNode, + type AnyNodeId, + DEFAULT_LEVEL_HEIGHT, + 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 level = wall.parentId ? nodes[wall.parentId as AnyNodeId] : undefined + const storeyHeight = + level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + const support = spatialGridManager.getSlabSupportForWall( + wall.parentId ?? 'default', + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + ) + return resolveWallEffectiveHeight(wall, storeyHeight, 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/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/panel.tsx b/packages/nodes/src/stair/panel.tsx index 00297893af..a57112df1e 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, type LevelNode, + resolveStairTotalRise, type StairNode, type StairRailingMode, type StairSegmentNode, @@ -389,7 +390,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/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/window/definition.ts b/packages/nodes/src/window/definition.ts index 6c6e8b4382..6c954d1164 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 c0418f9986..73e55bc7a0 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -285,7 +285,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/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/wall/wall-support-extension.test.ts b/packages/viewer/src/systems/wall/wall-support-extension.test.ts index 881fb29e6c..a94d012280 100644 --- a/packages/viewer/src/systems/wall/wall-support-extension.test.ts +++ b/packages/viewer/src/systems/wall/wall-support-extension.test.ts @@ -31,6 +31,32 @@ 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('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..2bc00199df 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, @@ -18,6 +18,7 @@ import { type Point2D, pointToKey, resolveLevelId, + resolveWallTop, sceneRegistry, spatialGridManager, useLiveNodeOverrides, @@ -445,15 +446,14 @@ function splitGeometryAtHorizontalPlanes( return split } -function getWallBandSplitPlanes(wall: WallNode): number[] { +function getWallBandSplitPlanes(wall: WallNode, wallTopLocalY: number): number[] { const bands = getWallFaceBandConfig(wall) 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) => plane > WALL_BAND_SPLIT_EPSILON && plane < wallTopLocalY - WALL_BAND_SPLIT_EPSILON, ) } @@ -695,6 +695,9 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { if (!mesh) return const levelId = resolveLevelId(node, nodes) + const level = nodes[levelId as AnyNodeId] + const storeyHeight = + level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT const slabSupport = spatialGridManager.getSlabSupportForWall( levelId, node.start, @@ -731,6 +734,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, + storeyHeight, ) 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 +759,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, + storeyHeight, ) collisionMesh.geometry.dispose() collisionMesh.geometry = collisionGeo @@ -845,14 +850,19 @@ 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 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) @@ -1015,7 +1025,7 @@ export function generateExtrudedWall( if (cutoutBrushes.length === 0) { const splitGeometry = splitGeometryAtHorizontalPlanes( geometry, - getWallBandSplitPlanes(wallNode), + getWallBandSplitPlanes(wallNode, topElevation - slabElevation), ) splitGeometry.computeVertexNormals() assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges) @@ -1052,7 +1062,7 @@ export function generateExtrudedWall( const resultGeometry = csgGeometry(resultBrush) const splitResultGeometry = splitGeometryAtHorizontalPlanes( resultGeometry, - getWallBandSplitPlanes(wallNode), + getWallBandSplitPlanes(wallNode, topElevation - slabElevation), ) splitResultGeometry.computeVertexNormals() assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges) From 03a48c9a253c3ea819adfdd00e57df0eace636bb Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 11:58:46 -0400 Subject: [PATCH 05/24] feat: clamp slabs/ceilings under the storey plane; sweep wall-height fallbacks; wall Top control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slab elevation writes clamp to plane − MIN_WALL_HEIGHT when plane-bound walls elect the slab (pure clampSlabElevationForWalls + registry handle bounds + shared panels for 2D/3D parity); ceiling heights clamp under the plane and auto-ceilings derive from resolved wall tops. Every remaining wall.height ?? 2.5 fallback resolves through resolveWallTop / resolveWallEffectiveHeight (panels, overlays, measurements, quantities, spatial grid, MCP reports); template walls matching their storey become plane-bound. Wall panel gains a Top control (Follows storey / Custom height) derived purely from height presence; the store update path now deletes keys passed as explicit undefined so plane-binding round-trips. Co-Authored-By: Claude Fable 5 --- .../spatial-grid/spatial-grid-manager.ts | 19 ++- packages/core/src/index.ts | 11 +- packages/core/src/lib/space-detection.test.ts | 53 ++++++++ packages/core/src/lib/space-detection.ts | 124 ++++++++++-------- packages/core/src/lib/zone-quantities.ts | 15 ++- packages/core/src/schema/nodes/wall.test.ts | 42 +++--- packages/core/src/schema/nodes/wall.ts | 10 +- .../core/src/services/level-height.test.ts | 2 +- packages/core/src/services/level-height.ts | 4 +- .../core/src/store/actions/node-actions.ts | 24 +++- .../actions/node-mutation-sanitize.test.ts | 58 ++++++++ .../use-scene-vertical-migration.test.ts | 5 +- .../src/systems/slab/slab-support.test.ts | 92 +++++++++++++ .../core/src/systems/slab/slab-support.ts | 76 +++++++++++ packages/core/src/systems/wall/wall-top.ts | 9 ++ .../editor/floating-action-menu.tsx | 27 +++- .../editor/wall-measurement-label.tsx | 42 +++--- .../editor/wall-move-side-handles.tsx | 35 ++++- .../editor/wall-snap-beacon-layer.tsx | 11 +- packages/mcp/src/templates/empty-studio.ts | 4 - packages/mcp/src/templates/garden-house.ts | 1 - packages/mcp/src/templates/two-bedroom.ts | 1 - packages/mcp/src/tools/construction-tools.ts | 15 ++- packages/mcp/src/tools/describe-node.ts | 17 ++- .../tools/photo-to-scene/photo-to-scene.ts | 6 +- packages/mcp/src/tools/scene-query.ts | 33 ++++- packages/nodes/src/ceiling/definition.ts | 24 +++- packages/nodes/src/ceiling/panel.tsx | 37 +++++- .../src/shared/opening-guides-runtime.ts | 3 +- .../shared/opening-placement-dimensions.ts | 7 +- packages/nodes/src/slab/definition.ts | 4 + packages/nodes/src/slab/elevation-limit.ts | 64 +++++++++ packages/nodes/src/slab/panel.tsx | 24 +++- packages/nodes/src/wall/measurement.ts | 15 ++- .../nodes/src/wall/move-endpoint-tool.tsx | 4 +- packages/nodes/src/wall/move-shared.ts | 4 +- packages/nodes/src/wall/move-tool.tsx | 5 + packages/nodes/src/wall/paint.ts | 25 +++- packages/nodes/src/wall/panel.tsx | 79 ++++++++--- packages/nodes/src/wall/parametrics.ts | 2 + packages/nodes/src/wall/quick-measurement.ts | 5 +- packages/nodes/src/wall/tool.tsx | 10 +- packages/nodes/src/wall/treatments.tsx | 4 +- .../viewer/src/systems/wall/wall-cutout.tsx | 22 +++- .../viewer/src/systems/wall/wall-system.tsx | 31 +++-- 45 files changed, 899 insertions(+), 206 deletions(-) create mode 100644 packages/core/src/systems/slab/slab-support.test.ts create mode 100644 packages/nodes/src/slab/elevation-limit.ts 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 3a720a1c53..cfc346f6c6 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,6 +1,7 @@ import { nodeRegistry } from '../../registry' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height' import useScene from '../../store/use-scene' import { computeWallSlabSupport, @@ -8,6 +9,7 @@ import { 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' @@ -328,7 +330,22 @@ 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 level = nodes[levelId as AnyNode['id']] + const storeyHeight = + level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + const support = this.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + ) + return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) } private getCeilingGrid(ceilingId: string): SpatialGrid { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db32e3653f..c82649ebfb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -264,6 +264,11 @@ 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' @@ -309,7 +314,11 @@ export { type WallMoveLinkedWallTargetPlan, type WallPlanPoint, } from './systems/wall/wall-move' -export { resolveWallEffectiveHeight, resolveWallTop } from './systems/wall/wall-top' +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/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 708f715380..8042b95a57 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -29,6 +29,17 @@ function squareWalls(height = 2.5) { ] } +// Post wall-top inversion the default wall stores NO height — its top is +// bound to the storey plane. +function squarePlaneBoundWalls() { + return [ + WallNode.parse({ start: [0, 0], end: [4, 0] }), + WallNode.parse({ start: [4, 0], end: [4, 3] }), + WallNode.parse({ start: [4, 3], end: [0, 3] }), + WallNode.parse({ start: [0, 3], end: [0, 0] }), + ] +} + function slab(elevation: number) { return SlabNode.parse({ polygon: square, @@ -38,15 +49,55 @@ function slab(elevation: number) { } describe('planAutoCeilingsForLevel', () => { + // Explicit-height walls keep the legacy elected-base + height top, so + // these expectations survive the wall-top inversion as long as the + // storey plane sits above them (heights clamp to ≤ storeyHeight). test('creates auto ceilings at the top of the room walls', () => { const created = planAutoCeilingsForLevel([roomPolygon()], [], { walls: squareWalls(), slabs: [slab(0.05)], + storeyHeight: 2.7, + }).create[0] + + expect(created?.height).toBeCloseTo(2.55) + }) + + test('creates auto ceilings at the storey plane for plane-bound walls', () => { + // Inversion: a plane-bound wall's top IS the storey plane, whatever + // slab lifts its base — the auto ceiling follows the plane, not the + // old slabElevation + wallHeight sum. + const created = planAutoCeilingsForLevel([roomPolygon()], [], { + walls: squarePlaneBoundWalls(), + slabs: [slab(0.05)], + storeyHeight: 2.55, }).create[0] expect(created?.height).toBeCloseTo(2.55) }) + test('keeps an explicit-height room ceiling based on its wall height', () => { + const created = planAutoCeilingsForLevel([roomPolygon()], [], { + walls: squareWalls(2.1), + slabs: [slab(0.05)], + storeyHeight: 2.55, + }).create[0] + + expect(created?.height).toBeCloseTo(2.15) + }) + + test('clamps the auto ceiling to the storey plane', () => { + // Explicit 2.5m walls on a 0.4m platform would top out at 2.9 — above + // the 2.7 storey plane, where the ceiling would poke into the level + // above. Conflicts clamp, never ask. + const created = planAutoCeilingsForLevel([roomPolygon()], [], { + walls: squareWalls(), + slabs: [slab(0.4)], + storeyHeight: 2.7, + }).create[0] + + expect(created?.height).toBeCloseTo(2.7) + }) + test('updates existing auto ceiling height when the slab elevation changes', () => { const ceiling = CeilingNode.parse({ polygon: square, @@ -57,6 +108,7 @@ describe('planAutoCeilingsForLevel', () => { const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { walls: squareWalls(), slabs: [slab(0.4)], + storeyHeight: 3, }) expect(plan.update).toHaveLength(1) @@ -75,6 +127,7 @@ describe('planAutoCeilingsForLevel', () => { const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { walls: squareWalls(3), slabs: [slab(0.05)], + storeyHeight: 3.2, }) expect(plan.update).toHaveLength(1) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index c125e275f1..69117c5f68 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -2,22 +2,27 @@ 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 { getStoredLevelHeight } from '../services/storey' import { getSceneHistoryPauseDepth, pauseSceneHistory, resumeSceneHistory, } from '../store/history-control' +import { computeWallSlabSupport } from '../systems/slab/slab-support' import { getClampedWallCurveOffset, getWallCurveFrameAt, isCurvedWall, } from '../systems/wall/wall-curve' +import { resolveWallTop } from '../systems/wall/wall-top' import { simplifyClosedPolygon } from './polygon-geometry' type Point2D = { x: number; y: number } @@ -97,6 +102,8 @@ const COVERAGE_SAMPLE_STEPS = 12 export type AutoCeilingPlanningContext = { walls?: WallNode[] slabs?: SlabNodeType[] + /** Stored storey height of the level being planned (floor-to-floor). */ + storeyHeight?: number } function pointFromTuple(point: [number, number]): Point2D { @@ -306,61 +313,42 @@ 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 +/** + * Auto-ceiling height for a detected room, post wall-top inversion: the + * max over the room's bounding walls of {@link resolveWallTop} (storey + * plane for plane-bound walls; elected base + height for explicit + * walls, elected via {@link computeWallSlabSupport} against the level's + * slabs), clamped to the storey plane so a ceiling can never poke into + * the level above. Rooms with no resolvable bounding wall fall back to + * the plane — the plane-bound default. + */ +function resolveAutoCeilingHeight( + roomPolygon: Point2D[], + context: AutoCeilingPlanningContext = {}, +) { + const storeyHeight = context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT + const walls = context.walls ?? [] + const slabs = context.slabs ?? [] + let maxTop = 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) - } + const electedBase = computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + const top = resolveWallTop(wall, storeyHeight, electedBase) + if (Number.isFinite(top)) maxTop = Math.max(maxTop, top) } - return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT -} - -function resolveAutoCeilingHeight( - roomPolygon: Point2D[], - context: AutoCeilingPlanningContext = {}, -) { - return ( - resolveRoomSlabElevation(roomPolygon, context.slabs) + - resolveRoomWallHeight(roomPolygon, context.walls) - ) + if (maxTop <= 0) return storeyHeight + return Math.min(maxTop, storeyHeight) } function getWallDirection(wall: Pick) { @@ -809,7 +797,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 +818,17 @@ 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 are inputs to the auto-ceiling +// height (elected bases / the storey plane), and neither is rewritten by +// the sync, so regeneration triggers when they change without feedback. function levelStructureSnapshots(nodes: Record) { const wallsByLevel = new Map() const zonesByLevel = new Map() + const slabElevationsByLevel = new Map() for (const node of Object.values(nodes)) { if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue @@ -846,6 +841,12 @@ 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) } } @@ -854,9 +855,13 @@ function levelStructureSnapshots(nodes: Record) { 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(';') snapshots.set( levelId, - `${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`, + `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}`, ) } @@ -1404,12 +1409,17 @@ function runSpaceDetection( const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan) + 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 }, + { walls, slabs: projectedSlabs, storeyHeight }, ) const zonePlan = planAutoZonesForLevel( spaces, diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index f120ff02c6..d09d2357bf 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -1,6 +1,9 @@ import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +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 +476,14 @@ 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 level = levelId ? sceneNodes[levelId] : undefined + const storeyHeight = + level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + const wallEffectiveHeight = (wall: WallNode) => { + const support = computeWallSlabSupport(wall, slabs, walls) + return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) + } const edgeLengths = zone.polygon.map((start, index) => { const end = zone.polygon[(index + 1) % zone.polygon.length] return end ? pointDistance(start, end) : 0 @@ -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/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..d0a3031a9c 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -198,8 +198,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 +226,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/level-height.test.ts b/packages/core/src/services/level-height.test.ts index 0316412679..c148198b80 100644 --- a/packages/core/src/services/level-height.test.ts +++ b/packages/core/src/services/level-height.test.ts @@ -96,7 +96,7 @@ describe('deriveLegacyLevelHeight', () => { const nodes = createFixture() const cases = [ ['level_no_slab', 2.5], - ['level_standard_slab', 2.55], + ['level_standard_slab', 2.5], ['level_tall_wall', 3.55], ['level_ceiling', 3.4], ['level_negative_slab', 2.8], diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 95cb3b3b99..d26717793e 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,7 +1,7 @@ import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' -import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' +import { resolveWallTop } from '../systems/wall/wall-top' export const DEFAULT_LEVEL_HEIGHT = 2.5 @@ -36,7 +36,7 @@ export function deriveLegacyLevelHeight( slabs, walls, ).elevation - const top = Math.max(0, electedElevation) + (wall.height ?? DEFAULT_WALL_HEIGHT) + const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation) if (top > maxTop) maxTop = top } } diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 3b0d09b509..12550e50e8 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 && 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-scene-vertical-migration.test.ts b/packages/core/src/store/use-scene-vertical-migration.test.ts index fc9e908572..5537d0f178 100644 --- a/packages/core/src/store/use-scene-vertical-migration.test.ts +++ b/packages/core/src/store/use-scene-vertical-migration.test.ts @@ -90,7 +90,7 @@ describe('scene vertical model migration', () => { useScene.temporal.getState().clear() }) - test('default legacy storey derives height 2.55 and keeps walls plane-bound', () => { + 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']), @@ -100,8 +100,7 @@ describe('scene vertical model migration', () => { wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4]), }) - // Exactly as derived: 0.05 slab support + 2.5 default wall — never snapped. - expect((nodes.level_a as LevelResult).height).toBe(0.05 + 2.5) + 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) }) 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..6e003ef82b --- /dev/null +++ b/packages/core/src/systems/slab/slab-support.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'bun:test' +import { SlabNode, WallNode } from '../../schema' +import { MIN_WALL_HEIGHT } from '../wall/wall-top' +import { clampSlabElevationForWalls, 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('never touches negative elevations (pool recess)', () => { + 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, + ) + }) +}) diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts index 5095671bf0..18a47e668f 100644 --- a/packages/core/src/systems/slab/slab-support.ts +++ b/packages/core/src/systems/slab/slab-support.ts @@ -2,6 +2,82 @@ 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 elevations (pool + * recess) are untouched: this is an upper bound only. + * + * 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. diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts index 142d73fc4e..208ed4bd8d 100644 --- a/packages/core/src/systems/wall/wall-top.ts +++ b/packages/core/src/systems/wall/wall-top.ts @@ -1,5 +1,14 @@ 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 diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index fa526574c4..c781c6397b 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -6,7 +6,7 @@ import { type CeilingNode, ColumnNode, createSceneApi, - DEFAULT_WALL_HEIGHT, + DEFAULT_LEVEL_HEIGHT, DoorNode, ElevatorNode, emitter, @@ -24,12 +24,15 @@ import { type NodeQuickAction, nodeRegistry, RoofSegmentNode, + resolveLevelId, + resolveWallEffectiveHeight, runAsSingleSceneHistoryStep, type SlabNode, SpawnNode, StairNode, StairSegmentNode, sceneRegistry, + spatialGridManager, summarizeSystemFor, useLiveNodeOverrides, useScene, @@ -271,7 +274,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): { } { if (node.type === 'wall') { return { - height: node.height ?? DEFAULT_WALL_HEIGHT, + height: getResolvedWallEffectiveHeight(node, useScene.getState().nodes), length: getWallCurveLength(node), thickness: getWallThickness(node), } @@ -283,6 +286,21 @@ function getHeightPillDimensions(node: WallNode | FenceNode): { } } +function getResolvedWallEffectiveHeight(wall: WallNode, nodes: Record): number { + const levelId = resolveLevelId(wall, nodes) + const level = nodes[levelId as AnyNodeId] + const storeyHeight = + level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + ) + return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) +} + export function FloatingActionMenu() { const reducedMotion = useReducedMotion() const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -413,7 +431,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' + ? getResolvedWallEffectiveHeight(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/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index 285b962933..6b770fd5f2 100644 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -1,9 +1,10 @@ 'use client' import { + type AnyNode, type AnyNodeId, calculateLevelMiters, - DEFAULT_WALL_HEIGHT, + DEFAULT_LEVEL_HEIGHT, getWallCurveLength, getWallMiterBoundaryPoints, getWallPlanFootprint, @@ -12,8 +13,11 @@ import { isCurvedWall, type Point2D, pointToKey, + resolveLevelId, + resolveWallEffectiveHeight, sampleWallCenterline, sceneRegistry, + spatialGridManager, useScene, type WallMiterData, type WallNode, @@ -91,10 +95,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] @@ -107,6 +108,21 @@ function getLevelWalls( .filter((node): node is WallNode => Boolean(node && node.type === 'wall')) } +function getWallEffectiveHeight(wall: WallNode, nodes: Record): number { + const levelId = resolveLevelId(wall, nodes) + const level = nodes[levelId] + const storeyHeight = + level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + ) + return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) +} + function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) { if (!point) return false @@ -308,7 +324,7 @@ function getCurvedWallMeasurementPath( function buildMeasurementGuide( wall: WallNode, - nodes: Record, + nodes: Record, ): MeasurementGuide | null { const levelWalls = getLevelWalls(wall, nodes) const miterData = calculateLevelMiters(levelWalls) @@ -317,7 +333,7 @@ function buildMeasurementGuide( const measurementPoints = measurementLine ?? fallbackMiddlePoints if (!measurementPoints) return null - const height = wall.height ?? DEFAULT_WALL_HEIGHT + const height = getWallEffectiveHeight(wall, nodes) const startLocal = worldPointToWallLocal(wall, measurementPoints.start) const endLocal = worldPointToWallLocal(wall, measurementPoints.end) const curvedMeasurementPath = isCurvedWall(wall) @@ -516,14 +532,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 +547,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(() => getWallEffectiveHeight(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..6f00d11fd0 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -1,13 +1,18 @@ 'use client' import { + type AnyNode, type AnyNodeId, - DEFAULT_WALL_HEIGHT, + DEFAULT_LEVEL_HEIGHT, type FenceNode, getWallCurveFrameAt, getWallThickness, isCurvedWall, + MIN_WALL_HEIGHT, + resolveLevelId, + resolveWallEffectiveHeight, sceneRegistry, + spatialGridManager, useLiveNodeOverrides, useScene, type WallNode, @@ -55,7 +60,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 @@ -73,6 +77,23 @@ type WallMoveHandle = { rotationY: number } +// Plane-bound walls (no explicit height) top out at the storey plane, so the +// handle geometry must use the resolved effective height, not a 2.5 fallback. +function getWallEffectiveHeight(wall: WallNode, nodes: Record): number { + const levelId = resolveLevelId(wall, nodes) + const level = nodes[levelId] + const storeyHeight = + level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + ) + return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) +} + // Pre-empt the synthetic `click` the browser fires immediately after a // drag's pointerup. Without this, PointerMissedHandler treats the click // as "missed" and deselects the wall when the height arrow drag commits. @@ -244,7 +265,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 = getWallEffectiveHeight(wall, useScene.getState().nodes) const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) @@ -433,7 +454,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 = getWallEffectiveHeight(wall, useScene.getState().nodes) const handleY = wallHeight + HEIGHT_HANDLE_OFFSET const activateHeightResize = (event: ThreeEvent) => { @@ -466,7 +487,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 = getWallEffectiveHeight(wall, useScene.getState().nodes) const initialY = hit.y const wallId = wall.id as AnyNodeId let pendingHeight = initialHeight @@ -774,7 +797,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 = getWallEffectiveHeight(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..4d7d5c695e 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, + DEFAULT_LEVEL_HEIGHT, getWallCurveFrameAt, getWallCurveLength, getWallThickness, isCurvedWall, resolveLevelId, + resolveWallTop, sceneRegistry, spatialGridManager, useScene, @@ -147,15 +148,17 @@ type WallTopHighlightSegment = { function getWallTopY(wall: WallNode, nodes: Readonly>) { const levelId = resolveLevelId(wall, nodes as Record) - const slabElevation = spatialGridManager.getSlabElevationForWall( + const level = nodes[levelId] + const storeyHeight = + level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + const support = spatialGridManager.getSlabSupportForWall( levelId, wall.start, wall.end, wall.curveOffset ?? 0, wall.thickness, ) - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT - return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT + return resolveWallTop(wall, storeyHeight, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT } function buildHighlightSegment(start: [number, number], end: [number, number]) { diff --git a/packages/mcp/src/templates/empty-studio.ts b/packages/mcp/src/templates/empty-studio.ts index 40b4663c74..75c9de4cdf 100644 --- a/packages/mcp/src/templates/empty-studio.ts +++ b/packages/mcp/src/templates/empty-studio.ts @@ -138,7 +138,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: [], thickness: 0.1, - height: 2.5, start: [-W, -D], end: [W, -D], frontSide: 'unknown', @@ -154,7 +153,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: [], thickness: 0.1, - height: 2.5, start: [W, -D], end: [W, D], frontSide: 'unknown', @@ -170,7 +168,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: ['door_front'], thickness: 0.1, - height: 2.5, start: [W, D], end: [-W, D], frontSide: 'unknown', @@ -186,7 +183,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: ['window_w'], thickness: 0.1, - height: 2.5, start: [-W, D], end: [-W, -D], frontSide: 'unknown', diff --git a/packages/mcp/src/templates/garden-house.ts b/packages/mcp/src/templates/garden-house.ts index 050565345a..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', diff --git a/packages/mcp/src/templates/two-bedroom.ts b/packages/mcp/src/templates/two-bedroom.ts index 91012d58b5..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', diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index 15ff6fff17..38188c2523 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { resolveStairTotalRise } from '@pascal-app/core' +import { DEFAULT_LEVEL_HEIGHT, getStoredLevelHeight, resolveStairTotalRise } from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { CeilingNode, @@ -24,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.', @@ -268,6 +269,8 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat ) } const points = footprint as [number, number][] + const resolvedWallHeight = + level.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT const wallIds: string[] = [] const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = [] @@ -277,7 +280,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 } : {}), @@ -305,7 +308,7 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat const ceiling = CeilingNode.parse({ name: namePrefix ? `${namePrefix} Ceiling` : undefined, polygon: points, - height: ceilingHeight ?? wallHeight, + height: ceilingHeight ?? wallHeight ?? resolvedWallHeight, ...(ceilingMaterialPreset ? { materialPreset: ceilingMaterialPreset } : {}), metadata: { role: 'story-ceiling' }, }) diff --git a/packages/mcp/src/tools/describe-node.ts b/packages/mcp/src/tools/describe-node.ts index 8be93177df..146280841e 100644 --- a/packages/mcp/src/tools/describe-node.ts +++ b/packages/mcp/src/tools/describe-node.ts @@ -3,6 +3,7 @@ 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 +24,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': @@ -83,14 +84,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 fd590ce978..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.` @@ -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 89e661f428..88f7c849a6 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -1,6 +1,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { getStoredLevelHeight, resolveStairTotalRise } from '@pascal-app/core' +import { + DEFAULT_LEVEL_HEIGHT, + getStoredLevelHeight, + 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 { @@ -112,6 +118,24 @@ function nodesOnLevel(bridge: SceneOperations, levelId: AnyNodeId): AnyNode[] { ) } +export function resolveReportedWallHeight( + bridge: SceneOperations, + wall: Extract, +): number { + const levelId = bridge.resolveLevelId(wall.id as AnyNodeId) + const level = levelId ? bridge.getNode(levelId) : null + const storeyHeight = level?.type === 'level' ? getStoredLevelHeight(level) : 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) + return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) +} + function metadataRecord(node: AnyNode): Record | null { return typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) @@ -160,6 +184,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, @@ -167,6 +192,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), } @@ -662,7 +689,7 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations): 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`, @@ -694,7 +721,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/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index 6dbc39ebea..d4a11de37e 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -1,7 +1,11 @@ -import type { - CeilingNode as CeilingNodeType, - HandleDescriptor, - NodeDefinition, +import { + type AnyNodeId, + type CeilingNode as CeilingNodeType, + getStoredLevelHeight, + type HandleDescriptor, + type LevelNode, + type NodeDefinition, + type SceneApi, } from '@pascal-app/core' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' import { buildCeilingFloorplan } from './floorplan' @@ -20,6 +24,17 @@ 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. A ceiling raised past the storey plane would poke into the level +// above, so height writes clamp at the plane (the renderer's -0.01 +// z-fight offset keeps the effective top just under it). +function ceilingStoreyPlane(n: CeilingNodeType, sceneApi: SceneApi): number { + const parent = n.parentId ? sceneApi.get(n.parentId as AnyNodeId) : undefined + return parent?.type === 'level' + ? getStoredLevelHeight(parent as LevelNode) + : Number.POSITIVE_INFINITY +} + function ceilingPolygonCenter(n: CeilingNodeType): [number, number] { const polygon = n.polygon ?? [] if (polygon.length === 0) return [0, 0] @@ -48,6 +63,7 @@ function ceilingHeightHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_CEILING_HEIGHT, + max: ceilingStoreyPlane, currentValue: (n) => n.height ?? 2.5, apply: (_n, newValue) => ({ height: newValue }), placement: { diff --git a/packages/nodes/src/ceiling/panel.tsx b/packages/nodes/src/ceiling/panel.tsx index 219b695f12..10b9effd44 100644 --- a/packages/nodes/src/ceiling/panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -1,6 +1,12 @@ 'use client' -import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' +import { + type AnyNode, + type CeilingNode, + getStoredLevelHeight, + type LevelNode, + useScene, +} from '@pascal-app/core' import { ActionButton, ActionGroup, @@ -35,11 +41,23 @@ 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 at the storey plane instead of poking + // into the level above (clamp, never ask). + const level = useScene((s) => { + const parent = node?.parentId ? s.nodes[node.parentId as AnyNode['id']] : undefined + return parent?.type === 'level' ? (parent as LevelNode) : undefined + }) + const maxHeight = level ? getStoredLevelHeight(level) : 6 + // 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 handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return @@ -48,6 +66,13 @@ export function CeilingPanel() { [selectedId], ) + const handleHeightChange = useCallback( + (proposed: number) => { + handleUpdate({ height: Math.min(proposed, maxHeightRef.current) }) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) useInteractionScope @@ -167,9 +192,9 @@ export function CeilingPanel() { handleUpdate({ height: v })} + onChange={handleHeightChange} precision={3} step={0.01} unit="m" @@ -177,9 +202,9 @@ export function CeilingPanel() { />
- handleUpdate({ height: 2.4 })} /> - handleUpdate({ height: 2.5 })} /> - handleUpdate({ height: 3.0 })} /> + handleHeightChange(2.4)} /> + handleHeightChange(2.5)} /> + handleHeightChange(3.0)} />
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/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 7368466437..83dbd7652c 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 { slabElevationUpperBound } from './elevation-limit' import { buildSlabFloorplan } from './floorplan' import { slabAddVertexAffordance, @@ -96,12 +97,15 @@ function slabHandleAnchor(slab: SlabNodeType): [number, number] { // 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. +// `max` clamps the drag under the storey plane while plane-bound walls +// elect this slab as their base (conflicts clamp, never ask). 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 }), placement: { diff --git a/packages/nodes/src/slab/elevation-limit.ts b/packages/nodes/src/slab/elevation-limit.ts new file mode 100644 index 0000000000..6f28c17654 --- /dev/null +++ b/packages/nodes/src/slab/elevation-limit.ts @@ -0,0 +1,64 @@ +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[] +} + +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/panel.tsx b/packages/nodes/src/slab/panel.tsx index 6587a589c3..0adf6ba254 100644 --- a/packages/nodes/src/slab/panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -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 { clampSlabElevation } from './elevation-limit' /** * Phase 5 Stage E — slab inspector (kind-owned). @@ -52,6 +53,19 @@ export function SlabPanel() { [selectedId], ) + // Clamp-never-ask: cap the written elevation under the storey plane + // while plane-bound walls elect this slab as their base. Recessed + // (negative) elevations pass through untouched. + const handleElevationChange = useCallback( + (proposed: number) => { + const current = nodeRef.current + if (!current) return + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) + handleUpdate({ elevation }) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) useInteractionScope @@ -174,7 +188,7 @@ export function SlabPanel() { label="Height" max={1} min={-1} - onChange={(v) => handleUpdate({ elevation: v })} + onChange={handleElevationChange} precision={3} step={0.01} unit="m" @@ -182,10 +196,10 @@ export function SlabPanel() { />
- handleUpdate({ elevation: -0.15 })} /> - handleUpdate({ elevation: 0 })} /> - handleUpdate({ elevation: 0.05 })} /> - handleUpdate({ elevation: 0.15 })} /> + handleElevationChange(-0.15)} /> + handleElevationChange(0)} /> + handleElevationChange(0.05)} /> + handleElevationChange(0.15)} />
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..53cf4ebf4d 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -3,7 +3,6 @@ import { type AnyNodeId, collectAlignmentAnchors, - DEFAULT_WALL_HEIGHT, emitter, type GridEvent, getWallCurveLength, @@ -39,6 +38,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). @@ -613,7 +613,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..08619dd23c 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -10,6 +10,8 @@ import { type GridEvent, getPerpendicularWallMoveAxis, getPlannedLinkedWallUpdates, + getStoredLevelHeight, + type LevelNode, pauseSceneHistory, planAutoCeilingsForLevel, planAutoSlabsForLevel, @@ -284,12 +286,15 @@ 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, }, ) 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..5d4a4a3f42 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/tool.tsx b/packages/nodes/src/wall/tool.tsx index f5acb490df..c3d95bb3f7 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, @@ -58,7 +59,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 @@ -513,13 +513,19 @@ 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 + const previewHeight = + typeof wallDefaults?.height === 'number' ? wallDefaults.height : activeLevelHeight const previewThickness = typeof wallDefaults?.thickness === 'number' ? wallDefaults.thickness : DRAFT_WALL_THICKNESS const previewHeightRef = useRef(previewHeight) 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/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index ebee5f3b02..72bdd90ad7 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -1,8 +1,12 @@ import { type AnyNodeId, + DEFAULT_LEVEL_HEIGHT, emitter, getWallFaceBandConfig, + resolveLevelId, + resolveWallEffectiveHeight, sceneRegistry, + spatialGridManager, useScene, type WallNode, } from '@pascal-app/core' @@ -130,8 +134,24 @@ 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 level = sceneState.nodes[levelId as AnyNodeId] + const storeyHeight = + level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wallNode.start, + wallNode.end, + wallNode.curveOffset ?? 0, + wallNode.thickness, + ) + const effectiveWallHeight = resolveWallEffectiveHeight( + wallNode, + storeyHeight, + 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-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 2bc00199df..1bedb22b00 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -223,15 +223,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)] } @@ -239,6 +240,7 @@ function assignWallMaterialGroups( geometry: THREE.BufferGeometry, wall: WallNode, boundaryEdges: TaggedWallBoundaryEdge[], + effectiveWallHeight: number, ) { const position = geometry.getAttribute('position') if (!position) return @@ -318,7 +320,12 @@ function assignWallMaterialGroups( continue } - triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y) + triangleMaterials[triangleIndex] = getWallFaceMaterialIndex( + wall, + nearestTag, + centroid.y, + effectiveWallHeight, + ) } geometry.clearGroups() @@ -446,14 +453,15 @@ function splitGeometryAtHorizontalPlanes( return split } -function getWallBandSplitPlanes(wall: WallNode, wallTopLocalY: number): 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 < wallTopLocalY - WALL_BAND_SPLIT_EPSILON, + (plane) => + plane > WALL_BAND_SPLIT_EPSILON && plane < effectiveWallHeight - WALL_BAND_SPLIT_EPSILON, ) } @@ -855,6 +863,7 @@ export function generateExtrudedWall( const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation) + const effectiveWallHeight = topElevation - slabElevation const effectiveBaseElevation = Math.min(baseElevation, slabElevation) const localBottom = effectiveBaseElevation - slabElevation const height = topElevation - effectiveBaseElevation @@ -923,7 +932,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 @@ -1025,10 +1034,10 @@ export function generateExtrudedWall( if (cutoutBrushes.length === 0) { const splitGeometry = splitGeometryAtHorizontalPlanes( geometry, - getWallBandSplitPlanes(wallNode, topElevation - slabElevation), + getWallBandSplitPlanes(wallNode, effectiveWallHeight), ) splitGeometry.computeVertexNormals() - assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges) + assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitGeometry) return splitGeometry } @@ -1062,10 +1071,10 @@ export function generateExtrudedWall( const resultGeometry = csgGeometry(resultBrush) const splitResultGeometry = splitGeometryAtHorizontalPlanes( resultGeometry, - getWallBandSplitPlanes(wallNode, topElevation - slabElevation), + getWallBandSplitPlanes(wallNode, effectiveWallHeight), ) splitResultGeometry.computeVertexNormals() - assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges) + assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitResultGeometry) return splitResultGeometry From 4ec517a1a90871b0d365d4a3e569984f2e9a591a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 12:26:50 -0400 Subject: [PATCH 06/24] =?UTF-8?q?feat(core):=20persisted=20support=20hosts?= =?UTF-8?q?=20=E2=80=94=20schema,=20host-preferring=20election,=20rendered?= =?UTF-8?q?-polygon=20unification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floor-placed nodes and walls gain a nullable supportSlabId. Elections surface the winning slab (getSlabSupportForItem, candidates query) and prefer a still-valid persisted host, falling back silently when the host is gone or reshaped away; deleting the host strips references in the same undo commit. Item-side support now tests the rendered slab polygon (like walls) through a per-level cache invalidated by the spatial-grid sync. Also adds the resolveStairTotalRise unit tests from the stage-1 gates. Co-Authored-By: Claude Fable 5 --- .../spatial-grid/floor-placed-elevation.ts | 22 +- .../spatial-grid/spatial-grid-manager.ts | 188 +++++++-- .../hooks/spatial-grid/spatial-grid-sync.ts | 23 +- .../hooks/spatial-grid/support-host.test.ts | 396 ++++++++++++++++++ packages/core/src/schema/nodes/cabinet.ts | 2 + packages/core/src/schema/nodes/column.ts | 2 + .../core/src/schema/nodes/duct-terminal.ts | 2 + .../core/src/schema/nodes/hvac-equipment.ts | 2 + packages/core/src/schema/nodes/item.ts | 11 + packages/core/src/schema/nodes/shelf.ts | 2 + packages/core/src/schema/nodes/spawn.ts | 2 + packages/core/src/schema/nodes/stair.ts | 2 + packages/core/src/schema/nodes/wall.ts | 2 + .../core/src/store/actions/node-actions.ts | 20 + .../src/systems/slab/slab-support.test.ts | 50 ++- .../core/src/systems/slab/slab-support.ts | 27 +- .../core/src/systems/stair/stair-rise.test.ts | 46 ++ .../core/src/utils/clone-scene-graph.test.ts | 54 ++- packages/core/src/utils/clone-scene-graph.ts | 14 + 19 files changed, 827 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/hooks/spatial-grid/support-host.test.ts create mode 100644 packages/core/src/systems/stair/stair-rise.test.ts 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..e8f410e800 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts @@ -66,8 +66,28 @@ export function getFloorPlacedElevation({ const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId if (!resolvedLevelId) return 0 + 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. + const supportSlabId = (effectiveNode as { supportSlabId?: string | null }).supportSlabId + if (supportSlabId) { + 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 maxElevation = Number.NEGATIVE_INFINITY - for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) { + for (const footprint of footprints) { const footprintPosition = footprint.position ?? position const elevation = finiteSlabElevation( spatialGridManager.getSlabElevationForItem( 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 cfc346f6c6..9f001618f5 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,3 +1,4 @@ +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' @@ -291,6 +292,18 @@ export function itemOverlapsPolygon( return false } +/** One slab overlapping a queried footprint, as seen by support election. */ +export type SlabSupportCandidate = { + slabId: string + elevation: number +} + +export type ItemSlabSupport = { + elevation: number + /** The winning slab, or null when no slab overlaps the footprint. */ + slabId: string | null +} + export class SpatialGridManager { private readonly floorGrids = new Map() // levelId -> grid private readonly wallGrids = new Map() // levelId -> wall grid @@ -344,6 +357,7 @@ export class SpatialGridManager { wall.end, wall.curveOffset ?? 0, wall.thickness, + wall.supportSlabId ?? null, ) return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) } @@ -362,15 +376,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') { @@ -423,11 +496,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') { @@ -485,12 +560,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 @@ -704,8 +783,8 @@ 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, @@ -713,36 +792,85 @@ export class SpatialGridManager { dimensions: [number, number, number], rotation: [number, number, number], ): number { + return this.getSlabSupportForItem(levelId, position, dimensions, rotation).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. + */ + getSlabSupportForItem( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): ItemSlabSupport { const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return 0 + if (!slabMap) return { elevation: 0, slabId: null } let maxElevation = 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 - let inHole = false - const [cx, , cz] = position - const holes = slab.holes || [] - for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) { - inHole = true - break - } - } - - if (!inHole) { - const elevation = slab.elevation ?? 0.05 - if (elevation > maxElevation) { - maxElevation = elevation - } - } + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + const elevation = slab.elevation ?? 0.05 + if (elevation > maxElevation) { + maxElevation = elevation + winnerId = slab.id } } - return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation + return winnerId === null + ? { elevation: 0, slabId: null } + : { elevation: maxElevation, slabId: winnerId } + } + + /** + * 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 } /** @@ -758,8 +886,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( @@ -768,6 +898,7 @@ export class SpatialGridManager { end: [number, number], curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, ): WallSlabSupport { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) { @@ -782,6 +913,7 @@ export class SpatialGridManager { { start, end, curveOffset, thickness }, [...slabMap.values()], this.getLevelWallNodes(levelId), + preferredSlabId, ) } @@ -890,6 +1022,7 @@ export class SpatialGridManager { } clearLevel(levelId: string) { + this.invalidateRenderedSlabPolygons(levelId) this.floorGrids.delete(levelId) this.wallGrids.delete(levelId) this.slabsByLevel.delete(levelId) @@ -903,6 +1036,7 @@ export class SpatialGridManager { this.ceilingGrids.clear() this.ceilings.clear() this.itemCeilingMap.clear() + this.renderedSlabPolygons.clear() } } 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..d2522f9410 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -168,6 +168,18 @@ export function initSpatialGridSync(): () => void { markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty) markNodesOverlappingSlab(node as SlabNode, 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)) + } } } }) @@ -190,10 +202,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 +262,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.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts new file mode 100644 index 0000000000..9dda22cd2a --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts @@ -0,0 +1,396 @@ +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 { getFloorPlacedElevation } from './floor-placed-elevation' +import { spatialGridManager } from './spatial-grid-manager' +import { initSpatialGridSync } from './spatial-grid-sync' + +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('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[])) }) + addSlab(makeSlab('slab_room', roomPolygon, 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] + + expect(spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end).elevation).toBeCloseTo( + 0.6, + ) + expect( + spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end, 0, 0.1, 'slab_low').elevation, + ).toBeCloseTo(0.1) + expect( + spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end, 0, 0.1, 'slab_missing') + .elevation, + ).toBeCloseTo(0.6) + }) +}) + +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) + }) +}) 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/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/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..17fc65fae0 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -143,6 +143,17 @@ 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. + 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/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/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 5b71186348..b85f1f9cd9 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -37,6 +37,8 @@ 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), diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index d0a3031a9c..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(), diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 12550e50e8..2b56a1212e 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1145,6 +1145,26 @@ export const deleteNodesAction = ( } } + // Deleting a slab strips `supportSlabId` references from surviving + // nodes in the same undo commit (mirrors the collectionIds cleanup + // below), so those nodes re-elect their support. 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 hostId = (node as { supportSlabId?: string }).supportSlabId + if (hostId && deletedSlabIds.has(hostId)) { + nextNodes[nodeId as AnyNodeId] = { ...node, supportSlabId: undefined } 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/systems/slab/slab-support.test.ts b/packages/core/src/systems/slab/slab-support.test.ts index 6e003ef82b..672dbd9590 100644 --- a/packages/core/src/systems/slab/slab-support.test.ts +++ b/packages/core/src/systems/slab/slab-support.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'bun:test' import { SlabNode, WallNode } from '../../schema' import { MIN_WALL_HEIGHT } from '../wall/wall-top' -import { clampSlabElevationForWalls, getSlabElevationUpperBound } from './slab-support' +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]> = [ @@ -90,3 +94,47 @@ describe('getSlabElevationUpperBound', () => { ) }) }) + +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 index 18a47e668f..cf449d40cf 100644 --- a/packages/core/src/systems/slab/slab-support.ts +++ b/packages/core/src/systems/slab/slab-support.ts @@ -442,10 +442,21 @@ export type WallSlabSupportSegment = { 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). + */ export function computeWallSlabSupport( wallLike: WallOverlapInput, slabs: readonly SlabNode[], levelWalls: WallNode[], + preferredSlabId?: string | null, ): WallSlabSupport { const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike const halfThickness = Math.max(thickness / 2, 0) @@ -460,6 +471,7 @@ export function computeWallSlabSupport( type ElevationGroup = { elevation: number; perPolyline: LengthInterval[][] } const groups: ElevationGroup[] = [] + let preferredElevation: number | null = null for (const slab of slabs) { if (slab.polygon.length < 3) continue @@ -482,6 +494,9 @@ export function computeWallSlabSupport( if (supported < minSupport) continue const elevation = slab.elevation ?? 0.05 + if (preferredSlabId != null && slab.id === preferredSlabId) { + preferredElevation = elevation + } let group = groups.find( (candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, ) @@ -526,11 +541,13 @@ export function computeWallSlabSupport( } const elevation = - majorityElevation !== Number.NEGATIVE_INFINITY - ? majorityElevation - : bestElevation === Number.NEGATIVE_INFINITY - ? 0 - : bestElevation + preferredElevation !== null + ? preferredElevation + : 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 [] 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..ce71fd438e --- /dev/null +++ b/packages/core/src/systems/stair/stair-rise.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'bun:test' +import { LevelNode, StairNode } from '../../schema' +import { resolveStairTotalRise } from './stair-rise' + +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 } } +} + +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) + }) +}) 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..c1d999d045 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -85,6 +85,13 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { ) as string | undefined } + // Remap supportSlabId (persisted slab-support hosts) + if ('supportSlabId' in clonedNode && typeof clonedNode.supportSlabId === 'string') { + ;(clonedNode as Record).supportSlabId = idMap.get( + clonedNode.supportSlabId, + ) as string | undefined + } + if (clonedNode.type === 'measurement') { clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap) } @@ -240,6 +247,13 @@ 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 (cloned.type === 'measurement') { cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) } From 38fddb5d7f091c8439565efcd6b79ad0b28f9091 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 12:50:02 -0400 Subject: [PATCH 07/24] feat: persist support hosts at commit; thread wall host preference everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floor-placed commits (draft pipeline, per-kind creation tools, registry move tool) and wall create/move/endpoint commits persist supportSlabId via shared resolveSupportSlabPatch helpers — only when overlapping supports disagree on elevation, clearing it otherwise or when the node leaves the floor. WallSlabSupport surfaces electedSlabId; every wall support read site passes wall.supportSlabId as the preferred host. Co-Authored-By: Claude Fable 5 --- .../spatial-grid/spatial-grid-manager.ts | 1 + .../hooks/spatial-grid/support-host-patch.ts | 86 ++++++++++++++++++ .../hooks/spatial-grid/support-host.test.ts | 91 +++++++++++++++++-- .../spatial-grid/wall-slab-overlap.test.ts | 4 + packages/core/src/index.ts | 5 + packages/core/src/lib/space-detection.ts | 1 + packages/core/src/lib/zone-quantities.ts | 2 +- .../core/src/systems/slab/slab-support.ts | 24 ++++- .../editor/floating-action-menu.tsx | 1 + .../editor/wall-measurement-label.tsx | 1 + .../editor/wall-move-side-handles.tsx | 1 + .../editor/wall-snap-beacon-layer.tsx | 1 + .../components/tools/item/use-draft-node.ts | 19 +++- .../registry/move-registry-node-tool.tsx | 32 ++++++- .../components/tools/wall/wall-drafting.ts | 13 ++- packages/mcp/src/tools/scene-query.ts | 2 +- packages/nodes/src/cabinet/tool.tsx | 33 ++++++- packages/nodes/src/column/tool.tsx | 14 ++- packages/nodes/src/duct-terminal/tool.tsx | 10 +- packages/nodes/src/hvac-equipment/tool.tsx | 17 +++- packages/nodes/src/shared/move-roof-tool.tsx | 20 +++- .../nodes/src/shared/wall-opening-ceiling.ts | 1 + packages/nodes/src/shelf/tool.tsx | 10 +- packages/nodes/src/spawn/tool.tsx | 18 +++- .../nodes/src/wall/move-endpoint-tool.tsx | 11 +++ packages/nodes/src/wall/move-tool.tsx | 14 +++ .../viewer/src/systems/wall/wall-cutout.tsx | 1 + .../viewer/src/systems/wall/wall-system.tsx | 1 + 28 files changed, 396 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/hooks/spatial-grid/support-host-patch.ts 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 9f001618f5..18352f5b44 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -904,6 +904,7 @@ export class SpatialGridManager { if (!slabMap) { return { elevation: 0, + electedSlabId: null, baseElevation: 0, baseSegments: [{ start: 0, end: 1, elevation: 0 }], } 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..aaf99442ce --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -0,0 +1,86 @@ +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' +import { getFloorPlacedFootprints } from './floor-placed-elevation' +import { spatialGridManager } from './spatial-grid-manager' + +export type SupportSlabPatch = { supportSlabId: string | undefined } + +export function resolveSupportSlabPatch( + node: AnyNode, + nodes: Record, +): 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 footprints = getFloorPlacedFootprints(floorPlaced, node, { nodes }) + const candidateElevations = new Set() + let winner: { slabId: string; elevation: number } | null = null + + 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, + ) + if (support.slabId && (!winner || support.elevation > winner.elevation)) { + winner = { slabId: support.slabId, elevation: support.elevation } + } + } + + return { + supportSlabId: candidateElevations.size >= 2 && winner !== null ? winner.slabId : undefined, + } +} + +export function resolveWallSupportSlabPatch( + wall: WallNode, + nodes: Record, +): SupportSlabPatch { + const parent = wall.parentId ? nodes[wall.parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + const support = spatialGridManager.getSlabSupportForWall( + parent.id, + wall.start, + wall.end, + wall.curveOffset, + wall.thickness, + ) + 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, + } +} diff --git a/packages/core/src/hooks/spatial-grid/support-host.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts index 9dda22cd2a..74362d7d93 100644 --- a/packages/core/src/hooks/spatial-grid/support-host.test.ts +++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts @@ -8,6 +8,7 @@ import useScene, { clearSceneHistory } from '../../store/use-scene' 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' @@ -245,6 +246,24 @@ describe('persisted support hosts (items)', () => { ).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() @@ -309,16 +328,70 @@ describe('persisted support hosts (walls, via the manager)', () => { const start: [number, number] = [0, 1.5] const end: [number, number] = [4, 1.5] - expect(spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end).elevation).toBeCloseTo( - 0.6, + 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( - spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end, 0, 0.1, 'slab_low').elevation, - ).toBeCloseTo(0.1) - expect( - spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end, 0, 0.1, 'slab_missing') - .elevation, - ).toBeCloseTo(0.6) + 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', + }) }) }) 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..8f3c7450ea 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 @@ -304,6 +304,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 +330,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: platform.id, baseElevation: 0.05, baseSegments: [ { start: 0, end: 2 / 3, elevation: 0.6 }, @@ -358,6 +360,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: high.id, baseElevation: 0.6, baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], }) @@ -401,6 +404,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 c82649ebfb..059f3bc1f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,6 +61,11 @@ export { resolveBuildingForLevel, resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' +export { + resolveSupportSlabPatch, + resolveWallSupportSlabPatch, + type SupportSlabPatch, +} from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' export { diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 69117c5f68..d78ca202d3 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -342,6 +342,7 @@ function resolveAutoCeilingHeight( }, slabs, walls, + wall.supportSlabId, ).elevation const top = resolveWallTop(wall, storeyHeight, electedBase) if (Number.isFinite(top)) maxTop = Math.max(maxTop, top) diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index d09d2357bf..1044008041 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -481,7 +481,7 @@ export function deriveZoneQuantityReport( const storeyHeight = level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT const wallEffectiveHeight = (wall: WallNode) => { - const support = computeWallSlabSupport(wall, slabs, walls) + const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) } const edgeLengths = zone.polygon.map((start, index) => { diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts index cf449d40cf..a3eada34ee 100644 --- a/packages/core/src/systems/slab/slab-support.ts +++ b/packages/core/src/systems/slab/slab-support.ts @@ -430,6 +430,8 @@ const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 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. */ @@ -464,14 +466,19 @@ export function computeWallSlabSupport( const polylineLengths = polylines.map(polylineLength) const wallLength = polylineLengths[0]! if (wallLength < 1e-9) { - return { elevation: 0, baseElevation: 0, baseSegments: [] } + 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; perPolyline: LengthInterval[][] } + 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 @@ -496,14 +503,16 @@ export function computeWallSlabSupport( 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, perPolyline: polylines.map(() => []) } + 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]!) } @@ -548,6 +557,13 @@ export function computeWallSlabSupport( : bestElevation === Number.NEGATIVE_INFINITY ? 0 : bestElevation + const electedSlabId = + preferredElectedSlabId ?? + evaluatedGroups + .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 [] @@ -612,7 +628,7 @@ export function computeWallSlabSupport( if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) - return { elevation, baseElevation, baseSegments } + return { elevation, electedSlabId, baseElevation, baseSegments } } export function computeWallSlabElevation( diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index c781c6397b..b03cd2ef13 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -297,6 +297,7 @@ function getResolvedWallEffectiveHeight(wall: WallNode, nodes: Record): wall.end, wall.curveOffset ?? 0, wall.thickness, + wall.supportSlabId, ) return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) } 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 6f00d11fd0..b16342f2a3 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -90,6 +90,7 @@ function getWallEffectiveHeight(wall: WallNode, nodes: Record): wall.end, wall.curveOffset ?? 0, wall.thickness, + wall.supportSlabId, ) return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) } 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 4d7d5c695e..3b17273713 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -157,6 +157,7 @@ function getWallTopY(wall: WallNode, nodes: Readonly>) { wall.end, wall.curveOffset ?? 0, wall.thickness, + wall.supportSlabId, ) return resolveWallTop(wall, storeyHeight, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT } 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 a53a47b900..9527a1a18f 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' @@ -139,6 +140,13 @@ export function useDraftNode(): DraftNodeHandle { // 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, @@ -154,6 +162,7 @@ export function useDraftNode(): DraftNodeHandle { // 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), }) useScene.temporal.getState().pause() @@ -192,15 +201,21 @@ export function useDraftNode(): DraftNodeHandle { roofFace: updateProps.roofFace, ...('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 }), }) - useScene.getState().createNode(finalNode, parentId) + useScene.getState().createNode(committedNode, parentId) // 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(() => { 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..e94382986f 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, @@ -781,9 +782,18 @@ 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, + ...resolveSupportSlabPatch(effectiveNode, { + ...useScene.getState().nodes, + [node.id]: effectiveNode, + }), ...(isNew ? { metadata: stripPlacementMetadataFlags(node.metadata), @@ -819,6 +829,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 +854,17 @@ 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, + }), + }) 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 } diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 20edaeed36..12352f77dc 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, @@ -541,8 +542,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), + ) + } sfxEmitter.emit('sfx:structure-build') - return wall + const committedWall = useScene.getState().nodes[wall.id] + return committedWall?.type === 'wall' ? committedWall : wall }) } diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index 88f7c849a6..00c4e44579 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -132,7 +132,7 @@ export function resolveReportedWallHeight( const walls = levelNodes.filter( (node): node is Extract => node.type === 'wall', ) - const support = computeWallSlabSupport(wall, slabs, walls) + const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) } 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/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 31befe79e4..7290053ba8 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() diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index b1b9cafcbc..b1fcef8702 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -6,6 +6,7 @@ import { emitter, pointInPolygon, resolveLevelId, + resolveSupportSlabPatch, sceneRegistry, useScene, type WallEvent, @@ -289,9 +290,14 @@ const DuctTerminalTool = () => { mount: p.mount, position: p.position, rotation: p.yaw, + parentId: activeLevelId, }) - useScene.getState().createNode(terminal, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [terminal.id] }) + const committedTerminal = DuctTerminalNode.parse({ + ...terminal, + ...resolveSupportSlabPatch(terminal, useScene.getState().nodes), + }) + useScene.getState().createNode(committedTerminal, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedTerminal.id] }) triggerSFX('sfx:item-place') } 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/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/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts index a43640f72a..58ae2e6122 100644 --- a/packages/nodes/src/shared/wall-opening-ceiling.ts +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -42,6 +42,7 @@ export function resolveWallOpeningCeiling( wall.end, wall.curveOffset ?? 0, wall.thickness, + wall.supportSlabId, ) return resolveWallEffectiveHeight(wall, storeyHeight, support.elevation) } 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/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/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index 53cf4ebf4d..f8bcc86b4b 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -9,6 +9,7 @@ import { getWallThickness, pauseSceneHistory, resolveAlignment, + resolveWallSupportSlabPatch, resumeSceneHistory, runAsSingleSceneHistoryStep, useLiveNodeOverrides, @@ -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) diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 08619dd23c..43c12f1a80 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -17,6 +17,7 @@ import { planAutoSlabsForLevel, planWallMoveJunctions, projectAutoSlabsForPlan, + resolveWallSupportSlabPatch, resumeSceneHistory, type SlabNode, useLiveNodeOverrides, @@ -599,6 +600,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/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 72bdd90ad7..af93928cdc 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -144,6 +144,7 @@ export const WallCutout = () => { wallNode.end, wallNode.curveOffset ?? 0, wallNode.thickness, + wallNode.supportSlabId, ) const effectiveWallHeight = resolveWallEffectiveHeight( wallNode, diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 1bedb22b00..1ffe3d014e 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -712,6 +712,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { node.end, node.curveOffset ?? 0, node.thickness, + node.supportSlabId, ) const slabElevation = slabSupport.elevation From a3de58bdbb9647792ed51cb6451bdc7dd620167f Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 13:07:29 -0400 Subject: [PATCH 08/24] feat: split slab into placement + thickness; pools become explicit recess intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slab.elevation stays the walking surface; new thickness grows downward so the solid occupies [elevation − thickness, elevation]. Migration writes thickness := elevation for solids (byte-identical intervals, including degenerate zero) and recessed: true for legacy negative pools. Geometry branches on recessed instead of the elevation sign; presets keep today's intervals; free elevation edits move the body without coupling thickness (the deck semantic). Dead viewer SlabSystem component deleted. Co-Authored-By: Claude Fable 5 --- .../floor-placed-elevation.test.ts | 2 + packages/core/src/schema/index.ts | 2 +- packages/core/src/schema/nodes/slab.ts | 13 +- .../use-scene-vertical-migration.test.ts | 68 +++++++++ packages/core/src/store/use-scene.ts | 17 +++ .../src/systems/slab/slab-support.test.ts | 2 +- .../core/src/systems/slab/slab-support.ts | 5 +- packages/mcp/src/tools/construction-tools.ts | 5 + .../site/recessed-slab-ground-holes.test.ts | 18 +++ .../src/site/recessed-slab-ground-holes.ts | 5 +- .../src/slab/__tests__/definition.test.ts | 11 +- .../nodes/src/slab/__tests__/geometry.test.ts | 27 ++++ packages/nodes/src/slab/definition.ts | 20 +-- packages/nodes/src/slab/geometry.ts | 5 +- packages/nodes/src/slab/panel.tsx | 48 ++++++- packages/nodes/src/slab/parametrics.ts | 15 +- packages/viewer/src/index.ts | 7 +- .../src/systems/slab/slab-system.test.ts | 74 ++++++++-- .../viewer/src/systems/slab/slab-system.tsx | 131 +++--------------- 19 files changed, 318 insertions(+), 157 deletions(-) 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/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/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/store/use-scene-vertical-migration.test.ts b/packages/core/src/store/use-scene-vertical-migration.test.ts index 5537d0f178..a7a666efba 100644 --- a/packages/core/src/store/use-scene-vertical-migration.test.ts +++ b/packages/core/src/store/use-scene-vertical-migration.test.ts @@ -78,6 +78,7 @@ function loadScene(nodes: Record): Record { type LevelResult = Extract type WallResult = Extract type StairResult = Extract +type SlabResult = Extract describe('scene vertical model migration', () => { beforeEach(() => { @@ -216,6 +217,73 @@ describe('scene vertical model migration', () => { 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']), diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index e550a6eab1..379c2d5ff4 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1013,6 +1013,23 @@ function migrateNodes(nodes: Record): { } } + // 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 } + } + return { nodes: patchedNodes as Record, mintedMaterials } } diff --git a/packages/core/src/systems/slab/slab-support.test.ts b/packages/core/src/systems/slab/slab-support.test.ts index 672dbd9590..758d31ab62 100644 --- a/packages/core/src/systems/slab/slab-support.test.ts +++ b/packages/core/src/systems/slab/slab-support.test.ts @@ -48,7 +48,7 @@ describe('clampSlabElevationForWalls', () => { expect(result.elevation).toBeCloseTo(BOUND) }) - it('never touches negative elevations (pool recess)', () => { + it('passes negative (recessed-committing) proposals through untouched', () => { const slab = roomSlab(0.05) const result = clampSlabElevationForWalls(-0.6, slab, roomWalls(), [slab], STOREY_HEIGHT) diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts index a3eada34ee..cc5eed1a72 100644 --- a/packages/core/src/systems/slab/slab-support.ts +++ b/packages/core/src/systems/slab/slab-support.ts @@ -15,8 +15,9 @@ export type SlabElevationClamp = { * 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 elevations (pool - * recess) are untouched: this is an upper bound only. + * 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 diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index 38188c2523..8f1bdcf492 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -296,6 +296,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' }, }) 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..edabb3d31a 100644 --- a/packages/nodes/src/slab/__tests__/definition.test.ts +++ b/packages/nodes/src/slab/__tests__/definition.test.ts @@ -58,6 +58,15 @@ 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, + 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 83dbd7652c..2f289a32ee 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -92,13 +92,15 @@ 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. -// `max` clamps the drag under the storey plane while plane-bound walls -// elect this slab as their base (conflicts clamp, never ask). +// Slab elevation arrow — vertical chevron on solid slab surface near the +// polygon center. Moves the walking surface (the body follows: underside +// stays at elevation − thickness) and drags through zero: a negative value +// flips the slab to a recessed floor whose depth follows the pointer, and +// the same patch writes the `recessed` intent so live preview + commit +// stay in one update. 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 (conflicts clamp, never ask). function slabHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', @@ -107,7 +109,7 @@ function slabHeightHandle(): HandleDescriptor { 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) => ({ elevation: newValue, recessed: newValue < 0 }), placement: { position: (n) => { const [cx, cz] = slabHandleAnchor(n) @@ -155,6 +157,8 @@ export const slabDefinition: NodeDefinition = { holes: [], holeMetadata: [], elevation: 0.05, + thickness: 0.05, + recessed: false, autoFromWalls: false, }), 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 0adf6ba254..0a4831882e 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, @@ -54,14 +54,35 @@ export function SlabPanel() { ) // Clamp-never-ask: cap the written elevation under the storey plane - // while plane-bound walls elect this slab as their base. Recessed - // (negative) elevations pass through untouched. + // while plane-bound walls elect this slab as their base. Negative + // elevations pass through untouched — committing one flips the + // `recessed` intent (and committing ≥ 0 clears it) in the same update. const handleElevationChange = useCallback( (proposed: number) => { const current = nodeRef.current if (!current) return const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) - handleUpdate({ elevation }) + handleUpdate({ elevation, recessed: elevation < 0 }) + }, + [handleUpdate], + ) + + const handleThicknessChange = useCallback( + (proposed: number) => { + handleUpdate({ thickness: Math.max(MIN_SLAB_THICKNESS, proposed) }) + }, + [handleUpdate], + ) + + // Presets reproduce the legacy extrude-from-zero look: grounded solids + // write thickness = elevation so the underside stays on the level plane + // (free elevation edits above deliberately don't couple thickness). + const handleGroundedPreset = useCallback( + (proposed: number) => { + const current = nodeRef.current + if (!current) return + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) + handleUpdate({ elevation, thickness: Math.max(elevation, 0), recessed: false }) }, [handleUpdate], ) @@ -195,11 +216,24 @@ export function SlabPanel() { value={Math.round(node.elevation * 1000) / 1000} /> + {!node.recessed && ( + + )} +
handleElevationChange(-0.15)} /> - handleElevationChange(0)} /> - handleElevationChange(0.05)} /> - handleElevationChange(0.15)} /> + handleGroundedPreset(0)} /> + handleGroundedPreset(0.05)} /> + handleGroundedPreset(0.15)} />
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/viewer/src/index.ts b/packages/viewer/src/index.ts index b075261089..06a2eea2f4 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/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) } From afb6ad1c9bfbca432c7b48bcdb866d2a83ea967f Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 13:30:34 -0400 Subject: [PATCH 09/24] feat: clamp ceilings to covering-slab undersides across levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getLevelAbove + getCoveringSlabUndersideAt give the first cross-level query; ceiling writes clamp to min(storey plane, lowest covering underside) − 0.01, and the space-detection reconcile now clamps manual ceilings down (never up) when a deck above intrudes — a flush deck reactively lowers the ceiling below it. Co-Authored-By: Claude Fable 5 --- packages/core/src/lib/space-detection.test.ts | 218 +++++++++++++++++- packages/core/src/lib/space-detection.ts | 86 ++++++- packages/core/src/services/index.ts | 5 + packages/core/src/services/storey.test.ts | 201 +++++++++++++++- packages/core/src/services/storey.ts | 170 +++++++++++++- packages/nodes/src/ceiling/definition.ts | 16 +- packages/nodes/src/ceiling/panel.tsx | 22 +- packages/nodes/src/wall/move-tool.tsx | 2 + 8 files changed, 685 insertions(+), 35 deletions(-) diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 8042b95a57..feb935b698 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -1,7 +1,10 @@ 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 { getCeilingClampBound } from '../services/storey' import { detectSpacesForLevel, + initSpaceDetectionSync, planAutoCeilingsForLevel, planAutoSlabsForLevel, planAutoZonesForLevel, @@ -72,7 +75,9 @@ describe('planAutoCeilingsForLevel', () => { storeyHeight: 2.55, }).create[0] - expect(created?.height).toBeCloseTo(2.55) + // Stage 3-B: the clamp target moved from the plane itself to the + // plane minus CEILING_CLAMP_MARGIN (0.01). + expect(created?.height).toBeCloseTo(2.54) }) test('keeps an explicit-height room ceiling based on its wall height', () => { @@ -95,7 +100,9 @@ describe('planAutoCeilingsForLevel', () => { storeyHeight: 2.7, }).create[0] - expect(created?.height).toBeCloseTo(2.7) + // Stage 3-B: the clamp target moved from the plane itself to the + // plane minus CEILING_CLAMP_MARGIN (0.01). + expect(created?.height).toBeCloseTo(2.69) }) test('updates existing auto ceiling height when the slab elevation changes', () => { @@ -141,9 +148,12 @@ 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) @@ -213,9 +223,12 @@ 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) @@ -224,6 +237,205 @@ 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('auto ceilings derive under the covering-slab bound', () => { + const created = planAutoCeilingsForLevel([roomPolygon()], [], { + walls: squarePlaneBoundWalls(), + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', stackedDeckNodes(), polygon), + }).create[0] + + expect(created?.height).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('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], { + walls: squarePlaneBoundWalls(), + 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 d78ca202d3..175632b1ce 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -10,7 +10,13 @@ import { type ZoneNode as ZoneNodeType, } from '../schema' import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' -import { getStoredLevelHeight } from '../services/storey' +import { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + getCeilingClampBound, + getLevelElevations, + getStoredLevelHeight, +} from '../services/storey' import { getSceneHistoryPauseDepth, pauseSceneHistory, @@ -104,6 +110,14 @@ export type AutoCeilingPlanningContext = { 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 { @@ -313,14 +327,28 @@ function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) { return matchingPoints.length >= 2 } +/** + * 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, +) { + if (context.ceilingClampBound) return context.ceilingClampBound(polygon) + return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN +} + /** * Auto-ceiling height for a detected room, post wall-top inversion: the * max over the room's bounding walls of {@link resolveWallTop} (storey * plane for plane-bound walls; elected base + height for explicit * walls, elected via {@link computeWallSlabSupport} against the level's - * slabs), clamped to the storey plane so a ceiling can never poke into - * the level above. Rooms with no resolvable bounding wall fall back to - * the plane — the plane-bound default. + * slabs), clamped to the stage 3-B ceiling bound — min(storey plane, + * covering-slab underside from the level above) − margin — so a ceiling + * can never poke into the level above or a deck hanging from it. Rooms + * with no resolvable bounding wall fall back to the plane (same clamp). */ function resolveAutoCeilingHeight( roomPolygon: Point2D[], @@ -329,6 +357,7 @@ function resolveAutoCeilingHeight( const storeyHeight = context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT const walls = context.walls ?? [] const slabs = context.slabs ?? [] + const bound = resolveCeilingClampBound(roomPolygon.map(pointToTuple), context) let maxTop = 0 for (const wall of walls) { @@ -348,8 +377,10 @@ function resolveAutoCeilingHeight( if (Number.isFinite(top)) maxTop = Math.max(maxTop, top) } - if (maxTop <= 0) return storeyHeight - return Math.min(maxTop, storeyHeight) + // `Math.min` (not just `bound`) so an Infinity bound from an + // unresolvable level degrades to the stage-1 plane clamp. + if (maxTop <= 0) return Math.min(storeyHeight, bound) + return Math.min(maxTop, bound) } function getWallDirection(wall: Pick) { @@ -826,10 +857,17 @@ function zoneGeometrySignature(zone: ZoneNodeType) { // stored storey height ARE included — both are inputs to the auto-ceiling // height (elected bases / 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 @@ -848,9 +886,17 @@ function levelStructureSnapshots(nodes: Record) { `${(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) { @@ -860,9 +906,13 @@ function levelStructureSnapshots(nodes: Record) { 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, - `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}`, + `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, ) } @@ -1261,6 +1311,20 @@ 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 ceiling poking into its solid. Clamp manual ceilings down to + // the bound; never raise them — a user-lowered ceiling is intent, only + // an over-bound one is a conflict. + const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => { + const bound = resolveCeilingClampBound(ceiling.polygon, context) + if (!Number.isFinite(bound)) return [] + const stored = ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT + return stored > bound + CEILING_HEIGHT_EPSILON + ? [{ id: ceiling.id, data: { height: bound } }] + : [] + }) + const ceilingsToUpdate = [ ...existingAuto .filter((ceiling) => updatesById.has(ceiling.id)) @@ -1282,6 +1346,7 @@ export function planAutoCeilingsForLevel( return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }] }), ...ceilingDemotions, + ...manualClamps, ] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] @@ -1420,7 +1485,12 @@ function runSpaceDetection( roomPolygons, ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), sceneStore, - { walls, slabs: projectedSlabs, storeyHeight }, + { + walls, + slabs: projectedSlabs, + storeyHeight, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), + }, ) const zonePlan = planAutoZonesForLevel( spaces, diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 58ad461461..d76c5f99cd 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -102,6 +102,11 @@ export { snapWorldXZToBuildingLocal, } from './snap' export { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + getCeilingClampBound, + getCoveringSlabUndersideAt, + getLevelAbove, getLevelElevations, getStoredLevelHeight, type LevelElevation, diff --git a/packages/core/src/services/storey.test.ts b/packages/core/src/services/storey.test.ts index c9f2058f58..345a16d25c 100644 --- a/packages/core/src/services/storey.test.ts +++ b/packages/core/src/services/storey.test.ts @@ -1,8 +1,15 @@ import { describe, expect, test } from 'bun:test' -import { BuildingNode, LevelNode } from '../schema' +import { BuildingNode, LevelNode, SlabNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' import { DEFAULT_LEVEL_HEIGHT } from './level-height' -import { getLevelElevations, getStoredLevelHeight } from './storey' +import { + CEILING_CLAMP_MARGIN, + getCeilingClampBound, + getCoveringSlabUndersideAt, + getLevelAbove, + getLevelElevations, + getStoredLevelHeight, +} from './storey' const buildNodes = (list: AnyNode[]): Record => Object.fromEntries(list.map((node) => [node.id, node])) as Record @@ -10,18 +17,45 @@ const buildNodes = (list: AnyNode[]): Record => const level = ( id: string, ordinal: number, - opts: { height?: number; parentId?: string | null } = {}, + 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) @@ -160,3 +194,164 @@ describe('getLevelElevations', () => { 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() + }) +}) + +// 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('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, + ) + }) +}) diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts index 74f54279cf..ce75b4aa3a 100644 --- a/packages/core/src/services/storey.ts +++ b/packages/core/src/services/storey.ts @@ -1,7 +1,15 @@ -import type { BuildingNode, LevelNode } from '../schema' +import type { BuildingNode, LevelNode, SlabNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' +import { pointInPolygon } 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` @@ -77,3 +85,163 @@ export function getLevelElevations(nodes: Record): 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 +} + +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 } +} + +/** + * Lowest underside among `slabs` covering `[x, z]`, in the queried + * level's local Y, or `null` when none covers the point. The slab solid + * occupies `[elevation - thickness, elevation]` in ITS level's local Y, + * which sits `storeyHeight` above the queried level's floor. + */ +function lowestCoveringUndersideAt( + context: CoveringSlabContext, + x: number, + z: number, +): number | null { + let lowest: number | null = null + for (const slab of context.slabs) { + if (!pointInPolygon(x, z, slab.polygon)) continue + if (slab.holes?.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue + // 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. + const underside = context.storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05)) + 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) +} + +/** + * 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. + * + * 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/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index d4a11de37e..713eb14dcd 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -1,9 +1,8 @@ import { type AnyNodeId, type CeilingNode as CeilingNodeType, - getStoredLevelHeight, + getCeilingClampBound, type HandleDescriptor, - type LevelNode, type NodeDefinition, type SceneApi, } from '@pascal-app/core' @@ -25,13 +24,14 @@ const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_CEILING_HEIGHT = 0.5 // Ceilings no longer drive the storey height; the stored level height -// does. A ceiling raised past the storey plane would poke into the level -// above, so height writes clamp at the plane (the renderer's -0.01 -// z-fight offset keeps the effective top just under it). -function ceilingStoreyPlane(n: CeilingNodeType, sceneApi: SceneApi): number { +// 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' - ? getStoredLevelHeight(parent as LevelNode) + ? getCeilingClampBound(parent.id, sceneApi.nodes(), n.polygon ?? []) : Number.POSITIVE_INFINITY } @@ -63,7 +63,7 @@ function ceilingHeightHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_CEILING_HEIGHT, - max: ceilingStoreyPlane, + max: ceilingHeightBound, currentValue: (n) => n.height ?? 2.5, apply: (_n, newValue) => ({ height: newValue }), placement: { diff --git a/packages/nodes/src/ceiling/panel.tsx b/packages/nodes/src/ceiling/panel.tsx index 10b9effd44..692f8d22a9 100644 --- a/packages/nodes/src/ceiling/panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -1,12 +1,6 @@ 'use client' -import { - type AnyNode, - type CeilingNode, - getStoredLevelHeight, - type LevelNode, - useScene, -} from '@pascal-app/core' +import { type AnyNode, type CeilingNode, getCeilingClampBound, useScene } from '@pascal-app/core' import { ActionButton, ActionGroup, @@ -42,13 +36,17 @@ export function CeilingPanel() { ) // Ceilings no longer drive the storey height — the stored level height - // does — so height writes clamp at the storey plane instead of poking - // into the level above (clamp, never ask). - const level = useScene((s) => { + // 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' ? (parent as LevelNode) : undefined + return parent?.type === 'level' + ? getCeilingClampBound(parent.id, s.nodes, node?.polygon ?? []) + : 6 }) - const maxHeight = level ? getStoredLevelHeight(level) : 6 // Panel slider-drag fix recipe (plans/editor-node-registry.md): stable // handler refs so slider drags don't trigger Maximum update depth. diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 43c12f1a80..7c2166350b 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -8,6 +8,7 @@ import { detectSpacesForLevel, emitter, type GridEvent, + getCeilingClampBound, getPerpendicularWallMoveAxis, getPlannedLinkedWallUpdates, getStoredLevelHeight, @@ -296,6 +297,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { slabs: projectAutoSlabsForPlan(existingSlabs, slabPlan), storeyHeight: levelNode?.type === 'level' ? getStoredLevelHeight(levelNode as LevelNode) : undefined, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, sceneState.nodes, polygon), }, ) From e30042dbc6af5e32dafb0afc0cebb7a9947deb3b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 13:30:34 -0400 Subject: [PATCH 10/24] feat: mezzanine and balcony build-tab tools One-gesture composites over the kernel: draw a deck footprint and commit deck slab + railings + stair (mezzanine) or deck + railings (balcony) in a single undo step. Fences gain supportSlabId and lift onto their host deck; railing runs split around the stair mouth; edges near wall centerlines are treated as closed. Stairs target the deck via explicit totalRise with no level-to-level opening sync. Co-Authored-By: Claude Fable 5 --- apps/editor/components/build-tab.tsx | 6 + packages/core/src/schema/nodes/fence.ts | 4 + .../src/components/editor/floorplan-panel.tsx | 22 +- .../components/tools/deck/balcony-tool.tsx | 27 ++ .../src/components/tools/deck/deck-commit.ts | 139 +++++++++ .../src/components/tools/deck/deck-draw.tsx | 285 ++++++++++++++++++ .../components/tools/deck/deck-plan.test.ts | 203 +++++++++++++ .../src/components/tools/deck/deck-plan.ts | 246 +++++++++++++++ .../components/tools/deck/mezzanine-tool.tsx | 43 +++ .../src/components/tools/tool-manager.tsx | 4 + packages/editor/src/lib/snapping-mode.ts | 14 + packages/editor/src/store/use-editor.tsx | 12 +- .../nodes/src/fence/__tests__/lift.test.ts | 65 ++++ packages/nodes/src/fence/definition.ts | 7 +- packages/nodes/src/fence/geometry.ts | 13 +- packages/nodes/src/fence/lift.ts | 25 ++ packages/nodes/src/fence/system.tsx | 50 +++ 17 files changed, 1158 insertions(+), 7 deletions(-) create mode 100644 packages/editor/src/components/tools/deck/balcony-tool.tsx create mode 100644 packages/editor/src/components/tools/deck/deck-commit.ts create mode 100644 packages/editor/src/components/tools/deck/deck-draw.tsx create mode 100644 packages/editor/src/components/tools/deck/deck-plan.test.ts create mode 100644 packages/editor/src/components/tools/deck/deck-plan.ts create mode 100644 packages/editor/src/components/tools/deck/mezzanine-tool.tsx create mode 100644 packages/nodes/src/fence/__tests__/lift.test.ts create mode 100644 packages/nodes/src/fence/lift.ts create mode 100644 packages/nodes/src/fence/system.tsx diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index bb4765429d..a8a9cce222 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -24,6 +24,8 @@ type BuildToolKind = | 'ceiling' | 'roof' | 'stair' + | 'mezzanine' + | 'balcony' | 'elevator' | 'door' | 'window' @@ -74,6 +76,10 @@ const BUILD_TYPES: BuildType[] = [ { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, + // No dedicated artwork yet — the level / floor tiles are the closest fits + // for a raised deck (+stair) and a floor-flush deck respectively. + { id: 'mezzanine', label: 'Mezzanine', iconSrc: '/icons/level.webp', kind: 'mezzanine' }, + { id: 'balcony', label: 'Balcony', iconSrc: '/icons/floor.webp', kind: 'balcony' }, { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, 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/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 9baf8848ef..b373cbef4b 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -6141,6 +6141,12 @@ export function FloorplanPanel({ const fenceContinuation = useEditor((state) => state.continuationByContext.fence) const isRoofBuildActive = phase === 'structure' && mode === 'build' && tool === 'roof' const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair' + // Mezzanine / balcony deck drafting — composite editor tools (no registry + // kind, so the registry catch-all misses them) that consume grid events + // exactly like the slab tool; the 2D ghost renders from the shared + // polygon-draft store the tools publish into. + const isDeckBuildActive = + phase === 'structure' && mode === 'build' && (tool === 'mezzanine' || tool === 'balcony') const isStairMoveActive = movingNode?.type === 'stair' const isRoofMoveActive = movingNode?.type === 'roof' || movingNode?.type === 'roof-segment' const isSlabMoveActive = movingNode?.type === 'slab' @@ -6179,6 +6185,7 @@ export function FloorplanPanel({ isRoofBuildActive || isCeilingBuildActive || isStairBuildActive || + isDeckBuildActive || isStairMoveActive || isRoofMoveActive || isSlabMoveActive || @@ -9871,7 +9878,9 @@ export function FloorplanPanel({ const handleBackgroundClick = useCallback( (event: ReactMouseEvent) => { - if (isPolygonBuildActive && event.detail >= 2) { + // Deck drafting joins the polygon tools here: the double-click's second + // click must not also emit a vertex-adding `grid:click`. + if ((isPolygonBuildActive || isDeckBuildActive) && event.detail >= 2) { return } @@ -9998,6 +10007,7 @@ export function FloorplanPanel({ handleBackgroundPlacementClick, canSelectElementFloorplanGeometry, canSelectFloorplanZones, + isDeckBuildActive, isPolygonBuildActive, isWallBuildActive, levelId, @@ -10028,7 +10038,7 @@ export function FloorplanPanel({ ) const handleBackgroundDoubleClick = useCallback( (event: ReactMouseEvent) => { - if (!(isPolygonDraftBuildActive && !isRoofBuildActive)) { + if (!((isPolygonDraftBuildActive || isDeckBuildActive) && !isRoofBuildActive)) { return } @@ -10037,6 +10047,13 @@ export function FloorplanPanel({ return } + // Deck drafting is grid-event driven end to end — forward the finish + // gesture so the mounted mezzanine / balcony tool commits the polygon. + if (isDeckBuildActive) { + emitFloorplanGridEvent('double-click', getSnappedFloorplanPoint(planPoint), event) + return + } + const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive() const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, @@ -10079,6 +10096,7 @@ export function FloorplanPanel({ handleSlabPlacementConfirm, handleZonePlacementConfirm, isCeilingBuildActive, + isDeckBuildActive, isPolygonDraftBuildActive, isRoofBuildActive, isZoneBuildActive, diff --git a/packages/editor/src/components/tools/deck/balcony-tool.tsx b/packages/editor/src/components/tools/deck/balcony-tool.tsx new file mode 100644 index 0000000000..74f6f28e7a --- /dev/null +++ b/packages/editor/src/components/tools/deck/balcony-tool.tsx @@ -0,0 +1,27 @@ +'use client' + +import { BALCONY_DECK_ELEVATION, commitDeck } from './deck-commit' +import { DeckDrawTool } from './deck-draw' + +/** + * Balcony tool — draw a deck footprint (typically hanging past the wall + * line) on the active storey; one gesture (and one undo step) creates the + * deck slab flush with the interior floor surface plus fence railings on + * every open edge. See `commitDeck` for the composition. + */ +export const BalconyTool: React.FC = () => ( + + commitDeck({ + levelId, + points, + elevation: BALCONY_DECK_ELEVATION, + withStair: false, + namePrefix: 'Balcony', + }) + } + /> +) + +export default BalconyTool diff --git a/packages/editor/src/components/tools/deck/deck-commit.ts b/packages/editor/src/components/tools/deck/deck-commit.ts new file mode 100644 index 0000000000..f218027a43 --- /dev/null +++ b/packages/editor/src/components/tools/deck/deck-commit.ts @@ -0,0 +1,139 @@ +import { + type AnyNode, + type AnyNodeId, + FenceNode, + type LevelNode, + SlabNode, + StairNode, + StairSegmentNode, + useScene, + type WallNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { triggerSFX } from '../../../lib/sfx-bus' +import { + DEFAULT_STAIR_ATTACHMENT_SIDE, + DEFAULT_STAIR_FILL_TO_FLOOR, + DEFAULT_STAIR_RAILING_HEIGHT, + DEFAULT_STAIR_RAILING_MODE, + DEFAULT_STAIR_THICKNESS, +} from '../stair/stair-defaults' +import { + buildRailingRuns, + classifyDeckEdges, + type DeckWallSegment, + type PlanPoint, + planDeckStair, +} from './deck-plan' + +/** Thin platform slab — the deck reads as a walking surface, not a storey floor. */ +export const DECK_THICKNESS = 0.05 + +/** Balcony decks sit flush with the interior floor surface (slab default). */ +export const BALCONY_DECK_ELEVATION = 0.05 + +/** Guardrail height for deck railings — the codebase's standard railing height. */ +export const DECK_RAILING_HEIGHT = DEFAULT_STAIR_RAILING_HEIGHT + +function collectLevelWallSegments( + nodes: Record, + levelId: LevelNode['id'], +): DeckWallSegment[] { + const walls: DeckWallSegment[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'wall' || node.parentId !== levelId) continue + const wall = node as WallNode + walls.push({ start: wall.start, end: wall.end, thickness: wall.thickness }) + } + return walls +} + +function nextDeckName(nodes: Record, prefix: 'Mezzanine' | 'Balcony'): string { + const count = Object.values(nodes).filter( + (node) => node.type === 'slab' && node.name?.startsWith(prefix), + ).length + return `${prefix} ${count + 1}` +} + +/** + * One-gesture deck commit shared by the mezzanine and balcony tools. Creates + * the deck slab, fence railings on every open (not wall-backed) edge hosted + * on the deck via `supportSlabId`, and — for the mezzanine — a straight + * access stair boarding the longest open edge, with the railing split around + * the stair mouth. Everything lands in a single `createNodes` call, so the + * whole gesture is one undo step; the deck is selected afterwards (slab-tool + * parity). A fully wall-enclosed deck clamps to just the slab: no railing, + * no stair, no prompt. + */ +export function commitDeck(options: { + levelId: LevelNode['id'] + points: PlanPoint[] + elevation: number + withStair: boolean + namePrefix: 'Mezzanine' | 'Balcony' +}): void { + const { levelId, points, elevation, withStair, namePrefix } = options + const { createNodes, nodes } = useScene.getState() + + const deck = SlabNode.parse({ + name: nextDeckName(nodes, namePrefix), + polygon: points, + elevation, + thickness: DECK_THICKNESS, + }) + + const { open } = classifyDeckEdges(points, collectLevelWallSegments(nodes, levelId)) + const stairPlan = withStair ? planDeckStair(points, open, elevation) : null + const railingRuns = buildRailingRuns(open, stairPlan) + + const ops: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [{ node: deck, parentId: levelId }] + + for (const run of railingRuns) { + const railing = FenceNode.parse({ + name: 'Railing', + start: run.start, + end: run.end, + height: DECK_RAILING_HEIGHT, + style: 'rail', + supportSlabId: deck.id, + }) + ops.push({ node: railing, parentId: levelId }) + } + + if (stairPlan) { + const segment = StairSegmentNode.parse({ + segmentType: 'stair', + width: stairPlan.width, + length: stairPlan.runLength, + height: elevation, + stepCount: stairPlan.stepCount, + attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE, + fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, + thickness: DEFAULT_STAIR_THICKNESS, + position: [0, 0, 0], + }) + const stair = StairNode.parse({ + name: `${namePrefix} stair`, + position: [stairPlan.foot[0], 0, stairPlan.foot[1]], + rotation: stairPlan.rotation, + stairType: 'straight', + fromLevelId: levelId, + // Same-level deck boarding: no destination level, no slab opening — + // you board the deck over its open edge, so nothing gets cut. + slabOpeningMode: 'none', + totalRise: elevation, + width: stairPlan.width, + stepCount: stairPlan.stepCount, + thickness: DEFAULT_STAIR_THICKNESS, + fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, + railingHeight: DEFAULT_STAIR_RAILING_HEIGHT, + railingMode: DEFAULT_STAIR_RAILING_MODE, + children: [segment.id], + }) + ops.push({ node: stair, parentId: levelId }, { node: segment, parentId: stair.id }) + } + + createNodes(ops) + useViewer.getState().setSelection({ selectedIds: [deck.id] }) + triggerSFX('sfx:structure-build') +} diff --git a/packages/editor/src/components/tools/deck/deck-draw.tsx b/packages/editor/src/components/tools/deck/deck-draw.tsx new file mode 100644 index 0000000000..d5d576b500 --- /dev/null +++ b/packages/editor/src/components/tools/deck/deck-draw.tsx @@ -0,0 +1,285 @@ +'use client' + +import { + DEFAULT_ANGLE_STEP, + emitter, + type GridEvent, + type LevelNode, + snapPointAlongAngleRay, + snapPointToGrid, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef, useState } from 'react' +import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { EDITOR_LAYER } from '../../../lib/constants' +import { triggerSFX } from '../../../lib/sfx-bus' +import { clearSlabSnapFeedback, resolveSlabPlanPointSnap } from '../../../lib/slab-plan-snap' +import useEditor, { isAngleSnapActive, isGridSnapActive } from '../../../store/use-editor' +import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview' +import { CursorSphere } from '../shared/cursor-sphere' +import { type PlanPoint, sanitizeDeckPolygon } from './deck-plan' + +/** + * Shared deck-footprint drawing for the mezzanine / balcony tools — the slab + * tool's multi-click polygon flow (same snap pipeline, same finish gestures: + * close near the first vertex, Enter, or double-click; Esc clears the draft) + * with the 3D preview lifted to the deck elevation. The 2D floor plan draws + * the same draft as a slab ghost via the shared draft-preview store; the + * panel's grid catch-all synthesizes the grid events this component consumes, + * so both views draft identically. + */ + +const Y_OFFSET = 0.02 + +type DeckDrawToolProps = { + /** Deck walking-surface elevation (meters above the level plane). */ + elevation: number + onCommit: (levelId: LevelNode['id'], points: PlanPoint[]) => void +} + +export const DeckDrawTool: React.FC = ({ elevation, onCommit }) => { + const cursorRef = useRef(null) + const mainLineRef = useRef(null!) + const closingLineRef = useRef(null!) + const currentLevelId = useViewer((s) => s.selection.levelId) + + const [points, setPoints] = useState([]) + const [cursorPosition, setCursorPosition] = useState([0, 0]) + const [snappedCursorPosition, setSnappedCursorPosition] = useState([0, 0]) + const [levelY, setLevelY] = useState(0) + const previousSnappedPointRef = useRef(null) + + useEffect(() => () => clearSlabSnapFeedback(), []) + + // Publish the live vertex count so the HUD shows "Finish" only at ≥ 3 points. + useEffect(() => { + useEditor.getState().setDraftVertexCount(points.length) + }, [points.length]) + useEffect(() => () => useEditor.getState().setDraftVertexCount(0), []) + + // The deck draft is a slab ghost in the 2D floor plan. + useEffect(() => { + useFloorplanDraftPreview.getState().setPolygonDraft('slab', points) + }, [points]) + useEffect( + () => () => { + const draftPreview = useFloorplanDraftPreview.getState() + if (draftPreview.polygonDraftType === 'slab') { + draftPreview.setPolygonDraft(null, []) + } + draftPreview.setCursorPoint(null) + }, + [], + ) + + useEffect(() => { + if (!currentLevelId) return + + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return + const rawPoint: PlanPoint = [event.localPosition[0], event.localPosition[2]] + const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const gridPosition: PlanPoint = [...snapPointToGrid(rawPoint, gridStep)] + setCursorPosition(gridPosition) + setLevelY(event.localPosition[1]) + const lastPoint = points[points.length - 1] + const orthoPoint: PlanPoint = + isAngleSnapActive() && lastPoint + ? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)] + : gridPosition + const displayPoint = resolveSlabPlanPointSnap({ + rawPoint, + fallbackPoint: orthoPoint, + levelId: currentLevelId, + }).point + useFloorplanDraftPreview.getState().setCursorPoint(displayPoint) + setSnappedCursorPosition(displayPoint) + if ( + points.length > 0 && + previousSnappedPointRef.current && + (displayPoint[0] !== previousSnappedPointRef.current[0] || + displayPoint[1] !== previousSnappedPointRef.current[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousSnappedPointRef.current = displayPoint + cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1]) + } + + const commitDraft = () => { + const polygon = sanitizeDeckPolygon(points) + if (polygon.length >= 3) { + onCommit(currentLevelId, polygon) + } + setPoints([]) + clearSlabSnapFeedback() + } + + const onGridClick = (_event: GridEvent) => { + if (!currentLevelId) return + const clickPoint = previousSnappedPointRef.current ?? cursorPosition + const firstPoint = points[0] + if ( + points.length >= 3 && + firstPoint && + Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && + Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 + ) { + commitDraft() + } else { + triggerSFX('sfx:structure-build-start') + setPoints([...points, clickPoint]) + } + } + + const finishDrawing = () => { + if (points.length < 3) return + commitDraft() + } + + const onGridDoubleClick = (_event: GridEvent) => { + finishDrawing() + } + + const onCancel = () => { + if (points.length > 0) markToolCancelConsumed() + setPoints([]) + clearSlabSnapFeedback() + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + finishDrawing() + } + } + document.addEventListener('keydown', onKeyDown) + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('grid:double-click', onGridDoubleClick) + emitter.on('tool:cancel', onCancel) + + return () => { + document.removeEventListener('keydown', onKeyDown) + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('grid:double-click', onGridDoubleClick) + emitter.off('tool:cancel', onCancel) + } + }, [currentLevelId, points, cursorPosition, onCommit]) + + // Draft polyline + closing line, drawn at the DECK elevation so the user + // sees where the platform will actually float. + useEffect(() => { + if (!(mainLineRef.current && closingLineRef.current)) return + if (points.length === 0) { + mainLineRef.current.visible = false + closingLineRef.current.visible = false + return + } + const y = levelY + elevation + Y_OFFSET + const snappedCursor = snappedCursorPosition + const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z)) + linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1])) + if (linePoints.length >= 2) { + mainLineRef.current.geometry.dispose() + mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints) + mainLineRef.current.visible = true + } else { + mainLineRef.current.visible = false + } + const firstPoint = points[0] + if (points.length >= 2 && firstPoint) { + const closingPoints = [ + new Vector3(snappedCursor[0], y, snappedCursor[1]), + new Vector3(firstPoint[0], y, firstPoint[1]), + ] + closingLineRef.current.geometry.dispose() + closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints) + closingLineRef.current.visible = true + } else { + closingLineRef.current.visible = false + } + }, [points, snappedCursorPosition, levelY, elevation]) + + const previewShape = useMemo(() => { + if (points.length < 3) return null + const snappedCursor = snappedCursorPosition + const allPoints = [...points, snappedCursor] + const firstPt = allPoints[0] + if (!firstPt) return null + const shape = new Shape() + shape.moveTo(firstPt[0], -firstPt[1]) + for (let i = 1; i < allPoints.length; i++) { + const pt = allPoints[i] + if (pt) shape.lineTo(pt[0], -pt[1]) + } + shape.closePath() + return shape + }, [points, snappedCursorPosition]) + + return ( + + + {previewShape && ( + + + + + )} + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {points.map(([x, z], index) => ( + + ))} + + ) +} diff --git a/packages/editor/src/components/tools/deck/deck-plan.test.ts b/packages/editor/src/components/tools/deck/deck-plan.test.ts new file mode 100644 index 0000000000..363eb1e527 --- /dev/null +++ b/packages/editor/src/components/tools/deck/deck-plan.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from 'bun:test' +import { + buildRailingRuns, + classifyDeckEdges, + type DeckEdge, + type PlanPoint, + planDeckStair, + quantizeDeckElevation, + sanitizeDeckPolygon, +} from './deck-plan' + +// Square deck, CCW in plan space: bottom edge (z = 0) is index 0. +const SQUARE: PlanPoint[] = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +const BOTTOM_EDGE: DeckEdge = { start: [0, 0], end: [4, 0] } + +describe('classifyDeckEdges', () => { + test('no walls means every edge is open', () => { + const { open, closed } = classifyDeckEdges(SQUARE, []) + expect(open).toHaveLength(4) + expect(closed).toHaveLength(0) + }) + + test('an edge whose midpoint hugs a wall centerline is closed', () => { + const { open, closed } = classifyDeckEdges(SQUARE, [ + { start: [-1, 0], end: [5, 0], thickness: 0.1 }, + ]) + expect(closed).toHaveLength(1) + expect(closed[0]?.start).toEqual([0, 0]) + expect(closed[0]?.end).toEqual([4, 0]) + expect(open).toHaveLength(3) + }) + + test('the ~0.3 m default threshold clamps: near wall closes, far wall stays open', () => { + const near = classifyDeckEdges(SQUARE, [ + { start: [-1, -0.29], end: [5, -0.29], thickness: 0.1 }, + ]) + expect(near.closed).toHaveLength(1) + + const far = classifyDeckEdges(SQUARE, [{ start: [-1, -0.35], end: [5, -0.35], thickness: 0.1 }]) + expect(far.closed).toHaveLength(0) + }) + + test('a thicker wall extends the closing reach by its half thickness', () => { + const { closed } = classifyDeckEdges(SQUARE, [ + { start: [-1, -0.35], end: [5, -0.35], thickness: 0.3 }, + ]) + expect(closed).toHaveLength(1) + }) + + test('a wall near only a corner (not the midpoint) leaves the edge open', () => { + const { open } = classifyDeckEdges(SQUARE, [{ start: [-1, 0], end: [0.5, 0], thickness: 0.1 }]) + expect(open.some((edge) => edge.start[0] === 0 && edge.end[0] === 4)).toBe(true) + }) +}) + +describe('planDeckStair', () => { + test('returns null when the deck is fully enclosed (no open edge)', () => { + expect(planDeckStair(SQUARE, [], 1.25)).toBeNull() + }) + + test('returns null for a zero rise', () => { + expect(planDeckStair(SQUARE, [BOTTOM_EDGE], 0)).toBeNull() + }) + + test('boards the edge at its midpoint with the foot outside the deck', () => { + const plan = planDeckStair(SQUARE, [BOTTOM_EDGE], 1.25) + expect(plan).not.toBeNull() + if (!plan) return + expect(plan.head).toEqual([2, 0]) + // Default slope preserved: run = rise * (3.0 / 2.5). + expect(plan.runLength).toBeCloseTo(1.5) + expect(plan.foot[0]).toBeCloseTo(2) + expect(plan.foot[1]).toBeCloseTo(-1.5) + // Ascent aims from the foot toward the deck: rotation 0 is local +Z. + expect(plan.rotation).toBeCloseTo(0) + // Default riser height preserved: 1.25 / 0.25. + expect(plan.stepCount).toBe(5) + }) + + test('winding order does not flip the foot inside the deck', () => { + const clockwise = [...SQUARE].reverse() + const edge: DeckEdge = { start: [4, 0], end: [0, 0] } + const plan = planDeckStair(clockwise, [edge], 1.25) + expect(plan).not.toBeNull() + if (!plan) return + expect(plan.foot[1]).toBeCloseTo(-1.5) + }) + + test('picks the longest open edge', () => { + const rect: PlanPoint[] = [ + [0, 0], + [6, 0], + [6, 2], + [0, 2], + ] + const shortEdge: DeckEdge = { start: [6, 0], end: [6, 2] } + const longEdge: DeckEdge = { start: [0, 0], end: [6, 0] } + const plan = planDeckStair(rect, [shortEdge, longEdge], 1) + expect(plan?.edge).toBe(longEdge) + }) + + test('clamps tiny rises to a usable run and at least two steps', () => { + const plan = planDeckStair(SQUARE, [BOTTOM_EDGE], 0.2) + expect(plan?.runLength).toBeCloseTo(0.6) + expect(plan?.stepCount).toBe(2) + }) +}) + +describe('buildRailingRuns', () => { + test('one full run per open edge without a stair', () => { + const runs = buildRailingRuns([BOTTOM_EDGE], null) + expect(runs).toHaveLength(1) + expect(runs[0]?.start).toEqual([0, 0]) + expect(runs[0]?.end).toEqual([4, 0]) + }) + + test('splits the boarding edge around the stair mouth', () => { + const runs = buildRailingRuns([BOTTOM_EDGE], { + edge: BOTTOM_EDGE, + head: [2, 0], + width: 1, + }) + // Gap = width + 2 * clearance = 1.2 m centered on the boarding point. + expect(runs).toHaveLength(2) + expect(runs[0]?.start[0]).toBeCloseTo(0) + expect(runs[0]?.end[0]).toBeCloseTo(1.4) + expect(runs[1]?.start[0]).toBeCloseTo(2.6) + expect(runs[1]?.end[0]).toBeCloseTo(4) + }) + + test('drops stubs shorter than the minimum run', () => { + const shortEdge: DeckEdge = { start: [0, 0], end: [1.6, 0] } + const runs = buildRailingRuns([shortEdge], { + edge: shortEdge, + head: [0.8, 0], + width: 1, + }) + // 1.6 m edge minus the 1.2 m mouth leaves two 0.2 m stubs — both culled. + expect(runs).toHaveLength(0) + }) + + test('boarding near an edge end keeps only the far run', () => { + const runs = buildRailingRuns([BOTTOM_EDGE], { + edge: BOTTOM_EDGE, + head: [0.5, 0], + width: 1, + }) + expect(runs).toHaveLength(1) + expect(runs[0]?.start[0]).toBeCloseTo(1.1) + expect(runs[0]?.end[0]).toBeCloseTo(4) + }) + + test('other open edges keep their full railing while the boarding edge is split', () => { + const rightEdge: DeckEdge = { start: [4, 0], end: [4, 4] } + const runs = buildRailingRuns([BOTTOM_EDGE, rightEdge], { + edge: BOTTOM_EDGE, + head: [2, 0], + width: 1, + }) + expect(runs).toHaveLength(3) + expect(runs[2]?.start).toEqual([4, 0]) + expect(runs[2]?.end).toEqual([4, 4]) + }) + + test('skips edges shorter than the minimum run', () => { + const tiny: DeckEdge = { start: [0, 0], end: [0.2, 0] } + expect(buildRailingRuns([tiny], null)).toHaveLength(0) + }) +}) + +describe('sanitizeDeckPolygon', () => { + test('drops consecutive duplicates and a trailing first-point repeat', () => { + const polygon = sanitizeDeckPolygon([ + [0, 0], + [0, 0], + [4, 0], + [4, 4], + [4, 4], + [0, 4], + [0.005, 0.005], + ]) + expect(polygon).toEqual([ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ]) + }) +}) + +describe('quantizeDeckElevation', () => { + test('quantizes to 0.05 m', () => { + expect(quantizeDeckElevation(1.27)).toBeCloseTo(1.25) + expect(quantizeDeckElevation(2.5 / 2)).toBeCloseTo(1.25) + expect(quantizeDeckElevation(1.31)).toBeCloseTo(1.3) + }) +}) diff --git a/packages/editor/src/components/tools/deck/deck-plan.ts b/packages/editor/src/components/tools/deck/deck-plan.ts new file mode 100644 index 0000000000..9e576a95c0 --- /dev/null +++ b/packages/editor/src/components/tools/deck/deck-plan.ts @@ -0,0 +1,246 @@ +import { DEFAULT_WALL_THICKNESS, pointInPolygon2D } from '@pascal-app/core' +import { + DEFAULT_STAIR_HEIGHT, + DEFAULT_STAIR_LENGTH, + DEFAULT_STAIR_STEP_COUNT, + DEFAULT_STAIR_WIDTH, +} from '../stair/stair-defaults' + +/** + * Pure planning math for the mezzanine / balcony deck tools: open-edge + * classification against level walls, straight-stair auto-placement, and + * railing runs (with the stair-mouth gap). Everything here is plan-space + * `[x, z]` in level meters — no store, no Three.js — so it unit-tests in + * isolation and both the 2D and 3D commit paths share one behavior. + */ + +export type PlanPoint = [number, number] + +export type DeckEdge = { start: PlanPoint; end: PlanPoint } + +export type DeckWallSegment = { start: PlanPoint; end: PlanPoint; thickness?: number } + +/** + * Extra reach past the wall face when deciding an edge is wall-backed — + * with the default 0.1 m wall the total midpoint threshold is ~0.3 m. + */ +export const RAILING_EDGE_MARGIN = 0.25 + +/** Railing stubs shorter than this are dropped rather than drawn. */ +export const MIN_RAILING_RUN = 0.3 + +/** Clearance added on each side of the stair width for the railing gap. */ +export const STAIR_MOUTH_CLEARANCE = 0.1 + +/** Shortest usable stair run — keeps tiny rises from degenerating the mesh. */ +export const MIN_STAIR_RUN = 0.6 + +export const DECK_ELEVATION_STEP = 0.05 + +export function quantizeDeckElevation(value: number): number { + // Integer divisor (1 / 0.05 = 20) keeps the result free of float dust. + return Math.round(value / DECK_ELEVATION_STEP) / 20 +} + +/** + * Drop consecutive duplicate vertices and a trailing vertex that repeats the + * first — double-click finishes re-emit the last click point, and a duplicate + * vertex would produce a zero-length railing edge. + */ +export function sanitizeDeckPolygon(points: PlanPoint[], tolerance = 0.01): PlanPoint[] { + const result: PlanPoint[] = [] + for (const point of points) { + const previous = result[result.length - 1] + if (previous && samePoint(previous, point, tolerance)) continue + result.push(point) + } + const first = result[0] + const last = result[result.length - 1] + if (result.length > 1 && first && last && samePoint(first, last, tolerance)) { + result.pop() + } + return result +} + +export function polygonEdges(polygon: PlanPoint[]): DeckEdge[] { + const edges: DeckEdge[] = [] + for (let i = 0; i < polygon.length; i++) { + const start = polygon[i] + const end = polygon[(i + 1) % polygon.length] + if (start && end) edges.push({ start, end }) + } + return edges +} + +function samePoint(a: PlanPoint, b: PlanPoint, tolerance = 1e-6): boolean { + return Math.abs(a[0] - b[0]) <= tolerance && Math.abs(a[1] - b[1]) <= tolerance +} + +function sameEdge(a: DeckEdge, b: DeckEdge): boolean { + return a === b || (samePoint(a.start, b.start) && samePoint(a.end, b.end)) +} + +function edgeLength(edge: DeckEdge): number { + return Math.hypot(edge.end[0] - edge.start[0], edge.end[1] - edge.start[1]) +} + +function edgeMidpoint(edge: DeckEdge): PlanPoint { + return [(edge.start[0] + edge.end[0]) / 2, (edge.start[1] + edge.end[1]) / 2] +} + +export function distancePointToSegment(point: PlanPoint, a: PlanPoint, b: PlanPoint): number { + const abX = b[0] - a[0] + const abZ = b[1] - a[1] + const lengthSq = abX * abX + abZ * abZ + if (lengthSq < 1e-12) return Math.hypot(point[0] - a[0], point[1] - a[1]) + const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * abX + (point[1] - a[1]) * abZ) / lengthSq)) + return Math.hypot(point[0] - (a[0] + t * abX), point[1] - (a[1] + t * abZ)) +} + +/** + * Split the deck outline into wall-backed ("closed") edges and free ("open") + * edges. An edge is closed when its midpoint sits within the wall's half + * thickness plus {@link RAILING_EDGE_MARGIN} of any wall centerline on the + * level — those edges need no railing. Curved walls are tested against their + * straight chord (v1 approximation). + */ +export function classifyDeckEdges( + polygon: PlanPoint[], + walls: DeckWallSegment[], + margin = RAILING_EDGE_MARGIN, +): { open: DeckEdge[]; closed: DeckEdge[] } { + const open: DeckEdge[] = [] + const closed: DeckEdge[] = [] + + for (const edge of polygonEdges(polygon)) { + if (edgeLength(edge) < 1e-6) continue + const midpoint = edgeMidpoint(edge) + const wallBacked = walls.some((wall) => { + const threshold = (wall.thickness ?? DEFAULT_WALL_THICKNESS) / 2 + margin + return distancePointToSegment(midpoint, wall.start, wall.end) <= threshold + }) + if (wallBacked) closed.push(edge) + else open.push(edge) + } + + return { open, closed } +} + +export type DeckStairPlan = { + /** The boarding edge — the longest open edge of the deck outline. */ + edge: DeckEdge + /** Stair origin (the entry) on the storey floor; the run ascends toward the deck. */ + foot: PlanPoint + /** Where the run meets the deck rim — the boarding edge's midpoint. */ + head: PlanPoint + /** Y rotation orienting the stair's local +Z run from foot to head. */ + rotation: number + runLength: number + stepCount: number + width: number +} + +/** + * Place a straight stair perpendicular to the longest open edge: foot on the + * storey floor outside the deck outline, head meeting the boarding edge at + * its midpoint. Run length keeps the default stair's slope; step count keeps + * its default riser height. Returns null when the deck is fully wall-enclosed + * (no open edge) — the caller then creates the bare deck (clamp, never ask). + */ +export function planDeckStair( + polygon: PlanPoint[], + openEdges: DeckEdge[], + rise: number, +): DeckStairPlan | null { + if (rise <= 0) return null + let edge: DeckEdge | null = null + for (const candidate of openEdges) { + if (!edge || edgeLength(candidate) > edgeLength(edge)) edge = candidate + } + if (!edge) return null + + const length = edgeLength(edge) + if (length < 1e-6) return null + const direction: PlanPoint = [ + (edge.end[0] - edge.start[0]) / length, + (edge.end[1] - edge.start[1]) / length, + ] + const head = edgeMidpoint(edge) + + // Perpendicular pointing away from the deck interior — probe a point just + // off the midpoint; if it lands inside the outline, flip. + let outward: PlanPoint = [direction[1], -direction[0]] + const probe: PlanPoint = [head[0] + outward[0] * 0.05, head[1] + outward[1] * 0.05] + if (pointInPolygon2D(probe, polygon)) { + outward = [-outward[0], -outward[1]] + } + + const runLength = Math.max(rise * (DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_HEIGHT), MIN_STAIR_RUN) + const foot: PlanPoint = [head[0] + outward[0] * runLength, head[1] + outward[1] * runLength] + const defaultRiser = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT + const stepCount = Math.max(2, Math.round(rise / defaultRiser)) + + return { + edge, + foot, + head, + // Stair local +Z is the ascent; aim it from the foot toward the deck. + rotation: Math.atan2(-outward[0], -outward[1]), + runLength, + stepCount, + width: DEFAULT_STAIR_WIDTH, + } +} + +export type RailingRun = { start: PlanPoint; end: PlanPoint } + +/** + * One railing run per open edge, except the stair's boarding edge, which is + * split into two runs around the stair mouth (stair width + clearance, + * centered on the boarding point). Runs shorter than `minRunLength` are + * dropped — an edge barely wider than the stair gets a clean gap instead of + * fence stubs. + */ +export function buildRailingRuns( + openEdges: DeckEdge[], + stair: Pick | null, + minRunLength = MIN_RAILING_RUN, +): RailingRun[] { + const runs: RailingRun[] = [] + + for (const edge of openEdges) { + const length = edgeLength(edge) + if (length < 1e-6) continue + const direction: PlanPoint = [ + (edge.end[0] - edge.start[0]) / length, + (edge.end[1] - edge.start[1]) / length, + ] + + const spans: Array<[number, number]> = [] + if (stair && sameEdge(edge, stair.edge)) { + const boardingT = + (stair.head[0] - edge.start[0]) * direction[0] + + (stair.head[1] - edge.start[1]) * direction[1] + const halfGap = stair.width / 2 + STAIR_MOUTH_CLEARANCE + spans.push([0, Math.min(boardingT - halfGap, length)]) + spans.push([Math.max(boardingT + halfGap, 0), length]) + } else { + spans.push([0, length]) + } + + for (const [from, to] of spans) { + const clampedFrom = Math.max(0, from) + const clampedTo = Math.min(length, to) + if (clampedTo - clampedFrom < minRunLength) continue + runs.push({ + start: [ + edge.start[0] + direction[0] * clampedFrom, + edge.start[1] + direction[1] * clampedFrom, + ], + end: [edge.start[0] + direction[0] * clampedTo, edge.start[1] + direction[1] * clampedTo], + }) + } + } + + return runs +} diff --git a/packages/editor/src/components/tools/deck/mezzanine-tool.tsx b/packages/editor/src/components/tools/deck/mezzanine-tool.tsx new file mode 100644 index 0000000000..1013217eef --- /dev/null +++ b/packages/editor/src/components/tools/deck/mezzanine-tool.tsx @@ -0,0 +1,43 @@ +'use client' + +import { + type AnyNodeId, + DEFAULT_LEVEL_HEIGHT, + getStoredLevelHeight, + type LevelNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { commitDeck } from './deck-commit' +import { DeckDrawTool } from './deck-draw' +import { quantizeDeckElevation } from './deck-plan' + +/** + * Mezzanine tool — draw a deck footprint on the active storey; one gesture + * (and one undo step) creates a thin deck slab at half the storey height, + * fence railings on the open edges, and a straight stair up to the deck. + * The elevation is plain slab placement afterwards — adjust it in the slab + * panel. See `commitDeck` for the composition. + */ +export const MezzanineTool: React.FC = () => { + const currentLevelId = useViewer((s) => s.selection.levelId) + const elevation = useScene((s) => { + const level = currentLevelId + ? (s.nodes[currentLevelId as AnyNodeId] as LevelNode | undefined) + : undefined + const storeyHeight = + level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT + return quantizeDeckElevation(storeyHeight / 2) + }) + + return ( + + commitDeck({ levelId, points, elevation, withStair: true, namePrefix: 'Mezzanine' }) + } + /> + ) +} + +export default MezzanineTool diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 05df845170..fb0e380186 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -23,6 +23,8 @@ import { import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer' import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' +import { BalconyTool } from './deck/balcony-tool' +import { MezzanineTool } from './deck/mezzanine-tool' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' @@ -64,6 +66,8 @@ const tools: Record>> = { roof: RoofTool, stair: StairTool, zone: ZoneTool, + mezzanine: MezzanineTool, + balcony: BalconyTool, }, furnish: {}, } diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index 634f16ffeb..59ad013303 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -108,6 +108,20 @@ export function cycleSnappingModeIn(context: SnapContext, mode: SnappingMode): S return modes[(index + 1) % modes.length] ?? modes[0] ?? DEFAULT_SNAPPING_MODE } +// Composite editor tools (mezzanine / balcony deck drawing) have no registry +// kind to declare a `snapProfile` on, yet draft with the same polygon-on-a- +// level snap pipeline as slab. `getActiveSnapContext` falls back to these maps +// after the registry lookup so the tools get the no-angle 'polygon' mode set. +export const COMPOSITE_TOOL_SNAP_PROFILES: Record = { + mezzanine: 'structural', + balcony: 'structural', +} + +export const COMPOSITE_TOOL_DRAFT_DIRECTIONAL: Record = { + mezzanine: false, + balcony: false, +} + // The kind's declared `snapProfile` (from the registry) → the active mode-set // context. The only behaviour difference is the angle lock, which a `structural` // kind gets while SETTING DIRECTION (drafting a run/polygon, dragging an endpoint diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 5bb6092a9c..28892b7c2b 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -61,6 +61,8 @@ import { type PaintScope, } from '../lib/paint-scope' import { + COMPOSITE_TOOL_DRAFT_DIRECTIONAL, + COMPOSITE_TOOL_SNAP_PROFILES, cycleSnappingModeIn, defaultSnappingModeFor, resolveSnapFlags, @@ -151,6 +153,8 @@ export type StructureTool = | 'column' | 'elevator' | 'stair' + | 'mezzanine' + | 'balcony' | 'item' | 'zone' | 'spawn' @@ -1377,12 +1381,16 @@ export function getActiveSnapContext(): SnapContext | null { scope: useInteractionScope.getState().scope, mode: editor.mode, tool: editor.tool, - profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile, + profileOf: (typeOrTool) => + nodeRegistry.get(typeOrTool)?.snapProfile ?? COMPOSITE_TOOL_SNAP_PROFILES[typeOrTool], profileOfNode: (nodeId) => { const node = useScene.getState().nodes[nodeId as AnyNodeId] return node ? nodeRegistry.get(node.type)?.snapProfile : undefined }, - draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true, + draftDirectionalOf: (typeOrTool) => + nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? + COMPOSITE_TOOL_DRAFT_DIRECTIONAL[typeOrTool] ?? + true, }) } 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/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/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..4791ab9e39 --- /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 (deck railings placed by the mezzanine / balcony tools). 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/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 From 2c875c8fb70bc89e499fc360617923f48703b9c5 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 14:08:45 -0400 Subject: [PATCH 11/24] feat: adaptive slab vertical editing; level vocabulary in UI copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a grounded slab's top stretches it (elevation and thickness move together — gaps impossible); floating decks move with thickness preserved and land grounded at zero; pools keep the drag-through-zero gesture. User-facing copy says level, not storey (Follows level, Level height). Co-Authored-By: Claude Fable 5 --- .../components/ui/floating-level-selector.tsx | 4 +- .../src/slab/__tests__/definition.test.ts | 3 +- .../slab/__tests__/elevation-limit.test.ts | 94 +++++++++++++++++++ packages/nodes/src/slab/definition.ts | 17 ++-- packages/nodes/src/slab/elevation-limit.ts | 21 +++++ packages/nodes/src/slab/panel.tsx | 23 ++--- packages/nodes/src/wall/panel.tsx | 2 +- 7 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 packages/nodes/src/slab/__tests__/elevation-limit.test.ts diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 0f0786901b..47b36badb7 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -212,7 +212,7 @@ function LevelRow({
From a176e68f68b4517ae1c13d073d926d2ca94b3259 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 08:27:24 -0400 Subject: [PATCH 21/24] feat: stairs converge to their resolved rise; deck attachment disables the cutout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncStairRises now converges every follows-mode straight stair (level or deck) plus deck-attached custom rises — detaching a stair from a deck re-derives its height, and ordinary stairs finally track level-height changes. Attaching via the panel writes slabOpeningMode none and hides the cutout controls; detaching restores the destination cutout and clears the stale explicit rise. Co-Authored-By: Claude Fable 5 --- .../systems/stair/stair-opening-system.tsx | 11 +- .../core/src/systems/stair/stair-rise.test.ts | 151 +++++++++++++----- packages/core/src/systems/stair/stair-rise.ts | 24 +-- packages/nodes/src/stair/destination.test.ts | 70 ++++++++ packages/nodes/src/stair/destination.ts | 30 ++++ packages/nodes/src/stair/panel.tsx | 62 +++---- 6 files changed, 263 insertions(+), 85 deletions(-) create mode 100644 packages/nodes/src/stair/destination.test.ts create mode 100644 packages/nodes/src/stair/destination.ts diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index b47ad95377..357134e643 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -12,7 +12,7 @@ import { hasLiveStairOpeningInputs, } from './stair-opening-preview' import { syncAutoStairOpenings } from './stair-opening-sync' -import { syncDeckAttachedStairRises } from './stair-rise' +import { syncStairRises } from './stair-rise' function isOpeningRelevantNode(node: AnyNode | undefined) { return ( @@ -105,10 +105,11 @@ export const StairOpeningSystem = () => { } const runAutoSync = () => { - // Rise first: deck-attached straight stairs write their flight heights - // from the deck's elevation, and the opening pass reads those segment - // heights — so it must run against the post-rise nodes. - applyUpdates(syncDeckAttachedStairRises(useScene.getState().nodes)) + // 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)) } diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts index c259560598..00309cbfa1 100644 --- a/packages/core/src/systems/stair/stair-rise.test.ts +++ b/packages/core/src/systems/stair/stair-rise.test.ts @@ -9,7 +9,7 @@ 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, syncDeckAttachedStairRises } from './stair-rise' +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 @@ -95,6 +95,42 @@ function buildDeckScene(options: { 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) @@ -142,15 +178,13 @@ describe('resolveStairTotalRise', () => { }) }) -describe('syncDeckAttachedStairRises', () => { +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(syncDeckAttachedStairRises(nodes)).toEqual([ - { id: 'sseg_1' as never, data: { height: 1.6 } }, - ]) + 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', () => { @@ -158,7 +192,7 @@ describe('syncDeckAttachedStairRises', () => { deckElevation: 1.25, segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], }) - expect(syncDeckAttachedStairRises(nodes)).toEqual([]) + expect(syncStairRises(nodes)).toEqual([]) }) it('scales multiple flights proportionally and leaves landings alone', () => { @@ -170,7 +204,7 @@ describe('syncDeckAttachedStairRises', () => { { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, ], }) - const updates = syncDeckAttachedStairRises(nodes) + 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 } }) @@ -182,47 +216,82 @@ describe('syncDeckAttachedStairRises', () => { totalRise: 2.0, segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], }) - expect(syncDeckAttachedStairRises(nodes)).toEqual([ - { id: 'sseg_1' as never, data: { height: 2.0 } }, - ]) + 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 stairs with a stale deckSlabId untouched', () => { + 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(syncDeckAttachedStairRises(nodes)).toEqual([]) + expect(syncStairRises(nodes)).toEqual([]) }) - it('ignores unattached stairs', () => { - const stair = StairNode.parse({ - id: 'stair_1', - type: 'stair', - position: [0, 0, 0], - children: ['sseg_1'], + 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 }], }) - const segment = StairSegmentNode.parse({ - id: 'sseg_1', - type: 'stair-segment', - segmentType: 'stair', - width: 1, - length: 2, - height: 1.0, - stepCount: 8, - parentId: 'stair_1', + 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 }], }) - const level = LevelNode.parse({ - id: 'level_1', - type: 'level', - level: 0, - height: 2.5, - children: ['stair_1'], + 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(syncDeckAttachedStairRises({ level_1: level, stair_1: stair, sseg_1: segment })).toEqual( - [], - ) + expect(syncStairRises(nodes)).toEqual([]) }) }) @@ -324,7 +393,7 @@ describe('deck-attached rise with a floor-lifted base', () => { deckElevation: 1.25, segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], }) - const updates = syncDeckAttachedStairRises(nodes) + 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) @@ -346,11 +415,11 @@ describe('deck-attached rise with a floor-lifted base', () => { deckElevation: 1.25, segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }], }) - expect(syncDeckAttachedStairRises(scene.nodes)).toEqual([]) + 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 = syncDeckAttachedStairRises(nodes) + const updates = syncStairRises(nodes) expect(updates).toHaveLength(1) expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.55) }) @@ -363,7 +432,7 @@ describe('deck-attached rise with a floor-lifted base', () => { 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 = syncDeckAttachedStairRises(nodes) + const updates = syncStairRises(nodes) expect(updates).toHaveLength(1) expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(0.95) }) @@ -378,7 +447,7 @@ describe('deck-attached rise with a floor-lifted base', () => { ], }) // Target flight rise = 2.15 − 0.05 (base) − 0.1 (landing) = 2.0 → 1.0 each. - const updates = syncDeckAttachedStairRises(nodes) + 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) diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts index ebb222b3ad..12054831cd 100644 --- a/packages/core/src/systems/stair/stair-rise.ts +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -39,23 +39,29 @@ export function resolveStairTotalRise(stair: StairNode, 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' || !node.deckSlabId) continue - const deck = nodes[node.deckSlabId] - if (deck?.type !== 'slab') continue + 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]) 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 c7e45e4577..fae2b5ac5c 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -37,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' }, @@ -155,16 +156,11 @@ export default function StairPanel() { const handleDestinationChange = useCallback( (value: string) => { + if (!node) return const target = useScene.getState().nodes[value as AnyNodeId] - if (target?.type === 'slab') { - // Attaching to a deck: the rise follows the deck's elevation from - // now on, so any explicit custom rise is cleared. - handleUpdate({ deckSlabId: value, totalRise: undefined }) - return - } - handleUpdate({ toLevelId: value, deckSlabId: undefined }) + handleUpdate(getStairDestinationUpdates(node, target, value)) }, - [handleUpdate], + [node, handleUpdate], ) const getLastSegmentFillDefaults = useCallback(() => { @@ -288,11 +284,13 @@ export default function StairPanel() {
- + {attachedDeck ? null : ( + + )}
@@ -369,24 +367,28 @@ export default function StairPanel() {
) : null} - handleAutoCutoutChange(value === 'destination')} - options={STAIR_SLAB_OPENING_OPTIONS} - value={node.slabOpeningMode ?? 'none'} - /> + {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.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' && ( <> From 6efcac146ef34e79d2afb0c940e5eef7556f329c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 08:27:40 -0400 Subject: [PATCH 22/24] =?UTF-8?q?fix:=20stacked-slab=20move=20hopping=20?= =?UTF-8?q?=E2=80=94=20one=20ray,=20one=20surface,=20one=20XZ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hop was hysteresis: the election consumed the riding grid plane's perspective-skewed hit, giving two self-consistent fixed points for one pointer ray. getPointedSupportSurface now returns the ray's crossing of the pointed surface and both the support cap and the cursor XZ derive from that single computation, so items stay on the surface the pointer aims at and sit exactly under the cursor across storeys. Co-Authored-By: Claude Fable 5 --- .../spatial-grid/pointer-support-cap.test.ts | 126 +++++++++++++++++- .../spatial-grid/spatial-grid-manager.ts | 75 +++++++---- packages/core/src/index.ts | 1 + .../tools/item/use-placement-coordinator.tsx | 35 +++-- .../registry/move-registry-node-tool.tsx | 25 ++-- .../tools/shared/pointer-support-cap.ts | 45 ++++++- 6 files changed, 254 insertions(+), 53 deletions(-) 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 index 4d50044984..1846afa690 100644 --- a/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts +++ b/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts @@ -197,7 +197,7 @@ describe('getPointedSupportSurface (ray → aimed-at walking surface)', () => { 0 - origin[2], ] expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toFloorUnderDeck)).toEqual( - { elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }, + { elevation: FLOOR_ELEVATION, slabId: 'slab_floor', point: [0, 0] }, ) // Aiming at the deck's top surface: the deck plane crossing lands @@ -210,6 +210,7 @@ describe('getPointedSupportSurface (ray → aimed-at walking surface)', () => { expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toDeckTop)).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck', + point: [0, 0.5], }) }) @@ -232,16 +233,137 @@ describe('getPointedSupportSurface (ray → aimed-at walking surface)', () => { 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', () => { + 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', () => { 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 d4c949dc2b..fa4b27bc7a 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -306,6 +306,19 @@ export type ItemSlabSupport = { slabId: string | null } +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 { private readonly floorGrids = new Map() // levelId -> grid private readonly wallGrids = new Map() // levelId -> wall grid @@ -840,46 +853,60 @@ export class SpatialGridManager { /** * 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 + * 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. + * 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], - ): ItemSlabSupport { + ): PointedSupportSurface { const slabMap = this.slabsByLevel.get(levelId) const [ox, oy, oz] = rayOrigin const [dx, dy, dz] = rayDirection - if (!slabMap || Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null } + if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null } let best: { t: number; elevation: number; slabId: string } | null = null - 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 - for (const hole of slab.holes || []) { - if (hole.length >= 3 && pointInPolygon(x, z, hole)) { - inHole = true - break + 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 + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(x, z, hole)) { + inHole = true + break + } } + if (inHole) continue + best = { t, elevation, slabId: slab.id } } - if (inHole) continue - best = { t, elevation, slabId: slab.id } } - return best - ? { elevation: best.elevation, slabId: best.slabId } - : { elevation: 0, slabId: null } + 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, + } } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e3d6996603..d48f43a9e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,6 +53,7 @@ export { } from './hooks/spatial-grid/floor-placed-elevation' export { getWallEffectiveHeightForNodes, + type PointedSupportSurface, pointInPolygon, SUPPORT_ELEVATION_EPSILON, spatialGridManager, 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 2650f3480b..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,7 +62,10 @@ import { type PreviewBounds, updateLineGeometry, } from '../shared/placement-box-geometry' -import { resolvePointerSupportElevation } from '../shared/pointer-support-cap' +import { + resolvePointerSupportElevation, + resolvePointerSupportSurface, +} from '../shared/pointer-support-cap' import { getDetachedAttachmentPreviewLift, getGridAlignedDimensions, @@ -860,15 +863,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea has3DPointerDrivenMoveRef.current = true - // The pointer decides the target surface: cap the floor-support - // election at the elevation of the surface the camera ray actually - // hits. The grid event's own Y can't be used directly — its plane - // rides at the ghost's last height, which is exactly the feedback - // loop that made the ghost blink under an elevated deck. - pointerSupportCapRef.current = resolvePointerSupportElevation( - cameraRef.current, - event.position, - ) + // 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 @@ -877,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], 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 609285a638..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 @@ -56,7 +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 { resolvePointerSupportElevation } from '../shared/pointer-support-cap' +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. */ @@ -607,14 +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: cap the floor-support - // election at the elevation of the surface the camera ray actually - // hits (deck top when aiming at the deck, floor/ground when aiming - // beneath it). The event's own Y rides the grid plane at the ghost's - // last height, so it can't be used directly. - supportCapRef.current = resolvePointerSupportElevation(cameraRef.current, event.position) + // 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({ diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index 846e7968c9..b0bc8398e1 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -5,18 +5,34 @@ 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`) and - * its world-space Y (for riding the grid event plane / draw preview on it). + * 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 @@ -24,10 +40,13 @@ export type PointerSupportSurface = { * 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 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). + * uncapped max election and the raw event hit). */ export function resolvePointerSupportSurface( camera: Camera, @@ -48,13 +67,27 @@ export function resolvePointerSupportSurface( hitScratch.sub(originScratch) if (hitScratch.lengthSq() < 1e-12) return null - const { elevation } = spatialGridManager.getPointedSupportSurface( + 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 - return { elevation, worldY } + + 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. */ From 12907ca8b86bf2a4be280f0a36a69ce90287cfe2 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 08:47:02 -0400 Subject: [PATCH 23/24] fix: single stair per click; tools restore select mode on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stair tool subscribed to both node clicks (synthesized on pointerup) and the native-click grid event with none of the guards sibling tools carry — one physical click over any node surface committed twice. A commit gate + follow-up click swallow fix the double dispatch, and the stair and column tools now restore select mode on exit instead of leaving the dead build-mode-without-tool state that ignored every click. Co-Authored-By: Claude Fable 5 --- .../tools/stair/stair-click-guard.test.ts | 58 +++++++++++++++ .../tools/stair/stair-click-guard.ts | 72 +++++++++++++++++++ .../src/components/tools/stair/stair-tool.tsx | 20 ++++++ packages/nodes/src/column/tool.tsx | 3 + 4 files changed, 153 insertions(+) create mode 100644 packages/editor/src/components/tools/stair/stair-click-guard.test.ts create mode 100644 packages/editor/src/components/tools/stair/stair-click-guard.ts 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 02178f1a77..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, @@ -230,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 @@ -440,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 @@ -464,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/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 7290053ba8..c8af70c59c 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -163,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) } From a6bb29f5da271e437e3968525fdc0a0d077374d0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 09:14:01 -0400 Subject: [PATCH 24/24] feat: compact multi-selection panel with host footer slot Selecting multiple nodes now docks the collapsed-by-default panel on the right: N selected header, kind breakdown, and Duplicate/Delete mirroring the floating pill. A new multiSelectionFooter slot lets the host app dock actions below it, exactly like inspectorFooter. Co-Authored-By: Claude Fable 5 --- .../editor/src/components/editor/index.tsx | 12 +++- .../ui/panels/multi-selection-panel.tsx | 57 +++++++++++++++++++ .../components/ui/panels/panel-manager.tsx | 15 ++++- .../ui/panels/selection-breakdown.test.ts | 24 ++++++++ .../ui/panels/selection-breakdown.ts | 20 +++++++ 5 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/components/ui/panels/multi-selection-panel.tsx create mode 100644 packages/editor/src/components/ui/panels/selection-breakdown.test.ts create mode 100644 packages/editor/src/components/ui/panels/selection-breakdown.ts 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/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(' · ') +}