feat: vertical building model — stored level heights, wall inversion, decks (#526)
* feat(core): stored storey heights groundwork — pure slab-support module + level height schema 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat(core): vertical-model load migration — stored heights, ordinal compaction, wall-top classification 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat: clamp slabs/ceilings under the storey plane; sweep wall-height fallbacks; wall Top control 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 <noreply@anthropic.com> * feat(core): persisted support hosts — schema, host-preferring election, rendered-polygon unification 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 <noreply@anthropic.com> * feat: persist support hosts at commit; thread wall host preference everywhere 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 <noreply@anthropic.com> * feat: split slab into placement + thickness; pools become explicit recess intent 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 <noreply@anthropic.com> * feat: clamp ceilings to covering-slab undersides across levels 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat: adaptive slab vertical editing; level vocabulary in UI copy 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 <noreply@anthropic.com> * feat: clamp plane-bound wall tops to covering-slab undersides; fix vertical reactivity getWallPlaneTop samples the wall span against the level above's slabs, so a thick or flush upper floor shortens the walls below it instead of colliding (automatic attach, no dialog). Level-height edits now dirty the level's walls, stairs, ceilings, and fences; covering-slab changes dirty the level below. The effective-height helper triplicated across editor overlays moved to core. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: ceilings follow the level top by default ceiling.height becomes optional — absent means the ceiling tracks min(level top, covering slab underside) − 0.01 live, so level-height edits no longer require ceiling fixes. Ceiling panel gains the same Follows level / Custom height control as walls; auto-from-walls ceilings are created height-less and their height-derivation machinery is deleted; migration drops stored heights within 0.20 of the bound on legacy scenes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: wall plane clamp missed max-side boundary walls Auto slabs derive their polygon from wall centerlines, so covering-clamp samples sat exactly on the boundary where ray-cast point-in-polygon is side-dependent (min edges in, max edges out) — walls clamped or not by orientation. getWallPlaneTop now clips the wall's thickness band against the covering polygon (boundary-inclusive, arc-aware) and the ceiling bound's point sampling gained an explicit on-boundary test. Verified against the reported repro scene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: stairs attach to decks; pointer-decided placement surface Stairs gain deckSlabId — rise follows the deck's elevation live (straight flights re-converge via a write-sync mirroring auto-openings), the panel shows a unified To destination with Follows deck / Custom rise, and the mezzanine tool attaches instead of baking a stale rise. Item placement under an elevated deck no longer flickers: grid events fed a feedback loop (the grid plane rode the ghost's elected height), so the support election is now capped at the surface the pointer ray actually hits, with a ground sentinel keeping under-deck commits deterministic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: remove the mezzanine/balcony composite tools Decks ship as catalog presets instead; the kernel the presets rely on (fence deck-hosting, stair deck attachment, clone remaps, pointer-decided placement) all stays. The tool code lives at e30042db for reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: gate wall adoption to grounded slabs; panel moves, drag stretches then unsticks Floating decks keep their drawn polygon (and stop being seam candidates for grounded neighbors) instead of growing into nearby walls. Panel elevation edits are pure placement; the viewport drag stretches a grounded slab up to 0.4 m then unsticks it into a thin deck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: deck-attached stairs land flush with the deck surface The rise now subtracts the stair's own elected base (same election the visual lift uses), so base + rise always equals the deck walking surface; the auto-sync defers a microtask so it reads a settled spatial grid and re-converges on both deck and base-slab moves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: draw walls and fences on elevated decks Wall and fence draw tools now publish the pointed surface, so the draw plane rides the deck top (no more perspective-skewed floor hits) and previews sit on the deck. Fences gain real support election: a pure resolveFenceSupportSlabPatch persists the deck host at draw and reshape commits; wall commits thread the pointer cap so aiming under a deck elects the floor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: unit-aware height presets Level, ceiling, and slab preset buttons show clean values per display system (8/9/10 ft storeys, 8'-9' ceilings, whole-inch slab steps) instead of converted metric labels; metric presets unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: stairs converge to their resolved rise; deck attachment disables the cutout 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 <noreply@anthropic.com> * fix: stacked-slab move hopping — one ray, one surface, one XZ 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 <noreply@anthropic.com> * fix: single stair per click; tools restore select mode on exit 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
34b601bb79
commit
bf89b5bcf2
@@ -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<string, AnyNode>): 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<string, AnyNode>,
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { SlabNode, WallNode } from '../../schema'
|
||||
import { MIN_WALL_HEIGHT } from '../wall/wall-top'
|
||||
import {
|
||||
clampSlabElevationForWalls,
|
||||
computeWallSlabSupport,
|
||||
getSlabElevationUpperBound,
|
||||
} from './slab-support'
|
||||
|
||||
// 4×3 room slab drawn on the wall centerlines, like an auto-slab.
|
||||
const SQUARE: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
]
|
||||
|
||||
const STOREY_HEIGHT = 2.7
|
||||
const BOUND = STOREY_HEIGHT - MIN_WALL_HEIGHT
|
||||
|
||||
function roomSlab(elevation: number) {
|
||||
return SlabNode.parse({ polygon: SQUARE, elevation, autoFromWalls: true })
|
||||
}
|
||||
|
||||
function roomWalls(height?: number) {
|
||||
return [
|
||||
WallNode.parse({ start: [0, 0], end: [4, 0], height }),
|
||||
WallNode.parse({ start: [4, 0], end: [4, 3], height }),
|
||||
WallNode.parse({ start: [4, 3], end: [0, 3], height }),
|
||||
WallNode.parse({ start: [0, 3], end: [0, 0], height }),
|
||||
]
|
||||
}
|
||||
|
||||
describe('clampSlabElevationForWalls', () => {
|
||||
it('clamps a slab under plane-bound walls at the plane minus MIN_WALL_HEIGHT', () => {
|
||||
const slab = roomSlab(0.05)
|
||||
const result = clampSlabElevationForWalls(2.5, slab, roomWalls(), [slab], STOREY_HEIGHT)
|
||||
|
||||
expect(result.clamped).toBe(true)
|
||||
expect(result.elevation).toBeCloseTo(BOUND)
|
||||
})
|
||||
|
||||
it('leaves proposals at or below the bound untouched', () => {
|
||||
const slab = roomSlab(0.05)
|
||||
const result = clampSlabElevationForWalls(BOUND, slab, roomWalls(), [slab], STOREY_HEIGHT)
|
||||
|
||||
expect(result.clamped).toBe(false)
|
||||
expect(result.elevation).toBeCloseTo(BOUND)
|
||||
})
|
||||
|
||||
it('passes negative (recessed-committing) proposals through untouched', () => {
|
||||
const slab = roomSlab(0.05)
|
||||
const result = clampSlabElevationForWalls(-0.6, slab, roomWalls(), [slab], STOREY_HEIGHT)
|
||||
|
||||
expect(result.clamped).toBe(false)
|
||||
expect(result.elevation).toBeCloseTo(-0.6)
|
||||
})
|
||||
|
||||
it('does not clamp when the walls all carry explicit heights', () => {
|
||||
const slab = roomSlab(0.05)
|
||||
const result = clampSlabElevationForWalls(2.5, slab, roomWalls(2.5), [slab], STOREY_HEIGHT)
|
||||
|
||||
expect(result.clamped).toBe(false)
|
||||
expect(result.elevation).toBeCloseTo(2.5)
|
||||
})
|
||||
|
||||
it('does not clamp a slab covering no walls', () => {
|
||||
const island = SlabNode.parse({
|
||||
polygon: [
|
||||
[10, 10],
|
||||
[12, 10],
|
||||
[12, 12],
|
||||
[10, 12],
|
||||
],
|
||||
elevation: 0.05,
|
||||
})
|
||||
const result = clampSlabElevationForWalls(2.5, island, roomWalls(), [island], STOREY_HEIGHT)
|
||||
|
||||
expect(result.clamped).toBe(false)
|
||||
expect(result.elevation).toBeCloseTo(2.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSlabElevationUpperBound', () => {
|
||||
it('bounds a slab electable by plane-bound walls', () => {
|
||||
const slab = roomSlab(0.05)
|
||||
expect(getSlabElevationUpperBound(slab, roomWalls(), [slab], STOREY_HEIGHT)).toBeCloseTo(BOUND)
|
||||
})
|
||||
|
||||
it('is unbounded under explicit-height walls', () => {
|
||||
const slab = roomSlab(0.05)
|
||||
expect(getSlabElevationUpperBound(slab, roomWalls(2.5), [slab], STOREY_HEIGHT)).toBe(
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeWallSlabSupport preferred host', () => {
|
||||
const wallLike = { start: [0, 1.5] as [number, number], end: [4, 1.5] as [number, number] }
|
||||
const low = SlabNode.parse({
|
||||
id: 'slab_low',
|
||||
polygon: SQUARE,
|
||||
elevation: 0.1,
|
||||
autoFromWalls: true,
|
||||
})
|
||||
const high = SlabNode.parse({
|
||||
id: 'slab_high',
|
||||
polygon: SQUARE,
|
||||
elevation: 0.6,
|
||||
autoFromWalls: true,
|
||||
})
|
||||
|
||||
it('elects the highest supporting elevation without a preference', () => {
|
||||
const support = computeWallSlabSupport(wallLike, [low, high], [])
|
||||
expect(support.elevation).toBeCloseTo(0.6)
|
||||
})
|
||||
|
||||
it('pins the elected elevation to a still-supporting preferred slab', () => {
|
||||
const support = computeWallSlabSupport(wallLike, [low, high], [], 'slab_low')
|
||||
expect(support.elevation).toBeCloseTo(0.1)
|
||||
// Fill-down machinery still derives from ALL supporting slabs.
|
||||
expect(support.baseSegments).toHaveLength(1)
|
||||
expect(support.baseSegments[0]!.elevation).toBeCloseTo(0.6)
|
||||
})
|
||||
|
||||
it('ignores a preferred slab that no longer supports the wall', () => {
|
||||
const island = SlabNode.parse({
|
||||
id: 'slab_island',
|
||||
polygon: [
|
||||
[10, 10],
|
||||
[12, 10],
|
||||
[12, 12],
|
||||
[10, 12],
|
||||
],
|
||||
elevation: 0.9,
|
||||
})
|
||||
const support = computeWallSlabSupport(wallLike, [low, high, island], [], 'slab_island')
|
||||
expect(support.elevation).toBeCloseTo(0.6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,688 @@
|
||||
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
|
||||
import type { SlabNode, WallNode } from '../../schema'
|
||||
import { getWallCurveFrameAt, isCurvedWall } from '../wall/wall-curve'
|
||||
import { DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint'
|
||||
import { MIN_WALL_HEIGHT } from '../wall/wall-top'
|
||||
|
||||
export type SlabElevationClamp = {
|
||||
elevation: number
|
||||
clamped: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp-never-ask upper bound for a slab's elevation. A plane-bound wall
|
||||
* (no stored `height`) keeps its top at the storey plane, so a slab that
|
||||
* rises past `storeyHeight - MIN_WALL_HEIGHT` while electing as that
|
||||
* wall's base would squeeze the wall body below its minimum (and at the
|
||||
* plane, to nothing). Walls with explicit heights don't constrain — their
|
||||
* top rides the elected base, not the plane. Negative proposals (the
|
||||
* drag-through-zero path that commits the `recessed` intent) pass
|
||||
* through untouched: this is a purely numeric upper bound.
|
||||
*
|
||||
* The election runs against `levelSlabs` with `proposedElevation`
|
||||
* substituted into `slab`, so a slab that would only WIN the election at
|
||||
* the proposed elevation still clamps, and a slab out-elected by a
|
||||
* sibling doesn't. Pure.
|
||||
*/
|
||||
export function clampSlabElevationForWalls(
|
||||
proposedElevation: number,
|
||||
slab: SlabNode,
|
||||
levelWalls: WallNode[],
|
||||
levelSlabs: readonly SlabNode[],
|
||||
storeyHeight: number,
|
||||
): SlabElevationClamp {
|
||||
const bound = storeyHeight - MIN_WALL_HEIGHT
|
||||
if (proposedElevation <= bound) return { elevation: proposedElevation, clamped: false }
|
||||
if (slab.polygon.length < 3) return { elevation: proposedElevation, clamped: false }
|
||||
|
||||
const substituted = levelSlabs.some((candidate) => candidate.id === slab.id)
|
||||
? levelSlabs.map((candidate) =>
|
||||
candidate.id === slab.id ? { ...candidate, elevation: proposedElevation } : candidate,
|
||||
)
|
||||
: [...levelSlabs, { ...slab, elevation: proposedElevation }]
|
||||
|
||||
for (const wall of levelWalls) {
|
||||
if (wall.height != null) continue
|
||||
const wallLike: WallOverlapInput = {
|
||||
start: wall.start,
|
||||
end: wall.end,
|
||||
curveOffset: wall.curveOffset,
|
||||
thickness: wall.thickness,
|
||||
}
|
||||
// Cheap pre-filter: a wall that never reaches the slab's footprint
|
||||
// can't elect it, whatever the election says about sibling slabs.
|
||||
if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue
|
||||
const support = computeWallSlabSupport(wallLike, substituted, levelWalls)
|
||||
if (Math.abs(support.elevation - proposedElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) {
|
||||
return { elevation: bound, clamped: true }
|
||||
}
|
||||
}
|
||||
|
||||
return { elevation: proposedElevation, clamped: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Static upper bound for a slab-elevation drag: probe the election with
|
||||
* the slab raised above every sibling and the storey plane. If any
|
||||
* plane-bound wall would elect it there, the drag may not pass
|
||||
* `storeyHeight - MIN_WALL_HEIGHT`; otherwise it is unbounded above.
|
||||
*/
|
||||
export function getSlabElevationUpperBound(
|
||||
slab: SlabNode,
|
||||
levelWalls: WallNode[],
|
||||
levelSlabs: readonly SlabNode[],
|
||||
storeyHeight: number,
|
||||
): number {
|
||||
const probe =
|
||||
Math.max(storeyHeight, ...levelSlabs.map((candidate) => candidate.elevation ?? 0.05)) + 1
|
||||
return clampSlabElevationForWalls(probe, slab, levelWalls, levelSlabs, storeyHeight).clamped
|
||||
? storeyHeight - MIN_WALL_HEIGHT
|
||||
: Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
/**
|
||||
* Point-in-polygon test using ray casting algorithm.
|
||||
*/
|
||||
export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean {
|
||||
let inside = false
|
||||
const n = polygon.length
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = polygon[i]![0],
|
||||
zi = polygon[i]![1]
|
||||
const xj = polygon[j]![0],
|
||||
zj = polygon[j]![1]
|
||||
|
||||
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
function pointSegmentDistance(
|
||||
px: number,
|
||||
pz: number,
|
||||
ax: number,
|
||||
az: number,
|
||||
bx: number,
|
||||
bz: number,
|
||||
): number {
|
||||
const dx = bx - ax
|
||||
const dz = bz - az
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az)
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared))
|
||||
return Math.hypot(px - (ax + dx * t), pz - (az + dz * t))
|
||||
}
|
||||
|
||||
// Ray-cast pointInPolygon is unreliable for points exactly on the polygon
|
||||
// boundary: the answer flips depending on which side of the polygon the edge
|
||||
// is on. Interval classification below therefore treats "within this distance
|
||||
// of the boundary" as inside explicitly, so walls sitting exactly on a slab
|
||||
// edge (the common case — auto-slab polygons derive from wall centerlines)
|
||||
// classify identically on every side of the slab.
|
||||
const ON_BOUNDARY_EPSILON = 1e-4
|
||||
|
||||
export function pointOnPolygonBoundary(
|
||||
px: number,
|
||||
pz: number,
|
||||
polygon: Array<[number, number]>,
|
||||
): boolean {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [ax, az] = polygon[i]!
|
||||
const [bx, bz] = polygon[(i + 1) % n]!
|
||||
if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Sub-interval along a segment or polyline: [start, end] in length units. */
|
||||
type LengthInterval = [number, number]
|
||||
|
||||
function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] {
|
||||
if (intervals.length <= 1) return intervals
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0])
|
||||
const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]]
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const [intervalStart, intervalEnd] = sorted[i]!
|
||||
const last = merged[merged.length - 1]!
|
||||
if (intervalStart <= last[1] + 1e-9) {
|
||||
last[1] = Math.max(last[1], intervalEnd)
|
||||
} else {
|
||||
merged.push([intervalStart, intervalEnd])
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Total length of a merged (sorted, disjoint) interval list. */
|
||||
function intervalsLength(intervals: readonly LengthInterval[]): number {
|
||||
let total = 0
|
||||
for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart
|
||||
return total
|
||||
}
|
||||
|
||||
/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */
|
||||
function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] {
|
||||
if (base.length === 0 || cut.length === 0) return mergeIntervals(base)
|
||||
const cuts = mergeIntervals(cut)
|
||||
const result: LengthInterval[] = []
|
||||
for (const [baseStart, baseEnd] of mergeIntervals(base)) {
|
||||
let cursor = baseStart
|
||||
for (const [cutStart, cutEnd] of cuts) {
|
||||
if (cutEnd <= cursor) continue
|
||||
if (cutStart >= baseEnd) break
|
||||
if (cutStart > cursor) result.push([cursor, cutStart])
|
||||
cursor = cutEnd
|
||||
if (cursor >= baseEnd) break
|
||||
}
|
||||
if (cursor < baseEnd) result.push([cursor, baseEnd])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and,
|
||||
* when `includeBoundary`, on its boundary), as [t0, t1] fractions of the
|
||||
* segment. The segment is split at every crossing with a polygon edge and
|
||||
* each sub-interval is classified by its midpoint, so no test point ever
|
||||
* sits on a crossing.
|
||||
*/
|
||||
function segmentInsideIntervals(
|
||||
ax: number,
|
||||
az: number,
|
||||
bx: number,
|
||||
bz: number,
|
||||
polygon: Array<[number, number]>,
|
||||
includeBoundary: boolean,
|
||||
): LengthInterval[] {
|
||||
const dx = bx - ax
|
||||
const dz = bz - az
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length < 1e-9) return []
|
||||
|
||||
const ts = [0, 1]
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [px, pz] = polygon[i]!
|
||||
const [qx, qz] = polygon[(i + 1) % n]!
|
||||
const ex = qx - px
|
||||
const ez = qz - pz
|
||||
const denom = dx * ez - dz * ex
|
||||
if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at
|
||||
const t = ((px - ax) * ez - (pz - az) * ex) / denom
|
||||
const s = ((px - ax) * dz - (pz - az) * dx) / denom
|
||||
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t)
|
||||
}
|
||||
ts.sort((a, b) => a - b)
|
||||
|
||||
const inside: LengthInterval[] = []
|
||||
for (let i = 1; i < ts.length; i++) {
|
||||
const t0 = ts[i - 1]!
|
||||
const t1 = ts[i]!
|
||||
if (t1 - t0 < 1e-9) continue
|
||||
const tm = (t0 + t1) / 2
|
||||
const mx = ax + dx * tm
|
||||
const mz = az + dz * tm
|
||||
const midpointInside = pointOnPolygonBoundary(mx, mz, polygon)
|
||||
? includeBoundary
|
||||
: pointInPolygon(mx, mz, polygon)
|
||||
if (midpointInside) inside.push([t0, t1])
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
function polylineLength(points: Array<{ x: number; y: number }>): number {
|
||||
let total = 0
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Inside sub-intervals of a polyline against a polygon, in cumulative
|
||||
* arc-length units from the polyline start (merged, disjoint). Boundary
|
||||
* contact counts as inside for slab support (walls sit exactly on slab
|
||||
* edges — see ON_BOUNDARY_EPSILON above); hole callers pass
|
||||
* `includeBoundary: false` so a wall running along a stairwell hole's
|
||||
* rim keeps the rim's support.
|
||||
*/
|
||||
function polylineInsideIntervals(
|
||||
points: Array<{ x: number; y: number }>,
|
||||
polygon: Array<[number, number]>,
|
||||
includeBoundary = true,
|
||||
): LengthInterval[] {
|
||||
const intervals: LengthInterval[] = []
|
||||
let offset = 0
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1]!
|
||||
const b = points[i]!
|
||||
const segmentLength = Math.hypot(b.x - a.x, b.y - a.y)
|
||||
if (segmentLength < 1e-9) continue
|
||||
for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) {
|
||||
intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength])
|
||||
}
|
||||
offset += segmentLength
|
||||
}
|
||||
return mergeIntervals(intervals)
|
||||
}
|
||||
|
||||
export type WallOverlapInput = {
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
curveOffset?: number
|
||||
thickness?: number
|
||||
}
|
||||
|
||||
// Minimum length of wall that must lie on/inside a slab polygon before the
|
||||
// wall counts as overlapping it. Point contact (a perpendicular wall butting
|
||||
// into a room's edge) clips to ~zero length and never reaches this, so such
|
||||
// walls don't follow the slab's elevation.
|
||||
const WALL_SLAB_MIN_OVERLAP = 0.05
|
||||
|
||||
/**
|
||||
* Centerline of the wall plus its two face lines (centerline offset by
|
||||
* ±halfThickness). The face lines catch walls whose centerline sits on or
|
||||
* just outside the slab boundary but whose body reaches onto the slab —
|
||||
* e.g. slab polygons drawn to the room's interior faces.
|
||||
*/
|
||||
function wallTestPolylines(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
curveOffset: number,
|
||||
halfThickness: number,
|
||||
): Array<Array<{ x: number; y: number }>> {
|
||||
const wallLike = { start, end, curveOffset }
|
||||
if (curveOffset !== 0 && isCurvedWall(wallLike)) {
|
||||
const count = 16
|
||||
const center: Array<{ x: number; y: number }> = []
|
||||
const left: Array<{ x: number; y: number }> = []
|
||||
const right: Array<{ x: number; y: number }> = []
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const frame = getWallCurveFrameAt(wallLike, i / count)
|
||||
center.push(frame.point)
|
||||
left.push({
|
||||
x: frame.point.x + frame.normal.x * halfThickness,
|
||||
y: frame.point.y + frame.normal.y * halfThickness,
|
||||
})
|
||||
right.push({
|
||||
x: frame.point.x - frame.normal.x * halfThickness,
|
||||
y: frame.point.y - frame.normal.y * halfThickness,
|
||||
})
|
||||
}
|
||||
return halfThickness > 0 ? [center, left, right] : [center]
|
||||
}
|
||||
|
||||
const center = [
|
||||
{ x: start[0], y: start[1] },
|
||||
{ x: end[0], y: end[1] },
|
||||
]
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const len = Math.hypot(dx, dz)
|
||||
if (len < 1e-10 || halfThickness <= 0) return [center]
|
||||
const nx = (-dz / len) * halfThickness
|
||||
const nz = (dx / len) * halfThickness
|
||||
return [
|
||||
center,
|
||||
[
|
||||
{ x: start[0] + nx, y: start[1] + nz },
|
||||
{ x: end[0] + nx, y: end[1] + nz },
|
||||
],
|
||||
[
|
||||
{ x: start[0] - nx, y: start[1] - nz },
|
||||
{ x: end[0] - nx, y: end[1] - nz },
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a wall overlaps a slab polygon along a segment of its length.
|
||||
*
|
||||
* The wall's centerline and both face lines are clipped against the polygon;
|
||||
* the wall overlaps when the longest clipped inside-or-on-boundary length
|
||||
* exceeds a threshold (5cm, halved for very short walls). Because interval
|
||||
* midpoints classify "on the boundary" as inside explicitly (never by
|
||||
* ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves
|
||||
* identically on every side of the slab.
|
||||
*
|
||||
* A wall that only touches the polygon at a point — a perpendicular wall
|
||||
* butting into a room's edge, or a corner-to-corner touch — clips to ~zero
|
||||
* length and does NOT overlap.
|
||||
*/
|
||||
export function wallOverlapsPolygon(
|
||||
startOrWall: [number, number] | WallOverlapInput,
|
||||
endOrPolygon: [number, number] | Array<[number, number]>,
|
||||
polygonArg?: Array<[number, number]>,
|
||||
): boolean {
|
||||
// Two call shapes:
|
||||
// wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware
|
||||
// wallOverlapsPolygon(start, end, polygon) — legacy chord-only
|
||||
let start: [number, number]
|
||||
let end: [number, number]
|
||||
let polygon: Array<[number, number]>
|
||||
let curveOffset = 0
|
||||
let thickness = DEFAULT_WALL_THICKNESS
|
||||
if (Array.isArray(startOrWall)) {
|
||||
start = startOrWall as [number, number]
|
||||
end = endOrPolygon as [number, number]
|
||||
polygon = polygonArg as Array<[number, number]>
|
||||
} else {
|
||||
start = startOrWall.start
|
||||
end = startOrWall.end
|
||||
curveOffset = startOrWall.curveOffset ?? 0
|
||||
thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS
|
||||
polygon = endOrPolygon as Array<[number, number]>
|
||||
}
|
||||
return wallOverlapsSlabFootprint({ start, end, curveOffset, thickness }, polygon)
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link wallOverlapsPolygon} with the slab's stored holes subtracted from
|
||||
* the covered length: a wall whose band only reaches the polygon inside a
|
||||
* hole does not overlap. Hole boundaries keep coverage (rim convention —
|
||||
* see {@link computeWallSlabSupport}). Polygon boundary contact counts as
|
||||
* covered, so a wall sitting exactly on a slab edge resolves identically
|
||||
* on every side of the slab. Pure.
|
||||
*/
|
||||
export function wallOverlapsSlabFootprint(
|
||||
wallLike: WallOverlapInput,
|
||||
polygon: Array<[number, number]>,
|
||||
holes?: ReadonlyArray<Array<[number, number]>>,
|
||||
): boolean {
|
||||
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
|
||||
const halfThickness = Math.max(thickness / 2, 0)
|
||||
|
||||
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
|
||||
const centerLength = polylineLength(polylines[0]!)
|
||||
if (centerLength < 1e-9) return false
|
||||
|
||||
let overlap = 0
|
||||
for (const line of polylines) {
|
||||
let intervals = polylineInsideIntervals(line, polygon)
|
||||
for (const hole of holes ?? []) {
|
||||
if (intervals.length === 0) break
|
||||
if (hole.length < 3) continue
|
||||
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
|
||||
}
|
||||
overlap = Math.max(overlap, intervalsLength(intervals))
|
||||
}
|
||||
const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5))
|
||||
return overlap >= threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerance for the pointer-decided support cap: a slab still counts as
|
||||
* "the surface you're pointing at (or below)" when its walking surface is
|
||||
* within this many meters ABOVE the pointed elevation. Absorbs elevation
|
||||
* noise between the ray hit and slab tops without letting a deck hanging
|
||||
* clearly above the hit point capture the election. Defined here (rather
|
||||
* than in the spatial-grid manager, which re-exports it) so the wall
|
||||
* election below can honour the same cap without an import cycle.
|
||||
*/
|
||||
export const SUPPORT_ELEVATION_EPSILON = 0.05
|
||||
|
||||
// A slab elevation must support at least this fraction of the wall's
|
||||
// length before it can dictate the wall's base. Below majority, a raised
|
||||
// slab reaching one endpoint would hoist the whole wall off the floor
|
||||
// that actually carries it.
|
||||
const WALL_SLAB_SUPPORT_MAJORITY = 0.5
|
||||
|
||||
// Slabs whose elevations differ by less than this pool their support:
|
||||
// a wall shared between two rooms' slabs is covered roughly half by
|
||||
// each, and must still follow their common elevation.
|
||||
const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4
|
||||
|
||||
/**
|
||||
* Base elevation for a wall, decided by which slabs actually SUPPORT it.
|
||||
*
|
||||
* Support is measured as covered length: the wall's centerline and face
|
||||
* lines are clipped against each slab's RENDERED footprint
|
||||
* (`getRenderableSlabPolygon` with the level walls + siblings, not the
|
||||
* stored polygon — legacy polygons stored at wall faces or with old
|
||||
* baked offsets fall short of the wall body, but their band-adopted
|
||||
* rendered edge reaches the wall's outer face) minus the slab's stored
|
||||
* holes (holes are data, never render-offset). A slab supporting less
|
||||
* than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point
|
||||
* contact, endpoint grazes).
|
||||
*
|
||||
* Same-elevation slabs pool their coverage. `elevation` preserves the
|
||||
* existing wall-relative origin: the highest elevation covering at
|
||||
* least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered
|
||||
* elevation when none reaches majority. `baseElevation` only fills down
|
||||
* where a lower support remains exposed on a wall face after higher,
|
||||
* overlapping support is accounted for. Coincident floor/platform slabs
|
||||
* therefore keep the wall on the platform, while slabs on opposite wall
|
||||
* sides bridge correctly. A slab touching only one endpoint never enters
|
||||
* either result. Pure;
|
||||
* exported for tests.
|
||||
*/
|
||||
export type WallSlabSupport = {
|
||||
/** Existing wall-relative floor elevation used by hosted children and wall height. */
|
||||
elevation: number
|
||||
/** Slab whose elevation won the election, or null when the wall has no support. */
|
||||
electedSlabId: string | null
|
||||
/** Lowest exposed adjacent support; wall geometry fills down to this elevation. */
|
||||
baseElevation: number
|
||||
/** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */
|
||||
baseSegments: WallSlabSupportSegment[]
|
||||
}
|
||||
|
||||
export type WallSlabSupportSegment = {
|
||||
start: number
|
||||
end: number
|
||||
elevation: number
|
||||
}
|
||||
|
||||
/**
|
||||
* `preferredSlabId` is a persisted support host (`wall.supportSlabId`):
|
||||
* while that slab is still in the candidate set (still overlaps the wall
|
||||
* band with enough covered length), the elected `elevation` is pinned to
|
||||
* it instead of the majority/best-coverage election. `baseSegments` /
|
||||
* `baseElevation` (fill-down) still derive from ALL supporting slabs
|
||||
* unchanged. A preferred slab that no longer qualifies is silently
|
||||
* ignored — deliberately never cleared here, so the host resumes if the
|
||||
* slab's polygon returns (only slab deletion strips the stored field).
|
||||
*
|
||||
* `maxElevation` is the pointer-decided support cap (level-local Y, same
|
||||
* semantics as the item election): when set, elevation groups whose
|
||||
* walking surface sits above `maxElevation + SUPPORT_ELEVATION_EPSILON`
|
||||
* are excluded from the majority/best election — a deck hanging above the
|
||||
* surface the cursor ray actually hit never captures the elected base.
|
||||
* `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and
|
||||
* an explicit `preferredSlabId` still wins over the cap.
|
||||
*/
|
||||
export function computeWallSlabSupport(
|
||||
wallLike: WallOverlapInput,
|
||||
slabs: readonly SlabNode[],
|
||||
levelWalls: WallNode[],
|
||||
preferredSlabId?: string | null,
|
||||
maxElevation?: number | null,
|
||||
): WallSlabSupport {
|
||||
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
|
||||
const halfThickness = Math.max(thickness / 2, 0)
|
||||
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
|
||||
const polylineLengths = polylines.map(polylineLength)
|
||||
const wallLength = polylineLengths[0]!
|
||||
if (wallLength < 1e-9) {
|
||||
return { elevation: 0, electedSlabId: null, baseElevation: 0, baseSegments: [] }
|
||||
}
|
||||
|
||||
const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5))
|
||||
|
||||
type ElevationGroup = {
|
||||
elevation: number
|
||||
slabIds: string[]
|
||||
perPolyline: LengthInterval[][]
|
||||
}
|
||||
const groups: ElevationGroup[] = []
|
||||
let preferredElevation: number | null = null
|
||||
let preferredElectedSlabId: string | null = null
|
||||
|
||||
for (const slab of slabs) {
|
||||
if (slab.polygon.length < 3) continue
|
||||
const renderedPolygon = getRenderableSlabPolygon(slab, {
|
||||
walls: levelWalls,
|
||||
siblingSlabs: slabs.filter((other) => other.id !== slab.id),
|
||||
})
|
||||
|
||||
let supported = 0
|
||||
const perPolyline = polylines.map((line) => {
|
||||
let intervals = polylineInsideIntervals(line, renderedPolygon)
|
||||
for (const hole of slab.holes || []) {
|
||||
if (intervals.length === 0) break
|
||||
if (hole.length < 3) continue
|
||||
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
|
||||
}
|
||||
supported = Math.max(supported, intervalsLength(intervals))
|
||||
return intervals
|
||||
})
|
||||
if (supported < minSupport) continue
|
||||
|
||||
const elevation = slab.elevation ?? 0.05
|
||||
if (preferredSlabId != null && slab.id === preferredSlabId) {
|
||||
preferredElevation = elevation
|
||||
preferredElectedSlabId = slab.id
|
||||
}
|
||||
let group = groups.find(
|
||||
(candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON,
|
||||
)
|
||||
if (!group) {
|
||||
group = { elevation, slabIds: [], perPolyline: polylines.map(() => []) }
|
||||
groups.push(group)
|
||||
}
|
||||
group.slabIds.push(slab.id)
|
||||
for (let i = 0; i < perPolyline.length; i++) {
|
||||
group.perPolyline[i]!.push(...perPolyline[i]!)
|
||||
}
|
||||
}
|
||||
|
||||
type EvaluatedGroup = ElevationGroup & {
|
||||
coverage: number
|
||||
mergedPerPolyline: LengthInterval[][]
|
||||
}
|
||||
const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => {
|
||||
let coverage = 0
|
||||
const mergedPerPolyline = group.perPolyline.map(mergeIntervals)
|
||||
for (let i = 0; i < group.perPolyline.length; i++) {
|
||||
const lineLength = polylineLengths[i]!
|
||||
if (lineLength < 1e-9) continue
|
||||
coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength)
|
||||
}
|
||||
return { ...group, coverage, mergedPerPolyline }
|
||||
})
|
||||
|
||||
const electableGroups =
|
||||
maxElevation == null
|
||||
? evaluatedGroups
|
||||
: evaluatedGroups.filter(
|
||||
(group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON,
|
||||
)
|
||||
|
||||
let majorityElevation = Number.NEGATIVE_INFINITY
|
||||
let bestElevation = Number.NEGATIVE_INFINITY
|
||||
let bestCoverage = -1
|
||||
for (const group of electableGroups) {
|
||||
if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) {
|
||||
majorityElevation = Math.max(majorityElevation, group.elevation)
|
||||
}
|
||||
if (
|
||||
group.coverage > bestCoverage + 1e-6 ||
|
||||
(Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation)
|
||||
) {
|
||||
bestCoverage = group.coverage
|
||||
bestElevation = group.elevation
|
||||
}
|
||||
}
|
||||
|
||||
const elevation =
|
||||
preferredElevation !== null
|
||||
? preferredElevation
|
||||
: majorityElevation !== Number.NEGATIVE_INFINITY
|
||||
? majorityElevation
|
||||
: bestElevation === Number.NEGATIVE_INFINITY
|
||||
? 0
|
||||
: bestElevation
|
||||
const electedSlabId =
|
||||
preferredElectedSlabId ??
|
||||
electableGroups
|
||||
.find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON)
|
||||
?.slabIds.slice()
|
||||
.sort()[0] ??
|
||||
null
|
||||
const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => {
|
||||
const lineLength = polylineLengths[polylineIndex]!
|
||||
if (lineLength < 1e-9) return []
|
||||
return group.mergedPerPolyline[polylineIndex]!.map(
|
||||
([intervalStart, intervalEnd]) =>
|
||||
[intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval,
|
||||
)
|
||||
}
|
||||
|
||||
const normalizedByGroup = evaluatedGroups.map((group) => ({
|
||||
elevation: group.elevation,
|
||||
perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)),
|
||||
}))
|
||||
const breakpoints = [0, 1]
|
||||
for (const group of normalizedByGroup) {
|
||||
for (const intervals of group.perPolyline) {
|
||||
for (const [intervalStart, intervalEnd] of intervals) {
|
||||
breakpoints.push(intervalStart, intervalEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
breakpoints.sort((left, right) => left - right)
|
||||
const uniqueBreakpoints = breakpoints.filter(
|
||||
(value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7,
|
||||
)
|
||||
|
||||
const highestAt = (polylineIndex: number, t: number) => {
|
||||
let highest = Number.NEGATIVE_INFINITY
|
||||
for (const group of normalizedByGroup) {
|
||||
if (
|
||||
group.perPolyline[polylineIndex]?.some(
|
||||
([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7,
|
||||
)
|
||||
) {
|
||||
highest = Math.max(highest, group.elevation)
|
||||
}
|
||||
}
|
||||
return highest
|
||||
}
|
||||
|
||||
const baseSegments: WallSlabSupportSegment[] = []
|
||||
for (let index = 1; index < uniqueBreakpoints.length; index++) {
|
||||
const start = uniqueBreakpoints[index - 1]!
|
||||
const end = uniqueBreakpoints[index]!
|
||||
if (end - start < 1e-7) continue
|
||||
const midpoint = (start + end) / 2
|
||||
const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY
|
||||
const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY
|
||||
const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite)
|
||||
const segmentElevation =
|
||||
faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0)
|
||||
const previous = baseSegments[baseSegments.length - 1]
|
||||
if (
|
||||
previous &&
|
||||
Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON
|
||||
) {
|
||||
previous.end = end
|
||||
} else {
|
||||
baseSegments.push({ start, end, elevation: segmentElevation })
|
||||
}
|
||||
}
|
||||
|
||||
if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation })
|
||||
const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation))
|
||||
return { elevation, electedSlabId, baseElevation, baseSegments }
|
||||
}
|
||||
|
||||
export function computeWallSlabElevation(
|
||||
wallLike: WallOverlapInput,
|
||||
slabs: readonly SlabNode[],
|
||||
levelWalls: WallNode[],
|
||||
): number {
|
||||
return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import type {
|
||||
StairSegmentNode,
|
||||
SurfaceHoleMetadata,
|
||||
} from '../../schema'
|
||||
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
||||
import { resolveCeilingHeight } from '../../services/level-height'
|
||||
import { getLevelElevations } from '../../services/storey'
|
||||
import { computeSegmentTransforms, rotateXZ } from './stair-footprint'
|
||||
import { resolveStairTotalRise } from './stair-rise'
|
||||
|
||||
type SegmentTransform = {
|
||||
position: [number, number, number]
|
||||
@@ -463,7 +465,7 @@ function getStraightOpeningPolygonsForSurface(
|
||||
const layouts = getStraightStairLayouts(stair, nodes)
|
||||
if (layouts.length === 0) return []
|
||||
|
||||
const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1)
|
||||
const riserHeight = resolveStairTotalRise(stair, nodes) / Math.max(stair.stepCount ?? 10, 1)
|
||||
const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
|
||||
const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
|
||||
const openingRects: AxisAlignedRect[] = []
|
||||
@@ -605,17 +607,16 @@ function getTargetSlabElevationForStair(
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
|
||||
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
||||
const slabLevel = getLevelNumber(slabLevelId, nodes)
|
||||
const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
||||
const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined
|
||||
const slabElevation = elevations.get(slabLevelId)
|
||||
|
||||
if (fromLevel === undefined || slabLevel === undefined) {
|
||||
if (!fromElevation || !slabElevation || fromElevation.buildingId !== slabElevation.buildingId) {
|
||||
return slab.elevation ?? 0.05
|
||||
}
|
||||
|
||||
return (
|
||||
(slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
|
||||
(slab.elevation ?? 0.05) -
|
||||
(stair.position[1] ?? 0)
|
||||
slabElevation.baseY - fromElevation.baseY + (slab.elevation ?? 0.05) - (stair.position[1] ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -626,18 +627,21 @@ function getTargetCeilingElevationForStair(
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
|
||||
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
||||
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes)
|
||||
const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
||||
const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined
|
||||
const ceilingElevation = elevations.get(ceilingLevelId)
|
||||
|
||||
if (fromLevel === undefined || ceilingLevel === undefined) {
|
||||
return ceiling.height ?? DEFAULT_WALL_HEIGHT
|
||||
const ceilingHeight = resolveCeilingHeight(ceiling, nodes as Record<AnyNodeId, AnyNode>)
|
||||
|
||||
if (
|
||||
!fromElevation ||
|
||||
!ceilingElevation ||
|
||||
fromElevation.buildingId !== ceilingElevation.buildingId
|
||||
) {
|
||||
return ceilingHeight
|
||||
}
|
||||
|
||||
return (
|
||||
(ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
|
||||
(ceiling.height ?? DEFAULT_WALL_HEIGHT) -
|
||||
(stair.position[1] ?? 0)
|
||||
)
|
||||
return ceilingElevation.baseY - fromElevation.baseY + ceilingHeight - (stair.position[1] ?? 0)
|
||||
}
|
||||
|
||||
function shouldApplyStairToSlab(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { AnyNode } from '../../schema'
|
||||
import type { AnyNode, AnyNodeId } from '../../schema'
|
||||
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
|
||||
import useLiveNodeOverrides from '../../store/use-live-node-overrides'
|
||||
import useLiveTransforms from '../../store/use-live-transforms'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
hasLiveStairOpeningInputs,
|
||||
} from './stair-opening-preview'
|
||||
import { syncAutoStairOpenings } from './stair-opening-sync'
|
||||
import { syncStairRises } from './stair-rise'
|
||||
|
||||
function isOpeningRelevantNode(node: AnyNode | undefined) {
|
||||
return (
|
||||
@@ -47,7 +48,7 @@ export const StairOpeningSystem = () => {
|
||||
const previewControllerRef = useRef(createSurfaceOpeningPreviewController())
|
||||
|
||||
useEffect(() => {
|
||||
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
|
||||
const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }>) => {
|
||||
if (updates.length === 0) return
|
||||
syncingAutoOpeningsRef.current = true
|
||||
pauseSceneHistory(useScene)
|
||||
@@ -103,14 +104,40 @@ export const StairOpeningSystem = () => {
|
||||
)
|
||||
}
|
||||
|
||||
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
|
||||
refreshLivePreview()
|
||||
const runAutoSync = () => {
|
||||
// Rise first: straight stairs converge their flight heights to the
|
||||
// resolved rise (level height or deck elevation), and the opening pass
|
||||
// reads those segment heights — so it must run against the post-rise
|
||||
// nodes.
|
||||
applyUpdates(syncStairRises(useScene.getState().nodes))
|
||||
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
|
||||
}
|
||||
|
||||
let disposed = false
|
||||
let autoSyncQueued = false
|
||||
const scheduleAutoSync = () => {
|
||||
if (autoSyncQueued) return
|
||||
autoSyncQueued = true
|
||||
// One microtask later so every other scene-store listener for the
|
||||
// triggering transition (and, at mount, the editor's spatial-grid
|
||||
// init) runs first — the spatial-grid sync in particular. The
|
||||
// deck-attached rise elects the stair's floor-stack base elevation
|
||||
// through the spatial grid; syncing before the grid listener would
|
||||
// rescale flights against the pre-transition slab state.
|
||||
queueMicrotask(() => {
|
||||
autoSyncQueued = false
|
||||
if (disposed) return
|
||||
runAutoSync()
|
||||
refreshLivePreview()
|
||||
})
|
||||
}
|
||||
|
||||
scheduleAutoSync()
|
||||
|
||||
const unsubscribeScene = useScene.subscribe((state, prevState) => {
|
||||
if (syncingAutoOpeningsRef.current) return
|
||||
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
|
||||
applyUpdates(syncAutoStairOpenings(state.nodes))
|
||||
refreshLivePreview()
|
||||
scheduleAutoSync()
|
||||
})
|
||||
|
||||
const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => {
|
||||
@@ -122,6 +149,7 @@ export const StairOpeningSystem = () => {
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribeScene()
|
||||
unsubscribeLiveTransforms()
|
||||
unsubscribeLiveOverrides()
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import { beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
GROUND_SUPPORT_ID,
|
||||
getFloorPlacedElevation,
|
||||
} from '../../hooks/spatial-grid/floor-placed-elevation'
|
||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||
import { nodeRegistry, registerNode } from '../../registry'
|
||||
import type { AnyNodeDefinition } from '../../registry/types'
|
||||
import type { AnyNode, StairNode as StairNodeType } from '../../schema'
|
||||
import { LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
|
||||
import { resolveStairTotalRise, syncStairRises } from './stair-rise'
|
||||
|
||||
// The deck branch elects the stair's floor-stack base through the node
|
||||
// registry + spatial grid singletons — reset them so tests are hermetic
|
||||
// (base elects 0 unless a test registers a stair footprint and slabs).
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
spatialGridManager.clear()
|
||||
})
|
||||
|
||||
function buildScene(levelHeight: number | undefined, totalRise: number | undefined) {
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_1',
|
||||
type: 'stair',
|
||||
position: [0, 0, 0],
|
||||
...(totalRise !== undefined ? { totalRise } : {}),
|
||||
})
|
||||
const level = LevelNode.parse({
|
||||
id: 'level_1',
|
||||
type: 'level',
|
||||
level: 0,
|
||||
children: ['stair_1'],
|
||||
...(levelHeight !== undefined ? { height: levelHeight } : {}),
|
||||
})
|
||||
return { stair, nodes: { level_1: level, stair_1: stair } }
|
||||
}
|
||||
|
||||
function makeDeck(elevation: number, polygon?: Array<[number, number]>) {
|
||||
return SlabNode.parse({
|
||||
id: 'slab_deck',
|
||||
type: 'slab',
|
||||
polygon: polygon ?? [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
elevation,
|
||||
thickness: 0.05,
|
||||
})
|
||||
}
|
||||
|
||||
function buildDeckScene(options: {
|
||||
deckElevation: number
|
||||
deckPolygon?: Array<[number, number]>
|
||||
totalRise?: number
|
||||
deckSlabId?: string
|
||||
segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
|
||||
}) {
|
||||
const deck = makeDeck(options.deckElevation, options.deckPolygon)
|
||||
const segments = (options.segments ?? []).map((segment) =>
|
||||
StairSegmentNode.parse({
|
||||
id: segment.id,
|
||||
type: 'stair-segment',
|
||||
segmentType: segment.segmentType,
|
||||
width: 1,
|
||||
length: 2,
|
||||
height: segment.height,
|
||||
stepCount: 8,
|
||||
parentId: 'stair_1',
|
||||
}),
|
||||
)
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_1',
|
||||
type: 'stair',
|
||||
position: [0, 0, 0],
|
||||
deckSlabId: options.deckSlabId ?? deck.id,
|
||||
children: segments.map((segment) => segment.id),
|
||||
...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}),
|
||||
})
|
||||
const level = LevelNode.parse({
|
||||
id: 'level_1',
|
||||
type: 'level',
|
||||
level: 0,
|
||||
height: 2.5,
|
||||
children: ['stair_1', deck.id],
|
||||
})
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
level_1: level,
|
||||
stair_1: stair,
|
||||
[deck.id]: deck,
|
||||
}
|
||||
for (const segment of segments) nodes[segment.id] = segment
|
||||
return { deck, stair, nodes }
|
||||
}
|
||||
|
||||
function buildLevelSceneWithSegments(options: {
|
||||
levelHeight: number
|
||||
totalRise?: number
|
||||
segments: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
|
||||
}) {
|
||||
const segments = options.segments.map((segment) =>
|
||||
StairSegmentNode.parse({
|
||||
id: segment.id,
|
||||
type: 'stair-segment',
|
||||
segmentType: segment.segmentType,
|
||||
width: 1,
|
||||
length: 2,
|
||||
height: segment.height,
|
||||
stepCount: 8,
|
||||
parentId: 'stair_1',
|
||||
}),
|
||||
)
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_1',
|
||||
type: 'stair',
|
||||
position: [0, 0, 0],
|
||||
children: segments.map((segment) => segment.id),
|
||||
...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}),
|
||||
})
|
||||
const level = LevelNode.parse({
|
||||
id: 'level_1',
|
||||
type: 'level',
|
||||
level: 0,
|
||||
height: options.levelHeight,
|
||||
children: ['stair_1'],
|
||||
})
|
||||
const nodes: Record<string, AnyNode> = { level_1: level, stair_1: stair }
|
||||
for (const segment of segments) nodes[segment.id] = segment
|
||||
return { level, stair, nodes }
|
||||
}
|
||||
|
||||
describe('resolveStairTotalRise', () => {
|
||||
it('derives the rise from the containing level stored height when absent', () => {
|
||||
const { stair, nodes } = buildScene(3.2, undefined)
|
||||
expect(resolveStairTotalRise(stair, nodes)).toBe(3.2)
|
||||
})
|
||||
|
||||
it('tracks a storey height change without any stair write', () => {
|
||||
const { stair, nodes } = buildScene(2.55, undefined)
|
||||
expect(resolveStairTotalRise(stair, nodes)).toBe(2.55)
|
||||
const level = nodes.level_1
|
||||
if (level.type !== 'level') throw new Error('expected level')
|
||||
const updated = { ...nodes, level_1: { ...level, height: 3.0 } }
|
||||
expect(resolveStairTotalRise(stair, updated)).toBe(3.0)
|
||||
})
|
||||
|
||||
it('prefers an explicit totalRise over the storey height', () => {
|
||||
const { stair, nodes } = buildScene(3.2, 2.5)
|
||||
expect(resolveStairTotalRise(stair, nodes)).toBe(2.5)
|
||||
})
|
||||
|
||||
it('falls back to the default when the stair has no containing level', () => {
|
||||
const { stair } = buildScene(3.2, undefined)
|
||||
expect(resolveStairTotalRise(stair, {})).toBe(2.5)
|
||||
})
|
||||
|
||||
it('derives the rise from the attached deck elevation', () => {
|
||||
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25 })
|
||||
expect(resolveStairTotalRise(stair, nodes)).toBe(1.25)
|
||||
})
|
||||
|
||||
it('tracks a deck elevation change without any stair write', () => {
|
||||
const { deck, stair, nodes } = buildDeckScene({ deckElevation: 1.25 })
|
||||
const updated = { ...nodes, [deck.id]: { ...deck, elevation: 1.6 } }
|
||||
expect(resolveStairTotalRise(stair, updated)).toBe(1.6)
|
||||
})
|
||||
|
||||
it('prefers an explicit totalRise over the attached deck', () => {
|
||||
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, totalRise: 2.0 })
|
||||
expect(resolveStairTotalRise(stair, nodes)).toBe(2.0)
|
||||
})
|
||||
|
||||
it('falls through a stale deckSlabId to the storey height silently', () => {
|
||||
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, deckSlabId: 'slab_gone' })
|
||||
expect(resolveStairTotalRise(stair, nodes)).toBe(2.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncStairRises', () => {
|
||||
it('writes the deck elevation into a single flight segment', () => {
|
||||
const { nodes } = buildDeckScene({
|
||||
deckElevation: 1.6,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
|
||||
})
|
||||
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 1.6 } }])
|
||||
})
|
||||
|
||||
it('is a no-op when the flights already match the deck elevation', () => {
|
||||
const { nodes } = buildDeckScene({
|
||||
deckElevation: 1.25,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
|
||||
})
|
||||
expect(syncStairRises(nodes)).toEqual([])
|
||||
})
|
||||
|
||||
it('scales multiple flights proportionally and leaves landings alone', () => {
|
||||
const { nodes } = buildDeckScene({
|
||||
deckElevation: 2.1,
|
||||
segments: [
|
||||
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
|
||||
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
|
||||
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
|
||||
],
|
||||
})
|
||||
const updates = syncStairRises(nodes)
|
||||
expect(updates).toHaveLength(2)
|
||||
expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } })
|
||||
expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } })
|
||||
})
|
||||
|
||||
it('distributes an explicit custom rise instead of the deck elevation', () => {
|
||||
const { nodes } = buildDeckScene({
|
||||
deckElevation: 1.25,
|
||||
totalRise: 2.0,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
|
||||
})
|
||||
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.0 } }])
|
||||
})
|
||||
|
||||
it('falls a stale deckSlabId back to the storey height', () => {
|
||||
const { nodes } = buildDeckScene({
|
||||
deckElevation: 1.6,
|
||||
deckSlabId: 'slab_gone',
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
|
||||
})
|
||||
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
|
||||
})
|
||||
|
||||
it('leaves a stale-deck stair with an explicit rise untouched', () => {
|
||||
const { nodes } = buildDeckScene({
|
||||
deckElevation: 1.6,
|
||||
deckSlabId: 'slab_gone',
|
||||
totalRise: 2.0,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
|
||||
})
|
||||
expect(syncStairRises(nodes)).toEqual([])
|
||||
})
|
||||
|
||||
it('converges a level-following straight stair to the storey height', () => {
|
||||
const { nodes } = buildLevelSceneWithSegments({
|
||||
levelHeight: 2.5,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.0 }],
|
||||
})
|
||||
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
|
||||
})
|
||||
|
||||
it('converges a level-following stair after a storey height change', () => {
|
||||
const scene = buildLevelSceneWithSegments({
|
||||
levelHeight: 2.5,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }],
|
||||
})
|
||||
expect(syncStairRises(scene.nodes)).toEqual([])
|
||||
const nodes = { ...scene.nodes, level_1: { ...scene.level, height: 3.0 } as AnyNode }
|
||||
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 3.0 } }])
|
||||
})
|
||||
|
||||
it('rescales level-following flights proportionally, landings untouched', () => {
|
||||
const { nodes } = buildLevelSceneWithSegments({
|
||||
levelHeight: 2.1,
|
||||
segments: [
|
||||
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
|
||||
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
|
||||
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
|
||||
],
|
||||
})
|
||||
const updates = syncStairRises(nodes)
|
||||
expect(updates).toHaveLength(2)
|
||||
expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } })
|
||||
expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } })
|
||||
})
|
||||
|
||||
it('converges back to the storey height after a deck detach', () => {
|
||||
const scene = buildDeckScene({
|
||||
deckElevation: 1.25,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
|
||||
})
|
||||
expect(syncStairRises(scene.nodes)).toEqual([])
|
||||
const { deckSlabId: _deckSlabId, ...detached } = scene.stair
|
||||
const nodes = { ...scene.nodes, stair_1: detached as AnyNode }
|
||||
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
|
||||
})
|
||||
|
||||
it('leaves a detached explicit-rise stair with hand-set segments untouched', () => {
|
||||
const { nodes } = buildLevelSceneWithSegments({
|
||||
levelHeight: 2.5,
|
||||
totalRise: 2.0,
|
||||
segments: [
|
||||
{ id: 'sseg_1', segmentType: 'stair', height: 0.9 },
|
||||
{ id: 'sseg_2', segmentType: 'stair', height: 0.6 },
|
||||
],
|
||||
})
|
||||
expect(syncStairRises(nodes)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// The stair stands on a floor slab (the default 0.05 one, or whatever the
|
||||
// floor-stack elects) — the deck-derived rise must be measured from that
|
||||
// lifted base so the last step lands flush with the deck's walking surface.
|
||||
describe('deck-attached rise with a floor-lifted base', () => {
|
||||
const FLOOR_POLYGON: Array<[number, number]> = [
|
||||
[-5, -5],
|
||||
[5, -5],
|
||||
[5, 5],
|
||||
[-5, 5],
|
||||
]
|
||||
// Away from the stair footprint at the origin so the base election never
|
||||
// sees the deck itself.
|
||||
const AWAY_DECK_POLYGON: Array<[number, number]> = [
|
||||
[8, 8],
|
||||
[10, 8],
|
||||
[10, 10],
|
||||
[8, 10],
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
registerNode({
|
||||
kind: 'stair',
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal('stair') }) as never,
|
||||
category: 'structure',
|
||||
defaults: () => ({}) as never,
|
||||
capabilities: {
|
||||
floorPlaced: {
|
||||
footprints: (node) => [
|
||||
{
|
||||
position: (node as StairNodeType).position,
|
||||
dimensions: [1, 1, 2] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as AnyNodeDefinition)
|
||||
})
|
||||
|
||||
function makeFloorSlab(elevation: number) {
|
||||
return SlabNode.parse({
|
||||
id: 'slab_floor',
|
||||
type: 'slab',
|
||||
polygon: FLOOR_POLYGON,
|
||||
elevation,
|
||||
thickness: 0.05,
|
||||
})
|
||||
}
|
||||
|
||||
function buildLiftedDeckScene(options: {
|
||||
deckElevation: number
|
||||
floorElevation?: number
|
||||
totalRise?: number
|
||||
supportSlabId?: string
|
||||
segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
|
||||
}) {
|
||||
const floor = makeFloorSlab(options.floorElevation ?? 0.05)
|
||||
const scene = buildDeckScene({
|
||||
deckElevation: options.deckElevation,
|
||||
deckPolygon: AWAY_DECK_POLYGON,
|
||||
totalRise: options.totalRise,
|
||||
segments: options.segments,
|
||||
})
|
||||
const stair = options.supportSlabId
|
||||
? ({ ...scene.stair, supportSlabId: options.supportSlabId } as typeof scene.stair)
|
||||
: scene.stair
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
...scene.nodes,
|
||||
stair_1: stair,
|
||||
[floor.id]: floor,
|
||||
}
|
||||
spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1')
|
||||
spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1')
|
||||
return { deck: scene.deck, floor, stair, nodes }
|
||||
}
|
||||
|
||||
it('lands the last step flush: rise = deck elevation − elected base', () => {
|
||||
const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25 })
|
||||
const base = getFloorPlacedElevation({
|
||||
node: stair,
|
||||
nodes,
|
||||
position: stair.position,
|
||||
rotation: stair.rotation,
|
||||
levelId: 'level_1',
|
||||
})
|
||||
expect(base).toBeCloseTo(0.05)
|
||||
const rise = resolveStairTotalRise(stair, nodes)
|
||||
expect(rise).toBeCloseTo(1.2)
|
||||
// Top surface = visual base + rise = the deck's walking surface, not 1.30.
|
||||
expect(base + rise).toBeCloseTo(1.25)
|
||||
})
|
||||
|
||||
it('rescales a flight converged under the old rule down to the flush rise', () => {
|
||||
const { nodes } = buildLiftedDeckScene({
|
||||
deckElevation: 1.25,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
|
||||
})
|
||||
const updates = syncStairRises(nodes)
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0]?.id).toBe('sseg_1' as never)
|
||||
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.2)
|
||||
})
|
||||
|
||||
it('keeps the full deck elevation when the stair stands on bare ground', () => {
|
||||
const scene = buildDeckScene({ deckElevation: 1.25, deckPolygon: AWAY_DECK_POLYGON })
|
||||
spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1')
|
||||
expect(resolveStairTotalRise(scene.stair, scene.nodes)).toBeCloseTo(1.25)
|
||||
})
|
||||
|
||||
it('lets an explicit totalRise win over the base-adjusted deck rise', () => {
|
||||
const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25, totalRise: 2.0 })
|
||||
expect(resolveStairTotalRise(stair, nodes)).toBe(2.0)
|
||||
})
|
||||
|
||||
it('re-converges to flush after a deck elevation change', () => {
|
||||
const scene = buildLiftedDeckScene({
|
||||
deckElevation: 1.25,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }],
|
||||
})
|
||||
expect(syncStairRises(scene.nodes)).toEqual([])
|
||||
const movedDeck = { ...scene.deck, elevation: 1.6 }
|
||||
const nodes = { ...scene.nodes, [scene.deck.id]: movedDeck as AnyNode }
|
||||
spatialGridManager.handleNodeUpdated(movedDeck as AnyNode, 'level_1')
|
||||
const updates = syncStairRises(nodes)
|
||||
expect(updates).toHaveLength(1)
|
||||
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.55)
|
||||
})
|
||||
|
||||
it('re-converges to flush after the base slab elevation changes', () => {
|
||||
const scene = buildLiftedDeckScene({
|
||||
deckElevation: 1.25,
|
||||
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }],
|
||||
})
|
||||
const movedFloor = { ...scene.floor, elevation: 0.3 }
|
||||
const nodes = { ...scene.nodes, [scene.floor.id]: movedFloor as AnyNode }
|
||||
spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1')
|
||||
const updates = syncStairRises(nodes)
|
||||
expect(updates).toHaveLength(1)
|
||||
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(0.95)
|
||||
})
|
||||
|
||||
it('rescales flights proportionally from the lifted base, landings untouched', () => {
|
||||
const { nodes } = buildLiftedDeckScene({
|
||||
deckElevation: 2.15,
|
||||
segments: [
|
||||
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
|
||||
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
|
||||
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
|
||||
],
|
||||
})
|
||||
// Target flight rise = 2.15 − 0.05 (base) − 0.1 (landing) = 2.0 → 1.0 each.
|
||||
const updates = syncStairRises(nodes)
|
||||
expect(updates).toHaveLength(2)
|
||||
expect(updates[0]?.id).toBe('sseg_1' as never)
|
||||
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.0)
|
||||
expect(updates[1]?.id).toBe('sseg_3' as never)
|
||||
expect((updates[1]?.data as { height?: number }).height).toBeCloseTo(1.0)
|
||||
})
|
||||
|
||||
it('honors a persisted ground host over the floor slab election', () => {
|
||||
const { stair, nodes } = buildLiftedDeckScene({
|
||||
deckElevation: 1.25,
|
||||
supportSlabId: GROUND_SUPPORT_ID,
|
||||
})
|
||||
expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getFloorStackedPosition } from '../../hooks/spatial-grid/floor-placed-elevation'
|
||||
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
|
||||
import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height'
|
||||
import { getStoredLevelHeight } from '../../services/storey'
|
||||
|
||||
export function resolveStairTotalRise(stair: StairNode, nodes: Record<string, AnyNode>): number {
|
||||
if (stair.totalRise !== undefined) return stair.totalRise
|
||||
|
||||
const level = Object.values(nodes).find(
|
||||
(node) => node.type === 'level' && node.children.includes(stair.id),
|
||||
)
|
||||
|
||||
if (stair.deckSlabId) {
|
||||
const deck = nodes[stair.deckSlabId]
|
||||
// The deck's `elevation` IS its walking surface (level-local), but the
|
||||
// stair's own base may be lifted onto a floor slab by the floor-stack
|
||||
// (`FloorElevationSystem` / `syncStairGroupElevation` put the group at
|
||||
// `position[1] + elected slab elevation`). The rise is measured from
|
||||
// that base, so subtract it — electing the base exactly the way the
|
||||
// visual systems do (persisted `supportSlabId` honored, uncapped
|
||||
// election otherwise) keeps base + rise landing precisely on the deck's
|
||||
// walking surface. A stale reference (deck gone) falls through to the
|
||||
// level-derived rise.
|
||||
if (deck?.type === 'slab') {
|
||||
const baseElevation = getFloorStackedPosition({
|
||||
node: stair,
|
||||
nodes,
|
||||
position: stair.position,
|
||||
rotation: stair.rotation,
|
||||
levelId: level?.id ?? null,
|
||||
})[1]
|
||||
return (deck.elevation ?? 0.05) - baseElevation
|
||||
}
|
||||
}
|
||||
|
||||
return level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT
|
||||
}
|
||||
|
||||
const RISE_SYNC_EPSILON = 1e-4
|
||||
|
||||
/**
|
||||
* Keeps straight stairs' flight segments in step with the resolved rise.
|
||||
* Straight-stair geometry derives from per-segment heights (not from
|
||||
* `resolveStairTotalRise`), so level-height and deck-elevation changes must
|
||||
* write through to the flight segments — curved/spiral stairs read the
|
||||
* resolved rise directly and need no sync.
|
||||
*
|
||||
* Scope: stairs whose total the system owns — follows-mode stairs (absent
|
||||
* `totalRise`, tracking their level or their deck) and deck-attached stairs
|
||||
* (an explicit rise converges to the typed value). A detached stair with an
|
||||
* explicit `totalRise` is the one place hand-edited segment chains are
|
||||
* legitimate, so it is never touched. Flight heights scale proportionally
|
||||
* (landings keep theirs); returns `updateNodes` patches, empty when every
|
||||
* stair is already in step.
|
||||
*/
|
||||
export function syncStairRises(
|
||||
nodes: Record<string, AnyNode>,
|
||||
): Array<{ id: AnyNodeId; data: Partial<AnyNode> }> {
|
||||
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'stair' || node.stairType !== 'straight') continue
|
||||
const deck = node.deckSlabId ? nodes[node.deckSlabId] : undefined
|
||||
if (node.totalRise !== undefined && deck?.type !== 'slab') continue
|
||||
|
||||
const segments = (node.children ?? [])
|
||||
.map((childId) => nodes[childId])
|
||||
.filter((child): child is StairSegmentNode => child?.type === 'stair-segment')
|
||||
const flights = segments.filter((segment) => segment.segmentType === 'stair')
|
||||
if (flights.length === 0) continue
|
||||
|
||||
const landingRise = segments
|
||||
.filter((segment) => segment.segmentType !== 'stair')
|
||||
.reduce((sum, segment) => sum + segment.height, 0)
|
||||
const flightRise = flights.reduce((sum, segment) => sum + segment.height, 0)
|
||||
const targetFlightRise = resolveStairTotalRise(node, nodes) - landingRise
|
||||
if (targetFlightRise <= 0) continue
|
||||
if (Math.abs(flightRise - targetFlightRise) <= RISE_SYNC_EPSILON) continue
|
||||
|
||||
for (const flight of flights) {
|
||||
const height =
|
||||
flightRise > RISE_SYNC_EPSILON
|
||||
? flight.height * (targetFlightRise / flightRise)
|
||||
: targetFlightRise / flights.length
|
||||
updates.push({ id: flight.id as AnyNodeId, data: { height } })
|
||||
}
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { WallNode } from '../../schema/nodes/wall'
|
||||
|
||||
/**
|
||||
* Minimum wall body height in meters. Governs both the wall height
|
||||
* arrow's lower drag bound and the slab-elevation clamp: a slab may not
|
||||
* rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall
|
||||
* elects it as its base, or the wall's extrusion (plane minus base)
|
||||
* would collapse below this minimum.
|
||||
*/
|
||||
export const MIN_WALL_HEIGHT = 0.5
|
||||
|
||||
/**
|
||||
* Wall-top inversion (vertical building model): a wall with no stored
|
||||
* `height` is plane-bound — its top sits at the storey plane (level-local
|
||||
* Y = the level's stored height), so a slab lifting the wall's base makes
|
||||
* the wall shorter, never taller, and no gap can open at the top of a
|
||||
* level. A wall WITH `height` is an explicit exception (half wall,
|
||||
* parapet) and keeps the legacy semantics: the top rides a raised elected
|
||||
* base (`electedBase + height`), while a zero or sunken base leaves the
|
||||
* top at `height` (the legacy negative-slab constraint).
|
||||
*
|
||||
* Returns the top in level-local Y (same frame as `electedBase`).
|
||||
*/
|
||||
export function resolveWallTop(
|
||||
wall: Pick<WallNode, 'height'>,
|
||||
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<WallNode, 'height'>,
|
||||
storeyHeight: number,
|
||||
electedBase: number,
|
||||
): number {
|
||||
return resolveWallTop(wall, storeyHeight, electedBase) - electedBase
|
||||
}
|
||||
Reference in New Issue
Block a user