Skip to content

fix: inherit terrain elevation in rooms, geometry builders, and plugins - #568

Merged
Aymericr merged 1 commit into
mainfrom
fix/inherit-terrain-elevation
Aug 2, 2026
Merged

fix: inherit terrain elevation in rooms, geometry builders, and plugins#568
Aymericr merged 1 commit into
mainfrom
fix/inherit-terrain-elevation

Conversation

@Aymericr

@Aymericr Aymericr commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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:

  • Rooms: an auto floor slab and ceiling placed at the storey plane while the walls that generated them stood on a hillside — floating above the ground or buried under it. Most visible when stamping a room preset.
  • Plugin/other kinds: the Nature plugin's trees, flowers and grass planted at the storey plane instead of on the terrain.

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 nullable terrainSupportLift ("is there terrain here"). Callers that just need a base resolve through it instead of hardcoding a zero.

2. ctx.levelBaseAt(x, z) on GeometryContext — 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 existing baseSegments.

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 useLiveTerrain and never touch the scene store, so this runs once on release, inside the stroke's own runAsSingleSceneHistoryStep — which puts the moved floor and the terrain that moved it in one undo step.

Also

  • Memoizes siteOf on the nodes record 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²).
  • fence resolves its lift through the new seam.
  • Docs updated: wiki/architecture/vertical-model.md and wiki/architecture/plugin-authoring.md.

Testing

  • bun test on 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.
  • New coverage in space-detection.test.ts (+151 lines) for the highest-base/lowest-top rule, and in fence/__tests__/lift.test.ts (+119).
  • Manually verified in the community app that the Nature plugin's plants and stamped room presets both follow sculpted ground.

🤖 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 levelBaseElevationAt as the default “resting surface when nothing built is under you” (replacing scattered terrainSupportLift(…) ?? 0), and ctx.levelBaseAt on GeometryContext so pure geometry builders can sample ground without the scene store. Calling levelBaseAt enrolls the node type via noteLevelBaseConsumer, and markTerrainSupportDependents now 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 levelBase instead of assuming 0; spatial grid and fence lift/guides follow the same sampling anchor. siteOf is 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.

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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

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


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.

@Aymericr
Aymericr merged commit 0230a48 into main Aug 2, 2026
3 checks passed
@Aymericr
Aymericr deleted the fix/inherit-terrain-elevation branch August 2, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant