Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cecd7e8
feat(core): stored storey heights groundwork — pure slab-support modu…
wass08 Jul 20, 2026
437cea2
feat(editor): storey height badge + edit popover on level rows
wass08 Jul 20, 2026
eb81863
feat(core): vertical-model load migration — stored heights, ordinal c…
wass08 Jul 20, 2026
7fb50f9
feat: pin wall tops to the storey plane; stored heights become the on…
wass08 Jul 20, 2026
03a48c9
feat: clamp slabs/ceilings under the storey plane; sweep wall-height …
wass08 Jul 20, 2026
4ec517a
feat(core): persisted support hosts — schema, host-preferring electio…
wass08 Jul 20, 2026
38fddb5
feat: persist support hosts at commit; thread wall host preference ev…
wass08 Jul 20, 2026
a3de58b
feat: split slab into placement + thickness; pools become explicit re…
wass08 Jul 20, 2026
afb6ad1
feat: clamp ceilings to covering-slab undersides across levels
wass08 Jul 20, 2026
e30042d
feat: mezzanine and balcony build-tab tools
wass08 Jul 20, 2026
2c875c8
feat: adaptive slab vertical editing; level vocabulary in UI copy
wass08 Jul 20, 2026
3495be8
feat: clamp plane-bound wall tops to covering-slab undersides; fix ve…
wass08 Jul 20, 2026
cb583f9
feat: ceilings follow the level top by default
wass08 Jul 20, 2026
cb59cc9
fix: wall plane clamp missed max-side boundary walls
wass08 Jul 20, 2026
7e4f342
feat: stairs attach to decks; pointer-decided placement surface
wass08 Jul 20, 2026
043857b
chore: remove the mezzanine/balcony composite tools
wass08 Jul 20, 2026
edac5c5
feat: gate wall adoption to grounded slabs; panel moves, drag stretch…
wass08 Jul 20, 2026
76d038e
fix: deck-attached stairs land flush with the deck surface
wass08 Jul 20, 2026
dba3ee4
feat: draw walls and fences on elevated decks
wass08 Jul 20, 2026
d4bc201
feat: unit-aware height presets
wass08 Jul 21, 2026
a176e68
feat: stairs converge to their resolved rise; deck attachment disable…
wass08 Jul 21, 2026
6efcac1
fix: stacked-slab move hopping — one ray, one surface, one XZ
wass08 Jul 21, 2026
12907ca
fix: single stair per click; tools restore select mode on exit
wass08 Jul 21, 2026
a6bb29f
feat: compact multi-selection panel with host footer slot
wass08 Jul 21, 2026
a359ecd
Merge origin/main (realtime collaboration previews) into feat/vertica…
wass08 Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
import { type FenceSupportInput, resolveFenceSupportSlabPatch } from './support-host-patch'

const LEVEL_ID = 'level_test'

/** Deck footprint in plan: x/z ∈ [0, 4] × [0, 3]. */
const DECK_POLYGON: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]

/** Ground floor slab under (and far beyond) the deck. */
const GROUND_POLYGON: Array<[number, number]> = [
[-6, -6],
[6, -6],
[6, 6],
[-6, 6],
]

const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05

function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}

function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}

function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}

/** Straight fence fully over the deck footprint. */
function fenceOnDeck(overrides: Partial<FenceSupportInput> = {}): FenceSupportInput {
return {
start: [0.5, 1.5],
end: [3.5, 1.5],
thickness: 0.08,
parentId: LEVEL_ID,
...overrides,
}
}

function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}

function sceneWith(...slabs: SlabNode[]): Record<string, AnyNode> {
const nodes = nodesFor(makeLevel(), ...(slabs as AnyNode[]))
useScene.setState({ nodes })
for (const slab of slabs) addSlab(slab)
return nodes
}

beforeEach(() => {
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})

describe('resolveFenceSupportSlabPatch', () => {
test('a fence drawn over a deck stacked on the floor persists the deck (uncapped max election)', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: 'slab_deck',
})
})

test('the pointer cap decides between stacked surfaces', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
// Aiming at the floor under the deck elects (and persists) the floor.
expect(
resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: FLOOR_ELEVATION }),
).toEqual({ supportSlabId: 'slab_ground' })
// Aiming at the deck top keeps the deck.
expect(
resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: DECK_ELEVATION }),
).toEqual({ supportSlabId: 'slab_deck' })
})

