Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
100 changes: 100 additions & 0 deletions packages/core/src/utils/vertical-scene-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, test } from 'bun:test'
import { migrateVerticalSceneNodes } from './vertical-scene-migration'

type RawNode = Record<string, unknown>

function baseNode(id: string, type: string, parentId: string | null, extra: RawNode = {}): RawNode {
return { object: 'node', id, type, parentId, visible: true, metadata: {}, ...extra }
}

/**
* A canonical (already-migrated) flat scene: level carries `height`, slab
* carries `thickness` — so only the ground-pin heal can report a change.
*/
function flatScene(wallExtra: RawNode, slabExtra: RawNode | null, siteExtra: RawNode = {}) {
const nodes: Record<string, RawNode> = {
site_a: baseNode('site_a', 'site', null, { children: ['building_a'], ...siteExtra }),
building_a: baseNode('building_a', 'building', 'site_a', {
children: ['level_a'],
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
level_a: baseNode('level_a', 'level', 'building_a', {
level: 0,
height: 3,
children: ['wall_a', ...(slabExtra ? ['slab_a'] : [])],
}),
wall_a: baseNode('wall_a', 'wall', 'level_a', {
start: [0, 0],
end: [4, 0],
children: [],
...wallExtra,
}),
}
if (slabExtra) {
nodes.slab_a = baseNode('slab_a', 'slab', 'level_a', {
polygon: [
[-1, -1],
[5, -1],
[5, 1],
[-1, 1],
],
holes: [],
...slabExtra,
})
}
return nodes
}

describe('ground-pin heal', () => {
test('strips a ground pin (and draft offset) from a wall buried in a floor slab', () => {
const result = migrateVerticalSceneNodes(
flatScene(
{ height: 3, supportSlabId: 'ground', supportOffset: 0.0000005 },
{ elevation: 0.15, thickness: 0.15 },
),
)
expect(result.changed).toBe(true)
const wall = result.nodes.wall_a as RawNode
expect('supportSlabId' in wall).toBe(false)
expect('supportOffset' in wall).toBe(false)
expect(wall.height).toBe(3)
})

test('keeps the pin when the elected slab is a deck hovering above the base', () => {
const result = migrateVerticalSceneNodes(
flatScene({ height: 3, supportSlabId: 'ground' }, { elevation: 2.2, thickness: 0.15 }),
)
expect(result.changed).toBe(false)
expect((result.nodes.wall_a as RawNode).supportSlabId).toBe('ground')
})

test('keeps the pin when no slab supports the wall', () => {
const result = migrateVerticalSceneNodes(
flatScene({ height: 3, supportSlabId: 'ground' }, null),
)
expect(result.changed).toBe(false)
expect((result.nodes.wall_a as RawNode).supportSlabId).toBe('ground')
})

test('keeps the pin when the site carries sculpted terrain', () => {
const result = migrateVerticalSceneNodes(
flatScene(
{ height: 3, supportSlabId: 'ground' },
{ elevation: 0.15, thickness: 0.15 },
{ terrain: { encoded: 'opaque' } },
),
)
expect(result.changed).toBe(false)
expect((result.nodes.wall_a as RawNode).supportSlabId).toBe('ground')
})

test('is idempotent', () => {
const first = migrateVerticalSceneNodes(
flatScene({ height: 3, supportSlabId: 'ground' }, { elevation: 0.15, thickness: 0.15 }),
)
expect(first.changed).toBe(true)
const second = migrateVerticalSceneNodes(first.nodes)
expect(second.changed).toBe(false)
})
})
51 changes: 51 additions & 0 deletions packages/core/src/utils/vertical-scene-migration.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/support-host-id'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { deriveLegacyLevelHeight } from '../services/level-height'
import { getCeilingClampBound } from '../services/storey'
Expand All @@ -24,6 +25,12 @@ function getStringArray(value: unknown) {
// follows-mode. The strict comparison preserves intentional 0.20-short walls.
const PLANE_BOUND_EPSILON = 0.2

// A ground-pinned wall counts as buried only when the elected slab's occupied
// interval strictly straddles the pinned base. The tolerance absorbs float
// drift in stamped offsets without capturing a slab that merely touches the
// base from above.
const BURIED_PIN_EPSILON = 1e-3

/**
* Applies the vertical-model load migration to serialized scene nodes.
*
Expand Down Expand Up @@ -163,5 +170,49 @@ export function migrateVerticalSceneNodes(
}
}

// Terrain-sculpt-era drafting stamped `supportSlabId: 'ground'` onto walls
// drawn in 3D on flat scenes. The pin short-circuits slab election, so a
// wall whose feet sit inside a floor slab keeps its base at the level floor
// — buried in the slab, z-fighting its side faces — while the panel shows
// the base as automatic. Heal the pin only in that buried state: a deck
// hovering above an intentionally grounded wall must keep its pin (dropping
// it would lift the wall onto the deck). Scenes with sculpted terrain are
// skipped wholesale — a ground host is load-bearing there, and the live
// terrain field isn't visible to this pure pass.
const hasSculptedTerrain = Object.values(nodes).some(
(node) => node?.type === 'site' && node.terrain != null,
)
if (!hasSculptedTerrain) {
for (const [id, node] of Object.entries(nodes)) {
if (node?.type !== 'wall' || node.supportSlabId !== GROUND_SUPPORT_ID) continue
const levelId = typeof node.parentId === 'string' ? node.parentId : null
if (!levelId || nodes[levelId]?.type !== 'level') continue
const siblings = Object.values(nodes).filter(
(sibling) => sibling != null && sibling.parentId === levelId,
)
const support = computeWallSlabSupport(
{
start: node.start,
end: node.end,
curveOffset: node.curveOffset,
thickness: node.thickness,
},
siblings.filter((sibling) => sibling.type === 'slab'),
siblings.filter((sibling) => sibling.type === 'wall'),
)
const elected = support.electedSlabId ? nodes[support.electedSlabId] : null
if (!elected) continue
const pinnedBase = getFiniteNumber(node.supportOffset, 0)
const electedTop = getFiniteNumber(elected.elevation, 0.05)
const electedBottom = electedTop - getFiniteNumber(elected.thickness, 0.05)
const buried =
electedBottom <= pinnedBase + BURIED_PIN_EPSILON &&
electedTop > pinnedBase + BURIED_PIN_EPSILON
if (!buried) continue
const { supportSlabId: _host, supportOffset: _offset, ...healed } = node
replaceNode(id, healed)
}
}

return changed ? { changed, nodes } : { changed, nodes: sourceNodes }
}
127 changes: 126 additions & 1 deletion packages/editor/src/components/tools/wall/wall-drafting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,66 @@ function seedLevel(walls: WallNode[], extraNodes: AnyNode[] = []) {
} as never)
}

// Seeds a site/building/level chain whose terrain field carries a sculpted
// patch raised to `liftTo` in the far corner — enough for ground drafts to be
// terrain chains, while the walls under test stand where the ground is 0.
function seedTerrainLevel(walls: WallNode[], liftTo: number) {
const field = createTerrainField({ cols: 9, rows: 9, spacing: 1, origin: [-4, -4] })
const patch = flattenPatch(field, { maxX: -2, maxZ: -2, minX: -4, minZ: -4 }, liftTo)
if (!patch) throw new Error('Expected terrain patch')
const terrain = applyHeightPatch(field, patch)
useScene.setState({
nodes: Object.fromEntries([
[
'site_test',
{
id: 'site_test',
type: 'site',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: ['building_test'],
terrain: encodeTerrainField(terrain),
} as unknown as AnyNode,
],
[
'building_test',
{
id: 'building_test',
type: 'building',
object: 'node',
parentId: 'site_test',
visible: true,
metadata: {},
children: [LEVEL_ID],
position: [0, 0, 0],
rotation: [0, 0, 0],
} as AnyNode,
],
[
LEVEL_ID,
{
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: 'building_test',
visible: true,
metadata: {},
children: walls.map((wall) => wall.id),
level: 0,
baseElevation: 0,
height: 2.5,
} as AnyNode,
],
...walls.map((wall) => [wall.id, wall] as const),
]),
rootNodeIds: ['site_test' as AnyNodeId],
dirtyNodes: new Set(),
collections: {},
} as never)
}

function levelWalls(): WallNode[] {
return Object.values(useScene.getState().nodes).filter(
(node): node is WallNode => node?.type === 'wall',
Expand Down Expand Up @@ -112,7 +172,9 @@ describe('createWallOnCurrentLevel', () => {
expect(levelWalls()).toHaveLength(2)
})

test('committed wall preserves the ghost construction elevation on ground', () => {
test('committed wall preserves the ghost construction elevation on terrain', () => {
seedTerrainLevel([makeWall([0, 0], [4, 0], 'wall_a')], 1.75)

const created = createWallOnCurrentLevel([2, 2], [3, 2], {
supportCap: 1.75,
preferredSupportSlabId: GROUND_SUPPORT_ID,
Expand All @@ -136,6 +198,69 @@ describe('createWallOnCurrentLevel', () => {
expect(support.elevation).toBe(1.75)
})

test('a flat-ground draft (no sculpted terrain) commits plane-bound', () => {
// Pointing at bare ground freezes a GROUND construction plane at 0. With
// no terrain field in the scene that plane is just the backdrop — none of
// the draft options may reach the committed node: no stamped height, no
// persisted ground host (a slab drawn later must lift the wall), no
// election cap.
const created = createWallOnCurrentLevel([2, 2], [3, 2], {
supportCap: 0,
preferredSupportSlabId: GROUND_SUPPORT_ID,
constructionElevation: 0,
constructionHeight: 2.5,
})

expect(created).not.toBeNull()
expect(created?.height).toBeUndefined()
expect(created?.supportOffset).toBeUndefined()
expect(created?.supportSlabId).toBeUndefined()
})

test('a wall started on a slab stays plane-bound (no stamped height or offset)', () => {
const slab = SlabNode.parse({
id: 'slab_floor',
parentId: LEVEL_ID,
polygon: [
[-1, -1],
[5, -1],
[5, 5],
[-1, 5],
],
elevation: 0.05,
thickness: 0.05,
})
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')], [slab])

// Mirrors the 3D tool's first click on the slab top: frozen plane at the
// slab elevation, ghost drawn at the level height. None of it may reach
// the committed node — plane-bound is the default.
const created = createWallOnCurrentLevel([2, 2], [3, 2], {
supportCap: 0.05,
preferredSupportSlabId: slab.id,
constructionElevation: 0.05,
constructionHeight: 2.55,
})

expect(created).not.toBeNull()
expect(created?.height).toBeUndefined()
expect(created?.supportOffset).toBeUndefined()
expect(created?.supportSlabId).not.toBe(GROUND_SUPPORT_ID)
})

test('a non-ground draft never freezes the ghost height, even at a raised plane', () => {
const created = createWallOnCurrentLevel([2, 2], [3, 2], {
supportCap: 1.2,
preferredSupportSlabId: null,
constructionElevation: 1.2,
constructionHeight: 2.5,
})

expect(created).not.toBeNull()
expect(created?.height).toBeUndefined()
expect(created?.supportOffset).toBeUndefined()
})

test('2D terrain construction options freeze the first-point elevation and wall height', () => {
const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [-2, -2] })
const patch = flattenPatch(field, { minX: -2, minZ: -2, maxX: 2, maxZ: 2 }, 1.5)
Expand Down
41 changes: 30 additions & 11 deletions packages/editor/src/components/tools/wall/wall-drafting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,22 @@ export function createWallOnCurrentLevel(
createdWall.start[0],
createdWall.start[1],
)
// A ground-preferred draft is the terrain exception only while sculpted
// terrain actually supports this storey. On flat ground the ground plane
// is just the backdrop the chain started from: drop the draft options so
// the wall commits plane-bound — no stamped height, no persisted ground
// host, no election cap at the draft plane.
const wallOptions =
options?.preferredSupportSlabId === GROUND_SUPPORT_ID && terrainBase == null
? undefined
: options
const preferredSupportSlabId =
options?.preferredSupportSlabId ??
(options?.constructionElevation != null && terrainBase != null ? GROUND_SUPPORT_ID : null)
wallOptions?.preferredSupportSlabId ??
(wallOptions?.constructionElevation != null && terrainBase != null
? GROUND_SUPPORT_ID
: null)
const supportPatch = resolveWallSupportSlabPatch(createdWall, committedNodes, {
maxElevation: options?.supportCap ?? null,
maxElevation: wallOptions?.supportCap ?? null,
preferredSlabId: preferredSupportSlabId,
})
const supportSlabId = supportPatch.supportSlabId
Expand All @@ -295,24 +306,32 @@ export function createWallOnCurrentLevel(
createdWall.curveOffset,
createdWall.thickness,
supportSlabId,
options?.supportCap ?? null,
wallOptions?.supportCap ?? null,
)
// Freezing the draft plane into the node (explicit height + offset from
// the elected support) is the terrain exception only: a ground-drafted
// chain keeps one construction plane and a fixed body height while
// sculpting moves the ground. Every other wall stays plane-bound —
// height absent, top at the storey plane, base re-elected from slab
// support — so a wall merely started on a slab or deck must never have
// the ghost height stamped onto it.
const groundDraft = preferredSupportSlabId === GROUND_SUPPORT_ID
const supportOffset =
options?.constructionElevation == null
? undefined
: options.constructionElevation - sourceSupport.elevation
groundDraft && wallOptions?.constructionElevation != null
? wallOptions.constructionElevation - sourceSupport.elevation
: undefined
const preserveDraftHeight =
groundDraft &&
createdWall.height == null &&
options?.constructionHeight != null &&
options.constructionElevation != null &&
(terrainBase != null || Math.abs(options.constructionElevation) > 1e-6)
wallOptions?.constructionHeight != null &&
wallOptions.constructionElevation != null
return [
{
id: createdWall.id,
data: {
...supportPatch,
height: preserveDraftHeight
? (options?.constructionHeight ?? createdWall.height)
? (wallOptions?.constructionHeight ?? createdWall.height)
: createdWall.height,
supportOffset:
supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
Expand Down
Loading
Loading