fix: inherit terrain elevation in rooms, geometry builders, and plugins - #568
Conversation
Auto-generated room floors and ceilings, and any node whose geometry
builder baked its own vertical origin, assumed the plane `y = 0`. On
sculpted ground that left room slabs and ceilings floating above or
buried under the walls that generated them, and plugin-shipped kinds
(the Nature plugin's trees, flowers, grass) planted at the storey plane
instead of on the hillside.
The root cause was that terrain was opt-in per kind: the only way to ask
for the ground was `terrainSupportLift(...) ?? 0`, so every new consumer
had to remember to ask, and the ones that forgot silently got a flat
world.
Three seams close that:
- `levelBaseElevationAt(nodes, levelId, x, z)` — a total function for
"how high is the floor of the world here", alongside the existing
nullable `terrainSupportLift` ("is there terrain here"). Consumers
resolve a base through it instead of hardcoding a zero.
- `ctx.levelBaseAt(x, z)` on `GeometryContext` — how a pure builder,
which must not import the scene store, inherits terrain. Calling it
also enrolls the kind in terrain invalidation, so asking for the
ground is the registration: a plugin follows sculpted terrain with no
flag, capability, or core change.
- Rooms derive their surfaces from the walls that enclose them. Auto
floors take the highest wall base and auto ceilings the lowest wall
top — the only pair that cannot open a hole, since a floor at the
lowest base leaves daylight under the higher walls and a ceiling at
the highest top pokes through the shortest one. Both stay flat (a slab
is one scalar elevation by schema), so a room on a slope is a level
room cut into the hillside, with the low-side walls filling down to
meet it. This replaces `consensusElevation`, which abstained whenever
the walls disagreed and so never placed a surface on a slope at all.
The wall geometry signature now folds in a terrain sample taken at the
same point the placement samples, so a stroke that moves ground under a
room re-derives its surfaces — sculpting touches only `site.terrain`, so
without that term every signature stayed byte-identical and the sync
early-exited. Sampling (not hashing the field, not resolving the full
slab election) keeps it per-stroke and avoids folding slab polygons into
the signature.
Also memoizes `siteOf` on the `nodes` identity — it is called per wall
per frame and per wall per store update, where an O(N) scan made those
callers O(N²).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 3fdf943. Configure here.
| resolveWallTop(wall, storeyHeight, wallBases[index] ?? base), | ||
| ) | ||
| const top = consensusElevation(wallTops) | ||
| const top = roomCeilingPlane(wallTops) |
There was a problem hiding this comment.
Auto ceiling can sit below floor
Medium Severity
Replacing consensusElevation with roomCeilingPlane (Math.min of wall tops) always writes auto ceiling height from the lowest top, while the auto floor uses the highest wall base plus the fixed slab offset. When enclosing walls mix explicit short heights or different bases, the derived ceiling can end up below the auto floor slab with no guard or skip.
Reviewed by Cursor Bugbot for commit 3fdf943. Configure here.
|
|
||
| const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced | ||
| if (!floorPlaced) continue | ||
| if (!floorPlaced) { |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 3fdf943. Configure here.


Problem
Auto-generated room floors and ceilings — and any node whose geometry builder bakes its own vertical origin — assumed the plane
y = 0. On sculpted ground that meant:The root cause is that terrain was opt-in per kind: the only way to ask for the ground was
terrainSupportLift(...) ?? 0, so every new consumer had to remember to ask, and the ones that forgot silently got a flat world.Approach
Rather than patch each kind, this adds a generic seam that kinds and plugins inherit.
1.
levelBaseElevationAt(nodes, levelId, x, z)— a total function answering "how high is the floor of the world here", next to the existing nullableterrainSupportLift("is there terrain here"). Callers that just need a base resolve through it instead of hardcoding a zero.2.
ctx.levelBaseAt(x, z)onGeometryContext— how a pure geometry builder, which must not import the scene store, reads the ground. It's a closure rather than a scalar because the sample point is the builder's business (a fence samples its own start; a kind draping a span could sample along it).Calling it also enrolls the kind in terrain invalidation, so asking for the ground is the registration — a plugin follows sculpted terrain with no flag, no capability, and no core change. The two declarative alternatives both fail the goal: a flag on the definition is another per-kind opt-in (the very thing that left half the scene flat), and over-approximating to "every kind with a geometry builder" would rebuild every cabinet and duct on every dab of a brush stroke.
3. Rooms derive their surfaces from the walls that enclose them. Auto floor = highest wall base; auto ceiling = lowest wall top. That's the only pair that cannot open a hole — a floor at the lowest base leaves daylight under every higher wall, and a ceiling at the highest top pokes through the shortest one. Both surfaces stay flat, since a slab is one scalar elevation by schema (
wiki/architecture/vertical-model.md), so a room on a slope reads as a level room cut into the hillside, with the low-side walls filling down to meet it via their existingbaseSegments.This replaces
consensusElevation, which abstained whenever the boundary walls disagreed — so it never placed a surface on a slope at all.Invalidation
The wall geometry signature now folds in a terrain sample taken at the same point the placement samples. Sculpting touches only
site.terrain, so without that term every signature stayed byte-identical and the space-detection sync early-exited — a room's floor could never follow ground that moved under its walls.Deliberately 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 (the delete/recreate feedback loop that code already warns 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
useLiveTerrainand never touch the scene store, so this runs once on release, inside the stroke's ownrunAsSingleSceneHistoryStep— which puts the moved floor and the terrain that moved it in one undo step.Also
siteOfon thenodesrecord identity. It's called per wall per frame (spatial grid) and per wall per store update (space-detection trigger), where an O(N) scan made those callers O(N²).fenceresolves its lift through the new seam.wiki/architecture/vertical-model.mdandwiki/architecture/plugin-authoring.md.Testing
bun teston package sources: 2506 pass, 1 skip, 0 fail (288 files).bun run check-types: clean across all 9 tasks.biome check: clean on the changed packages.space-detection.test.ts(+151 lines) for the highest-base/lowest-top rule, and infence/__tests__/lift.test.ts(+119).🤖 Generated with Claude Code
Note
Medium Risk
Changes core vertical placement, wall support, and auto-room sync used across the editor; behavior is broad but covered by new tests and follows existing terrain grade gating.
Overview
Introduces
levelBaseElevationAtas the default “resting surface when nothing built is under you” (replacing scatteredterrainSupportLift(…) ?? 0), andctx.levelBaseAtonGeometryContextso pure geometry builders can sample ground without the scene store. CallinglevelBaseAtenrolls the node type vianoteLevelBaseConsumer, andmarkTerrainSupportDependentsnow dirties those kinds on terrain changes so baked meshes rebuild.Auto-room floors/ceilings no longer require all boundary walls to agree: the floor uses the highest resolved wall base and the ceiling the lowest wall top (same slab election + ground as rendering). Space-detection includes a per-wall terrain sample in the structure signature so sculpting under an existing room re-derives auto surfaces once per committed stroke.
Wall slab support takes a caller-supplied
levelBaseinstead of assuming0; spatial grid and fence lift/guides follow the same sampling anchor.siteOfis memoized on the nodes record for hot paths. Docs and tests cover sloped rooms and fence/terrain behavior.Reviewed by Cursor Bugbot for commit 3fdf943. Bugbot is set up for automated code reviews on this repo. Configure here.