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
@@ -0,0 +1,89 @@
|
||||
// Real-scene repro for the max-side boundary clamp miss (project_O1z9NLOylyb5kFX4).
|
||||
// Auto slab polygon derives from wall centerlines, putting wall samples exactly on
|
||||
// the polygon boundary — the shape that exposed ray-cast side dependence.
|
||||
export const wallPlaneTopBoundaryRepro = {
|
||||
building_rr4rx7weux2fpdbh: {
|
||||
id: 'building_rr4rx7weux2fpdbh',
|
||||
type: 'building',
|
||||
object: 'node',
|
||||
visible: true,
|
||||
children: ['level_pomuk0sbwec15mf3', 'level_5msog1z8hy2lyvxr'],
|
||||
metadata: {},
|
||||
parentId: null,
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
},
|
||||
level_pomuk0sbwec15mf3: {
|
||||
id: 'level_pomuk0sbwec15mf3',
|
||||
type: 'level',
|
||||
level: 0,
|
||||
height: 2.7,
|
||||
object: 'node',
|
||||
visible: true,
|
||||
children: ['wall_39bnnq29h824ryy0', 'wall_on4rj410n69n3rzf'],
|
||||
metadata: {},
|
||||
parentId: null,
|
||||
},
|
||||
level_5msog1z8hy2lyvxr: {
|
||||
id: 'level_5msog1z8hy2lyvxr',
|
||||
type: 'level',
|
||||
level: 1,
|
||||
height: 2.5,
|
||||
object: 'node',
|
||||
visible: true,
|
||||
children: ['slab_j3i4ebjg4nsu8xk7'],
|
||||
metadata: {},
|
||||
parentId: 'building_rr4rx7weux2fpdbh',
|
||||
},
|
||||
wall_39bnnq29h824ryy0: {
|
||||
id: 'wall_39bnnq29h824ryy0',
|
||||
end: [-1, 4],
|
||||
name: 'Wall 1',
|
||||
type: 'wall',
|
||||
start: [3, 4],
|
||||
object: 'node',
|
||||
visible: true,
|
||||
backSide: 'exterior',
|
||||
children: [],
|
||||
metadata: {},
|
||||
parentId: 'level_pomuk0sbwec15mf3',
|
||||
frontSide: 'interior',
|
||||
},
|
||||
wall_on4rj410n69n3rzf: {
|
||||
id: 'wall_on4rj410n69n3rzf',
|
||||
end: [-1, -1],
|
||||
name: 'Wall 2',
|
||||
type: 'wall',
|
||||
start: [-1, 4],
|
||||
object: 'node',
|
||||
visible: true,
|
||||
backSide: 'exterior',
|
||||
children: [],
|
||||
metadata: {},
|
||||
parentId: 'level_pomuk0sbwec15mf3',
|
||||
frontSide: 'interior',
|
||||
},
|
||||
slab_j3i4ebjg4nsu8xk7: {
|
||||
id: 'slab_j3i4ebjg4nsu8xk7',
|
||||
name: 'Room 1 Slab',
|
||||
type: 'slab',
|
||||
holes: [],
|
||||
object: 'node',
|
||||
polygon: [
|
||||
[-1, 4],
|
||||
[-1, -1],
|
||||
[4, -1],
|
||||
[4, 1],
|
||||
[3, 1],
|
||||
[3, 4],
|
||||
],
|
||||
visible: true,
|
||||
metadata: {},
|
||||
parentId: 'level_5msog1z8hy2lyvxr',
|
||||
recessed: false,
|
||||
elevation: 0.19757210573188194,
|
||||
thickness: 0.5,
|
||||
holeMetadata: [],
|
||||
autoFromWalls: true,
|
||||
},
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export {
|
||||
DEFAULT_LEVEL_HEIGHT,
|
||||
getCeilingAt,
|
||||
getCeilingHeightAt,
|
||||
getLevelHeight,
|
||||
resolveCeilingHeight,
|
||||
} from './level-height'
|
||||
export {
|
||||
type AxisLock,
|
||||
@@ -102,6 +102,19 @@ export {
|
||||
snapVec3ToGrid,
|
||||
snapWorldXZToBuildingLocal,
|
||||
} from './snap'
|
||||
export {
|
||||
CEILING_CLAMP_MARGIN,
|
||||
findLevelAboveId,
|
||||
findLevelBelowId,
|
||||
getCeilingClampBound,
|
||||
getCoveringSlabUndersideAt,
|
||||
getLevelAbove,
|
||||
getLevelBelow,
|
||||
getLevelElevations,
|
||||
getStoredLevelHeight,
|
||||
getWallPlaneTop,
|
||||
type LevelElevation,
|
||||
} from './storey'
|
||||
export {
|
||||
buildPortComponents,
|
||||
type SystemSummary,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { deriveLegacyLevelHeight, getCeilingAt, resolveCeilingHeight } from './level-height'
|
||||
|
||||
function createFixture(): Record<AnyNodeId, AnyNode> {
|
||||
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<AnyNodeId, AnyNode>
|
||||
}
|
||||
|
||||
describe('deriveLegacyLevelHeight', () => {
|
||||
const nodes = createFixture()
|
||||
const cases = [
|
||||
['level_no_slab', 2.5],
|
||||
['level_standard_slab', 2.5],
|
||||
['level_tall_wall', 3.55],
|
||||
['level_ceiling', 3.4],
|
||||
['level_negative_slab', 2.8],
|
||||
['level_empty', 2.5],
|
||||
] as const
|
||||
|
||||
for (const [levelId, expected] of cases) {
|
||||
it(`derives ${expected} for ${levelId}`, () => {
|
||||
expect(deriveLegacyLevelHeight(levelId, nodes)).toBeCloseTo(expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const SQUARE: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
]
|
||||
|
||||
// Post-migration stack: two stored-height levels; the upper level carries a
|
||||
// deck slab occupying [-0.3, 0] over the lower level's plane, so the lower
|
||||
// level's ceiling clamp bound is 2.5 − 0.3 − 0.01 = 2.19 under the deck.
|
||||
function createResolverFixture(options: { deck?: boolean } = {}): Record<AnyNodeId, AnyNode> {
|
||||
const list: AnyNode[] = [
|
||||
BuildingNode.parse({
|
||||
id: 'building_a',
|
||||
children: ['level_low', 'level_high'],
|
||||
}),
|
||||
LevelNode.parse({ id: 'level_low', level: 0, height: 2.5, parentId: 'building_a' }),
|
||||
LevelNode.parse({
|
||||
id: 'level_high',
|
||||
level: 1,
|
||||
height: 2.5,
|
||||
parentId: 'building_a',
|
||||
children: options.deck ? ['slab_deck'] : [],
|
||||
}),
|
||||
]
|
||||
if (options.deck) {
|
||||
list.push(
|
||||
SlabNode.parse({
|
||||
id: 'slab_deck',
|
||||
parentId: 'level_high',
|
||||
polygon: SQUARE,
|
||||
elevation: 0,
|
||||
thickness: 0.3,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
|
||||
}
|
||||
|
||||
describe('resolveCeilingHeight', () => {
|
||||
it('returns the explicit height verbatim when stored', () => {
|
||||
const nodes = createResolverFixture({ deck: true })
|
||||
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE, height: 2.0 })
|
||||
|
||||
expect(resolveCeilingHeight(ceiling, nodes)).toBe(2.0)
|
||||
})
|
||||
|
||||
it('resolves an absent height to the level-top clamp bound', () => {
|
||||
const nodes = createResolverFixture()
|
||||
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
|
||||
|
||||
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49)
|
||||
})
|
||||
|
||||
it('tracks a level height change without any ceiling write', () => {
|
||||
const nodes = createResolverFixture()
|
||||
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
|
||||
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49)
|
||||
|
||||
const level = nodes['level_low' as AnyNodeId] as AnyNode & { height?: number }
|
||||
const raised = {
|
||||
...nodes,
|
||||
level_low: { ...level, height: 3.2 } as AnyNode,
|
||||
} as Record<AnyNodeId, AnyNode>
|
||||
|
||||
expect(resolveCeilingHeight(ceiling, raised)).toBeCloseTo(3.19)
|
||||
})
|
||||
|
||||
it('resolves under a covering deck from the level above', () => {
|
||||
const nodes = createResolverFixture({ deck: true })
|
||||
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
|
||||
|
||||
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.19)
|
||||
})
|
||||
|
||||
it('falls back to the default plane when the level is unresolvable', () => {
|
||||
const ceiling = CeilingNode.parse({ parentId: null, polygon: SQUARE })
|
||||
|
||||
expect(resolveCeilingHeight(ceiling, {} as Record<AnyNodeId, AnyNode>)).toBeCloseTo(2.49)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCeilingAt lowest-wins with mixed follows/explicit', () => {
|
||||
it('picks the explicit low ceiling under a follows-mode one, and vice versa', () => {
|
||||
const base = createResolverFixture()
|
||||
const follows = CeilingNode.parse({
|
||||
id: 'ceiling_follows',
|
||||
parentId: 'level_low',
|
||||
polygon: SQUARE,
|
||||
})
|
||||
const explicitLow = CeilingNode.parse({
|
||||
id: 'ceiling_low',
|
||||
parentId: 'level_low',
|
||||
polygon: SQUARE,
|
||||
height: 2.0,
|
||||
})
|
||||
const level = base['level_low' as AnyNodeId] as AnyNode & { children: string[] }
|
||||
const nodes = {
|
||||
...base,
|
||||
level_low: { ...level, children: ['ceiling_follows', 'ceiling_low'] } as AnyNode,
|
||||
ceiling_follows: follows,
|
||||
ceiling_low: explicitLow,
|
||||
} as Record<AnyNodeId, AnyNode>
|
||||
|
||||
// Explicit 2.0 undercuts the 2.49 follows bound.
|
||||
expect(getCeilingAt('level_low', nodes, 2, 2)?.id).toBe(explicitLow.id)
|
||||
|
||||
// Raise the explicit one above the bound comparison: 2.6 stored — the
|
||||
// follows ceiling (2.49) is now the lowest surface over the point.
|
||||
const nodesHighExplicit = {
|
||||
...nodes,
|
||||
ceiling_low: { ...explicitLow, height: 2.6 } as AnyNode,
|
||||
} as Record<AnyNodeId, AnyNode>
|
||||
expect(getCeilingAt('level_low', nodesHighExplicit, 2, 2)?.id).toBe(follows.id)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +1,68 @@
|
||||
import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager'
|
||||
import type { CeilingNode, LevelNode, WallNode } from '../schema'
|
||||
import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support'
|
||||
import { resolveWallTop } from '../systems/wall/wall-top'
|
||||
// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe:
|
||||
// both sides only reference the other inside function bodies.
|
||||
import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey'
|
||||
|
||||
export const DEFAULT_LEVEL_HEIGHT = 2.5
|
||||
|
||||
/**
|
||||
* Optional resolver for a wall's rendered base Y (mesh elevation).
|
||||
*
|
||||
* `packages/core` is pure domain logic and must not read viewer/Three.js
|
||||
* state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have
|
||||
* registry access (viewer systems, node tools) may pass a resolver so the
|
||||
* mesh elevation is factored in; pure/headless callers (MCP, tests, server)
|
||||
* omit it and get a deterministic result from serialized node data alone.
|
||||
* Effective ceiling height in level-local meters. An explicit stored
|
||||
* `height` wins; absent height means the ceiling follows the level top —
|
||||
* the same bound its write-clamp uses: min(storey plane, lowest
|
||||
* covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see
|
||||
* {@link getCeilingClampBound}). Falls back to the default plane minus
|
||||
* the same margin when the owning level is unresolvable.
|
||||
*/
|
||||
export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined
|
||||
export function resolveCeilingHeight(
|
||||
ceiling: Pick<CeilingNode, 'height' | 'parentId' | 'polygon'>,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): number {
|
||||
if (ceiling.height != null) return ceiling.height
|
||||
const bound =
|
||||
typeof ceiling.parentId === 'string'
|
||||
? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon)
|
||||
: Number.POSITIVE_INFINITY
|
||||
return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN
|
||||
}
|
||||
|
||||
export function getLevelHeight(
|
||||
export function deriveLegacyLevelHeight(
|
||||
levelId: string,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
resolveWallBaseY?: WallBaseYResolver,
|
||||
): number {
|
||||
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
||||
if (!level) return DEFAULT_LEVEL_HEIGHT
|
||||
|
||||
const levelChildren = level.children
|
||||
.map((childId) => nodes[childId as keyof typeof nodes])
|
||||
.filter((child): child is AnyNode => child !== undefined)
|
||||
const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab')
|
||||
const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall')
|
||||
|
||||
let maxTop = 0
|
||||
|
||||
for (const childId of level.children) {
|
||||
const child = nodes[childId as keyof typeof nodes]
|
||||
if (!child) continue
|
||||
for (const child of levelChildren) {
|
||||
if (child.type === 'ceiling') {
|
||||
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
|
||||
if (ch > maxTop) maxTop = ch
|
||||
// Absence here is the PRE-migration legacy schema default (2.5), not
|
||||
// follows-mode — this derivation runs before the level has a height
|
||||
// for a follows-mode bound to track.
|
||||
const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
|
||||
if (height > maxTop) maxTop = height
|
||||
} else if (child.type === 'wall') {
|
||||
let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0
|
||||
if (baseY < 0) baseY = 0
|
||||
const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
||||
const wall = child as WallNode
|
||||
const electedElevation = computeWallSlabSupport(
|
||||
{
|
||||
start: wall.start,
|
||||
end: wall.end,
|
||||
curveOffset: wall.curveOffset,
|
||||
thickness: wall.thickness,
|
||||
},
|
||||
slabs,
|
||||
walls,
|
||||
).elevation
|
||||
const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation)
|
||||
if (top > maxTop) maxTop = top
|
||||
}
|
||||
}
|
||||
@@ -58,14 +86,18 @@ export function getCeilingAt(
|
||||
if (!level) return null
|
||||
|
||||
let best: CeilingNode | null = null
|
||||
let bestHeight = Number.POSITIVE_INFINITY
|
||||
for (const childId of level.children) {
|
||||
const child = nodes[childId as keyof typeof nodes]
|
||||
if (child?.type !== 'ceiling') continue
|
||||
const ceiling = child as CeilingNode
|
||||
if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue
|
||||
if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue
|
||||
const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT
|
||||
if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling
|
||||
const h = resolveCeilingHeight(ceiling, nodes)
|
||||
if (best === null || h < bestHeight) {
|
||||
best = ceiling
|
||||
bestHeight = h
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -82,5 +114,5 @@ export function getCeilingHeightAt(
|
||||
z: number,
|
||||
): number | null {
|
||||
const ceiling = getCeilingAt(levelId, nodes, x, z)
|
||||
return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null
|
||||
return ceiling ? resolveCeilingHeight(ceiling, nodes) : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { BuildingNode, LevelNode, SlabNode, type WallNode } from '../schema'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { wallPlaneTopBoundaryRepro as reproFixture } from './__fixtures__/wall-plane-top-boundary-repro'
|
||||
import { DEFAULT_LEVEL_HEIGHT } from './level-height'
|
||||
import {
|
||||
CEILING_CLAMP_MARGIN,
|
||||
getCeilingClampBound,
|
||||
getCoveringSlabUndersideAt,
|
||||
getLevelAbove,
|
||||
getLevelBelow,
|
||||
getLevelElevations,
|
||||
getStoredLevelHeight,
|
||||
getWallPlaneTop,
|
||||
} from './storey'
|
||||
|
||||
const buildNodes = (list: AnyNode[]): Record<AnyNodeId, AnyNode> =>
|
||||
Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
|
||||
|
||||
const level = (
|
||||
id: string,
|
||||
ordinal: number,
|
||||
opts: { height?: number; parentId?: string | null; children?: string[] } = {},
|
||||
): LevelNode =>
|
||||
LevelNode.parse({
|
||||
id,
|
||||
level: ordinal,
|
||||
parentId: opts.parentId ?? null,
|
||||
children: opts.children ?? [],
|
||||
...(opts.height === undefined ? {} : { height: opts.height }),
|
||||
})
|
||||
|
||||
const building = (id: string, children: string[]): BuildingNode =>
|
||||
BuildingNode.parse({ id, children })
|
||||
|
||||
const slabNode = (
|
||||
id: string,
|
||||
opts: {
|
||||
polygon?: Array<[number, number]>
|
||||
holes?: Array<Array<[number, number]>>
|
||||
elevation?: number
|
||||
thickness?: number
|
||||
recessed?: boolean
|
||||
},
|
||||
): SlabNode =>
|
||||
SlabNode.parse({
|
||||
id,
|
||||
polygon:
|
||||
opts.polygon ??
|
||||
([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
] as Array<[number, number]>),
|
||||
holes: opts.holes ?? [],
|
||||
...(opts.elevation === undefined ? {} : { elevation: opts.elevation }),
|
||||
...(opts.thickness === undefined ? {} : { thickness: opts.thickness }),
|
||||
...(opts.recessed === undefined ? {} : { recessed: opts.recessed }),
|
||||
})
|
||||
|
||||
describe('getStoredLevelHeight', () => {
|
||||
test('returns the stored height when present', () => {
|
||||
expect(getStoredLevelHeight(level('level_a', 0, { height: 3.25 }))).toBe(3.25)
|
||||
})
|
||||
|
||||
test('falls back to the default for unmigrated legacy levels', () => {
|
||||
expect(getStoredLevelHeight(level('level_a', 0))).toBe(DEFAULT_LEVEL_HEIGHT)
|
||||
expect(getStoredLevelHeight(level('level_a', 0))).toBe(2.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLevelElevations', () => {
|
||||
test('single building matches a hand-computed prefix sum', () => {
|
||||
const nodes = buildNodes([
|
||||
building('building_a', ['level_0', 'level_1', 'level_2', 'level_3']),
|
||||
level('level_0', 0, { height: 3, parentId: 'building_a' }),
|
||||
level('level_1', 1, { height: 2.5, parentId: 'building_a' }),
|
||||
level('level_2', 2, { height: 2.75, parentId: 'building_a' }),
|
||||
level('level_3', 3, { height: 4, parentId: 'building_a' }),
|
||||
])
|
||||
|
||||
const elevations = getLevelElevations(nodes)
|
||||
expect(elevations.get('level_0')).toEqual({
|
||||
baseY: 0,
|
||||
height: 3,
|
||||
buildingId: 'building_a',
|
||||
ordinal: 0,
|
||||
})
|
||||
expect(elevations.get('level_1')?.baseY).toBe(3)
|
||||
expect(elevations.get('level_2')?.baseY).toBe(5.5)
|
||||
expect(elevations.get('level_3')?.baseY).toBe(8.25)
|
||||
})
|
||||
|
||||
test('stacks two buildings independently with interleaved, unsorted ordinals', () => {
|
||||
const nodes = buildNodes([
|
||||
level('level_b1', 1, { height: 2.5, parentId: 'building_b' }),
|
||||
level('level_a2', 2, { height: 3, parentId: 'building_a' }),
|
||||
building('building_a', ['level_a0', 'level_a1', 'level_a2']),
|
||||
level('level_a0', 0, { height: 3.5, parentId: 'building_a' }),
|
||||
building('building_b', ['level_b0', 'level_b1']),
|
||||
level('level_b0', 0, { height: 4, parentId: 'building_b' }),
|
||||
level('level_a1', 1, { height: 3.25, parentId: 'building_a' }),
|
||||
])
|
||||
|
||||
const elevations = getLevelElevations(nodes)
|
||||
expect(elevations.get('level_a0')?.baseY).toBe(0)
|
||||
expect(elevations.get('level_a1')?.baseY).toBe(3.5)
|
||||
expect(elevations.get('level_a2')?.baseY).toBe(6.75)
|
||||
expect(elevations.get('level_b0')?.baseY).toBe(0)
|
||||
expect(elevations.get('level_b1')?.baseY).toBe(4)
|
||||
expect(elevations.get('level_a2')?.buildingId).toBe('building_a')
|
||||
expect(elevations.get('level_b1')?.buildingId).toBe('building_b')
|
||||
})
|
||||
|
||||
test('negative ordinals stack from the lowest level up', () => {
|
||||
const nodes = buildNodes([
|
||||
building('building_a', ['level_basement', 'level_ground', 'level_upper']),
|
||||
level('level_upper', 1, { height: 3, parentId: 'building_a' }),
|
||||
level('level_basement', -1, { height: 2.25, parentId: 'building_a' }),
|
||||
level('level_ground', 0, { height: 2.5, parentId: 'building_a' }),
|
||||
])
|
||||
|
||||
const elevations = getLevelElevations(nodes)
|
||||
expect(elevations.get('level_basement')?.baseY).toBe(0)
|
||||
expect(elevations.get('level_ground')?.baseY).toBe(2.25)
|
||||
expect(elevations.get('level_upper')?.baseY).toBe(4.75)
|
||||
})
|
||||
|
||||
test('duplicate and fractional ordinals stack stably without NaN', () => {
|
||||
const nodes = buildNodes([
|
||||
building('building_a', ['level_ground', 'level_mezz', 'level_dup_b', 'level_dup_a']),
|
||||
level('level_dup_b', 1, { height: 3, parentId: 'building_a' }),
|
||||
level('level_dup_a', 1, { height: 2.5, parentId: 'building_a' }),
|
||||
level('level_mezz', 0.5, { height: 1.5, parentId: 'building_a' }),
|
||||
level('level_ground', 0, { height: 2.5, parentId: 'building_a' }),
|
||||
])
|
||||
|
||||
const elevations = getLevelElevations(nodes)
|
||||
expect(elevations.get('level_ground')?.baseY).toBe(0)
|
||||
expect(elevations.get('level_mezz')?.baseY).toBe(2.5)
|
||||
// Stable sort: equal ordinals keep nodes-record insertion order.
|
||||
expect(elevations.get('level_dup_b')?.baseY).toBe(4)
|
||||
expect(elevations.get('level_dup_a')?.baseY).toBe(7)
|
||||
for (const elevation of elevations.values()) {
|
||||
expect(Number.isFinite(elevation.baseY)).toBe(true)
|
||||
expect(Number.isFinite(elevation.height)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('levels missing height fall back to 2.5 for both height and stacking', () => {
|
||||
const nodes = buildNodes([
|
||||
building('building_a', ['level_0', 'level_1', 'level_2']),
|
||||
level('level_0', 0, { parentId: 'building_a' }),
|
||||
level('level_1', 1, { height: 3, parentId: 'building_a' }),
|
||||
level('level_2', 2, { parentId: 'building_a' }),
|
||||
])
|
||||
|
||||
const elevations = getLevelElevations(nodes)
|
||||
expect(elevations.get('level_0')?.height).toBe(2.5)
|
||||
expect(elevations.get('level_1')?.baseY).toBe(2.5)
|
||||
expect(elevations.get('level_2')?.baseY).toBe(5.5)
|
||||
expect(elevations.get('level_2')?.height).toBe(2.5)
|
||||
})
|
||||
|
||||
test('resolves buildings via parentId, legacy children membership, and non-building parents', () => {
|
||||
const nodes = buildNodes([
|
||||
// level_direct is not in children; level_site has a non-building parentId.
|
||||
building('building_x', ['level_legacy', 'level_site']),
|
||||
level('level_direct', 0, { height: 3, parentId: 'building_x' }),
|
||||
level('level_legacy', 1, { height: 2.5, parentId: null }),
|
||||
level('level_site', 2, { height: 2.75, parentId: 'site_main' }),
|
||||
])
|
||||
|
||||
const elevations = getLevelElevations(nodes)
|
||||
expect(elevations.get('level_direct')?.buildingId).toBe('building_x')
|
||||
expect(elevations.get('level_legacy')?.buildingId).toBe('building_x')
|
||||
expect(elevations.get('level_site')?.buildingId).toBe('building_x')
|
||||
expect(elevations.get('level_direct')?.baseY).toBe(0)
|
||||
expect(elevations.get('level_legacy')?.baseY).toBe(3)
|
||||
expect(elevations.get('level_site')?.baseY).toBe(5.5)
|
||||
})
|
||||
|
||||
test('levels with no resolvable building share one legacy stack from 0', () => {
|
||||
const nodes = buildNodes([
|
||||
level('level_orphan_1', 1, { height: 3 }),
|
||||
level('level_orphan_0', 0, { height: 2.75 }),
|
||||
])
|
||||
|
||||
const elevations = getLevelElevations(nodes)
|
||||
expect(elevations.get('level_orphan_0')).toEqual({
|
||||
baseY: 0,
|
||||
height: 2.75,
|
||||
buildingId: null,
|
||||
ordinal: 0,
|
||||
})
|
||||
expect(elevations.get('level_orphan_1')?.baseY).toBe(2.75)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLevelAbove', () => {
|
||||
test('returns the next-higher ordinal in the same building, skipping ordinal gaps', () => {
|
||||
const nodes = buildNodes([
|
||||
building('building_a', ['level_0', 'level_2', 'level_5']),
|
||||
level('level_0', 0, { parentId: 'building_a' }),
|
||||
level('level_5', 5, { parentId: 'building_a' }),
|
||||
level('level_2', 2, { parentId: 'building_a' }),
|
||||
])
|
||||
|
||||
expect(getLevelAbove('level_0', nodes)?.id).toBe('level_2')
|
||||
expect(getLevelAbove('level_2', nodes)?.id).toBe('level_5')
|
||||
expect(getLevelAbove('level_5', nodes)).toBeNull()
|
||||
})
|
||||
|
||||
test('never crosses into another building', () => {
|
||||
const nodes = buildNodes([
|
||||
building('building_a', ['level_a0']),
|
||||
building('building_b', ['level_b0', 'level_b1']),
|
||||
level('level_a0', 0, { parentId: 'building_a' }),
|
||||
level('level_b0', 0, { parentId: 'building_b' }),
|
||||
level('level_b1', 1, { parentId: 'building_b' }),
|
||||
])
|
||||
|
||||
expect(getLevelAbove('level_a0', nodes)).toBeNull()
|
||||
expect(getLevelAbove('level_b0', nodes)?.id).toBe('level_b1')
|
||||
})
|
||||
|
||||
test('orphan levels resolve within the shared legacy stack', () => {
|
||||
const nodes = buildNodes([
|
||||
level('level_orphan_0', 0, { height: 2.75 }),
|
||||
level('level_orphan_1', 1, { height: 3 }),
|
||||
])
|
||||
|
||||
expect(getLevelAbove('level_orphan_0', nodes)?.id).toBe('level_orphan_1')
|
||||
expect(getLevelAbove('level_orphan_1', nodes)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null for an unknown level id', () => {
|
||||
const nodes = buildNodes([level('level_0', 0)])
|
||||
expect(getLevelAbove('level_missing', nodes)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLevelBelow', () => {
|
||||
test('returns the next-lower ordinal in the same building, skipping ordinal gaps', () => {
|
||||
const nodes = buildNodes([
|
||||
building('building_a', ['level_0', 'level_2', 'level_5']),
|
||||
level('level_0', 0, { parentId: 'building_a' }),
|
||||
level('level_5', 5, { parentId: 'building_a' }),
|
||||
level('level_2', 2, { parentId: 'building_a' }),
|
||||
])
|
||||
|
||||
expect(getLevelBelow('level_5', nodes)?.id).toBe('level_2')
|
||||
expect(getLevelBelow('level_2', nodes)?.id).toBe('level_0')
|
||||
expect(getLevelBelow('level_0', nodes)).toBeNull()
|
||||
})
|
||||
|
||||
test('never crosses into another building', () => {
|
||||
const nodes = buildNodes([
|
||||
building('building_a', ['level_a0']),
|
||||
building('building_b', ['level_b0', 'level_b1']),
|
||||
level('level_a0', 0, { parentId: 'building_a' }),
|
||||
level('level_b0', 0, { parentId: 'building_b' }),
|
||||
level('level_b1', 1, { parentId: 'building_b' }),
|
||||
])
|
||||
|
||||
expect(getLevelBelow('level_a0', nodes)).toBeNull()
|
||||
expect(getLevelBelow('level_b1', nodes)?.id).toBe('level_b0')
|
||||
})
|
||||
|
||||
test('returns null for an unknown level id', () => {
|
||||
const nodes = buildNodes([level('level_0', 0)])
|
||||
expect(getLevelBelow('level_missing', nodes)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Two stacked levels in one building; `slabs` become children of the level
|
||||
// above the queried one.
|
||||
const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5) =>
|
||||
buildNodes([
|
||||
building('building_a', ['level_0', 'level_1']),
|
||||
level('level_0', 0, { height: queriedHeight, parentId: 'building_a' }),
|
||||
level('level_1', 1, {
|
||||
height: 2.5,
|
||||
parentId: 'building_a',
|
||||
children: slabs.map((node) => node.id),
|
||||
}),
|
||||
...slabs,
|
||||
])
|
||||
|
||||
describe('getCoveringSlabUndersideAt', () => {
|
||||
test('expresses a flush deck underside in the queried level local Y', () => {
|
||||
// Flush deck occupying [-0.3, 0] above the plane: underside sits at
|
||||
// storeyHeight + (0 - 0.3) = 2.2 over the queried level's floor.
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
|
||||
})
|
||||
|
||||
test('returns null outside the slab polygon', () => {
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
expect(getCoveringSlabUndersideAt('level_0', nodes, 10, 10)).toBeNull()
|
||||
})
|
||||
|
||||
test('a hole in the slab vetoes coverage', () => {
|
||||
const nodes = stackedNodes([
|
||||
slabNode('slab_deck', {
|
||||
elevation: 0,
|
||||
thickness: 0.3,
|
||||
holes: [
|
||||
[
|
||||
[1, 1],
|
||||
[3, 1],
|
||||
[3, 3],
|
||||
[1, 3],
|
||||
],
|
||||
],
|
||||
}),
|
||||
])
|
||||
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull()
|
||||
expect(getCoveringSlabUndersideAt('level_0', nodes, 0.5, 0.5)).toBeCloseTo(2.2)
|
||||
})
|
||||
|
||||
test('recessed pools never cover', () => {
|
||||
const nodes = stackedNodes([
|
||||
slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }),
|
||||
])
|
||||
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull()
|
||||
})
|
||||
|
||||
test('the lowest underside wins among overlapping covering slabs', () => {
|
||||
const nodes = stackedNodes([
|
||||
// Default floor slab occupying [0, 0.05]: underside at the plane (2.5).
|
||||
slabNode('slab_floor', {}),
|
||||
slabNode('slab_deck', { elevation: 0, thickness: 0.3 }),
|
||||
])
|
||||
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
|
||||
})
|
||||
|
||||
test('returns null when there is no level above', () => {
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
expect(getCoveringSlabUndersideAt('level_1', nodes, 2, 2)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWallPlaneTop', () => {
|
||||
const wallAt = (
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
): { start: [number, number]; end: [number, number] } => ({ start, end })
|
||||
|
||||
test('no covering slab → the stored level height', () => {
|
||||
const nodes = stackedNodes([], 3)
|
||||
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(3)
|
||||
})
|
||||
|
||||
test('a flush thick deck above clamps the plane to its underside', () => {
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
|
||||
})
|
||||
|
||||
test('a slab covering only part of the span clamps via the min of the samples', () => {
|
||||
// Deck over x ∈ [3.5, 6]: start (0,2) and chord midpoint (2,2) miss it,
|
||||
// only the end sample (4,2) lands inside — the min still clamps.
|
||||
const nodes = stackedNodes([
|
||||
slabNode('slab_deck', {
|
||||
polygon: [
|
||||
[3.5, 0],
|
||||
[6, 0],
|
||||
[6, 4],
|
||||
[3.5, 4],
|
||||
],
|
||||
elevation: 0,
|
||||
thickness: 0.3,
|
||||
}),
|
||||
])
|
||||
expect(getWallPlaneTop(wallAt([0, 2], [4, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
|
||||
})
|
||||
|
||||
test('a recessed slab above is ignored', () => {
|
||||
const nodes = stackedNodes([
|
||||
slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }),
|
||||
])
|
||||
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(2.5)
|
||||
})
|
||||
|
||||
test('falls back to the default height when the level does not resolve', () => {
|
||||
const nodes = stackedNodes([])
|
||||
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_missing', nodes)).toBe(
|
||||
DEFAULT_LEVEL_HEIGHT,
|
||||
)
|
||||
})
|
||||
|
||||
test('repro project: both boundary walls clamp to the covering slab underside', () => {
|
||||
// Real scene subset (project_O1z9NLOylyb5kFX4): the level-1 auto slab's
|
||||
// polygon derives from the level-0 wall CENTERLINES, so every perimeter
|
||||
// wall's samples sit exactly ON the polygon boundary. Wall 2 (min-x edge)
|
||||
// clamped while Wall 1 (max-z edge) ran full height — ray-cast
|
||||
// pointInPolygon includes min-side boundaries and excludes max-side ones.
|
||||
const nodes = reproFixture as unknown as Record<AnyNodeId, AnyNode>
|
||||
const levelId = 'level_pomuk0sbwec15mf3'
|
||||
const wall1 = nodes['wall_39bnnq29h824ryy0' as AnyNodeId] as WallNode
|
||||
const wall2 = nodes['wall_on4rj410n69n3rzf' as AnyNodeId] as WallNode
|
||||
// storeyHeight 2.7 + (slab elevation 0.19757… - thickness 0.5)
|
||||
const underside = 2.7 + (0.19757210573188194 - 0.5)
|
||||
expect(getWallPlaneTop(wall1, levelId, nodes)).toBeCloseTo(underside)
|
||||
expect(getWallPlaneTop(wall2, levelId, nodes)).toBeCloseTo(underside)
|
||||
})
|
||||
|
||||
test('all four rectangle walls under a same-footprint covering slab clamp', () => {
|
||||
// The repro shape distilled: wall centerlines lie exactly on the covering
|
||||
// slab's polygon edges. Every orientation must clamp identically.
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
const walls: Array<[[number, number], [number, number]]> = [
|
||||
[
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
],
|
||||
[
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
],
|
||||
[
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
],
|
||||
[
|
||||
[0, 4],
|
||||
[0, 0],
|
||||
],
|
||||
]
|
||||
for (const [start, end] of walls) {
|
||||
expect(getWallPlaneTop(wallAt(start, end), 'level_0', nodes)).toBeCloseTo(2.2)
|
||||
}
|
||||
})
|
||||
|
||||
test('a diagonal wall under the covering slab clamps', () => {
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
expect(getWallPlaneTop(wallAt([0.5, 0.5], [3.5, 3.5]), 'level_0', nodes)).toBeCloseTo(2.2)
|
||||
})
|
||||
|
||||
test('a wall fully outside the covering slab keeps the storey height', () => {
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
expect(getWallPlaneTop(wallAt([6, 0], [6, 4]), 'level_0', nodes)).toBe(2.5)
|
||||
})
|
||||
|
||||
test('a wall partially overlapping the covering slab clamps', () => {
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
expect(getWallPlaneTop(wallAt([2, 2], [8, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCeilingClampBound', () => {
|
||||
const ceilingPolygon: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
]
|
||||
|
||||
test('with no covering slab the bound is the storey plane minus the margin', () => {
|
||||
const nodes = stackedNodes([])
|
||||
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
|
||||
2.5 - CEILING_CLAMP_MARGIN,
|
||||
)
|
||||
})
|
||||
|
||||
test('a covering deck lowers the bound to its underside minus the margin', () => {
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
|
||||
2.2 - CEILING_CLAMP_MARGIN,
|
||||
)
|
||||
})
|
||||
|
||||
test('a slab covering only the interior is caught by the centroid sample', () => {
|
||||
// Deck hovers over the middle of the ceiling — every vertex sample
|
||||
// misses, only the centroid (2, 2) lands inside it.
|
||||
const nodes = stackedNodes([
|
||||
slabNode('slab_deck', {
|
||||
polygon: [
|
||||
[1.5, 1.5],
|
||||
[2.5, 1.5],
|
||||
[2.5, 2.5],
|
||||
[1.5, 2.5],
|
||||
],
|
||||
elevation: 0,
|
||||
thickness: 0.3,
|
||||
}),
|
||||
])
|
||||
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
|
||||
2.2 - CEILING_CLAMP_MARGIN,
|
||||
)
|
||||
})
|
||||
|
||||
test('returns Infinity for an unresolvable level', () => {
|
||||
const nodes = stackedNodes([])
|
||||
expect(getCeilingClampBound('level_missing', nodes, ceilingPolygon)).toBe(
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
})
|
||||
|
||||
test('vertices on the covering slab boundary clamp identically on every side', () => {
|
||||
// Two mirrored strips share an edge with the 4x4 deck: one along its
|
||||
// min-z edge, one along its max-z edge. Their interiors and centroids sit
|
||||
// outside the deck, so only the shared-edge vertices can register —
|
||||
// ray-cast pointInPolygon used to admit the min-side vertices and reject
|
||||
// the max-side ones, giving orientation-dependent clamps.
|
||||
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
|
||||
const minSideStrip: Array<[number, number]> = [
|
||||
[0, -1],
|
||||
[4, -1],
|
||||
[4, 0],
|
||||
[0, 0],
|
||||
]
|
||||
const maxSideStrip: Array<[number, number]> = [
|
||||
[0, 4],
|
||||
[4, 4],
|
||||
[4, 5],
|
||||
[0, 5],
|
||||
]
|
||||
expect(getCeilingClampBound('level_0', nodes, minSideStrip)).toBeCloseTo(
|
||||
2.2 - CEILING_CLAMP_MARGIN,
|
||||
)
|
||||
expect(getCeilingClampBound('level_0', nodes, maxSideStrip)).toBeCloseTo(
|
||||
2.2 - CEILING_CLAMP_MARGIN,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
import type { BuildingNode, LevelNode, SlabNode, WallNode } from '../schema'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import {
|
||||
pointInPolygon,
|
||||
pointOnPolygonBoundary,
|
||||
wallOverlapsSlabFootprint,
|
||||
} from '../systems/slab/slab-support'
|
||||
import { DEFAULT_LEVEL_HEIGHT } from './level-height'
|
||||
|
||||
/**
|
||||
* Gap kept between a ceiling's stored height and its clamp bound (storey
|
||||
* plane or covering-slab underside), so the ceiling surface never
|
||||
* coincides with the solid above it.
|
||||
*/
|
||||
export const CEILING_CLAMP_MARGIN = 0.01
|
||||
|
||||
/**
|
||||
* Stored storey height in meters (floor-to-floor). Falls back to
|
||||
* {@link DEFAULT_LEVEL_HEIGHT} for unmigrated legacy levels whose `height`
|
||||
* field is absent.
|
||||
*/
|
||||
export function getStoredLevelHeight(level: Pick<LevelNode, 'height'>): number {
|
||||
return level.height ?? DEFAULT_LEVEL_HEIGHT
|
||||
}
|
||||
|
||||
export type LevelElevation = {
|
||||
/** World Y of the level's floor: prefix sum of the storey heights below it. */
|
||||
baseY: number
|
||||
/** Stored storey height of this level (fallback applied). */
|
||||
height: number
|
||||
buildingId: string | null
|
||||
ordinal: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the owning building: explicit `parentId` pointing at a building
|
||||
* wins; legacy levels that only appear in a building's `children` array
|
||||
* resolve through that membership.
|
||||
*/
|
||||
function resolveLevelBuildingId(
|
||||
levelId: LevelNode['id'],
|
||||
parentId: string | null,
|
||||
buildings: readonly BuildingNode[],
|
||||
): string | null {
|
||||
const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined
|
||||
if (directParent) return directParent.id
|
||||
|
||||
return buildings.find((building) => building.children.includes(levelId))?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-building stacked elevations from stored storey heights: levels are
|
||||
* sorted by ordinal ascending within each building, the lowest level's floor
|
||||
* sits at 0, and each next floor sits on top of the previous storey height.
|
||||
* Levels with no resolvable building share one legacy stack from 0.
|
||||
*
|
||||
* Pure — operates on the serialized nodes record only.
|
||||
*/
|
||||
export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<string, LevelElevation> {
|
||||
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<string, LevelElevation>()
|
||||
const cumulativeYByBuilding = new Map<string | null, number>()
|
||||
for (const entry of entries.sort((a, b) => a.ordinal - b.ordinal)) {
|
||||
const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0
|
||||
elevations.set(entry.levelId, {
|
||||
baseY,
|
||||
height: entry.height,
|
||||
buildingId: entry.buildingId,
|
||||
ordinal: entry.ordinal,
|
||||
})
|
||||
cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height)
|
||||
}
|
||||
|
||||
return elevations
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the level directly above `levelId` in its own stack (same
|
||||
* resolved building, or the shared legacy stack for building-less levels):
|
||||
* the level with the lowest ordinal strictly greater than the queried
|
||||
* level's. `null` when the level is topmost or unresolvable.
|
||||
*/
|
||||
export function findLevelAboveId(
|
||||
levelId: string,
|
||||
elevations: Map<string, LevelElevation>,
|
||||
): 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<AnyNodeId, AnyNode>,
|
||||
): LevelNode | null {
|
||||
const aboveId = findLevelAboveId(levelId, getLevelElevations(nodes))
|
||||
if (!aboveId) return null
|
||||
const above = nodes[aboveId as LevelNode['id']]
|
||||
return above?.type === 'level' ? (above as LevelNode) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the level directly below `levelId` in its own stack — mirror of
|
||||
* {@link findLevelAboveId}: the level with the highest ordinal strictly less
|
||||
* than the queried level's. `null` when the level is lowest or unresolvable.
|
||||
*/
|
||||
export function findLevelBelowId(
|
||||
levelId: string,
|
||||
elevations: Map<string, LevelElevation>,
|
||||
): string | null {
|
||||
const entry = elevations.get(levelId)
|
||||
if (!entry) return null
|
||||
|
||||
let belowId: string | null = null
|
||||
let belowOrdinal = Number.NEGATIVE_INFINITY
|
||||
for (const [candidateId, candidate] of elevations) {
|
||||
if (candidateId === levelId) continue
|
||||
if (candidate.buildingId !== entry.buildingId) continue
|
||||
if (candidate.ordinal < entry.ordinal && candidate.ordinal > belowOrdinal) {
|
||||
belowOrdinal = candidate.ordinal
|
||||
belowId = candidateId
|
||||
}
|
||||
}
|
||||
return belowId
|
||||
}
|
||||
|
||||
/**
|
||||
* The level directly below `levelId` — see {@link findLevelBelowId}.
|
||||
* `null` when lowest or unresolvable. Pure.
|
||||
*/
|
||||
export function getLevelBelow(
|
||||
levelId: string,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): LevelNode | null {
|
||||
const belowId = findLevelBelowId(levelId, getLevelElevations(nodes))
|
||||
if (!belowId) return null
|
||||
const below = nodes[belowId as LevelNode['id']]
|
||||
return below?.type === 'level' ? (below as LevelNode) : null
|
||||
}
|
||||
|
||||
type CoveringSlabContext = {
|
||||
/** Stored storey height of the QUERIED level. */
|
||||
storeyHeight: number
|
||||
/** Non-recessed slab children of the level above. */
|
||||
slabs: SlabNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Storey height of the queried level plus the level-above's covering
|
||||
* (non-recessed) slabs. `null` when `levelId` doesn't resolve to a level.
|
||||
* A missing level above yields an empty slab list, not `null` — the
|
||||
* storey height is still meaningful for the clamp bound.
|
||||
*/
|
||||
function resolveCoveringSlabContext(
|
||||
levelId: string,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): CoveringSlabContext | null {
|
||||
const level = nodes[levelId as LevelNode['id']]
|
||||
if (level?.type !== 'level') return null
|
||||
|
||||
const above = getLevelAbove(levelId, nodes)
|
||||
const slabs: SlabNode[] = []
|
||||
for (const childId of above?.children ?? []) {
|
||||
const child = nodes[childId as keyof typeof nodes]
|
||||
if (child?.type !== 'slab') continue
|
||||
const slab = child as SlabNode
|
||||
// Recessed slabs (pools) are open shells, not covering solids.
|
||||
if (slab.recessed === true) continue
|
||||
if (slab.polygon.length < 3) continue
|
||||
slabs.push(slab)
|
||||
}
|
||||
|
||||
return { storeyHeight: getStoredLevelHeight(level as LevelNode), slabs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Underside of `slab`'s solid in the QUERIED level's local Y. The solid
|
||||
* occupies `[elevation - thickness, elevation]` in ITS level's local Y,
|
||||
* which sits `storeyHeight` above the queried level's floor.
|
||||
*/
|
||||
function coveringUndersideY(storeyHeight: number, slab: SlabNode): number {
|
||||
return storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `slab`'s stored footprint (polygon minus holes) covers `[x, z]`.
|
||||
* Ray-cast pointInPolygon flips arbitrarily for points exactly ON the
|
||||
* boundary (min-side edges read inside, max-side edges outside), so
|
||||
* boundary contact counts as covered explicitly — the same convention as
|
||||
* the slab-support interval classification. A point on a hole's rim keeps
|
||||
* coverage (mirrors the support election's hole handling).
|
||||
*
|
||||
* Raw stored polygon + holes on purpose (mirrors getCeilingAt): the
|
||||
* clamp bound doesn't need the rendered footprint's junction trims,
|
||||
* and staying off the render path keeps this query cheap and pure.
|
||||
*/
|
||||
function slabCoversPoint(slab: SlabNode, x: number, z: number): boolean {
|
||||
if (!pointInPolygon(x, z, slab.polygon) && !pointOnPolygonBoundary(x, z, slab.polygon)) {
|
||||
return false
|
||||
}
|
||||
for (const hole of slab.holes ?? []) {
|
||||
if (hole.length < 3) continue
|
||||
if (pointInPolygon(x, z, hole) && !pointOnPolygonBoundary(x, z, hole)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowest underside among `slabs` covering `[x, z]`, in the queried
|
||||
* level's local Y, or `null` when none covers the point.
|
||||
*/
|
||||
function lowestCoveringUndersideAt(
|
||||
context: CoveringSlabContext,
|
||||
x: number,
|
||||
z: number,
|
||||
): number | null {
|
||||
let lowest: number | null = null
|
||||
for (const slab of context.slabs) {
|
||||
if (!slabCoversPoint(slab, x, z)) continue
|
||||
const underside = coveringUndersideY(context.storeyHeight, slab)
|
||||
if (lowest === null || underside < lowest) lowest = underside
|
||||
}
|
||||
return lowest
|
||||
}
|
||||
|
||||
/**
|
||||
* Underside of the LOWEST slab from the level above that covers
|
||||
* level-local point `[x, z]`, expressed in the queried level's local Y:
|
||||
* `storeyHeight + (slab.elevation - slab.thickness)`. `recessed` slabs
|
||||
* (pools) never cover. `null` when no covering slab (or no level above).
|
||||
*
|
||||
* Coordinate spaces: levels stack in Y only (`LevelNode` carries no XZ
|
||||
* transform and the viewer's LevelSystem writes only `position.y`), so a
|
||||
* level-local `[x, z]` is valid in every level of the stack unchanged.
|
||||
*/
|
||||
export function getCoveringSlabUndersideAt(
|
||||
levelId: string,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
x: number,
|
||||
z: number,
|
||||
): number | null {
|
||||
const context = resolveCoveringSlabContext(levelId, nodes)
|
||||
if (!context) return null
|
||||
return lowestCoveringUndersideAt(context, x, z)
|
||||
}
|
||||
|
||||
/**
|
||||
* Top plane for a plane-bound wall on `levelId`, in level-local Y:
|
||||
* `min(stored storey height, lowest covering-slab underside over the wall's
|
||||
* span)` — a thick or flush slab on the level above SHORTENS the walls below
|
||||
* instead of colliding with them (Revit-style automatic attach).
|
||||
*
|
||||
* Coverage: the wall's thickness band (centerline + face lines, arc-aware)
|
||||
* is clipped against each covering slab's stored polygon minus holes via
|
||||
* {@link wallOverlapsSlabFootprint} — the same overlap machinery as the
|
||||
* support election. Point sampling is deliberately avoided: auto-slab
|
||||
* polygons derive from wall CENTERLINES, so perimeter walls sit exactly ON
|
||||
* the polygon boundary, where ray-cast point-in-polygon flips with the
|
||||
* edge's orientation (one wall clamped, its neighbor didn't). Boundary
|
||||
* contact counts as covered on every side of the slab.
|
||||
*
|
||||
* This is THE plane for a plane-bound wall (`height` absent). Explicit-height
|
||||
* walls ignore the value (`resolveWallTop` returns their stored height), so
|
||||
* passing it wherever a raw storey height feeds `resolveWallTop` /
|
||||
* `resolveWallEffectiveHeight` is always safe. Falls back to
|
||||
* {@link DEFAULT_LEVEL_HEIGHT} when `levelId` doesn't resolve to a level.
|
||||
*/
|
||||
export function getWallPlaneTop(
|
||||
wall: Pick<WallNode, 'start' | 'end'> & Partial<Pick<WallNode, 'thickness' | 'curveOffset'>>,
|
||||
levelId: string,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): number {
|
||||
const context = resolveCoveringSlabContext(levelId, nodes)
|
||||
if (!context) return DEFAULT_LEVEL_HEIGHT
|
||||
|
||||
let plane = context.storeyHeight
|
||||
for (const slab of context.slabs) {
|
||||
const underside = coveringUndersideY(context.storeyHeight, slab)
|
||||
if (underside >= plane) continue
|
||||
if (!wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) continue
|
||||
plane = underside
|
||||
}
|
||||
return plane
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound for a ceiling's stored height over `polygon` on `levelId`:
|
||||
* `min(storey plane, lowest covering-slab underside) - CEILING_CLAMP_MARGIN`.
|
||||
* The covering underside is sampled at every polygon vertex plus the
|
||||
* centroid — cheap, and a slab overlapping a convex-ish ceiling almost
|
||||
* always covers one of those points; exact polygon-vs-polygon overlap is
|
||||
* not worth its cost for a clamp bound. Ceiling outlines share footprint
|
||||
* edges with the slabs above them the same way walls do, so vertices
|
||||
* sitting exactly on a slab's boundary count as covered on every side
|
||||
* (see `slabCoversPoint`) instead of flipping with the edge orientation.
|
||||
*
|
||||
* Returns `Infinity` when `levelId` doesn't resolve, so callers clamp
|
||||
* against nothing rather than a garbage plane.
|
||||
*/
|
||||
export function getCeilingClampBound(
|
||||
levelId: string,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user