diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts index d2b6746252..886f360fe4 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts @@ -1,4 +1,4 @@ -import { terrainSupportLift } from '../../lib/terrain-support' +import { levelBaseElevationAt } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' import type { FloorPlacedConfig, @@ -108,7 +108,7 @@ export function getFloorPlacedElevation({ */ let groundLiftCache: number | null = null const groundLift = (): number => { - groundLiftCache ??= terrainSupportLift(nodes, resolvedLevelId, position[0], position[2]) ?? 0 + groundLiftCache ??= levelBaseElevationAt(nodes, resolvedLevelId, position[0], position[2]) return groundLiftCache } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index f08e3b1ab0..7eb8234dd3 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,5 +1,5 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' -import { terrainSupportLift } from '../../lib/terrain-support' +import { levelBaseElevationAt } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' import type { AnyNode, AnyNodeId, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' @@ -1057,10 +1057,13 @@ export class SpatialGridManager { maxElevation?: number | null, supportOffset = 0, ): WallSlabSupport { + // Sampled at the wall's own start point — the same anchor the mesh is + // positioned at, so the resolver and the renderer cannot disagree about + // where the ground is under this wall. + const levelBase = levelBaseElevationAt(useScene.getState().nodes, levelId, start[0], start[1]) + if (preferredSlabId === GROUND_SUPPORT_ID) { - const nodes = useScene.getState().nodes - const elevation = - (terrainSupportLift(nodes, levelId, start[0], start[1]) ?? 0) + supportOffset + const elevation = levelBase + supportOffset return { elevation, electedSlabId: null, @@ -1071,7 +1074,7 @@ export class SpatialGridManager { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) { - const elevation = supportOffset + const elevation = levelBase + supportOffset return { elevation, electedSlabId: null, @@ -1086,6 +1089,7 @@ export class SpatialGridManager { this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), preferredSlabId, maxElevation, + levelBase, ) if (supportOffset === 0) return support return { diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 4453fdc070..d05d0d68e4 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,5 +1,5 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' -import { isLevelAtSiteDatum } from '../../lib/terrain-support' +import { isLevelAtSiteDatum, isLevelBaseConsumer } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' import type { AnyNode, AnyNodeId, LevelNode, SiteNode, SlabNode, WallNode } from '../../schema' import { getLevelBelow } from '../../services/storey' @@ -352,7 +352,19 @@ export function markTerrainSupportDependents( } const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced - if (!floorPlaced) continue + if (!floorPlaced) { + // A kind whose geometry builder resolved its own origin from the ground + // (`ctx.levelBaseAt`) has that ground baked into its meshes, so it has to + // rebuild even though nothing about the node changed. This is the + // invalidation half of the builder seam: without it a fence keeps the + // hillside it was built on and floats after the next stroke. Kinds that + // are `floorPlaced` need no entry here — the sweep below already covers + // them, through a mesh transform rather than a rebuild. + if (isLevelBaseConsumer(node.type) && isGrade(resolveLevelId(node, nodes))) { + markDirty(node.id) + } + continue + } if (floorPlaced.applies && !floorPlaced.applies(node)) continue // Items hosted on a shelf or table inherit Y from the parent group; only // level-parented nodes read the ground. Mirrors the resolver's own gate. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25526728e9..3c88db3ca8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -190,7 +190,10 @@ export { export { raycastTerrain, type TerrainHit } from './lib/terrain-raycast' export { commitTerrainField, terrainFieldForEdit, terrainFieldOf } from './lib/terrain-source' export { + isLevelBaseConsumer, isSiteDatum, + levelBaseElevationAt, + noteLevelBaseConsumer, SITE_DATUM_EPSILON, SITE_DATUM_Y, terrainSupportLift, diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 97a6462419..d667bf8bc9 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -12,6 +12,8 @@ import { resolveAutoZonePolygon, wallClosesRoom, } from './space-detection' +import { encodeTerrainField } from './terrain-codec' +import { applyHeightPatch, createTerrainField, flattenPatch } from './terrain-field' const square: Array<[number, number]> = [ [0, 0], @@ -491,6 +493,155 @@ describe('raised auto-room surfaces', () => { }) }) +// A 1 m ramp across the room's x span: ground 0 at x ≤ 0 rising to 1 at +// x ≥ 4, flat in z. Written column by column so the field is exactly +// monotonic across the walls, rather than depending on brush falloff. +function rampedSite(): AnyNode { + const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] }) + let field = base + for (let col = 0; col <= 16; col += 1) { + const x = -8 + col + const height = Math.max(0, Math.min(1, x / 4)) + const patch = flattenPatch(field, { minX: x, minZ: -8, maxX: x + 0.001, maxZ: 8 }, height) + if (patch) field = applyHeightPatch(field, patch) + } + return { + id: 'site_test', + type: 'site', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['building_a'], + terrain: encodeTerrainField(field), + } as unknown as AnyNode +} + +/** The 4×3 `square` room on `level_0` of a building on `site_test`. */ +function slopedRoomScene(site: AnyNode | null) { + const wallData = [ + { id: 'wall_bottom', start: [0, 0], end: [4, 0] }, + { id: 'wall_right', start: [4, 0], end: [4, 3] }, + { id: 'wall_top', start: [4, 3], end: [0, 3] }, + { id: 'wall_left', start: [0, 3], end: [0, 0] }, + ] as const + const walls = wallData.map((wall) => + WallNode.parse({ ...wall, parentId: 'level_0', height: 2.5 }), + ) + const initialWalls = walls.slice(0, 3) + const nodes = Object.fromEntries( + [ + ...(site ? [site] : []), + BuildingNode.parse({ id: 'building_a', parentId: site?.id ?? null, children: ['level_0'] }), + LevelNode.parse({ + id: 'level_0', + level: 0, + height: 2.5, + parentId: 'building_a', + children: initialWalls.map((wall) => wall.id), + }), + ...initialWalls, + ].map((node) => [node.id, node]), + ) as Record + return { nodes, closingWall: walls[3]! } +} + +function closeRoom(sceneStore: ReturnType, closingWall: AnyNode) { + const current = sceneStore.getState().nodes + const level = current.level_0 as LevelNode + sceneStore.setNodes({ + ...current, + [closingWall.id]: closingWall, + level_0: { ...level, children: [...level.children, closingWall.id] } as LevelNode, + }) +} + +function autoSurfacesOf(sceneStore: ReturnType) { + const all = Object.values(sceneStore.getState().nodes) + return { + slab: all.find((node): node is SlabNode => node.type === 'slab' && node.autoFromWalls), + ceiling: all.find((node): node is CeilingNode => node.type === 'ceiling' && node.autoFromWalls), + } +} + +describe('auto-room surfaces over terrain', () => { + test('a room on a slope takes its floor from the HIGHEST wall base', () => { + // Walls on bare terrain: no `supportSlabId`, no `supportOffset` — exactly + // what a stamped room preset or a 3D draw over untouched ground produces. + // Bases run 0 → 1 across the ramp, so a floor at the lowest base would + // leave daylight under the walls at the high end. + const { nodes, closingWall } = slopedRoomScene(rampedSite()) + const sceneStore = createSceneStoreStub(nodes) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + closeRoom(sceneStore, closingWall) + const { slab, ceiling } = autoSurfacesOf(sceneStore) + + // Highest wall base = the ramp at x = 4 (the right wall's start), +the + // 5 cm auto-slab lift. + expect(slab?.elevation).toBeCloseTo(1.05) + // Lowest wall top = the LOWEST base + 2.5 (explicit-height walls ride + // their own base), −the 1 cm clamp margin. Bottom/left walls start at + // x = 0, i.e. ground 0. + expect(ceiling?.height).toBeCloseTo(2.49) + } finally { + unsubscribe() + } + }) + + test('flat ground is unchanged — the same room with no terrain', () => { + const { nodes, closingWall } = slopedRoomScene(null) + const sceneStore = createSceneStoreStub(nodes) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + closeRoom(sceneStore, closingWall) + const { slab, ceiling } = autoSurfacesOf(sceneStore) + expect(slab?.elevation).toBeCloseTo(0.05) + expect(ceiling?.height).toBeCloseTo(2.49) + } finally { + unsubscribe() + } + }) + + test('sculpting under an existing room re-derives its floor and ceiling', () => { + // The trigger half of the bug: a sculpt writes only `site.terrain`, so + // without a terrain term in the structure signature every level hashes + // identically and the sync early-exits. + const { nodes, closingWall } = slopedRoomScene(rampedSite()) + const sceneStore = createSceneStoreStub(nodes) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + closeRoom(sceneStore, closingWall) + expect(autoSurfacesOf(sceneStore).slab?.elevation).toBeCloseTo(1.05) + + // Level the whole lot to 2 m — the ground under every wall moves, and + // nothing else in the scene changes. + const flat = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] }) + const levelled = applyHeightPatch( + flat, + flattenPatch(flat, { minX: -8, minZ: -8, maxX: 8, maxZ: 8 }, 2) as never, + ) + const current = sceneStore.getState().nodes + sceneStore.setNodes({ + ...current, + site_test: { + ...(current.site_test as Record), + terrain: encodeTerrainField(levelled), + } as AnyNode, + }) + + const { slab, ceiling } = autoSurfacesOf(sceneStore) + expect(slab?.elevation).toBeCloseTo(2.05) + expect(ceiling?.height).toBeCloseTo(4.49) + } finally { + unsubscribe() + } + }) +}) + describe('detectSpacesForLevel', () => { const areaOf = (polygon: Array<{ x: number; y: number }>) => { let area = 0 diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index e5b8f8d896..28949e32a3 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -31,7 +31,7 @@ import { } from '../systems/wall/wall-curve' import { resolveWallTop } from '../systems/wall/wall-top' import { simplifyClosedPolygon } from './polygon-geometry' -import { terrainSupportLift } from './terrain-support' +import { levelBaseElevationAt } from './terrain-support' type Point2D = { x: number; y: number } @@ -105,11 +105,13 @@ const WALL_JUNCTION_TOLERANCE = 0.08 // deleted) and the node is demoted to manual so user data survives. const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 const COVERAGE_SAMPLE_STEPS = 12 +// Rewrite deadband for an existing auto surface's elevation/height: below this +// the derived plane is the same plane and writing it would churn history. const ROOM_VERTICAL_PLANE_EPSILON = 1e-3 // Pure planner callers omit `heightForRoom`, so auto ceilings keep their // height-less level-following behavior. The live room sync supplies a height -// only when every enclosing wall agrees on one base and top plane. +// derived from the enclosing walls' own bases and tops. export type AutoCeilingPlanningContext = { /** Stored storey height of the level being planned (floor-to-floor). */ storeyHeight?: number @@ -344,12 +346,56 @@ function resolveCeilingClampBound( return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN } -function consensusElevation(values: number[]): number | undefined { - if (values.length === 0 || values.some((value) => !Number.isFinite(value))) return undefined - const min = Math.min(...values) - const max = Math.max(...values) - if (max - min > ROOM_VERTICAL_PLANE_EPSILON) return undefined - return values.reduce((sum, value) => sum + value, 0) / values.length +/** + * The base a boundary wall actually stands on, in level-local metres. + * + * Resolved the same way the wall renderer resolves it — the level base under + * the wall's own start point (sculpted ground or the flat plane), the slab + * election on top of that, plus the wall's stored `supportOffset`. Reading the + * ground for `GROUND_SUPPORT_ID` walls only is what left stamped room presets + * flat: `resolveWallSupportSlabPatch` writes no host at all for a wall on bare + * terrain, so the sentinel is a hint about pointer intent, never a precondition + * for standing on the ground. + */ +function boundaryWallBase( + wall: WallNode, + walls: WallNode[], + supportSlabs: readonly SlabNodeType[], + nodes: Record, + levelId: string, +): number { + const levelBase = levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]) + const offset = wall.supportOffset ?? 0 + if (wall.supportSlabId === GROUND_SUPPORT_ID) return levelBase + offset + return ( + computeWallSlabSupport(wall, supportSlabs, walls, wall.supportSlabId ?? null, null, levelBase) + .elevation + offset + ) +} + +/** + * The plane an auto floor/ceiling takes when its enclosing walls disagree. + * + * `floor` takes the HIGHEST wall base and `ceiling` the LOWEST wall top — + * the only pair that cannot open a hole: a floor at the lowest base would + * leave daylight under every wall standing higher, and a ceiling at the + * highest top would poke out through the shortest wall. Both surfaces stay + * flat (a slab is one scalar elevation by schema; see `vertical-model.md`), + * so a room on a slope is a level room cut into the hillside — the walls on + * the low side extend down to meet it, which is what their `baseSegments` + * fill-down already does. + * + * Non-finite inputs are the one abstain: a broken graph should keep the + * existing placement rather than move a surface to NaN. + */ +function roomFloorPlane(wallBases: number[]): number | undefined { + if (wallBases.length === 0 || wallBases.some((value) => !Number.isFinite(value))) return undefined + return Math.max(...wallBases) +} + +function roomCeilingPlane(wallTops: number[]): number | undefined { + if (wallTops.length === 0 || wallTops.some((value) => !Number.isFinite(value))) return undefined + return Math.min(...wallTops) } function autoRoomVerticalPlacements( @@ -369,25 +415,16 @@ function autoRoomVerticalPlacements( }) if (boundaryWalls.length !== space.wallIds.length) continue - const wallBases = boundaryWalls.map((wall) => { - const offset = wall.supportOffset ?? 0 - if (wall.supportSlabId === GROUND_SUPPORT_ID) { - return ( - (terrainSupportLift(nodes, space.levelId, wall.start[0], wall.start[1]) ?? 0) + offset - ) - } - return ( - computeWallSlabSupport(wall, supportSlabs, walls, wall.supportSlabId ?? null).elevation + - offset - ) - }) - const base = consensusElevation(wallBases) + const wallBases = boundaryWalls.map((wall) => + boundaryWallBase(wall, walls, supportSlabs, nodes, space.levelId), + ) + const base = roomFloorPlane(wallBases) if (base === undefined) continue const wallTops = boundaryWalls.map((wall, index) => resolveWallTop(wall, storeyHeight, wallBases[index] ?? base), ) - const top = consensusElevation(wallTops) + const top = roomCeilingPlane(wallTops) if (top === undefined) continue placements.set(polygonSignature(space.polygon.map(pointFromTuple)), { @@ -837,7 +874,7 @@ function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, ) } -function wallGeometrySignature(wall: WallNode) { +function wallGeometrySignature(wall: WallNode, nodes: Record, levelId: string) { return [ wall.id, wall.start[0].toFixed(4), @@ -852,11 +889,36 @@ function wallGeometrySignature(wall: WallNode) { wall.supportSlabId ?? 'elected', (wall.supportOffset ?? 0).toFixed(4), getClampedWallCurveOffset(wall).toFixed(4), + // The ground under this wall, sampled at the SAME point + // `boundaryWallBase` samples it. Sculpting changes only `site.terrain`, + // so without a terrain term here every signature stays byte-identical + // and the sync early-exits — a room's floor and ceiling could never + // follow ground that moved beneath its walls. + // + // The sample, not the field, and not the resolved base: hashing the + // heightfield would re-trigger every level for a stroke on the far side + // of the lot, and resolving the full slab election would fold slab + // POLYGONS into the signature, which is exactly the delete/recreate + // feedback the comment below is about. Sampling where the placement + // samples means the two cannot disagree in either direction — no missed + // re-run, no spurious one. + // + // Granularity is per stroke, not per dab: live dabs publish to + // `useLiveTerrain` and never touch the scene store, so this runs once on + // release — inside the stroke's own `runAsSingleSceneHistoryStep`, which + // is what puts the moved floor and the terrain that moved it in the same + // undo step. Mid-drag the ground-hosted walls follow the brush while the + // floor waits for release; re-deriving per dab would mean a scene write + // per dab and a floor that jitters under the cursor. + levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]).toFixed(4), ].join('|') } -function levelWallSnapshot(walls: WallNode[]) { - return walls.map(wallGeometrySignature).sort().join('||') +function levelWallSnapshot(walls: WallNode[], nodes: Record, levelId: string) { + return walls + .map((wall) => wallGeometrySignature(wall, nodes, levelId)) + .sort() + .join('||') } function zoneGeometrySignature(zone: ZoneNodeType) { @@ -930,7 +992,7 @@ function levelStructureSnapshots(nodes: Record) { : '' snapshots.set( levelId, - `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, + `${storeyKey}#${levelWallSnapshot(walls, nodes, levelId)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, ) } diff --git a/packages/core/src/lib/terrain-support.ts b/packages/core/src/lib/terrain-support.ts index fecfed1085..68725bb5ad 100644 --- a/packages/core/src/lib/terrain-support.ts +++ b/packages/core/src/lib/terrain-support.ts @@ -64,11 +64,26 @@ function cachedLevelElevations( return elevations } +/** + * `siteOf` memoized on the `nodes` record identity, for the same reason + * `levelElevationCache` exists: the resolver is called per wall per frame (the + * spatial grid) and per wall per store update (the space-detection trigger), so + * an O(N) scan inside it makes those callers O(N²). + */ +const siteCache = new WeakMap() + function siteOf(nodes: Record): SiteNode | null { + const cached = siteCache.get(nodes) + if (cached !== undefined) return cached + let site: SiteNode | null = null for (const node of Object.values(nodes)) { - if (node?.type === 'site') return node as SiteNode + if (node?.type === 'site') { + site = node as SiteNode + break + } } - return null + siteCache.set(nodes, site) + return site } /** @@ -172,3 +187,66 @@ export function terrainSupportLift( // datum other than 0 does not double-count it. return heightAt(field, worldX, worldZ) - datum.baseWorldY } + +/** + * **The level base, in level-local metres, at level-local `x`/`z`** — the surface + * a node rests on when nothing built is under it. Sculpted ground where terrain + * supports this storey, `0` everywhere else. + * + * This is the one function every "nothing is under me, so I'm at zero" site in + * the codebase should call, and the reason it exists separately from + * {@link terrainSupportLift}: that function answers *"is there terrain here"* + * (nullable, so a caller can branch), while this one answers *"how high is the + * floor of the world here"* (total, so a caller does not have to know terrain + * exists). Spelling the second question `terrainSupportLift(…) ?? 0` at each + * site is what made terrain opt-in per kind — every new consumer had to remember + * to ask, and the ones that forgot silently assumed the plane `y = 0`. Kinds + * inherit terrain by resolving their base through here instead of hardcoding a + * zero; nothing has to be registered for that to work. + * + * Callers that must distinguish "flat ground" from "a built surface flush with + * the storey base" still need {@link terrainSupportLift}'s null — both read `0` + * here and only the first follows a hillside. + */ +export function levelBaseElevationAt( + nodes: Record, + levelId: string, + x: number, + z: number, +): number { + return terrainSupportLift(nodes, levelId, x, z) ?? 0 +} + +/** + * Kinds whose geometry builder has actually asked for the level base, learned at + * runtime from the first build rather than declared. + * + * A builder that bakes its vertical origin (`ctx.levelBaseAt`) must be rebuilt + * when the ground moves, and core cannot see inside a pure function to know + * which kinds do. The two declarative alternatives both fail the goal: a flag on + * the definition is another per-kind opt-in — the exact thing that left half the + * scene flat on a hillside — and over-approximating to "every kind with a + * geometry builder" would rebuild every cabinet and duct on the ground floor on + * every dab of a brush stroke. + * + * So the question is answered by the call itself: asking for the ground is what + * enrolls the kind in following it. A plugin inherits terrain by reading + * `ctx.levelBaseAt` and nothing else — no registration, no capability, no core + * change. Keyed by node *type*, not id: types are bounded and stable, so the set + * cannot leak with the scene, and a kind that asked once will ask again on every + * subsequent build of every instance. + * + * Ordering is not a hazard: geometry builds on mount, long before any sculpt can + * happen, and a node created after a stroke builds against the current field. + */ +const levelBaseConsumerKinds = new Set() + +/** Record that `type`'s geometry builder resolved its origin from the ground. */ +export function noteLevelBaseConsumer(type: string): void { + levelBaseConsumerKinds.add(type) +} + +/** Whether `type`'s geometry has to be rebuilt when the sculpted ground moves. */ +export function isLevelBaseConsumer(type: string): boolean { + return levelBaseConsumerKinds.has(type) +} diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 22150bcb51..d85da0d846 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -28,6 +28,25 @@ export type GeometryContext = { siblings: AnyNode[] /** Resolved parent (null for root-level nodes). */ parent: AnyNode | null + /** + * **The level base at level-local `x`/`z`** — the surface a node rests on + * when nothing built is under it. Sculpted ground where terrain supports this + * node's storey, `0` everywhere else (`levelBaseElevationAt`). + * + * This is how a pure builder that bakes its own vertical origin inherits + * terrain. Without it the only way to ask was to import the scene store, + * which a builder must not do, so every such kind hardcoded the plane + * `y = 0` — and stayed flat on a hillside. A kind that resolves its base + * through here follows the ground with nothing registered and nothing + * opted into; kinds whose Y comes from a parent group or from + * `capabilities.floorPlaced` ignore it. + * + * Populated by `` for every `def.geometry` call. Absent for + * `def.floorplan` — the plan view draws no elevation — so builders shared + * between the two must treat it as optional rather than assume flat ground + * in 2D. + */ + levelBaseAt?: (x: number, z: number) => number /** * Pre-computed level-batch data, populated by the dispatcher when the * kind declares `def.computeLevelData` (3D) or diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts index c24fb3e1b7..0ff2725e99 100644 --- a/packages/core/src/systems/slab/slab-support.ts +++ b/packages/core/src/systems/slab/slab-support.ts @@ -497,6 +497,19 @@ export type WallSlabSupportSegment = { * surface the cursor ray actually hit never captures the elected base. * `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and * an explicit `preferredSlabId` still wins over the cap. + * + * `levelBase` is what "no slab supports this here" evaluates to — the + * sculpted ground under the wall (`levelBaseElevationAt`), or 0 for a level + * with no terrain under it. A caller-resolved scalar rather than a terrain + * lookup in here, so this stays pure and the sample stays at the wall's own + * XZ. It substitutes for every place this function used to write a literal + * `0`, and nowhere else: a slab that supports the wall's faces still wins + * outright, so a slab pad on a hillside keeps its wall at the pad's + * elevation instead of being overruled by the ground around it. The one + * clamp — center-only support, where the old code already clamped a + * recessed slab up to `0` so a wall over a pool didn't sink — generalizes to + * "never below the ground", which is the same rule with the ground no longer + * assumed flat. */ export function computeWallSlabSupport( wallLike: WallOverlapInput, @@ -504,6 +517,7 @@ export function computeWallSlabSupport( levelWalls: WallNode[], preferredSlabId?: string | null, maxElevation?: number | null, + levelBase = 0, ): WallSlabSupport { const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike const halfThickness = Math.max(thickness / 2, 0) @@ -511,7 +525,12 @@ export function computeWallSlabSupport( const polylineLengths = polylines.map(polylineLength) const wallLength = polylineLengths[0]! if (wallLength < 1e-9) { - return { elevation: 0, electedSlabId: null, baseElevation: 0, baseSegments: [] } + return { + elevation: levelBase, + electedSlabId: null, + baseElevation: levelBase, + baseSegments: [], + } } const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5)) @@ -646,7 +665,7 @@ export function computeWallSlabSupport( polylines.length >= 3 ? highestAt(normalizedByGroup, 2, midpoint) : Number.NEGATIVE_INFINITY const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) const segmentElevation = - faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(centerElevation, 0) + faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(centerElevation, levelBase) if (electableNormalizedGroups === normalizedByGroup) { if (faceElevations.length > 0 || Number.isFinite(centerElevation)) { @@ -665,7 +684,7 @@ export function computeWallSlabSupport( const electFaces = [electLeft, electRight].filter(Number.isFinite) if (electFaces.length > 0 || Number.isFinite(electCenter)) { accumulateCarry( - electFaces.length > 0 ? Math.min(...electFaces) : Math.max(electCenter, 0), + electFaces.length > 0 ? Math.min(...electFaces) : Math.max(electCenter, levelBase), end - start, ) } @@ -704,7 +723,7 @@ export function computeWallSlabSupport( : majorityElevation !== Number.NEGATIVE_INFINITY ? majorityElevation : bestElevation === Number.NEGATIVE_INFINITY - ? 0 + ? levelBase : bestElevation const electedSlabId = preferredElectedSlabId ?? diff --git a/packages/editor/src/lib/elevation-guides.ts b/packages/editor/src/lib/elevation-guides.ts index 021afbf4ea..b828be9ba7 100644 --- a/packages/editor/src/lib/elevation-guides.ts +++ b/packages/editor/src/lib/elevation-guides.ts @@ -1,8 +1,10 @@ import { type AnyNode, type AnyNodeId, + findLevelAncestorId, getWallBaseElevationForNodes, getWallEffectiveHeightForNodes, + levelBaseElevationAt, resolveCeilingHeight, } from '@pascal-app/core' import useElevationGuides from '../store/use-elevation-guides' @@ -46,14 +48,22 @@ function segmentCenter( return [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] } +// Mirrors `resolveFenceLiftElevationForNodes` in `@pascal-app/nodes`, which this +// package cannot import (the fence definition imports the guides from here). +// A stale host resolves to the level base — the sculpted ground under the +// fence's start point, sampled where the builder samples it, so the guide line +// lands on the rail it claims to describe. function fenceBaseElevation(node: AnyNode, nodes: Record): number { if (node.type !== 'fence') return 0 const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined - const hostElevation = - host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null) - ? (host.elevation ?? 0) + const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null) + const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes) + const support = hosted + ? (host.elevation ?? 0) + : levelId + ? levelBaseElevationAt(nodes, levelId, node.start[0], node.start[1]) : 0 - return hostElevation + (node.supportOffset ?? 0) + return support + (node.supportOffset ?? 0) } /** @@ -75,6 +85,25 @@ export function collectElevationSnapTargets( label: 'Level', }, ] + // Sculpted ground under the thing being dragged. A separate target rather than + // a redefinition of `Level`: the storey plane is still a real datum a user may + // want (a fence sunk to the building's floor line), and on a hillside the + // ground is a second, different one. Emitted only when they actually differ, + // so a flat scene keeps exactly one target at 0. + const groundElevation = levelBaseElevationAt( + nodes, + source.levelId, + source.anchor[0], + source.anchor[1], + ) + if (Math.abs(groundElevation) > GUIDE_MATCH_EPSILON_M) { + targets.push({ + id: `${source.levelId}:ground`, + elevation: groundElevation, + anchor: source.anchor, + label: 'Ground', + }) + } const level = nodes[source.levelId as AnyNodeId] if (level?.type !== 'level') return targets diff --git a/packages/nodes/src/fence/__tests__/lift.test.ts b/packages/nodes/src/fence/__tests__/lift.test.ts index de578936ba..773aaba3e6 100644 --- a/packages/nodes/src/fence/__tests__/lift.test.ts +++ b/packages/nodes/src/fence/__tests__/lift.test.ts @@ -1,6 +1,16 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNode, FenceNode, SlabNode } from '@pascal-app/core' -import { resolveFenceLiftElevation } from '../lift' +import { + type AnyNode, + applyHeightPatch, + BuildingNode, + createTerrainField, + encodeTerrainField, + FenceNode, + flattenPatch, + LevelNode, + SlabNode, +} from '@pascal-app/core' +import { resolveFenceLiftElevation, resolveFenceLiftElevationForNodes } from '../lift' const LEVEL_ID = 'level-1' @@ -78,4 +88,109 @@ describe('resolveFenceLiftElevation', () => { const railing = makeRailing(impostor.id) expect(resolveFenceLiftElevation(railing, resolverFor(deck, impostor))).toBe(0) }) + + test('an unhosted fence stands on the ground, not the storey plane', () => { + const railing = makeRailing(undefined) + expect(resolveFenceLiftElevation(railing, resolverFor(), 1.4)).toBe(1.4) + // The offset is a delta from the support, so it rides the ground with it. + expect( + resolveFenceLiftElevation(makeRailing(undefined, LEVEL_ID, 0.2), resolverFor(), 1.4), + ).toBeCloseTo(1.6) + }) + + test('a live host slab wins over the ground under it', () => { + // A deck pad on a hillside is a built surface: the railing on it stays flat + // at the pad's elevation rather than following the terrain around it. + const deck = makeDeck(1.25) + expect(resolveFenceLiftElevation(makeRailing(deck.id), resolverFor(deck), 1.4)).toBe(1.25) + }) + + test('a stale host falls back to the ground, not to zero', () => { + const deck = makeDeck(1.25) + const railing = makeRailing(deck.id) + // Deleted host. + expect(resolveFenceLiftElevation(railing, resolverFor(), 1.4)).toBe(1.4) + // Host on another level. + const offLevel = makeDeck(1.25, 'level-2') + expect(resolveFenceLiftElevation(makeRailing(offLevel.id), resolverFor(offLevel), 1.4)).toBe( + 1.4, + ) + }) +}) + +describe('resolveFenceLiftElevationForNodes', () => { + const SPACING = 1 + const COLS = 9 + + /** A site whose ground is a flat plateau at `height`, covering the fence. */ + function plateauSite(height: number) { + let field = createTerrainField({ + cols: COLS, + rows: COLS, + spacing: SPACING, + origin: [-4, -4], + }) + const patch = flattenPatch(field, { minX: -4, minZ: -4, maxX: 4, maxZ: 4 }, height) + if (patch) field = applyHeightPatch(field, patch) + return { + id: 'site_test', + type: 'site', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['building_a'], + terrain: encodeTerrainField(field), + } as unknown as AnyNode + } + + /** `level_0` at grade under a building on `site_test`, holding one fence. */ + function sceneWith(fence: AnyNode, site: AnyNode | null) { + return Object.fromEntries( + [ + ...(site ? [site] : []), + BuildingNode.parse({ + id: 'building_a', + parentId: site?.id ?? null, + children: ['level_0'], + }), + LevelNode.parse({ + id: 'level_0', + level: 0, + height: 2.5, + parentId: 'building_a', + children: [fence.id], + }), + fence, + ].map((node) => [node.id, node]), + ) as Record + } + + function railingOnLevel(supportSlabId?: string, supportOffset?: number) { + return FenceNode.parse({ + id: 'fence_test', + parentId: 'level_0', + start: [0, 0], + end: [3, 0], + supportSlabId, + supportOffset, + }) as AnyNode + } + + test('an unhosted fence inherits the sculpted ground', () => { + const fence = railingOnLevel() + const nodes = sceneWith(fence, plateauSite(1.5)) + expect(resolveFenceLiftElevationForNodes(fence as never, nodes)).toBeCloseTo(1.5) + }) + + test('no terrain keeps the fence on the storey plane', () => { + const fence = railingOnLevel() + expect(resolveFenceLiftElevationForNodes(fence as never, sceneWith(fence, null))).toBe(0) + }) + + test('a manual offset stays a delta from the ground', () => { + const fence = railingOnLevel(undefined, 0.25) + const nodes = sceneWith(fence, plateauSite(1.5)) + expect(resolveFenceLiftElevationForNodes(fence as never, nodes)).toBeCloseTo(1.75) + }) }) diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index e854a2cab0..cbc537bfbb 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -1,5 +1,4 @@ import { - type AnyNodeId, type FenceNode as FenceNodeType, getFenceControlHandle, type HandleDescriptor, @@ -21,7 +20,7 @@ import { } from './floorplan-affordances' import { fenceFloorplanMoveTarget } from './floorplan-move' import { buildFenceGeometry } from './geometry' -import { resolveFenceLiftElevation } from './lift' +import { resolveFenceLiftElevation, resolveFenceLiftElevationForNodes } from './lift' import { fencePaint } from './paint' import { fenceParametrics } from './parametrics' import { FenceNode } from './schema' @@ -35,7 +34,10 @@ const HEIGHT_HANDLE_OFFSET = 0.45 const MIN_FENCE_HEIGHT = 0.3 function fenceBaseElevation(n: FenceNodeType, sceneApi?: SceneApi): number { - return resolveFenceLiftElevation(n, (id) => sceneApi?.get(id as AnyNodeId)) + const nodes = sceneApi?.nodes() + return nodes + ? resolveFenceLiftElevationForNodes(n, nodes) + : resolveFenceLiftElevation(n, () => undefined) } function fenceMidpointFrame(n: FenceNodeType): { @@ -149,9 +151,13 @@ function fenceElevationHandle(currentBase: number): HandleDescriptor clearStructuralElevationGuide(n.id), apply: (initial, newBase, sceneApi) => { - const supportBase = resolveFenceLiftElevation( + // The offset is measured from the support WITHOUT the current offset — + // including the ground, so dragging a fence's base to an absolute height + // on a hillside stores the delta from that hillside and the fence keeps + // following it. + const supportBase = resolveFenceLiftElevationForNodes( { ...initial, supportOffset: undefined }, - (id) => sceneApi.get(id as AnyNodeId), + sceneApi.nodes(), ) const nextOffset = newBase - supportBase return { diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts index b8bebb8ba8..b1b634cfef 100644 --- a/packages/nodes/src/fence/geometry.ts +++ b/packages/nodes/src/fence/geometry.ts @@ -111,10 +111,20 @@ export function buildFenceGeometry( const group = new Group() const geometries = generateFenceSlotGeometries(node) - // A hosted railing (`supportSlabId`) stands on its slab's walking surface. - // The builder emits local-space children, so the lift lives on an inner - // group rather than the registered (React-transformed) root. - const lift = ctx ? resolveFenceLiftElevation(node, (id) => ctx.resolve(id as AnyNodeId)) : 0 + // A hosted railing (`supportSlabId`) stands on its slab's walking surface; + // an unhosted one stands on the ground, which `ctx.levelBaseAt` resolves at + // the fence's own start point — the anchor its plan geometry is measured + // from, so the resolver and the mesh cannot disagree about where the ground + // is under this fence. The builder emits local-space children, so the lift + // lives on an inner group rather than the registered (React-transformed) + // root. + const lift = ctx + ? resolveFenceLiftElevation( + node, + (id) => ctx.resolve(id as AnyNodeId), + ctx.levelBaseAt?.(node.start[0], node.start[1]) ?? 0, + ) + : 0 const meshParent = new Group() meshParent.position.y = lift group.add(meshParent) diff --git a/packages/nodes/src/fence/lift.ts b/packages/nodes/src/fence/lift.ts index d36187ab7e..b9261f60fd 100644 --- a/packages/nodes/src/fence/lift.ts +++ b/packages/nodes/src/fence/lift.ts @@ -1,4 +1,10 @@ -import type { AnyNode, SlabNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + findLevelAncestorId, + levelBaseElevationAt, + type SlabNode, +} from '@pascal-app/core' import type { FenceNode } from './schema' /** @@ -11,16 +17,48 @@ import type { FenceNode } from './schema' * falls back to the level floor, mirroring the read-path rules of the * other `supportSlabId` carriers. Pure so it is unit-testable and * callable from the geometry builder with `ctx.resolve`. + * + * `levelBase` is what "the level floor" means here — the sculpted ground + * under the fence (`levelBaseElevationAt`, reached through `ctx.levelBaseAt` + * in the builder and {@link resolveFenceLiftElevationForNodes} everywhere + * else), or 0 for a level with no terrain under it. A caller-resolved scalar + * rather than a terrain lookup inside, so this stays pure and the sample stays + * at the caller's chosen point; it substitutes for the literal `0` this used to + * return in every unhosted branch, and nowhere else — a host slab that still + * exists wins outright, so a railing on a deck pad keeps the pad's elevation + * instead of following the hillside around it. */ export function resolveFenceLiftElevation( node: Pick, resolve: (id: string) => AnyNode | undefined, + levelBase = 0, ): number { const offset = node.supportOffset ?? 0 - if (!node.supportSlabId) return offset + if (!node.supportSlabId) return levelBase + offset const host = resolve(node.supportSlabId) - if (host?.type !== 'slab') return offset - if ((host.parentId ?? null) !== (node.parentId ?? null)) return offset + if (host?.type !== 'slab') return levelBase + offset + if ((host.parentId ?? null) !== (node.parentId ?? null)) return levelBase + offset const elevation = (host as SlabNode).elevation return (Number.isFinite(elevation) ? elevation : 0) + offset } + +/** + * {@link resolveFenceLiftElevation} against a nodes record, with the level base + * resolved from the terrain under the fence's start point — the same anchor the + * geometry builder samples, so a handle, a snap guide and the rendered rail all + * agree about where the ground is. + * + * Exists so the callers that already hold the whole scene (handles via + * `sceneApi.nodes()`, the dependency tracker, the editor's elevation guides) + * don't each re-derive the level walk and the sample point. The builder keeps + * using the pure form, since `GeometryContext` deliberately exposes the ground + * as a closure rather than the store. + */ +export function resolveFenceLiftElevationForNodes( + node: Pick, + nodes: Record, +): number { + const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes) + const levelBase = levelId ? levelBaseElevationAt(nodes, levelId, node.start[0], node.start[1]) : 0 + return resolveFenceLiftElevation(node, (id) => nodes[id], levelBase) +} diff --git a/packages/nodes/src/fence/system.tsx b/packages/nodes/src/fence/system.tsx index dedfc96e2c..8ccb852b71 100644 --- a/packages/nodes/src/fence/system.tsx +++ b/packages/nodes/src/fence/system.tsx @@ -2,7 +2,7 @@ import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' import { useEffect } from 'react' -import { resolveFenceLiftElevation } from './lift' +import { resolveFenceLiftElevationForNodes } from './lift' import type { FenceNode } from './schema' /** @@ -20,11 +20,13 @@ function fenceLiftSignatures(nodes: Record): Map nodes[id]), - ) + signatures.set(fence.id, resolveFenceLiftElevationForNodes(fence, nodes)) } return signatures } diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index 0b9f3b7f9a..f537a4e981 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -3,9 +3,12 @@ import { type AnyNode, type AnyNodeId, + findLevelAncestorId, type GeometryContext, getEffectiveNode, + levelBaseElevationAt, nodeRegistry, + noteLevelBaseConsumer, type SurfaceRole, sceneRegistry, useLiveNodeOverrides, @@ -304,7 +307,24 @@ function buildGeometryContext( } } - return { resolve, children, siblings, parent, levelData, materials } + // The ground under this node, for builders that bake their own vertical + // origin (`ctx.levelBaseAt`). A closure rather than a scalar because the + // sample point is the builder's business: a fence samples its own start, + // and a kind that wanted to drape a span could sample along it. Resolved + // against the node's level ancestor, so a builder never has to know how + // the level graph is walked — and gets `0` when it has no level, which is + // the flat-ground answer those nodes already assumed. + // + // Calling it also enrolls the kind in terrain invalidation + // (`noteLevelBaseConsumer`) — see `terrain-support.ts` for why asking is + // the registration. + const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes) + const levelBaseAt = (x: number, z: number) => { + noteLevelBaseConsumer(node.type) + return levelId ? levelBaseElevationAt(nodes, levelId, x, z) : 0 + } + + return { resolve, children, siblings, parent, levelBaseAt, levelData, materials } } function disposeChildren(group: Group) { diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index a2686a59c0..6fbf07db9e 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -53,6 +53,35 @@ A plugin's `nodes` array is the only meaningful contribution point in v1. Each e See [`node-definitions.md`](node-definitions.md) for the three-checkbox composition model that ties these together. +## Standing on the ground (terrain) + +The site carries a sculpted heightfield, so "the floor" is not the plane `y = 0`. A plugin kind that +hardcodes `0` as its base looks correct on a flat lot and buries itself in the hillside on a sculpted +one. There is no capability to declare and nothing to register — pick whichever of these matches how +your kind gets its Y, and terrain follows: + +- **Your node stands on a surface** → declare `capabilities.floorPlaced` with a `footprint` (or + `footprints` for a composite). `FloorElevationSystem` then lifts the registered mesh every frame, + electing between overlapping slabs and the ground per footprint. This is the whole contract: a tree, + a bench, a planter needs nothing else. +- **Your `def.geometry` builder bakes its own vertical origin** → read `ctx.levelBaseAt(x, z)` + instead of writing `0`. It returns the ground at that level-local point (`0` when there is no + terrain under the storey). Calling it also enrols your kind in terrain invalidation, so the builder + re-runs when the ground moves; you don't wire a dirty rule. It is **absent for `def.floorplan`** — + the plan view has no elevation — so a builder shared between 2D and 3D must use + `ctx.levelBaseAt?.(x, z) ?? 0`. +- **You ship a collective `renderer`** (one component drawing many nodes — instanced meshes, a merged + buffer) → you own each instance's Y. `FloorElevationSystem` writes to the node's *registered* + object, which for a collective kind is the invisible selection proxy, not the instance, so a raw + `node.position[1]` per instance ignores both slabs and terrain. Resolve through + `getFloorStackedPosition({ node, nodes, position })` when writing each instance matrix, and commit + the **base** position (`[x, 0, z]`) from your placement tool — the lift is presentation, never + stored. + +Sample the ground at the same XZ your geometry is anchored at. A handle, a snap guide and the mesh +that sample different points on a slope will visibly disagree. Background: +[`vertical-model.md`](vertical-model.md#inheriting-terrain-the-generic-seam). + ## Importing host packages A plugin imports from the published `@pascal-app/*` packages — same surface the built-ins use, peer-dependency-style: diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 80c5882154..e4f3617402 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -75,13 +75,51 @@ fixed underside; it never changes the slab interval. Recessed slabs keep an expl construction plane is tagged `fixed-plane` so a plane at world Y=0 cannot be mistaken for the terrain query plane on later pointer moves. -Auto-room surfaces derive their vertical placement from the enclosing walls when every boundary -wall agrees on one base and top plane. The floor keeps its established 0.05 m walking-surface -offset above that base; the ceiling sits 0.01 m below the common wall top. Existing -`autoFromWalls` surfaces reconcile with later wall support-offset changes. Manual surfaces are -never rewritten, and a mixed-elevation enclosure keeps its existing/fallback placement rather -than choosing an arbitrary wall. Auto slabs are excluded from this wall-base election so a -derived floor cannot recursively lift its own walls and then itself. +Auto-room surfaces derive their vertical placement from the enclosing walls. Each boundary wall's +base is resolved through the same election the renderer uses — including the ground under it, so a +room stamped or drawn on bare terrain gets terrain input even though no wall carries an explicit +`supportSlabId`. The floor takes the **highest** wall base and keeps its established 0.05 m +walking-surface offset above it; the ceiling takes the **lowest** wall top and sits 0.01 m below it. +Both surfaces stay flat: a mixed-elevation enclosure no longer bails to a fallback placement, and +the highest base is chosen because the lower walls extend down into the ground anyway, so no +daylight opens under any wall. Existing `autoFromWalls` surfaces reconcile with later wall +support-offset changes **and with sculpts** — the level's structure signature hashes each wall's +resolved level base, so moving the ground under a finished room re-derives its floor and ceiling +(once per stroke, inside the stroke's own undo step). Manual surfaces are never rewritten. Auto +slabs are excluded from this wall-base election so a derived floor cannot recursively lift its own +walls and then itself. + +## Inheriting terrain (the generic seam) + +`levelBaseElevationAt(nodes, levelId, x, z)` is **the** answer to "what surface does a node rest on +when nothing built is under it" — the sculpted ground where terrain supports that storey, `0` +everywhere else. Terrain used to be opt-in per kind because each site spelled the question +`terrainSupportLift(…) ?? 0` and every consumer that forgot to ask silently assumed the plane +`y = 0`. Resolving a base through this function is all a kind needs to follow the ground; there is +nothing to register. Callers that must tell flat ground apart from a built surface flush with the +storey base still need `terrainSupportLift`'s null — both read `0` and only the first drapes. + +Three ways a kind's Y reaches the ground, in the order to prefer them: + +1. **`capabilities.floorPlaced`** — the resolver (`getFloorPlacedElevation`) elects per footprint + between the overlapping slabs and the level base, and `FloorElevationSystem` writes the result to + the registered mesh every frame. Correct for anything that stands on a surface. +2. **`ctx.levelBaseAt(x, z)` in a pure `def.geometry` builder** — for kinds that bake their own + vertical origin into the meshes (a fence's inner lift group). Calling it also *enrols* the kind in + terrain invalidation: `noteLevelBaseConsumer` records the type on first build, and + `markTerrainSupportDependents` dirties those nodes on every terrain change so the baked origin + rebuilds. Asking is the registration — deliberately, because a declarative flag would be another + per-kind opt-in and "every kind with a builder" would rebuild the whole ground floor per brush dab. + Absent for `def.floorplan` (the plan view draws no elevation), so shared builders must treat it as + optional rather than assume flat ground in 2D. +3. **`levelBaseElevationAt` directly** — for resolvers outside the render path that already hold the + nodes record (support election, snap guides, handle placement). Sample at the *same* XZ the + renderer samples, or the overlay and the mesh will disagree. + +A collective renderer (one component drawing many nodes, e.g. instanced meshes) gets none of this for +free: `FloorElevationSystem` writes to the node's registered object, which for those kinds is the +selection proxy, not the instance. Such renderers must resolve each instance's Y through +`getFloorStackedPosition` themselves. ## Load migration (lives in `migrateNodes` Pass 3, indefinitely) @@ -98,7 +136,8 @@ Because community autosave only persists after the first post-load edit, the mig - **Ordinals are semantic.** `level < 0` renders "Basement N"; `level === 0` is the ground-floor lookup. Never renumber without the zero anchor. - **Boundary geometry.** Auto slabs derive polygons from wall centerlines, so wall/ceiling clamp samples sit exactly on polygon edges — always use the boundary-inclusive band-overlap helpers (`wallOverlapsSlabFootprint`, `slabCoversPoint`), never raw ray-cast point-in-polygon on those paths. - **Straight stairs build from stored segment heights**, not the resolved rise — any rise change must go through `syncStairRises` (applied by `StairOpeningSystem`, history-paused, one microtask after store updates so the spatial grid has settled). -- **Reactivity is explicit.** A `level.height` change dirties that level's walls/stairs/ceilings/fences; a slab change dirties overlapping same-level supports, the level below's walls/ceilings, and deck-attached stairs. Every live terrain dab dirties ground-hosted structures and `fillToTerrain` walls/slabs through the transient `useLiveTerrain` subscription in `spatial-grid-sync.ts`; the scene graph and undo history are still written only once when the stroke commits. Ending or canceling a stroke runs the same sweep so dependents settle back onto the persisted field. Slab handles reuse the slab-change dependency helper for live previews. If a new consumer reads these bounds, wire its dirty rule there. +- **Reactivity is explicit.** A `level.height` change dirties that level's walls/stairs/ceilings/fences; a slab change dirties overlapping same-level supports, the level below's walls/ceilings, and deck-attached stairs. Every live terrain dab dirties ground-hosted structures, `fillToTerrain` walls/slabs, every `floorPlaced` node at grade, and every kind whose builder asked for `ctx.levelBaseAt`, through the transient `useLiveTerrain` subscription in `spatial-grid-sync.ts`; the scene graph and undo history are still written only once when the stroke commits. Ending or canceling a stroke runs the same sweep so dependents settle back onto the persisted field. Slab handles reuse the slab-change dependency helper for live previews. If a new consumer reads these bounds, wire its dirty rule there. +- **Auto-room re-derivation is per stroke, not per dab.** Live dabs publish only to `useLiveTerrain` and never touch the scene store, so the structure-signature diff that re-derives room floors and ceilings runs once on release. Mid-drag the ground-hosted walls follow the brush while the floor waits for release — deliberate: re-deriving per dab means a scene write per dab and a floor that jitters under the cursor. - **Host lifecycle.** Deleting a slab strips `supportSlabId`/`deckSlabId` from survivors in the same undo commit; a host merely reshaped away falls back silently and resumes if the slab returns. - **Clone paths differ.** `clone-scene-graph.ts` remaps `supportSlabId`/`deckSlabId`; the editor clipboard (`scene-clipboard.ts`) intentionally does not (it re-elects); room placement remaps them (fixed in the private repo's `room-placement.ts`). When adding a new clone/instantiation path, remap both fields.