From c003610f31a3070a6a57d103f418b9e153cf4996 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 11 Aug 2026 11:03:08 +0200 Subject: [PATCH 1/6] fix(editor): keep non-terrain walls plane-bound on creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terrain drafting flow stamped the ghost's explicit height and a supportOffset onto every wall whose frozen construction plane sat above y=0 — i.e. any wall started on a slab or deck, not just ground-hosted terrain chains. Such walls stopped following the level height and no longer re-elected their base from slab support. Gate the stamping on a ground-drafted plane (the terrain exception), and re-resolve the aimed support surface per commit for non-ground chains so a later segment still elects the slab it visibly crosses instead of being capped at the first click's elevation. Co-Authored-By: Claude Fable 5 --- .../tools/wall/wall-drafting.test.ts | 44 +++++++++++++++++++ .../components/tools/wall/wall-drafting.ts | 18 +++++--- packages/nodes/src/wall/tool.tsx | 14 ++++-- 3 files changed, 68 insertions(+), 8 deletions(-) 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..c5ac7da5af 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -136,6 +136,50 @@ describe('createWallOnCurrentLevel', () => { expect(support.elevation).toBe(1.75) }) + 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..59a966e7e9 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -297,15 +297,23 @@ export function createWallOnCurrentLevel( supportSlabId, options?.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 && options?.constructionElevation != null + ? options.constructionElevation - sourceSupport.elevation + : undefined const preserveDraftHeight = + groundDraft && createdWall.height == null && options?.constructionHeight != null && - options.constructionElevation != null && - (terrainBase != null || Math.abs(options.constructionElevation) > 1e-6) + options.constructionElevation != null return [ { id: createdWall.id, 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, }, ) From 5859567ee581ad532b9da5185f1c91de0daeb2ac Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 11 Aug 2026 12:20:49 +0200 Subject: [PATCH 2/6] feat(nodes): rework wall panel around plane-bound top Top: "Follows level" (no explicit height, shows the resolved height) vs "Custom height" (seeded from the effective height so geometry doesn't jump on detach). Bottom: the terrain infill toggle no longer materializes an explicit height, so toggling it can't silently detach the wall top from the storey plane. Co-Authored-By: Claude Fable 5 --- packages/nodes/src/wall/panel.tsx | 84 ++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 0841857d8e..008ffad883 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -22,6 +22,7 @@ import { ActionButton, ActionGroup, curveReshapeScope, + formatLinearMeasurement, getLinearUnitLabel, linearControlValueToMeters, metersToLinearUnit, @@ -155,15 +156,30 @@ 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 height = n.height ?? resolveWallOpeningCeiling(n, useScene.getState().nodes) - handleUpdate({ - height: Math.max(0.1, height), - fillToTerrain: mode === 'terrain' ? true : undefined, - }) + 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 }) + } + }, + [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. + const handleInfillChange = useCallback( + (mode: 'terrain' | 'auto') => { + handleUpdate({ fillToTerrain: mode === 'terrain' ? true : undefined }) }, [handleUpdate], ) @@ -184,6 +200,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 +240,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 && (
From 5f0c65bded15ec27143a3b5ebed5279b781d5b05 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 11 Aug 2026 12:35:32 +0200 Subject: [PATCH 3/6] fix(editor): keep flat-ground wall drafts plane-bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pointing at bare ground freezes a GROUND construction plane, and the commit path treated every ground-preferred draft as a terrain chain: stamped ghost height, persisted ground host, election capped at the draft plane. On flat ground (no sculpted terrain) all three are wrong — the wall showed "Custom height 2.50" instead of following the level, and a slab drawn later could never lift it, leaving the wall buried in the slab (z-fighting band at the base). Gate the terrain exception on terrainSupportLift(): ground-preferred drafts with no terrain support drop their draft options entirely and commit plane-bound. The terrain-freeze test now seeds a real terrain field; a new regression test pins the flat-ground shape. Co-Authored-By: Claude Fable 5 --- .../tools/wall/wall-drafting.test.ts | 83 ++++++++++++++++++- .../components/tools/wall/wall-drafting.ts | 29 +++++-- 2 files changed, 102 insertions(+), 10 deletions(-) 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 c5ac7da5af..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,25 @@ 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', diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 59a966e7e9..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,7 +306,7 @@ 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 @@ -306,21 +317,21 @@ export function createWallOnCurrentLevel( // the ghost height stamped onto it. const groundDraft = preferredSupportSlabId === GROUND_SUPPORT_ID const supportOffset = - groundDraft && options?.constructionElevation != null - ? 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 + 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, From ad8114275f66d808fc708f99148122ff58726c14 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 11 Aug 2026 12:35:32 +0200 Subject: [PATCH 4/6] fix(nodes): restore full plane-bound defaults from the follows-level toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching Top back to "Follows level" only cleared the stored height, so regression-era walls (stamped ground host + draft offset) kept their base pinned at the level floor, still buried in any slab. The toggle now also drops the draft offset, and drops a ground host when no sculpted terrain supports it — giving existing broken walls a one-click repair. Co-Authored-By: Claude Fable 5 --- packages/nodes/src/wall/panel.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 008ffad883..338f2dcb1a 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, @@ -168,7 +170,21 @@ export default function WallPanel() { handleUpdate({ height: Math.max(0.1, seeded) }) } else if (mode === 'storey' && isCustom) { // Absent `height` = plane-bound; the store strips undefined keys. - handleUpdate({ height: undefined }) + // Restore the full plane-bound defaults: a stamped draft offset goes + // too, 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. + const nodes = useScene.getState().nodes + const terrainSupported = + n.parentId != null && + terrainSupportLift(nodes, n.parentId, n.start[0], n.start[1]) != null + handleUpdate({ + height: undefined, + supportOffset: undefined, + ...(n.supportSlabId === GROUND_SUPPORT_ID && !terrainSupported + ? { supportSlabId: undefined } + : {}), + }) } }, [handleUpdate], From 08c091a624374b77d11a6319edc082da35ff9c79 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 11 Aug 2026 12:47:14 +0200 Subject: [PATCH 5/6] fix(nodes): carry the base repair through the bottom Auto toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follows-level toggle repairs regression-era walls, but walls whose Bottom already displays "Auto" while secretly ground-pinned had no clickable path that kept their custom height — the pin isn't reflected in the control, and the base stayed buried in the slab (z-fighting its side faces). "Auto" now performs the same re-election repair (drop the draft offset, drop a terrain-less ground host), and the segmented control fires on already-selected clicks, so clicking "Auto" itself heals the wall. Co-Authored-By: Claude Fable 5 --- packages/nodes/src/wall/panel.tsx | 47 ++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 338f2dcb1a..f0cf09de4f 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -40,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< @@ -170,21 +188,7 @@ export default function WallPanel() { handleUpdate({ height: Math.max(0.1, seeded) }) } else if (mode === 'storey' && isCustom) { // Absent `height` = plane-bound; the store strips undefined keys. - // Restore the full plane-bound defaults: a stamped draft offset goes - // too, 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. - const nodes = useScene.getState().nodes - const terrainSupported = - n.parentId != null && - terrainSupportLift(nodes, n.parentId, n.start[0], n.start[1]) != null - handleUpdate({ - height: undefined, - supportOffset: undefined, - ...(n.supportSlabId === GROUND_SUPPORT_ID && !terrainSupported - ? { supportSlabId: undefined } - : {}), - }) + handleUpdate({ height: undefined, ...wallBaseRepairPatch(n) }) } }, [handleUpdate], @@ -192,10 +196,19 @@ export default function WallPanel() { // 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. + // 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') => { - handleUpdate({ fillToTerrain: mode === 'terrain' ? true : undefined }) + const n = nodeRef.current + if (!n) return + if (mode === 'terrain') { + handleUpdate({ fillToTerrain: true }) + return + } + handleUpdate({ fillToTerrain: undefined, ...wallBaseRepairPatch(n) }) }, [handleUpdate], ) From 85c92290cf2aaf4f2ae3b48c96d39e8bb58da7c4 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 11 Aug 2026 14:12:08 +0200 Subject: [PATCH 6/6] fix(core): self-heal buried ground pins in the vertical canonicalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression-era walls carry supportSlabId 'ground', which short-circuits slab election: the base stays at the level floor, buried in the room slab and z-fighting its side faces, while the panel truthfully-but- uselessly displays the base as automatic. Users shouldn't have to click anything: the load-time canonicalization now strips the pin (and draft offset) when the un-pinned election would land the wall on a slab its base is currently embedded in. Deliberately narrow so the one legitimate flat-scene ground pin survives: a wall kept on the ground under a hovering deck is not buried (the deck's occupied interval sits above the base) and keeps its pin, and scenes with sculpted terrain are skipped wholesale — the pin is load-bearing there and the live terrain field isn't visible to this pure, authority-shared pass. Co-Authored-By: Claude Fable 5 --- .../utils/vertical-scene-migration.test.ts | 100 ++++++++++++++++++ .../src/utils/vertical-scene-migration.ts | 51 +++++++++ 2 files changed, 151 insertions(+) create mode 100644 packages/core/src/utils/vertical-scene-migration.test.ts 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 } }