test('a lone elevated deck (balcony, nothing underneath) still persists its host', () => {
// Unambiguous single candidate — but fences resolve an absent host to
// the level floor, so an elevated winner must be written or the fence
// renders buried under the deck.
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: 'slab_deck',
})
})

test('a plain default ground slab stays unpersisted (fence keeps sitting at the level base)', () => {
const nodes = sceneWith(makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: undefined,
})
})

test('capped at bare ground under a deck-only overlap resolves to the floor default', () => {
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: 0 })).toEqual({
supportSlabId: undefined,
})
})

test('no slabs / off-slab fence persists nothing', () => {
const nodes = sceneWith()
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: undefined,
})

const withDeck = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(
resolveFenceSupportSlabPatch(fenceOnDeck({ start: [10, 10], end: [13, 10] }), withDeck),
).toEqual({ supportSlabId: undefined })
})

test('a spline fence elects through its path band segments', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
const spline = fenceOnDeck({
start: [0.5, 0.5],
end: [3.5, 2.5],
path: [
[0.5, 0.5],
[2, 1.5],
[3.5, 2.5],
],
})
expect(resolveFenceSupportSlabPatch(spline, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})

test('a fence not parented to a level persists nothing', () => {
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck({ parentId: 'not_a_level' }), nodes)).toEqual({
supportSlabId: undefined,
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab
holes: [],
holeMetadata: [],
elevation,
thickness: Math.max(elevation, 0),
recessed: elevation < 0,
autoFromWalls: false,
} as SlabNode
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
Expand Down
52 changes: 47 additions & 5 deletions packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,29 @@ import type {
import type { AnyNode, AnyNodeId } from '../../schema'
import { spatialGridManager } from './spatial-grid-manager'

/**
* Sentinel `supportSlabId` meaning "hosted by the level base (ground)".
* Persisted when a pointer-capped commit elects the ground while one or
* more slabs (e.g. an elevated deck) still overlap the footprint above the
* cap — without it, the uncapped per-frame election would lift the
* committed node back onto the deck.
*/
export const GROUND_SUPPORT_ID = 'ground'

export type FloorPlacedElevationArgs = {
node: AnyNode
nodes: Record<string, AnyNode>
position: [number, number, number]
rotation?: unknown
levelId?: string | null
/**
* Pointer-decided support cap (level-local Y): only slabs whose walking
* surface sits at or below `maxElevation + SUPPORT_ELEVATION_EPSILON`
* may be elected, and the persisted `supportSlabId` is bypassed — during
* a drag the pointer, not the stored host, decides the target surface.
* Omit (or pass null) for the uncapped committed-read behavior.
*/
maxElevation?: number | null
}

function finiteSlabElevation(elevation: number): number {
Expand Down Expand Up @@ -50,6 +67,7 @@ export function getFloorPlacedElevation({
position,
rotation,
levelId,
maxElevation,
}: FloorPlacedElevationArgs): number {
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced) return 0
Expand All @@ -66,23 +84,47 @@ export function getFloorPlacedElevation({
const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId
if (!resolvedLevelId) return 0

let maxElevation = Number.NEGATIVE_INFINITY
for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) {
const footprints = getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })

// A persisted support host pins the elevation while it still exists and
// overlaps a footprint — deterministic across stacked slabs. A stale
// host (deleted or reshaped away) silently falls through to the
// election below; this per-frame read path never writes the field.
// Skipped entirely under a pointer cap: the cursor, not the stored
// host, decides the target surface during a drag.
const supportSlabId = (effectiveNode as { supportSlabId?: string | null }).supportSlabId
if (maxElevation == null && supportSlabId) {
if (supportSlabId === GROUND_SUPPORT_ID) return 0
for (const footprint of footprints) {
const hosted = spatialGridManager.getHostSlabElevationForFootprint(
resolvedLevelId,
supportSlabId,
footprint.position ?? position,
footprint.dimensions,
footprint.rotation,
)
if (hosted !== null) return finiteSlabElevation(hosted)
}
}

let elected = Number.NEGATIVE_INFINITY
for (const footprint of footprints) {
const footprintPosition = footprint.position ?? position
const elevation = finiteSlabElevation(
spatialGridManager.getSlabElevationForItem(
resolvedLevelId,
footprintPosition,
footprint.dimensions,
footprint.rotation,
maxElevation,
),
)
if (elevation > maxElevation) {
maxElevation = elevation
if (elevation > elected) {
elected = elevation
}
}

return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
return elected === Number.NEGATIVE_INFINITY ? 0 : elected
}

export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] {
Expand Down
Loading
Loading