Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { terrainSupportLift } from '../../lib/terrain-support'
import { levelBaseElevationAt } from '../../lib/terrain-support'
import { nodeRegistry } from '../../registry'
import type {
FloorPlacedConfig,
Expand Down Expand Up @@ -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
}

Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -1086,6 +1089,7 @@ export class SpatialGridManager {
this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)),
preferredSlabId,
maxElevation,
levelBase,
)
if (supportOffset === 0) return support
return {
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -352,7 +352,19 @@ export function markTerrainSupportDependents(
}

const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced) continue
if (!floorPlaced) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grade walls skip terrain invalidation

Medium Severity

After a terrain commit, markTerrainSupportDependents only marks walls with GROUND_SUPPORT_ID or fillToTerrain, yet getSlabSupportForWall and computeWallSlabSupport now bake levelBaseElevationAt into every wall’s base and baseSegments. WallSystem rebuilds only on markDirty, so typical boundary walls (as in the new slope-room tests) keep stale mesh while space-detection re-derives auto floors/ceilings from fresh terrain samples in wallGeometrySignature.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3fdf943. Configure here.

// 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.
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
151 changes: 151 additions & 0 deletions packages/core/src/lib/space-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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<string, AnyNode>
return { nodes, closingWall: walls[3]! }
}

function closeRoom(sceneStore: ReturnType<typeof createSceneStoreStub>, 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<typeof createSceneStoreStub>) {
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<string, unknown>),
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
Expand Down
Loading
Loading