diff --git a/packages/core/src/utils/vertical-scene-migration.test.ts b/packages/core/src/utils/vertical-scene-migration.test.ts new file mode 100644 index 0000000000..7b523112cc --- /dev/null +++ b/packages/core/src/utils/vertical-scene-migration.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test' +import { migrateVerticalSceneNodes } from './vertical-scene-migration' + +type RawNode = Record + +function baseNode(id: string, type: string, parentId: string | null, extra: RawNode = {}): RawNode { + return { object: 'node', id, type, parentId, visible: true, metadata: {}, ...extra } +} + +/** + * 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 = { + 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) + }) +}) diff --git a/packages/core/src/utils/vertical-scene-migration.ts b/packages/core/src/utils/vertical-scene-migration.ts index c872191ea5..ad188f057f 100644 --- a/packages/core/src/utils/vertical-scene-migration.ts +++ b/packages/core/src/utils/vertical-scene-migration.ts @@ -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' @@ -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. * @@ -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 } } diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 50cc93262f..cde6a6a4ff 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -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', @@ -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, @@ -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) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index ea37661225..5e9760ac96 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -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 @@ -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, diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 0841857d8e..f0cf09de4f 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -4,11 +4,13 @@ import { type AnyNode, type AnyNodeId, buildWallFaceBandCountPatch, + GROUND_SUPPORT_ID, getClampedWallCurveOffset, getMaxWallCurveOffset, getWallCurveLength, getWallFaceBandConfig, normalizeWallCurveOffset, + terrainSupportLift, useLiveNodeOverrides, useScene, WALL_CHAIR_RAIL_DEFAULT, @@ -22,6 +24,7 @@ import { ActionButton, ActionGroup, curveReshapeScope, + formatLinearMeasurement, getLinearUnitLabel, linearControlValueToMeters, metersToLinearUnit, @@ -37,6 +40,24 @@ import { Spline } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' +/** + * Base half of the plane-bound repair: a stamped draft offset goes, and a + * ground host is dropped unless sculpted terrain actually supports it — a + * terrain-less ground host (regression-era data) pins the base at the level + * floor and buries the wall in any later slab. + */ +function wallBaseRepairPatch(n: WallNode): Partial { + const nodes = useScene.getState().nodes + const terrainSupported = + n.parentId != null && terrainSupportLift(nodes, n.parentId, n.start[0], n.start[1]) != null + return { + supportOffset: undefined, + ...(n.supportSlabId === GROUND_SUPPORT_ID && !terrainSupported + ? { supportSlabId: undefined } + : {}), + } +} + type WallTrimKey = 'skirting' | 'crown' | 'chairRail' const WALL_TRIM_PROFILE_OPTIONS: Record< @@ -155,15 +176,39 @@ export default function WallPanel() { [handleUpdate], ) - const handleBaseModeChange = useCallback( - (mode: 'terrain' | 'fixed') => { + const handleTopModeChange = useCallback( + (mode: 'storey' | 'custom') => { + const n = nodeRef.current + if (!n) return + const isCustom = n.height != null + if (mode === 'custom' && !isCustom) { + // Seed from the current effective height so the geometry doesn't + // jump at the moment of detaching from the storey plane. + const seeded = resolveWallOpeningCeiling(n, useScene.getState().nodes) + handleUpdate({ height: Math.max(0.1, seeded) }) + } else if (mode === 'storey' && isCustom) { + // Absent `height` = plane-bound; the store strips undefined keys. + handleUpdate({ height: undefined, ...wallBaseRepairPatch(n) }) + } + }, + [handleUpdate], + ) + + // Terrain infill only extends the bottom; it must never materialize an + // explicit height, or toggling it would silently detach the wall top from + // the storey plane. "Auto" is a re-election, so it carries the same base + // repair as the follows-level toggle — and the control fires on a click of + // the already-selected segment, so regression-era walls that DISPLAY Auto + // while secretly ground-pinned heal from a click on Auto itself. + const handleInfillChange = useCallback( + (mode: 'terrain' | 'auto') => { const n = nodeRef.current if (!n) return - const height = n.height ?? resolveWallOpeningCeiling(n, useScene.getState().nodes) - handleUpdate({ - height: Math.max(0.1, height), - fillToTerrain: mode === 'terrain' ? true : undefined, - }) + if (mode === 'terrain') { + handleUpdate({ fillToTerrain: true }) + return + } + handleUpdate({ fillToTerrain: undefined, ...wallBaseRepairPatch(n) }) }, [handleUpdate], ) @@ -184,6 +229,7 @@ export default function WallPanel() { const length = getWallCurveLength(node) const followsTerrain = node.fillToTerrain === true + const isPlaneBound = node.height == null const height = node.height ?? resolvedHeightMeters ?? 2.5 const thickness = node.thickness ?? 0.1 const curveOffset = getClampedWallCurveOffset(node) @@ -223,30 +269,47 @@ export default function WallPanel() { unit={unitLabel} value={displayLength} /> - - handleUpdate({ - height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), - }) - } - precision={2} - step={0.1} - unit={unitLabel} - value={Math.round(displayHeight * 100) / 100} +
+ Top +
+ + {isPlaneBound ? ( +
+ Currently {formatLinearMeasurement(height, unit)} +
+ ) : ( + + handleUpdate({ + height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), + }) + } + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayHeight * 100) / 100} + /> + )}
- Base + Bottom
{followsTerrain && (
diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index b376b81555..184fdec2d6 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -5,6 +5,7 @@ import { collectAlignmentAnchors, DEFAULT_LEVEL_HEIGHT, emitter, + GROUND_SUPPORT_ID, type GridEvent, getWallMiterBoundaryPoints, type LevelNode, @@ -789,14 +790,21 @@ export const WallTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return + // A ground(terrain)-hosted chain keeps its frozen construction plane; + // any other chain re-resolves the aimed surface per commit so a later + // segment can still elect the slab it visibly crosses instead of + // being capped at the first click's elevation. + const draftPlane = constructionPlane.current + const commitPointed = + draftPlane?.supportSlabId === GROUND_SUPPORT_ID ? null : pointedSurfaceFor(event) // Both start and end are building-local ✓ const createdWall = createWallOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, { - supportCap: constructionPlane.current?.elevation ?? null, - preferredSupportSlabId: constructionPlane.current?.supportSlabId ?? null, - constructionElevation: constructionPlane.current?.elevation ?? null, + supportCap: commitPointed ? commitPointed.elevation : (draftPlane?.elevation ?? null), + preferredSupportSlabId: draftPlane?.supportSlabId ?? null, + constructionElevation: draftPlane?.elevation ?? null, constructionHeight: previewHeightRef.current, }, )