diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 06adf35d2..458b8165f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,13 +16,14 @@ on:
- ifc-converter
- all
bump:
- description: "Version bump (use 'none' to publish current version as-is)"
+ description: "Version bump (beta publishes a prerelease on the beta dist-tag)"
required: true
type: choice
options:
- patch
- minor
- major
+ - beta
- none
dry-run:
description: "Dry run (no publish)"
@@ -63,6 +64,19 @@ jobs:
bump_version() {
local v=$1
+ if [ "$BUMP" = "beta" ]; then
+ if [[ "$v" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-beta\.([0-9]+)$ ]]; then
+ echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}-beta.$((BASH_REMATCH[4]+1))"
+ return
+ fi
+ IFS='.' read -r MAJ _ _ <<< "${v%%-*}"
+ if [ "$MAJ" -lt 1 ]; then
+ echo "1.0.0-beta.1"
+ else
+ echo "$((MAJ+1)).0.0-beta.1"
+ fi
+ return
+ fi
IFS='.' read -r MAJ MIN PAT <<< "$v"
if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi
if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi
@@ -71,6 +85,12 @@ jobs:
echo "$MAJ.$MIN.$PAT"
}
+ if [ "$BUMP" = "beta" ]; then
+ echo "NPM_TAG=beta" >> "$GITHUB_ENV"
+ else
+ echo "NPM_TAG=latest" >> "$GITHUB_ENV"
+ fi
+
# Track new versions in shell-local vars.
# NOTE: $GITHUB_ENV writes don't surface within the same step, so the
# peerDeps sync below must use shell vars, not env indirection.
@@ -118,9 +138,9 @@ jobs:
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/core@$CORE_VERSION"
- npm publish --dry-run --access public
+ npm publish --dry-run --access public --tag "$NPM_TAG"
else
- npm publish --access public
+ npm publish --access public --tag "$NPM_TAG"
echo "📦 Published @pascal-app/core@$CORE_VERSION"
fi
@@ -133,9 +153,9 @@ jobs:
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/viewer@$VIEWER_VERSION"
- npm publish --dry-run --access public
+ npm publish --dry-run --access public --tag "$NPM_TAG"
else
- npm publish --access public
+ npm publish --access public --tag "$NPM_TAG"
echo "📦 Published @pascal-app/viewer@$VIEWER_VERSION"
fi
@@ -147,9 +167,9 @@ jobs:
run: |
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/editor@$EDITOR_VERSION"
- npm publish --dry-run --access public
+ npm publish --dry-run --access public --tag "$NPM_TAG"
else
- npm publish --access public
+ npm publish --access public --tag "$NPM_TAG"
echo "📦 Published @pascal-app/editor@$EDITOR_VERSION"
fi
@@ -162,9 +182,9 @@ jobs:
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/nodes@$NODES_VERSION"
- npm publish --dry-run --access public
+ npm publish --dry-run --access public --tag "$NPM_TAG"
else
- npm publish --access public
+ npm publish --access public --tag "$NPM_TAG"
echo "📦 Published @pascal-app/nodes@$NODES_VERSION"
fi
@@ -177,9 +197,9 @@ jobs:
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/mcp@$MCP_VERSION"
- npm publish --dry-run --access public
+ npm publish --dry-run --access public --tag "$NPM_TAG"
else
- npm publish --access public
+ npm publish --access public --tag "$NPM_TAG"
echo "📦 Published @pascal-app/mcp@$MCP_VERSION"
fi
@@ -194,9 +214,9 @@ jobs:
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION"
- npm publish --dry-run --access public
+ npm publish --dry-run --access public --tag "$NPM_TAG"
else
- npm publish --access public
+ npm publish --access public --tag "$NPM_TAG"
echo "📦 Published @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION"
fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42b0a6fea..bf9e89076 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,41 @@
# Changelog
+## 1.0.0-beta.1 (2026-07-30)
+
+The first Pascal Editor 1.0 beta. Relative to
+[v0.9.1](https://github.com/pascalorg/editor/releases/tag/v0.9.1), this release
+focuses the editor around a stable extensible scene model and production-grade
+architectural workflows.
+
+### Highlights
+
+- **Terrain sculpting** — raise, lower, flatten, and smooth a compact height field with a persistent brush, live grid feedback, subtle continuous sound, undo-safe strokes, terrain raycasting, and first-person collision.
+- **Terrain-aware construction** — walls, slabs, stairs, fences, columns, items, and other floor-placed nodes resolve stacked support and update live while terrain is sculpted. Wall and slab foundations can fill down to terrain without changing authored height or thickness.
+- **Vertical modeling** — stored storey heights, raised-support drafting, explicit elevation anchors and guides, slab/deck stacking, auto-room surface elevation, wall/ceiling clamps, and support-aware placement above or below slabs.
+- **Plugin and node architecture** — public node definitions, the built-in nodes package, plugin management, host integration primitives, and first-party Nature and MEP workflows.
+- **Floor-plan and export workflows** — faster navigation, contextual dimensions and modes, more reliable placement and selection, textured GLB plus STL/OBJ export, capture framing, and hardened bake/walkthrough paths.
+- **Rendering and interaction quality** — grounded lighting, safer WebGPU/WebGL fallbacks, deterministic snapping, group manipulation, improved camera/compass synchronization, and resilient legacy-scene migration.
+
+### Packages
+
+All public packages are published as `1.0.0-beta.1` under the npm `beta`
+dist-tag. Stable `latest` installations remain on the 0.x line during the beta.
+
+### Contributors
+
+Thank you to [@wass08](https://github.com/wass08),
+[@sudhir9297](https://github.com/sudhir9297),
+[@anton-pascal](https://github.com/anton-pascal),
+[@konevenkatesh](https://github.com/konevenkatesh),
+[@MateoSaettone](https://github.com/MateoSaettone),
+[@ruok-dev](https://github.com/ruok-dev),
+[@mvanhorn](https://github.com/mvanhorn), and
+[@kuishou68](https://github.com/kuishou68) for their work across the editor,
+viewer, node library, MCP integration, documentation, and stability fixes.
+
+**Full changelog**:
+https://github.com/pascalorg/editor/compare/v0.9.1...v1.0.0-beta.1
+
## 0.6.0 (2026-04-21)
### Features
diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx
index c43136aa1..0440e1d1e 100644
--- a/apps/editor/components/build-tab.tsx
+++ b/apps/editor/components/build-tab.tsx
@@ -6,6 +6,7 @@ import {
getFloorplanNodeExtension,
isFloorplanToolAvailableInMode,
MaterialPaintPanel,
+ TerrainSculptPanel,
triggerSFX,
useEditor,
useFloorplanMode,
@@ -46,7 +47,7 @@ type BuildType = {
kind?: string
paletteOrder?: number
/** Non-placement special mode. */
- mode?: 'material-paint'
+ mode?: 'material-paint' | 'terrain-sculpt'
}
type MepItem = {
@@ -74,6 +75,7 @@ const BASE_BUILD_TYPES: BuildType[] = [
// Group tile — no tool of its own; opens the MEP sub-grid below (like Roof).
{ id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' },
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
+ { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' },
]
function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] {
@@ -156,6 +158,14 @@ function activatePaintMode(): void {
ed.setMode('material-paint')
}
+/**
+ * Enter terrain-sculpt mode — the Build tab's "Terrain" category. No `setPhase`:
+ * `setMode` moves to the site phase itself, since sculpting is a site-phase mode.
+ */
+function activateTerrainSculptMode(): void {
+ useEditor.getState().setMode('terrain-sculpt')
+}
+
type RoofFeature = { kind: string; label: string; iconSrc: string }
const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp'
@@ -251,7 +261,7 @@ export function BuildTab() {
const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)
const isTypeActive = (type: BuildType) => {
- if (type.mode === 'material-paint') return mode === 'material-paint'
+ if (type.mode) return mode === type.mode
if (type.id === 'mep') return isMepActive
if (type.id === 'roof')
return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive)
@@ -261,6 +271,8 @@ export function BuildTab() {
const handleTypeClick = useCallback((type: BuildType) => {
if (type.mode === 'material-paint') {
activatePaintMode()
+ } else if (type.mode === 'terrain-sculpt') {
+ activateTerrainSculptMode()
} else if (type.id === 'mep') {
// MEP is a group tile: arm its first tool so a usable tool is active
// (and we leave any prior paint mode), then reveal the MEP sub-grid.
@@ -332,6 +344,10 @@ export function BuildTab() {
+ ) : mode === 'terrain-sculpt' ? (
+
+
+
) : mode === 'build' &&
(activeTool === 'roof' || isRoofFeatureActive) &&
roofFeatures.length > 0 ? (
diff --git a/apps/editor/public/audios/sfx/terrain_flatten.mp3 b/apps/editor/public/audios/sfx/terrain_flatten.mp3
new file mode 100644
index 000000000..c472bd9ac
Binary files /dev/null and b/apps/editor/public/audios/sfx/terrain_flatten.mp3 differ
diff --git a/apps/editor/public/audios/sfx/terrain_lower.mp3 b/apps/editor/public/audios/sfx/terrain_lower.mp3
new file mode 100644
index 000000000..d8bfaa495
Binary files /dev/null and b/apps/editor/public/audios/sfx/terrain_lower.mp3 differ
diff --git a/apps/editor/public/audios/sfx/terrain_raise.mp3 b/apps/editor/public/audios/sfx/terrain_raise.mp3
new file mode 100644
index 000000000..f54e651b8
Binary files /dev/null and b/apps/editor/public/audios/sfx/terrain_raise.mp3 differ
diff --git a/apps/editor/public/audios/sfx/terrain_smooth.mp3 b/apps/editor/public/audios/sfx/terrain_smooth.mp3
new file mode 100644
index 000000000..3b541c8dc
Binary files /dev/null and b/apps/editor/public/audios/sfx/terrain_smooth.mp3 differ
diff --git a/apps/editor/public/icons/terrain-flatten.webp b/apps/editor/public/icons/terrain-flatten.webp
new file mode 100644
index 000000000..f62d3281e
Binary files /dev/null and b/apps/editor/public/icons/terrain-flatten.webp differ
diff --git a/apps/editor/public/icons/terrain-lower.webp b/apps/editor/public/icons/terrain-lower.webp
new file mode 100644
index 000000000..e60a3fe0d
Binary files /dev/null and b/apps/editor/public/icons/terrain-lower.webp differ
diff --git a/apps/editor/public/icons/terrain-raise.webp b/apps/editor/public/icons/terrain-raise.webp
new file mode 100644
index 000000000..ad9cee6a9
Binary files /dev/null and b/apps/editor/public/icons/terrain-raise.webp differ
diff --git a/apps/editor/public/icons/terrain-smooth.webp b/apps/editor/public/icons/terrain-smooth.webp
new file mode 100644
index 000000000..f6162dc06
Binary files /dev/null and b/apps/editor/public/icons/terrain-smooth.webp differ
diff --git a/package.json b/package.json
index 083e1e251..7c318deab 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache",
"restart": "bun kill && bun clean:cache && bun dev",
"release": "gh workflow run release.yml -f package=all -f bump=patch",
+ "release:beta": "gh workflow run release.yml -f package=all -f bump=beta",
"release:viewer": "gh workflow run release.yml -f package=viewer -f bump=patch",
"release:core": "gh workflow run release.yml -f package=core -f bump=patch",
"release:editor": "gh workflow run release.yml -f package=editor -f bump=patch",
diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts
index ebdb24c3e..21c9eb5a6 100644
--- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts
+++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts
@@ -1,10 +1,16 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
+import { encodeTerrainField } from '../../lib/terrain-codec'
+import { applyHeightPatch, createTerrainField, flattenPatch } from '../../lib/terrain-field'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
-import { getFloorPlacedElevation, getFloorStackedPosition } from './floor-placed-elevation'
+import {
+ GROUND_SUPPORT_ID,
+ getFloorPlacedElevation,
+ getFloorStackedPosition,
+} from './floor-placed-elevation'
import { spatialGridManager } from './spatial-grid-manager'
const LEVEL_ID = 'level_test'
@@ -85,6 +91,26 @@ function nodesFor(...nodes: AnyNode[]): Record {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
+/** A point on the plateau below, and the ground height there. */
+const ON_PLATEAU: [number, number, number] = [3, 0, 3]
+const PLATEAU_HEIGHT = 2.5
+
+/** A site whose ground is a 2.5 m plateau over x,z ∈ [2,5] and flat at 0 elsewhere. */
+function makeSite(): AnyNode {
+ const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, PLATEAU_HEIGHT)
+ return {
+ id: 'site_test',
+ type: 'site',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ terrain: encodeTerrainField(applyHeightPatch(base, patch as never)),
+ } as unknown as AnyNode
+}
+
describe('floor-placed elevation resolver', () => {
beforeEach(() => {
nodeRegistry._reset()
@@ -270,8 +296,13 @@ describe('floor-placed elevation resolver', () => {
}),
)
- const original = spatialGridManager.getSlabElevationForItem
- spatialGridManager.getSlabElevationForItem = (() => Number.NaN) as typeof original
+ // A winning slab whose stored elevation is corrupt: the id must be present, or
+ // the resolver takes the no-slab path and this asserts nothing.
+ const original = spatialGridManager.getSlabSupportForItem
+ spatialGridManager.getSlabSupportForItem = (() => ({
+ elevation: Number.NaN,
+ slabId: 'slab_corrupt',
+ })) as typeof original
try {
const level = makeLevel()
@@ -286,7 +317,7 @@ describe('floor-placed elevation resolver', () => {
}),
).toBe(0)
} finally {
- spatialGridManager.getSlabElevationForItem = original
+ spatialGridManager.getSlabSupportForItem = original
}
})
@@ -478,4 +509,208 @@ describe('floor-placed elevation resolver', () => {
}),
).toBeCloseTo(0.8)
})
+
+ describe('terrain as the level base', () => {
+ function registerItem(capabilities: Record = {}) {
+ registerNode(
+ makeDefinition('item', {
+ floorPlaced: {
+ footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
+ ...capabilities,
+ },
+ }),
+ )
+ }
+
+ test('the no-winning-support fallback follows the ground', () => {
+ registerItem()
+ const level = makeLevel()
+ const site = makeSite()
+ const node = makeFloorNode()
+
+ // No slab anywhere, so the node rests on the level base — the terrain.
+ expect(
+ getFloorPlacedElevation({
+ node,
+ nodes: nodesFor(site, level, node),
+ position: ON_PLATEAU,
+ rotation: [0, 0, 0],
+ }),
+ ).toBeCloseTo(PLATEAU_HEIGHT)
+
+ // Off the plateau the same scene reports flat ground, so nothing moves.
+ expect(
+ getFloorPlacedElevation({
+ node,
+ nodes: nodesFor(site, level, node),
+ position: [-3, 0, -3],
+ rotation: [0, 0, 0],
+ }),
+ ).toBeCloseTo(0)
+ })
+
+ test('an elected slab still wins over the ground under it', () => {
+ registerItem()
+ addSlab(
+ [
+ [2, 2],
+ [5, 2],
+ [5, 5],
+ [2, 5],
+ ],
+ 0.35,
+ )
+ const level = makeLevel()
+ const site = makeSite()
+ const node = makeFloorNode()
+
+ // A built floor is flat by definition — terrain does not compete with one.
+ expect(
+ getFloorPlacedElevation({
+ node,
+ nodes: nodesFor(site, level, node),
+ position: ON_PLATEAU,
+ rotation: [0, 0, 0],
+ }),
+ ).toBeCloseTo(0.35)
+ })
+
+ test('the ground sentinel host resolves to the ground, not to 0', () => {
+ registerItem()
+ // A deck overlapping the footprint above the pointer cap: the sentinel is
+ // exactly what stops the election from lifting the node onto it, and before
+ // terrain it meant "y = 0". Now it means "the ground under this node".
+ addSlab(
+ [
+ [2, 2],
+ [5, 2],
+ [5, 5],
+ [2, 5],
+ ],
+ 1.2,
+ 'slab_deck',
+ )
+ const level = makeLevel()
+ const site = makeSite()
+ const node = makeFloorNode({ supportSlabId: GROUND_SUPPORT_ID } as Partial)
+
+ expect(
+ getFloorPlacedElevation({
+ node,
+ nodes: nodesFor(site, level, node),
+ position: ON_PLATEAU,
+ rotation: [0, 0, 0],
+ }),
+ ).toBeCloseTo(PLATEAU_HEIGHT)
+ })
+
+ test('stacks the ground onto the canonical position', () => {
+ registerItem()
+ const level = makeLevel()
+ const site = makeSite()
+ const node = makeFloorNode()
+
+ const stacked = getFloorStackedPosition({
+ node,
+ nodes: nodesFor(site, level, node),
+ position: [3, 0.1, 3],
+ rotation: [0, 0, 0],
+ })
+ expect(stacked[0]).toBe(3)
+ expect(stacked[1]).toBeCloseTo(PLATEAU_HEIGHT + 0.1)
+ expect(stacked[2]).toBe(3)
+ })
+
+ test('a composite footprint straddling a recess rests on the higher support', () => {
+ registerNode(
+ makeDefinition('item', {
+ floorPlaced: {
+ footprints: () => [
+ { position: [3, 0, 3], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
+ { position: [-3, 0, -3], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
+ ],
+ },
+ }),
+ )
+ // A recess under one half only. Electing the base per footprint is what keeps
+ // the node on the flat ground the other half sits on instead of sinking it.
+ addSlab(
+ [
+ [2, 2],
+ [5, 2],
+ [5, 5],
+ [2, 5],
+ ],
+ -0.3,
+ 'slab_recess',
+ )
+ const level = makeLevel()
+ const node = makeFloorNode()
+
+ // Without terrain: the bare-floor half wins at 0, not the recess at -0.3.
+ expect(
+ getFloorPlacedElevation({
+ node,
+ nodes: nodesFor(level, node),
+ position: [0, 0, 0],
+ rotation: [0, 0, 0],
+ }),
+ ).toBeCloseTo(0)
+
+ // With terrain the ground under the node is the plateau, which still wins.
+ expect(
+ getFloorPlacedElevation({
+ node,
+ nodes: nodesFor(makeSite(), level, node),
+ position: ON_PLATEAU,
+ rotation: [0, 0, 0],
+ }),
+ ).toBeCloseTo(PLATEAU_HEIGHT)
+ })
+
+ test('the six no-op branches still return exactly 0 over terrain', () => {
+ // The whole risk of this change: `0` in these branches means "do not touch
+ // this node's Y", not "the ground is at 0". Substituting terrain here would
+ // lift wall sconces off their walls and cabinet interiors out of their runs.
+ const site = makeSite()
+ const level = makeLevel()
+
+ const atZero = (nodes: Record, node: AnyNode, levelId?: string) =>
+ getFloorPlacedElevation({
+ node,
+ nodes,
+ position: ON_PLATEAU,
+ rotation: [0, 0, 0],
+ ...(levelId ? { levelId } : {}),
+ })
+
+ // 1. No floorPlaced capability.
+ registerNode(makeDefinition('item'))
+ const plain = makeFloorNode()
+ expect(atZero(nodesFor(site, level, plain), plain)).toBe(0)
+
+ // 2. `applies` opts out — the wall-mounted case.
+ nodeRegistry._reset()
+ registerItem({ applies: () => false })
+ expect(atZero(nodesFor(site, level, plain), plain)).toBe(0)
+
+ // 3. The declared parent is missing.
+ nodeRegistry._reset()
+ registerItem()
+ const orphan = makeFloorNode({ parentId: 'missing_level' })
+ expect(atZero(nodesFor(site, orphan), orphan)).toBe(0)
+
+ // 4. A non-`level` parent — hosted by a shelf, not by the storey.
+ const shelf = { id: 'shelf_test', type: 'shelf', parentId: LEVEL_ID } as unknown as AnyNode
+ const shelved = makeFloorNode({ parentId: shelf.id })
+ expect(atZero(nodesFor(site, level, shelf, shelved), shelved)).toBe(0)
+
+ // 5. No parent and no levelId.
+ const unparented = makeFloorNode({ parentId: null })
+ expect(atZero(nodesFor(site, unparented), unparented)).toBe(0)
+
+ // 6. An empty levelId string resolves to nothing.
+ expect(atZero(nodesFor(site, unparented), unparented, '')).toBe(0)
+ })
+ })
})
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 ca66b3cc3..d2b674625 100644
--- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts
+++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts
@@ -1,3 +1,4 @@
+import { terrainSupportLift } from '../../lib/terrain-support'
import { nodeRegistry } from '../../registry'
import type {
FloorPlacedConfig,
@@ -8,6 +9,10 @@ import type {
import type { AnyNode, AnyNodeId } from '../../schema'
import { spatialGridManager } from './spatial-grid-manager'
+export { GROUND_SUPPORT_ID } from './support-host-id'
+
+import { GROUND_SUPPORT_ID } from './support-host-id'
+
/**
* Sentinel `supportSlabId` meaning "hosted by the level base (ground)".
* Persisted when a pointer-capped commit elects the ground while one or
@@ -15,8 +20,6 @@ import { spatialGridManager } from './spatial-grid-manager'
* cap — without it, the uncapped per-frame election would lift the
* committed node back onto the deck.
*/
-export const GROUND_SUPPORT_ID = 'ground'
-
export type FloorPlacedElevationArgs = {
node: AnyNode
nodes: Record
@@ -86,6 +89,29 @@ export function getFloorPlacedElevation({
const footprints = getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })
+ /**
+ * What "the level base" evaluates to: the sculpted ground under this node, or 0
+ * when the scene has no terrain / this storey is not at grade.
+ *
+ * Sampled once at the node's own XZ — not per footprint, and not averaged over
+ * the footprint. One sample per node is what keeps a row of columns individually
+ * correct on a slope while a composite node (a cabinet run, an L-shaped desk)
+ * stays rigid instead of shearing across its own parts. Kinds that need a level
+ * pad (stairs, a building's ground slab) get one by flattening the terrain under
+ * them, which is a scene edit and therefore visible and undoable — not by
+ * silently disagreeing with the ground here.
+ *
+ * Deliberately called only where level-base support is asserted. The six
+ * `0`-returns above mean "do not touch this node's Y" — an attached item, a
+ * non-`level` parent, a broken graph — and lifting those would pull wall sconces
+ * and cabinet interiors off their hosts.
+ */
+ let groundLiftCache: number | null = null
+ const groundLift = (): number => {
+ groundLiftCache ??= terrainSupportLift(nodes, resolvedLevelId, position[0], position[2]) ?? 0
+ return groundLiftCache
+ }
+
// A persisted support host pins the elevation while it still exists and
// overlaps a footprint — deterministic across stacked slabs. A stale
// host (deleted or reshaped away) silently falls through to the
@@ -94,7 +120,7 @@ export function getFloorPlacedElevation({
// host, decides the target surface during a drag.
const supportSlabId = (effectiveNode as { supportSlabId?: string | null }).supportSlabId
if (maxElevation == null && supportSlabId) {
- if (supportSlabId === GROUND_SUPPORT_ID) return 0
+ if (supportSlabId === GROUND_SUPPORT_ID) return groundLift()
for (const footprint of footprints) {
const hosted = spatialGridManager.getHostSlabElevationForFootprint(
resolvedLevelId,
@@ -107,24 +133,34 @@ export function getFloorPlacedElevation({
}
}
+ // Per footprint the support is its winning slab, or the level base when no slab
+ // overlaps it; the node rests on the highest of them. The *slab id* is what says
+ // which of the two it is — `getSlabSupportForItem` reports a no-winner election
+ // as elevation 0, and terrain makes that ambiguous: a slab flush with the storey
+ // base and bare ground both read as 0, and only the second follows a hillside.
+ //
+ // Electing the base per footprint rather than as a whole-node fallback is what
+ // keeps this identical on flat ground, where `groundLift()` is 0: a composite node
+ // straddling a recessed slab and bare floor still rests on the floor rather than
+ // sinking into the recess.
let elected = Number.NEGATIVE_INFINITY
for (const footprint of footprints) {
- const footprintPosition = footprint.position ?? position
- const elevation = finiteSlabElevation(
- spatialGridManager.getSlabElevationForItem(
- resolvedLevelId,
- footprintPosition,
- footprint.dimensions,
- footprint.rotation,
- maxElevation,
- ),
+ const support = spatialGridManager.getSlabSupportForItem(
+ resolvedLevelId,
+ footprint.position ?? position,
+ footprint.dimensions,
+ footprint.rotation,
+ maxElevation,
)
+ const elevation =
+ support.slabId === null ? groundLift() : finiteSlabElevation(support.elevation)
if (elevation > elected) {
elected = elevation
}
}
- return elected === Number.NEGATIVE_INFINITY ? 0 : elected
+ // A kind with no footprint at all still rests on the ground.
+ return elected === Number.NEGATIVE_INFINITY ? groundLift() : elected
}
export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] {
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 f73fce4c1..f08e3b1ab 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
@@ -1,4 +1,5 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
+import { terrainSupportLift } from '../../lib/terrain-support'
import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
@@ -16,6 +17,7 @@ import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { SpatialGrid } from './spatial-grid'
+import { GROUND_SUPPORT_ID } from './support-host-id'
import { WallSpatialGrid } from './wall-spatial-grid'
export {
@@ -372,6 +374,8 @@ export class SpatialGridManager {
wall.curveOffset ?? 0,
wall.thickness,
wall.supportSlabId ?? null,
+ undefined,
+ wall.supportOffset,
)
return resolveWallEffectiveHeight(
wall,
@@ -1051,24 +1055,48 @@ export class SpatialGridManager {
thickness = DEFAULT_WALL_THICKNESS,
preferredSlabId?: string | null,
maxElevation?: number | null,
+ supportOffset = 0,
): WallSlabSupport {
+ if (preferredSlabId === GROUND_SUPPORT_ID) {
+ const nodes = useScene.getState().nodes
+ const elevation =
+ (terrainSupportLift(nodes, levelId, start[0], start[1]) ?? 0) + supportOffset
+ return {
+ elevation,
+ electedSlabId: null,
+ baseElevation: elevation,
+ baseSegments: [{ start: 0, end: 1, elevation }],
+ }
+ }
+
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) {
+ const elevation = supportOffset
return {
- elevation: 0,
+ elevation,
electedSlabId: null,
- baseElevation: 0,
- baseSegments: [{ start: 0, end: 1, elevation: 0 }],
+ baseElevation: elevation,
+ baseSegments: [{ start: 0, end: 1, elevation }],
}
}
- return computeWallSlabSupport(
+ const support = computeWallSlabSupport(
{ start, end, curveOffset, thickness },
[...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)),
this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)),
preferredSlabId,
maxElevation,
)
+ if (supportOffset === 0) return support
+ return {
+ ...support,
+ elevation: support.elevation + supportOffset,
+ baseElevation: support.baseElevation + supportOffset,
+ baseSegments: support.baseSegments.map((segment) => ({
+ ...segment,
+ elevation: segment.elevation + supportOffset,
+ })),
+ }
}
/**
@@ -1197,6 +1225,24 @@ export class SpatialGridManager {
// Singleton instance
export const spatialGridManager = new SpatialGridManager()
+/** Level-local Y where the rendered wall mesh begins. */
+export function getWallBaseElevationForNodes(
+ wall: WallNode,
+ nodes: Record,
+): number {
+ const levelId = resolveNodeLevelId(wall, nodes)
+ return spatialGridManager.getSlabSupportForWall(
+ levelId,
+ wall.start,
+ wall.end,
+ wall.curveOffset ?? 0,
+ wall.thickness,
+ wall.supportSlabId ?? null,
+ undefined,
+ wall.supportOffset,
+ ).elevation
+}
+
/**
* Effective (extruded) height of a wall resolved from a nodes record:
* {@link resolveWallEffectiveHeight} over the covering-clamped plane top
@@ -1210,13 +1256,6 @@ export function getWallEffectiveHeightForNodes(
nodes: Record,
): number {
const levelId = resolveNodeLevelId(wall, nodes)
- const support = spatialGridManager.getSlabSupportForWall(
- levelId,
- wall.start,
- wall.end,
- wall.curveOffset ?? 0,
- wall.thickness,
- wall.supportSlabId ?? null,
- )
- return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), support.elevation)
+ const baseElevation = getWallBaseElevationForNodes(wall, nodes)
+ return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation)
}
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts
index 280db599b..d96ee2f36 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts
@@ -1,12 +1,21 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
+import { z } from 'zod'
+import { encodeTerrainField } from '../../lib/terrain-codec'
+import { applyHeightPatch, createTerrainField, flattenPatch } from '../../lib/terrain-field'
+import { nodeRegistry, registerNode } from '../../registry'
+import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, AnyNodeId } from '../../schema'
+import useLiveTerrain from '../../store/use-live-terrain'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
import {
initSpatialGridSync,
markCoveringDependentsBelow,
markLevelHeightDependents,
+ markSlabChangeDependents,
+ markTerrainSupportDependents,
} from './spatial-grid-sync'
+import { GROUND_SUPPORT_ID } from './support-host-id'
const SQUARE: Array<[number, number]> = [
[0, 0],
@@ -286,4 +295,273 @@ describe('sync dirty helpers (pure)', () => {
markCoveringDependentsBelow('level_0', nodes, markDirty)
expect(marked).toEqual([])
})
+
+ test('markSlabChangeDependents covers top, deck, and underside consumers', () => {
+ const previous = makeSlab('slab_a', 'level_1', { elevation: 0.2, thickness: 0.2 })
+ const next = { ...previous, elevation: 0.4, thickness: 0.4 } as AnyNode
+ const sameLevelWall = makeChild('wall_same', 'wall', 'level_1')
+ const attachedStair = {
+ ...makeChild('stair_a', 'stair', 'level_1'),
+ deckSlabId: 'slab_a',
+ } as AnyNode
+ const belowWall = makeChild('wall_below', 'wall', 'level_0')
+ const belowCeiling = makeChild('ceiling_below', 'ceiling', 'level_0')
+ const nodes = nodesFor(
+ makeLevel('level_0', 0, 2.5, ['wall_below', 'ceiling_below']),
+ makeLevel('level_1', 1, 2.5, ['slab_a', 'wall_same', 'stair_a']),
+ next,
+ sameLevelWall,
+ attachedStair,
+ belowWall,
+ belowCeiling,
+ )
+
+ const { marked, markDirty } = collect()
+ markSlabChangeDependents(previous as never, next as never, nodes, markDirty)
+
+ expect([...new Set(marked)].sort()).toEqual([
+ 'ceiling_below',
+ 'stair_a',
+ 'wall_below',
+ 'wall_same',
+ ])
+ })
+})
+
+// The sculpt-desync rule. Nothing else in this file gates on a *node's* support
+// being terrain, so these are the tests that keep a stroke from silently leaving
+// the scene standing at the height the ground used to be.
+describe('spatial-grid sync dirty rules (terrain support)', () => {
+ const PLATEAU_HEIGHT = 2.5
+
+ /** Ground that is a 2.5 m plateau over x,z ∈ [2,5] and flat at the datum elsewhere. */
+ function terrainData() {
+ const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, PLATEAU_HEIGHT)
+ return encodeTerrainField(applyHeightPatch(base, patch as never))
+ }
+
+ function makeSite(overrides: Record = {}): AnyNode {
+ return {
+ id: 'site_test',
+ type: 'site',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ ...overrides,
+ } as unknown as AnyNode
+ }
+
+ function makeFloorNode(id: string, parentId: string, position: [number, number, number]) {
+ return {
+ id,
+ type: 'column',
+ object: 'node',
+ parentId,
+ visible: true,
+ metadata: {},
+ children: [],
+ position,
+ rotation: [0, 0, 0],
+ } as unknown as AnyNode
+ }
+
+ function registerFloorPlaced(kind: string) {
+ registerNode({
+ kind,
+ schemaVersion: 1,
+ schema: z.object({ type: z.literal(kind) }) as never,
+ category: 'structure',
+ defaults: () => ({}) as never,
+ capabilities: { floorPlaced: { footprint: () => ({ dimensions: [0.3, 2.5, 0.3] }) } },
+ } as unknown as AnyNodeDefinition)
+ }
+
+ let stopSync = () => {}
+
+ function startWith(nodes: Record, rootNodeIds: string[]) {
+ spatialGridManager.clear()
+ useScene.setState({
+ collections: {},
+ dirtyNodes: new Set(),
+ nodes,
+ readOnly: false,
+ rootNodeIds: rootNodeIds as AnyNodeId[],
+ } as never)
+ clearSceneHistory()
+ stopSync = initSpatialGridSync()
+ useScene.setState({ dirtyNodes: new Set() })
+ }
+
+ beforeEach(() => {
+ nodeRegistry._reset()
+ registerFloorPlaced('column')
+ useLiveTerrain.getState().endAll()
+ })
+
+ afterEach(() => {
+ stopSync()
+ stopSync = () => {}
+ useLiveTerrain.getState().endAll()
+ nodeRegistry._reset()
+ })
+
+ test('a sculpt stroke marks the nodes standing on the ground it moved', () => {
+ const level = makeLevel('level_0', 0, 2.5, ['column_a'])
+ const column = makeFloorNode('column_a', 'level_0', [3, 0, 3])
+ const site = makeSite()
+ startWith(nodesFor(level, column, site), ['level_0', 'site_test'])
+
+ useScene.setState({
+ nodes: {
+ ...useScene.getState().nodes,
+ site_test: makeSite({ terrain: terrainData() }),
+ } as never,
+ })
+
+ expect(useScene.getState().dirtyNodes.has('column_a' as AnyNodeId)).toBe(true)
+ })
+
+ test('live dabs re-elevate dependents without writing the scene graph', () => {
+ const level = makeLevel('level_0', 0, 2.5, ['column_a'])
+ const column = makeFloorNode('column_a', 'level_0', [3, 0, 3])
+ const site = makeSite()
+ startWith(nodesFor(level, column, site), ['level_0', 'site_test'])
+ const nodesBeforeStroke = useScene.getState().nodes
+
+ const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, PLATEAU_HEIGHT)
+ const live = applyHeightPatch(base, patch as never)
+ useLiveTerrain.getState().begin(site.id, base)
+ useScene.setState({ dirtyNodes: new Set() })
+
+ useLiveTerrain.getState().advance(site.id, live, patch as never)
+
+ expect(useScene.getState().nodes).toBe(nodesBeforeStroke)
+ expect(dirtyIds()).toEqual(['column_a'])
+
+ useScene.setState({ dirtyNodes: new Set() })
+ useLiveTerrain.getState().end(site.id)
+ expect(dirtyIds()).toEqual(['column_a'])
+ })
+
+ test('clearing the terrain marks them too, so they come back down with the ground', () => {
+ const level = makeLevel('level_0', 0, 2.5, ['column_a'])
+ const column = makeFloorNode('column_a', 'level_0', [3, 0, 3])
+ startWith(nodesFor(level, column, makeSite({ terrain: terrainData() })), [
+ 'level_0',
+ 'site_test',
+ ])
+
+ useScene.setState({
+ nodes: { ...useScene.getState().nodes, site_test: makeSite() } as never,
+ })
+
+ expect(useScene.getState().dirtyNodes.has('column_a' as AnyNodeId)).toBe(true)
+ })
+
+ test('a site edit that leaves the terrain object alone marks nothing', () => {
+ const level = makeLevel('level_0', 0, 2.5, ['column_a'])
+ const column = makeFloorNode('column_a', 'level_0', [3, 0, 3])
+ const terrain = terrainData()
+ startWith(nodesFor(level, column, makeSite({ terrain })), ['level_0', 'site_test'])
+
+ // Same `terrain` object, different polygon — renaming a lot or reshaping its
+ // boundary must not re-elevate the whole scene every keystroke.
+ useScene.setState({
+ nodes: {
+ ...useScene.getState().nodes,
+ site_test: makeSite({ terrain, polygon: { points: SQUARE } }),
+ } as never,
+ })
+
+ expect(dirtyIds()).toEqual([])
+ })
+
+ test('only storeys at grade follow the ground', () => {
+ // level_1 sits a storey up on level_0, so its floor is not the datum: its
+ // contents have a real slab under them and must not be draped.
+ const ground = makeLevel('level_0', 0, 2.5, ['column_ground'])
+ const upper = makeLevel('level_1', 1, 2.5, ['column_upper'])
+ const building = {
+ id: 'building_a',
+ type: 'building',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: ['level_0', 'level_1'],
+ position: [0, 0, 0],
+ rotation: [0, 0, 0],
+ } as unknown as AnyNode
+ const groundColumn = makeFloorNode('column_ground', 'level_0', [3, 0, 3])
+ const upperColumn = makeFloorNode('column_upper', 'level_1', [3, 0, 3])
+ startWith(
+ nodesFor(
+ building,
+ { ...ground, parentId: 'building_a' } as AnyNode,
+ { ...upper, parentId: 'building_a' } as AnyNode,
+ groundColumn,
+ upperColumn,
+ makeSite(),
+ ),
+ ['building_a', 'site_test'],
+ )
+
+ useScene.setState({
+ nodes: {
+ ...useScene.getState().nodes,
+ site_test: makeSite({ terrain: terrainData() }),
+ } as never,
+ })
+
+ expect(dirtyIds()).toEqual(['column_ground'])
+ })
+
+ test('markTerrainSupportDependents skips nodes hosted on another node', () => {
+ // A column parented to another column inherits Y from that group; lifting it
+ // here would double-count the ground under its host.
+ const level = makeLevel('level_0', 0, 2.5, ['column_host'])
+ const host = makeFloorNode('column_host', 'level_0', [3, 0, 3])
+ const hosted = makeFloorNode('column_hosted', 'column_host', [0, 0, 0])
+ const nodes = nodesFor(level, host, hosted)
+
+ const marked: string[] = []
+ markTerrainSupportDependents(nodes, (id) => marked.push(id))
+
+ expect(marked).toEqual(['column_host'])
+ })
+
+ test('markTerrainSupportDependents includes ground-hosted and terrain-fill structures', () => {
+ const level = makeLevel('level_0', 0, 2.5, [
+ 'wall_ground',
+ 'wall_fill',
+ 'wall_other',
+ 'slab_fill',
+ 'slab_other',
+ 'column_a',
+ ])
+ const nodes = nodesFor(
+ level,
+ {
+ ...makeChild('wall_ground', 'wall', 'level_0'),
+ supportSlabId: GROUND_SUPPORT_ID,
+ } as AnyNode,
+ {
+ ...makeChild('wall_fill', 'wall', 'level_0'),
+ fillToTerrain: true,
+ } as AnyNode,
+ makeChild('wall_other', 'wall', 'level_0'),
+ makeSlab('slab_fill', 'level_0', { fillToTerrain: true } as Partial),
+ makeSlab('slab_other', 'level_0'),
+ makeFloorNode('column_a', 'level_0', [3, 0, 3]),
+ )
+
+ const marked: string[] = []
+ markTerrainSupportDependents(nodes, (id) => marked.push(id))
+
+ expect(marked).toEqual(['wall_ground', 'wall_fill', 'slab_fill', 'column_a'])
+ })
})
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 886d59701..4453fdc07 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
@@ -1,7 +1,9 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
+import { isLevelAtSiteDatum } from '../../lib/terrain-support'
import { nodeRegistry } from '../../registry'
-import type { AnyNode, AnyNodeId, LevelNode, SlabNode, WallNode } from '../../schema'
+import type { AnyNode, AnyNodeId, LevelNode, SiteNode, SlabNode, WallNode } from '../../schema'
import { getLevelBelow } from '../../services/storey'
+import useLiveTerrain from '../../store/use-live-terrain'
import useScene from '../../store/use-scene'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import {
@@ -9,6 +11,7 @@ import {
spatialGridManager,
wallOverlapsPolygon,
} from './spatial-grid-manager'
+import { GROUND_SUPPORT_ID } from './support-host-id'
export function resolveLevelId(node: AnyNode, nodes: Record): string {
// If the node itself is a level
@@ -107,7 +110,7 @@ export function initSpatialGridSync(): () => void {
const markDirty = (id: AnyNodeId) => store.getState().markDirty(id)
// Subscribe to all changes
- const unsubscribe = store.subscribe((state, prevState) => {
+ const unsubscribeScene = store.subscribe((state, prevState) => {
// Detect added nodes
for (const [id, node] of Object.entries(state.nodes)) {
if (!prevState.nodes[id as AnyNode['id']]) {
@@ -119,6 +122,13 @@ export function initSpatialGridSync(): () => void {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
markCoveringDependentsBelow(levelId, state.nodes, markDirty)
}
+
+ // A site arriving with terrain already on it (scene load, paste,
+ // imported elevation data) is the same event as a stroke: ground exists
+ // where flat ground was assumed.
+ if (node.type === 'site' && (node as SiteNode).terrain) {
+ markTerrainSupportDependents(state.nodes, markDirty)
+ }
}
}
@@ -133,6 +143,12 @@ export function initSpatialGridSync(): () => void {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
markCoveringDependentsBelow(levelId, state.nodes, markDirty)
}
+
+ // Deleting a sculpted site drops the ground back to the datum, so its
+ // contents have to come down with it.
+ if (node.type === 'site' && (node as SiteNode).terrain) {
+ markTerrainSupportDependents(state.nodes, markDirty)
+ }
}
}
@@ -166,28 +182,20 @@ export function initSpatialGridSync(): () => void {
if (supportChanged) {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId)
-
- // Mark nodes overlapping old polygon and new polygon as dirty
- markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty)
- markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
- }
- if (node.elevation !== prev.elevation) {
- markDeckAttachedStairs(node.id, state.nodes, markDirty)
- }
- // The covering bound over the level below also moves with thickness
- // (underside = elevation − thickness) and recessed (pools never
- // cover), which same-level support ignores.
- if (
- supportChanged ||
- node.thickness !== prev.thickness ||
- node.recessed !== prev.recessed
- ) {
- markCoveringDependentsBelow(resolveLevelId(node, state.nodes), state.nodes, markDirty)
}
+ markSlabChangeDependents(prev as SlabNode, node as SlabNode, state.nodes, markDirty)
} else if (node.type === 'level' && prev.type === 'level') {
if (node.height !== prev.height) {
markLevelHeightDependents(node as LevelNode, state.nodes, markDirty)
}
+ } else if (node.type === 'site' && prev.type === 'site') {
+ // Object identity, not deep equality: the store is
+ // immutable-by-convention, so a sculpt commit necessarily produces a new
+ // `terrain` object and an unrelated site edit (polygon, name) keeps the
+ // old one. The same reasoning `terrain-source`'s field cache is keyed on.
+ if ((node as SiteNode).terrain !== (prev as SiteNode).terrain) {
+ markTerrainSupportDependents(state.nodes, markDirty)
+ }
} else if (node.type === 'wall' && prev.type === 'wall') {
if (
node.start !== prev.start ||
@@ -204,7 +212,19 @@ export function initSpatialGridSync(): () => void {
}
})
- return unsubscribe
+ // Live terrain is deliberately not written into `useScene` per dab: doing so
+ // would encode the whole field, flood history, and wake every scene subscriber.
+ // Reuse the committed-terrain dependency sweep against the transient field
+ // instead. Dirty marks coalesce in their Set until the next frame, while an
+ // `end` notification also restores every dependent after an abandoned stroke.
+ const unsubscribeLiveTerrain = useLiveTerrain.subscribe(() => {
+ markTerrainSupportDependents(store.getState().nodes, markDirty)
+ })
+
+ return () => {
+ unsubscribeScene()
+ unsubscribeLiveTerrain()
+ }
}
function arraysEqual(a: number[], b: number[]): boolean {
@@ -253,6 +273,97 @@ export function markDeckAttachedStairs(
}
}
+/**
+ * Dirty every consumer of a slab's top or underside. Kept pure so committed
+ * scene writes and live handle previews use the same dependency boundary.
+ */
+export function markSlabChangeDependents(
+ previous: SlabNode,
+ next: SlabNode,
+ nodes: Record,
+ markDirty: (id: AnyNodeId) => void,
+) {
+ const supportChanged =
+ next.polygon !== previous.polygon ||
+ next.elevation !== previous.elevation ||
+ next.holes !== previous.holes
+
+ if (supportChanged) {
+ markNodesOverlappingSlab(previous, nodes, markDirty)
+ markNodesOverlappingSlab(next, nodes, markDirty)
+ }
+ if (next.elevation !== previous.elevation) {
+ markDeckAttachedStairs(next.id, nodes, markDirty)
+ }
+ if (
+ supportChanged ||
+ next.thickness !== previous.thickness ||
+ next.recessed !== previous.recessed
+ ) {
+ markCoveringDependentsBelow(resolveLevelId(next, nodes), nodes, markDirty)
+ }
+}
+
+/**
+ * The sculpted ground moved: every node the terrain *supports* must re-elevate.
+ *
+ * The other rules in this file gate on a footprint overlapping the changed
+ * surface. Terrain has no such gate — a stroke rewrites a field that spans the
+ * whole lot, and the resolver samples it at each node's own XZ — so the sweep is
+ * every floor-placed node on a storey at grade, plus every wall whose explicit
+ * terrain infill samples that field. During a live stroke it fires per dab, but
+ * dirty ids coalesce in a Set until the frame systems consume them; the scene
+ * graph itself is still written only once on commit.
+ *
+ * Without this a sculpt silently desyncs the scene from its own ground. Nothing
+ * re-runs `getFloorPlacedElevation`, so the React commit that rebinds a node
+ * group's base Y leaves it there: a column that was resting on a hillside drops
+ * to the datum and stays buried under the terrain it used to stand on.
+ *
+ * Gated on `isLevelAtSiteDatum` — the same predicate `terrainSupportLift` uses to
+ * decide whether it drapes at all, so the two cannot disagree about which storey
+ * is on the ground.
+ */
+export function markTerrainSupportDependents(
+ nodes: Record,
+ markDirty: (id: AnyNodeId) => void,
+) {
+ const gradeLevels = new Map()
+ const isGrade = (levelId: string) => {
+ let cached = gradeLevels.get(levelId)
+ if (cached === undefined) {
+ cached = isLevelAtSiteDatum(nodes, levelId)
+ gradeLevels.set(levelId, cached)
+ }
+ return cached
+ }
+
+ for (const node of Object.values(nodes)) {
+ if (node.type === 'slab' && node.fillToTerrain === true) {
+ if (isGrade(resolveLevelId(node, nodes))) markDirty(node.id)
+ continue
+ }
+
+ if (node.type === 'wall') {
+ if (node.supportSlabId !== GROUND_SUPPORT_ID && node.fillToTerrain !== true) continue
+ if (!isGrade(resolveLevelId(node, nodes))) continue
+ markDirty(node.id)
+ continue
+ }
+
+ const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
+ if (!floorPlaced) 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.
+ const parentId = node.parentId as AnyNodeId | null
+ const parent = parentId ? nodes[parentId] : null
+ if (parent && parent.type !== 'level') continue
+ if (!isGrade(resolveLevelId(node, nodes))) continue
+ markDirty(node.id)
+ }
+}
+
/**
* A slab on `slabLevelId` was created/deleted or changed shape/placement:
* the covering bound (slab underside) over the level BELOW moved, so that
diff --git a/packages/core/src/hooks/spatial-grid/support-host-id.ts b/packages/core/src/hooks/spatial-grid/support-host-id.ts
new file mode 100644
index 000000000..44cfc104c
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/support-host-id.ts
@@ -0,0 +1,2 @@
+/** Persisted support host meaning the level base rather than a slab. */
+export const GROUND_SUPPORT_ID = 'ground'
diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts
index 588ca7ee6..7a4f43259 100644
--- a/packages/core/src/hooks/spatial-grid/support-host-patch.ts
+++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts
@@ -15,6 +15,8 @@ export type SupportSlabPatchOptions = {
* ground is elected while capped-out slabs still overlap the footprint.
*/
maxElevation?: number | null
+ /** Pointer- or snap-decided host. Ground is a first-class support source. */
+ preferredSlabId?: string | null
}
export function resolveSupportSlabPatch(
@@ -79,6 +81,9 @@ export function resolveWallSupportSlabPatch(
): SupportSlabPatch {
const parent = wall.parentId ? nodes[wall.parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
+ if (options?.preferredSlabId === GROUND_SUPPORT_ID) {
+ return { supportSlabId: GROUND_SUPPORT_ID }
+ }
// Winner under the pointer cap (when given): a deck hanging above the
// aimed-at surface can't capture the elected base, so a wall drawn at the
@@ -89,7 +94,7 @@ export function resolveWallSupportSlabPatch(
wall.end,
wall.curveOffset,
wall.thickness,
- null,
+ options?.preferredSlabId,
options?.maxElevation,
)
const candidateElevations = new Set()
@@ -109,11 +114,38 @@ export function resolveWallSupportSlabPatch(
}
}
+ if (support.electedSlabId === options?.preferredSlabId) {
+ return { supportSlabId: support.electedSlabId ?? undefined }
+ }
+ if (
+ options?.maxElevation != null &&
+ support.electedSlabId === null &&
+ candidateElevations.size > 0
+ ) {
+ return { supportSlabId: GROUND_SUPPORT_ID }
+ }
return {
supportSlabId: candidateElevations.size >= 2 ? (support.electedSlabId ?? undefined) : undefined,
}
}
+/**
+ * Re-elect a moved wall without discarding an explicit construction source.
+ *
+ * Move tools change only the wall's plan geometry. A persisted host therefore
+ * remains preferred when it still supports the new footprint; the normal
+ * resolver falls back when a slab no longer overlaps. Ground is also an
+ * explicit source, so preserving it keeps terrain-hosted walls on terrain.
+ */
+export function resolveMovedWallSupportSlabPatch(
+ wall: WallNode,
+ nodes: Record,
+): SupportSlabPatch {
+ return resolveWallSupportSlabPatch(wall, nodes, {
+ preferredSlabId: wall.supportSlabId ?? null,
+ })
+}
+
/** Fence-like shape the fence host election needs — plain segment, arc, or spline. */
export type FenceSupportInput = Pick<
FenceNode,
diff --git a/packages/core/src/hooks/spatial-grid/support-host.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts
index 88ed94532..0138ed715 100644
--- a/packages/core/src/hooks/spatial-grid/support-host.test.ts
+++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts
@@ -1,15 +1,21 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
+import { encodeTerrainField } from '../../lib/terrain-codec'
+import { applyHeightPatch, createTerrainField, flattenPatch } from '../../lib/terrain-field'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, AnyNodeId, SlabNode } from '../../schema'
import { WallNode } from '../../schema'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { resolveWallEffectiveHeight, resolveWallTop } from '../../systems/wall/wall-top'
-import { getFloorPlacedElevation } from './floor-placed-elevation'
-import { spatialGridManager } from './spatial-grid-manager'
+import { GROUND_SUPPORT_ID, getFloorPlacedElevation } from './floor-placed-elevation'
+import { getWallBaseElevationForNodes, spatialGridManager } from './spatial-grid-manager'
import { initSpatialGridSync } from './spatial-grid-sync'
-import { resolveSupportSlabPatch, resolveWallSupportSlabPatch } from './support-host-patch'
+import {
+ resolveMovedWallSupportSlabPatch,
+ resolveSupportSlabPatch,
+ resolveWallSupportSlabPatch,
+} from './support-host-patch'
const LEVEL_ID = 'level_test'
@@ -358,6 +364,112 @@ describe('persisted support hosts (walls, via the manager)', () => {
expect(fallback.electedSlabId).toBe('slab_high')
})
+ test('ground host applies the compact construction-plane offset', () => {
+ const support = spatialGridManager.getSlabSupportForWall(
+ LEVEL_ID,
+ [0, 0],
+ [4, 0],
+ 0,
+ 0.1,
+ GROUND_SUPPORT_ID,
+ undefined,
+ 1.75,
+ )
+
+ expect(support).toEqual({
+ elevation: 1.75,
+ electedSlabId: null,
+ baseElevation: 1.75,
+ baseSegments: [{ start: 0, end: 1, elevation: 1.75 }],
+ })
+ })
+
+ test('ground host resolves sculpted terrain plus the construction-plane offset', () => {
+ const base = createTerrainField({ cols: 9, rows: 9, spacing: 1, origin: [-4, -4] })
+ const patch = flattenPatch(base, { minX: 0, minZ: 0, maxX: 2, maxZ: 2 }, 1.5)
+ const site = {
+ id: 'site_test',
+ type: 'site',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: ['building_test'],
+ terrain: encodeTerrainField(applyHeightPatch(base, patch as never)),
+ } as AnyNode
+ const building = {
+ id: 'building_test',
+ type: 'building',
+ object: 'node',
+ parentId: site.id,
+ visible: true,
+ metadata: {},
+ children: [LEVEL_ID],
+ position: [0, 0, 0],
+ rotation: [0, 0, 0],
+ } as AnyNode
+ const level = { ...makeLevel(), parentId: building.id, height: 3 } as AnyNode
+ useScene.setState({ nodes: nodesFor(site, building, level) })
+
+ const support = spatialGridManager.getSlabSupportForWall(
+ LEVEL_ID,
+ [1, 1],
+ [3, 1],
+ 0,
+ 0.1,
+ GROUND_SUPPORT_ID,
+ undefined,
+ 0.25,
+ )
+
+ expect(support.elevation).toBeCloseTo(1.75, 6)
+ expect(support.baseSegments).toEqual([{ start: 0, end: 1, elevation: 1.75 }])
+
+ const wall = WallNode.parse({
+ id: 'wall_ground',
+ parentId: LEVEL_ID,
+ start: [1, 1],
+ end: [3, 1],
+ supportSlabId: GROUND_SUPPORT_ID,
+ supportOffset: 0.25,
+ })
+ expect(getWallBaseElevationForNodes(wall, useScene.getState().nodes)).toBeCloseTo(1.75, 6)
+ })
+
+ test('an explicitly pointed ground source persists even without slab candidates', () => {
+ const wall = WallNode.parse({
+ id: 'wall_ground',
+ parentId: LEVEL_ID,
+ start: [0, 0],
+ end: [4, 0],
+ })
+ const level = makeLevel([wall.id])
+ const nodes = nodesFor(level, wall as AnyNode)
+
+ expect(
+ resolveWallSupportSlabPatch(wall, nodes, {
+ maxElevation: 1.75,
+ preferredSlabId: GROUND_SUPPORT_ID,
+ }),
+ ).toEqual({ supportSlabId: GROUND_SUPPORT_ID })
+ })
+
+ test('moving a terrain-hosted wall preserves its explicit ground source', () => {
+ const wall = WallNode.parse({
+ id: 'wall_ground',
+ parentId: LEVEL_ID,
+ start: [0, 0],
+ end: [4, 0],
+ supportSlabId: GROUND_SUPPORT_ID,
+ })
+ const level = makeLevel([wall.id])
+ const nodes = nodesFor(level, wall as AnyNode)
+
+ expect(resolveMovedWallSupportSlabPatch(wall, nodes)).toEqual({
+ supportSlabId: GROUND_SUPPORT_ID,
+ })
+ })
+
test('resolveWallSupportSlabPatch persists the winner over two elevations', () => {
const low = makeSlab(
'slab_low',
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 3b633ebae..25526728e 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -54,6 +54,7 @@ export {
getFloorStackedPosition,
} from './hooks/spatial-grid/floor-placed-elevation'
export {
+ getWallBaseElevationForNodes,
getWallEffectiveHeightForNodes,
type PointedSupportSurface,
pointInPolygon,
@@ -64,12 +65,14 @@ export {
export {
findLevelAncestorId,
initSpatialGridSync,
+ markSlabChangeDependents,
resolveBuildingForLevel,
resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync'
export {
type FenceSupportInput,
resolveFenceSupportSlabPatch,
+ resolveMovedWallSupportSlabPatch,
resolveSupportSlabPatch,
resolveWallSupportSlabPatch,
type SupportSlabPatch,
@@ -130,6 +133,7 @@ export {
export {
type AutoCeilingPlanningContext,
type AutoCeilingSyncPlan,
+ type AutoSlabPlanningContext,
type AutoSlabSyncPlan,
type AutoZoneSyncPlan,
detectSpacesForLevel,
@@ -146,6 +150,51 @@ export {
wallClosesRoom,
wallTouchesOthers,
} from './lib/space-detection'
+export {
+ advanceStroke,
+ type BrushSettings,
+ type BrushShape,
+ beginStroke,
+ brushHeightAt,
+ DEFAULT_BRUSH_SETTINGS,
+ detachStrokeAnchor,
+ highestOver,
+ MIN_BRUSH_RADIUS_IN_SPACINGS,
+ maxCoverage,
+ minBrushRadius,
+ RAISE_METRES_PER_STROKE,
+ sampleTarget,
+ type TerrainStroke,
+ type TerrainVerb,
+ weightAt,
+} from './lib/terrain-brush'
+export { decodeTerrainField, encodeTerrainField, isDatumField } from './lib/terrain-codec'
+export {
+ applyHeightPatch,
+ createTerrainField,
+ DEFAULT_TERRAIN_SPACING,
+ DEFAULT_TERRAIN_STEP,
+ diffToPatches,
+ flattenPatch,
+ type HeightPatch,
+ heightAt,
+ heightAtSample,
+ isFlatOver,
+ normalAt,
+ quantize,
+ sampleRangeOver,
+ slopeAt,
+ surfaceHeightAt,
+ type TerrainField,
+} from './lib/terrain-field'
+export { raycastTerrain, type TerrainHit } from './lib/terrain-raycast'
+export { commitTerrainField, terrainFieldForEdit, terrainFieldOf } from './lib/terrain-source'
+export {
+ isSiteDatum,
+ SITE_DATUM_EPSILON,
+ SITE_DATUM_Y,
+ terrainSupportLift,
+} from './lib/terrain-support'
export {
closestOnSegment,
collectLevelWallSegments,
@@ -225,6 +274,10 @@ export {
getEffectiveNode,
type LiveNodeOverrides,
} from './store/use-live-node-overrides'
+export {
+ default as useLiveTerrain,
+ type LiveTerrainStroke,
+} from './store/use-live-terrain'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export {
type ApplySceneSnapshotOptions,
@@ -288,6 +341,7 @@ export {
isSplineFence,
sampleFenceSpline,
} from './systems/fence/fence-spline'
+export { resolveSlabPlacementElevation } from './systems/slab/slab-placement'
export {
clampSlabElevationForWalls,
getSlabElevationUpperBound,
@@ -297,7 +351,7 @@ export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/sta
export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export { StairOpeningSystem } from './systems/stair/stair-opening-system'
-export { resolveStairTotalRise } from './systems/stair/stair-rise'
+export { resolveStairTotalRise, syncStairRises } from './systems/stair/stair-rise'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts
index c7eb3949a..97a646241 100644
--- a/packages/core/src/lib/space-detection.test.ts
+++ b/packages/core/src/lib/space-detection.test.ts
@@ -70,6 +70,25 @@ describe('planAutoCeilingsForLevel', () => {
expect(plan.delete).toHaveLength(0)
})
+ test('creates and reconciles an auto ceiling at the enclosing wall top', () => {
+ const context = {
+ heightForRoom: () => 3.09,
+ }
+ const created = planAutoCeilingsForLevel([roomPolygon()], [], context).create[0]
+
+ expect(created?.height).toBeCloseTo(3.09)
+
+ const existing = CeilingNode.parse({
+ polygon: square,
+ height: 2.49,
+ autoFromWalls: true,
+ })
+ const update = planAutoCeilingsForLevel([roomPolygon()], [existing], context).update[0]
+
+ expect(update?.id).toBe(existing.id)
+ expect(update?.data.height).toBeCloseTo(3.09)
+ })
+
test('a leftover explicit height on a matched auto ceiling is not rewritten', () => {
const ceiling = CeilingNode.parse({
polygon: square,
@@ -391,6 +410,87 @@ describe('reactive ceiling re-clamp through the detection sync', () => {
})
})
+describe('raised auto-room surfaces', () => {
+ test('inherits the enclosing walls construction plane when the room closes', () => {
+ 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,
+ supportOffset: 0.6,
+ }),
+ )
+ const initialWalls = walls.slice(0, 3)
+ const initialNodes = Object.fromEntries(
+ [
+ BuildingNode.parse({ id: 'building_a', 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
+
+ const sceneStore = createSceneStoreStub(initialNodes)
+ const editorStore = createEditorStoreStub()
+ const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore)
+
+ try {
+ const current = sceneStore.getState().nodes
+ const level = current.level_0 as LevelNode
+ const closingWall = walls[3]!
+ sceneStore.setNodes({
+ ...current,
+ [closingWall.id]: closingWall,
+ level_0: {
+ ...level,
+ children: [...level.children, closingWall.id],
+ } as LevelNode,
+ })
+
+ const generated = Object.values(sceneStore.getState().nodes)
+ const autoSlab = generated.find(
+ (node): node is SlabNode => node.type === 'slab' && node.autoFromWalls,
+ )
+ const autoCeiling = generated.find(
+ (node): node is CeilingNode => node.type === 'ceiling' && node.autoFromWalls,
+ )
+
+ expect(autoSlab?.elevation).toBeCloseTo(0.65)
+ expect(autoSlab?.thickness).toBeCloseTo(0.05)
+ expect(autoCeiling?.height).toBeCloseTo(3.09)
+
+ const raisedAgain = { ...sceneStore.getState().nodes }
+ for (const wall of walls) {
+ raisedAgain[wall.id] = { ...raisedAgain[wall.id], supportOffset: 0.8 } as AnyNode
+ }
+ sceneStore.setNodes(raisedAgain)
+
+ const reconciled = Object.values(sceneStore.getState().nodes)
+ const reconciledSlab = reconciled.find(
+ (node): node is SlabNode => node.type === 'slab' && node.autoFromWalls,
+ )
+ const reconciledCeiling = reconciled.find(
+ (node): node is CeilingNode => node.type === 'ceiling' && node.autoFromWalls,
+ )
+ expect(reconciledSlab?.elevation).toBeCloseTo(0.85)
+ expect(reconciledCeiling?.height).toBeCloseTo(3.29)
+ } finally {
+ unsubscribe()
+ }
+ })
+})
+
describe('detectSpacesForLevel', () => {
const areaOf = (polygon: Array<{ x: number; y: number }>) => {
let area = 0
@@ -556,6 +656,21 @@ describe('wallClosesRoom', () => {
})
describe('planAutoSlabsForLevel', () => {
+ test('creates and reconciles an auto slab on the enclosing wall plane', () => {
+ const context = {
+ elevationForRoom: () => 0.65,
+ }
+ const created = planAutoSlabsForLevel([roomPolygon()], [], context).create[0]
+
+ expect(created?.elevation).toBeCloseTo(0.65)
+
+ const existing = slab(0.05)
+ const update = planAutoSlabsForLevel([roomPolygon()], [existing], context).update[0]
+
+ expect(update?.id).toBe(existing.id)
+ expect(update?.data.elevation).toBeCloseTo(0.65)
+ })
+
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
// Two rooms with identical polygon signatures previously collided in a
// signature-keyed Map, so one detected room never matched an existing slab
diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts
index 41302a742..e5b8f8d89 100644
--- a/packages/core/src/lib/space-detection.ts
+++ b/packages/core/src/lib/space-detection.ts
@@ -1,3 +1,4 @@
+import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/support-host-id'
import {
type AnyNodeId,
CeilingNode,
@@ -22,12 +23,15 @@ import {
pauseSceneHistory,
resumeSceneHistory,
} from '../store/history-control'
+import { computeWallSlabSupport } from '../systems/slab/slab-support'
import {
getClampedWallCurveOffset,
getWallCurveFrameAt,
isCurvedWall,
} from '../systems/wall/wall-curve'
+import { resolveWallTop } from '../systems/wall/wall-top'
import { simplifyClosedPolygon } from './polygon-geometry'
+import { terrainSupportLift } from './terrain-support'
type Point2D = { x: number; y: number }
@@ -71,6 +75,10 @@ export type AutoSlabSyncPlan = {
delete: Array
}
+export type AutoSlabPlanningContext = {
+ elevationForRoom?: (polygon: Array<[number, number]>) => number | undefined
+}
+
export type AutoCeilingSyncPlan = {
create: CeilingNodeType[]
update: Array<{ id: CeilingNodeType['id']; data: Partial }>
@@ -97,11 +105,11 @@ 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
+const ROOM_VERTICAL_PLANE_EPSILON = 1e-3
-// Auto ceilings are created height-less (follows-mode: they track the
-// clamp bound live through `resolveCeilingHeight`), so the planner needs
-// no wall/slab inputs anymore — only the bound for the explicit-height
-// reactive re-clamp below.
+// 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.
export type AutoCeilingPlanningContext = {
/** Stored storey height of the level being planned (floor-to-floor). */
storeyHeight?: number
@@ -113,6 +121,7 @@ export type AutoCeilingPlanningContext = {
* the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`.
*/
ceilingClampBound?: (polygon: Array<[number, number]>) => number
+ heightForRoom?: (polygon: Array<[number, number]>) => number | undefined
}
function pointFromTuple(point: [number, number]): Point2D {
@@ -335,6 +344,61 @@ 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
+}
+
+function autoRoomVerticalPlacements(
+ spaces: readonly Space[],
+ walls: WallNode[],
+ supportSlabs: readonly SlabNodeType[],
+ nodes: Record,
+ storeyHeight: number,
+) {
+ const wallsById = new Map(walls.map((wall) => [wall.id, wall]))
+ const placements = new Map()
+
+ for (const space of spaces) {
+ const boundaryWalls = space.wallIds.flatMap((id) => {
+ const wall = wallsById.get(id)
+ return wall ? [wall] : []
+ })
+ 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)
+ if (base === undefined) continue
+
+ const wallTops = boundaryWalls.map((wall, index) =>
+ resolveWallTop(wall, storeyHeight, wallBases[index] ?? base),
+ )
+ const top = consensusElevation(wallTops)
+ if (top === undefined) continue
+
+ placements.set(polygonSignature(space.polygon.map(pointFromTuple)), {
+ slabElevation: base + DEFAULT_AUTO_SLAB_ELEVATION,
+ ceilingHeight: top - CEILING_CLAMP_MARGIN,
+ })
+ }
+
+ return placements
+}
+
function getWallDirection(wall: Pick) {
const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]
@@ -785,6 +849,8 @@ function wallGeometrySignature(wall: WallNode) {
// value: it resolves to the storey plane, so it must not alias an
// explicit height of the same magnitude in the trigger signature.
wall.height == null ? 'plane' : wall.height.toFixed(4),
+ wall.supportSlabId ?? 'elected',
+ (wall.supportOffset ?? 0).toFixed(4),
getClampedWallCurveOffset(wall).toFixed(4),
].join('|')
}
@@ -943,6 +1009,7 @@ export function resolveAutoZonePolygon(
export function planAutoSlabsForLevel(
roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[],
+ context: AutoSlabPlanningContext = {},
): AutoSlabSyncPlan {
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls)
const manualSignatures = new Set(
@@ -986,7 +1053,10 @@ export function planAutoSlabsForLevel(
const matchedSlabIds = new Set()
const matchedDetectedIdx = new Set()
- const updatesById = new Map()
+ const updatesById = new Map<
+ string,
+ { polygon: [number, number][]; elevation: number | undefined }
+ >()
const autoBySignature = new Map>()
for (const entry of existingAutoMeta) {
@@ -1001,7 +1071,11 @@ export function planAutoSlabsForLevel(
matchedDetectedIdx.add(index)
matchedSlabIds.add(existing.slab.id)
- updatesById.set(existing.slab.id, room.poly.map(pointToTuple))
+ const polygon = room.poly.map(pointToTuple)
+ updatesById.set(existing.slab.id, {
+ polygon,
+ elevation: context.elevationForRoom?.(polygon),
+ })
})
const remainingDetected = detected
@@ -1036,7 +1110,11 @@ export function planAutoSlabsForLevel(
matchedDetectedIdx.add(index)
matchedSlabIds.add(bestMatch.entry.slab.id)
- updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple))
+ const polygon = room.poly.map(pointToTuple)
+ updatesById.set(bestMatch.entry.slab.id, {
+ polygon,
+ elevation: context.elevationForRoom?.(polygon),
+ })
}
const detectedRoomPolygons = detectedAll.map((room) => room.poly)
@@ -1059,10 +1137,17 @@ export function planAutoSlabsForLevel(
...existingAuto
.filter((slab) => updatesById.has(slab.id))
.flatMap((slab) => {
- const polygon = updatesById.get(slab.id)
- if (!polygon) return []
-
- return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }]
+ const update = updatesById.get(slab.id)
+ if (!update) return []
+ const data: Partial = {}
+ if (!sameTuplePolygon(slab.polygon, update.polygon)) data.polygon = update.polygon
+ if (
+ update.elevation !== undefined &&
+ Math.abs(slab.elevation - update.elevation) > ROOM_VERTICAL_PLANE_EPSILON
+ ) {
+ data.elevation = update.elevation
+ }
+ return Object.keys(data).length > 0 ? [{ id: slab.id, data }] : []
}),
...slabDemotions,
]
@@ -1078,12 +1163,17 @@ export function planAutoSlabsForLevel(
const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab')
plannedSlabsForNaming.push({ name })
+ const polygon = room.poly.map(pointToTuple)
+ const elevation = context.elevationForRoom?.(polygon)
slabsToCreate.push(
SlabNode.parse({
name,
- polygon: room.poly.map(pointToTuple),
+ polygon,
holes: [],
- elevation: DEFAULT_AUTO_SLAB_ELEVATION,
+ elevation:
+ elevation !== undefined && Number.isFinite(elevation)
+ ? elevation
+ : DEFAULT_AUTO_SLAB_ELEVATION,
autoFromWalls: true,
}),
)
@@ -1101,8 +1191,9 @@ function syncAutoSlabsForLevel(
roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[],
sceneStore: any,
+ context: AutoSlabPlanningContext = {},
) {
- const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
+ const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context)
if (plan.delete.length > 0) {
sceneStore.getState().deleteNodes(plan.delete)
@@ -1166,7 +1257,7 @@ export function planAutoCeilingsForLevel(
const matchedCeilingIds = new Set()
const matchedDetectedIdx = new Set()
- const updatesById = new Map()
+ const updatesById = new Map()
const autoBySignature = new Map>()
for (const entry of existingAutoMeta) {
@@ -1181,8 +1272,10 @@ export function planAutoCeilingsForLevel(
matchedDetectedIdx.add(index)
matchedCeilingIds.add(existing.ceiling.id)
+ const polygon = room.poly.map(pointToTuple)
updatesById.set(existing.ceiling.id, {
- polygon: room.poly.map(pointToTuple),
+ polygon,
+ height: context.heightForRoom?.(polygon),
})
})
@@ -1218,8 +1311,10 @@ export function planAutoCeilingsForLevel(
matchedDetectedIdx.add(index)
matchedCeilingIds.add(bestMatch.entry.ceiling.id)
+ const polygon = room.poly.map(pointToTuple)
updatesById.set(bestMatch.entry.ceiling.id, {
- polygon: room.poly.map(pointToTuple),
+ polygon,
+ height: context.heightForRoom?.(polygon),
})
}
@@ -1255,15 +1350,21 @@ export function planAutoCeilingsForLevel(
})
const ceilingsToUpdate = [
- // Auto ceilings only track their room's POLYGON here — their height is
- // follows-mode (absent) and derives from the level top at read time.
...existingAuto
.filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => {
const update = updatesById.get(ceiling.id)
if (!update) return []
- if (sameTuplePolygon(ceiling.polygon, update.polygon)) return []
- return [{ id: ceiling.id, data: { polygon: update.polygon } }]
+ const data: Partial = {}
+ if (!sameTuplePolygon(ceiling.polygon, update.polygon)) data.polygon = update.polygon
+ if (
+ update.height !== undefined &&
+ (ceiling.height === undefined ||
+ Math.abs(ceiling.height - update.height) > ROOM_VERTICAL_PLANE_EPSILON)
+ ) {
+ data.height = update.height
+ }
+ return Object.keys(data).length > 0 ? [{ id: ceiling.id, data }] : []
}),
...ceilingDemotions,
...manualClamps,
@@ -1280,14 +1381,14 @@ export function planAutoCeilingsForLevel(
const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling')
plannedCeilingsForNaming.push({ name })
- // Height-less on purpose: auto ceilings follow the level top (the
- // clamp bound) through `resolveCeilingHeight` instead of baking a
- // derived height that would go stale on level-height edits.
+ const polygon = room.poly.map(pointToTuple)
+ const height = context.heightForRoom?.(polygon)
ceilingsToCreate.push(
CeilingNode.parse({
name,
- polygon: room.poly.map(pointToTuple),
+ polygon,
holes: [],
+ ...(height !== undefined && Number.isFinite(height) ? { height } : {}),
autoFromWalls: true,
}),
)
@@ -1394,13 +1495,26 @@ function runSpaceDetection(
)
}
- const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab))
- syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
const levelNode = nodes[levelId]
const storeyHeight =
levelNode?.type === 'level'
? getStoredLevelHeight(levelNode as LevelNode)
: DEFAULT_LEVEL_HEIGHT
+ const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab))
+ const verticalPlacements = autoRoomVerticalPlacements(
+ spaces,
+ walls,
+ // A derived floor cannot be evidence for its own next elevation: that
+ // would lift unpinned walls, then lift the floor again on every pass.
+ parsedSlabs.filter((slab) => !slab.autoFromWalls),
+ nodes,
+ storeyHeight,
+ )
+ const placementFor = (polygon: Array<[number, number]>) =>
+ verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)))
+ syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore, {
+ elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation,
+ })
syncAutoCeilingsForLevel(
levelId,
roomPolygons,
@@ -1409,6 +1523,7 @@ function runSpaceDetection(
{
storeyHeight,
ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
+ heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight,
},
)
const zonePlan = planAutoZonesForLevel(
diff --git a/packages/core/src/lib/terrain-brush.test.ts b/packages/core/src/lib/terrain-brush.test.ts
new file mode 100644
index 000000000..22f284cbc
--- /dev/null
+++ b/packages/core/src/lib/terrain-brush.test.ts
@@ -0,0 +1,457 @@
+import { describe, expect, test } from 'bun:test'
+import {
+ advanceStroke,
+ beginStroke,
+ DEFAULT_BRUSH_SETTINGS,
+ detachStrokeAnchor,
+ highestOver,
+ MIN_BRUSH_RADIUS_IN_SPACINGS,
+ maxCoverage,
+ minBrushRadius,
+ RAISE_METRES_PER_STROKE,
+ sampleTarget,
+ weightAt,
+} from './terrain-brush'
+import {
+ applyHeightPatch,
+ createTerrainField,
+ heightAt,
+ isFlatOver,
+ quantize,
+ type TerrainField,
+} from './terrain-field'
+
+function field(): TerrainField {
+ return createTerrainField({ cols: 33, rows: 33, spacing: 0.5, origin: [-8, -8] })
+}
+
+/** Run a whole stroke and return the resulting field. */
+function stroke(
+ start: TerrainField,
+ options: Omit[0], 'field'>,
+ path: Array<[number, number]>,
+): TerrainField {
+ const active = beginStroke({ field: start, ...options })
+ let result = start
+ for (const [x, z] of path) {
+ const patch = advanceStroke(active, x, z)
+ if (patch) result = applyHeightPatch(result, patch)
+ }
+ return result
+}
+
+describe('weightAt', () => {
+ test('is 1 at the centre and 0 at the rim', () => {
+ const settings = { ...DEFAULT_BRUSH_SETTINGS, radius: 2 }
+ expect(weightAt(settings, 0, 0)).toBeCloseTo(1, 6)
+ expect(weightAt(settings, 2, 0)).toBe(0)
+ expect(weightAt(settings, 3, 0)).toBe(0)
+ })
+
+ test('has zero derivative at both ends — the anti-ridge property', () => {
+ // A kernel that is only C¹ at the centre leaves a visible crease at the
+ // brush boundary. Approximate the derivative just inside the rim: it must be
+ // vanishing, not merely small.
+ const settings = { ...DEFAULT_BRUSH_SETTINGS, radius: 2, falloff: 1 }
+ const nearRim = weightAt(settings, 1.99, 0) - weightAt(settings, 1.98, 0)
+ const midway = weightAt(settings, 1.01, 0) - weightAt(settings, 1.0, 0)
+ expect(Math.abs(nearRim)).toBeLessThan(Math.abs(midway) / 10)
+ })
+
+ test('falloff 0 is a hard stamp — full coverage right up to the rim', () => {
+ const settings = { ...DEFAULT_BRUSH_SETTINGS, radius: 2, falloff: 0 }
+ expect(weightAt(settings, 1.99, 0)).toBe(1)
+ expect(weightAt(settings, 2.01, 0)).toBe(0)
+ })
+
+ test('square uses Chebyshev distance so the corners are covered', () => {
+ const round = { ...DEFAULT_BRUSH_SETTINGS, radius: 2, falloff: 0, shape: 'round' as const }
+ const square = { ...DEFAULT_BRUSH_SETTINGS, radius: 2, falloff: 0, shape: 'square' as const }
+ // A point at the corner of the 2 m box is 2.8 m away: outside the circle,
+ // inside the square.
+ expect(weightAt(round, 1.9, 1.9)).toBe(0)
+ expect(weightAt(square, 1.9, 1.9)).toBe(1)
+ })
+})
+
+describe('raise', () => {
+ test('a single dab raises the ground under the brush and nothing outside it', () => {
+ const base = field()
+ const result = stroke(base, { verb: 'raise', settings: { radius: 2, falloff: 0.5 } }, [[0, 0]])
+
+ expect(heightAt(result, 0, 0)).toBeGreaterThan(0)
+ // 3 m out is well past the 2 m radius.
+ expect(heightAt(result, 3.5, 0)).toBeCloseTo(0, 6)
+ })
+
+ test('dwelling in one spot does not compound — the saturating invariant', () => {
+ // The naive `h += strength * falloff` model makes this test's height grow
+ // linearly with the number of events, which is the documented cause of
+ // "hash edges and jagged". Here, twenty events at the same point produce
+ // exactly what one produces.
+ const base = field()
+ const once = stroke(base, { verb: 'raise', settings: { radius: 2 } }, [[0, 0]])
+ const twenty = stroke(
+ base,
+ { verb: 'raise', settings: { radius: 2 } },
+ Array.from({ length: 20 }, () => [0, 0] as [number, number]),
+ )
+ expect(heightAt(twenty, 0, 0)).toBeCloseTo(heightAt(once, 0, 0), 6)
+ })
+
+ test('re-crossing your own stroke does not compound either', () => {
+ const base = field()
+ const oneWay = stroke(base, { verb: 'raise', settings: { radius: 1.5 } }, [
+ [-3, 0],
+ [3, 0],
+ ])
+ const backAndForth = stroke(base, { verb: 'raise', settings: { radius: 1.5 } }, [
+ [-3, 0],
+ [3, 0],
+ [-3, 0],
+ [3, 0],
+ ])
+ expect(heightAt(backAndForth, 0, 0)).toBeCloseTo(heightAt(oneWay, 0, 0), 6)
+ })
+
+ test('one full-strength pass is bounded by RAISE_METRES_PER_STROKE', () => {
+ const base = field()
+ const result = stroke(
+ base,
+ { verb: 'raise', settings: { radius: 2, strength: 1, falloff: 0 } },
+ [[0, 0]],
+ )
+ expect(heightAt(result, 0, 0)).toBeCloseTo(RAISE_METRES_PER_STROKE, 2)
+ })
+
+ test('a second stroke goes further — repeats are how you get more', () => {
+ // Bounded per stroke, not bounded overall. Press again to keep going.
+ const base = field()
+ const first = stroke(
+ base,
+ { verb: 'raise', settings: { radius: 2, strength: 1, falloff: 0 } },
+ [[0, 0]],
+ )
+ const second = stroke(
+ first,
+ { verb: 'raise', settings: { radius: 2, strength: 1, falloff: 0 } },
+ [[0, 0]],
+ )
+ expect(heightAt(second, 0, 0)).toBeCloseTo(2 * RAISE_METRES_PER_STROKE, 2)
+ })
+
+ test('lower is raise with the sign inverted', () => {
+ const base = field()
+ const up = stroke(base, { verb: 'raise', settings: { radius: 2, strength: 1, falloff: 0 } }, [
+ [0, 0],
+ ])
+ const down = stroke(base, { verb: 'lower', settings: { radius: 2, strength: 1, falloff: 0 } }, [
+ [0, 0],
+ ])
+ expect(heightAt(down, 0, 0)).toBeCloseTo(-heightAt(up, 0, 0), 6)
+ })
+
+ test('lower digs below the datum — basements need negative ground', () => {
+ const base = field()
+ const result = stroke(
+ base,
+ { verb: 'lower', settings: { radius: 2, strength: 1, falloff: 0 } },
+ [[0, 0]],
+ )
+ expect(heightAt(result, 0, 0)).toBeLessThan(0)
+ })
+})
+
+describe('arc-length spacing', () => {
+ test('a fast drag and a slow drag deposit the same material', () => {
+ // Same path, different event density. Without arc-length spacing the
+ // 40-event version would deposit far more, making stroke strength a
+ // function of frame rate.
+ const base = field()
+ const settings = { radius: 1.5, strength: 0.8 }
+ const coarse = stroke(base, { verb: 'raise', settings }, [
+ [-4, 0],
+ [4, 0],
+ ])
+ const fine = stroke(
+ base,
+ { verb: 'raise', settings },
+ Array.from({ length: 40 }, (_, i) => [-4 + (8 * i) / 39, 0] as [number, number]),
+ )
+ expect(heightAt(fine, 0, 0)).toBeCloseTo(heightAt(coarse, 0, 0), 2)
+ })
+
+ test('motion shorter than one dab spacing deposits nothing new', () => {
+ const base = field()
+ const active = beginStroke({ verb: 'raise', field: base, settings: { radius: 4 } })
+ expect(advanceStroke(active, 0, 0)).not.toBeNull()
+ // Spacing is radius/4 = 1 m; 0.1 m is well under it.
+ expect(advanceStroke(active, 0.1, 0)).toBeNull()
+ expect(advanceStroke(active, 0.2, 0)).toBeNull()
+ // Accumulated motion eventually crosses the threshold.
+ expect(advanceStroke(active, 1.5, 0)).not.toBeNull()
+ })
+
+ test('a click with no drag still deposits one dab', () => {
+ const active = beginStroke({ verb: 'raise', field: field() })
+ const patch = advanceStroke(active, 0, 0)
+ expect(patch).not.toBeNull()
+ expect(maxCoverage(active)).toBeCloseTo(1, 6)
+ })
+})
+
+describe('detachStrokeAnchor', () => {
+ // The gap a camera zoom leaves in the pointer stream. The tool skips dabs while
+ // `cameraDragging`, and arc-length spacing means the *skip alone* is not enough:
+ // the next real dab would interpolate all the way back to the pre-zoom centre.
+ const settings = { radius: 1.5, strength: 0.8 }
+ const jump = (detach: boolean): TerrainField => {
+ const base = field()
+ const active = beginStroke({ verb: 'raise', field: base, settings })
+ let result = applyHeightPatch(base, advanceStroke(active, -4, 0)!)
+ if (detach) detachStrokeAnchor(active)
+ const patch = advanceStroke(active, 4, 0)
+ if (patch) result = applyHeightPatch(result, patch)
+ return result
+ }
+
+ test('the skipped stretch stays unpainted', () => {
+ // Midway between the two ends — nowhere the pointer was while painting.
+ expect(heightAt(jump(true), 0, 0)).toBe(0)
+ })
+
+ test('without it the same gap is bridged, which is the bug', () => {
+ // Not a redundant inverse: this is the assertion that fails if someone
+ // "simplifies" the tool by dropping the detach and keeping only the skip.
+ expect(heightAt(jump(false), 0, 0)).toBeGreaterThan(0)
+ })
+
+ test('both ends of the jump are still painted', () => {
+ const detached = jump(true)
+ expect(heightAt(detached, -4, 0)).toBeGreaterThan(0)
+ expect(heightAt(detached, 4, 0)).toBeGreaterThan(0)
+ })
+
+ test('the stroke stays saturating across the break', () => {
+ // The mask survives, so re-crossing painted ground does not stack a ridge —
+ // the property that makes a stroke one gesture rather than N dabs.
+ const base = field()
+ const active = beginStroke({ verb: 'raise', field: base, settings })
+ let result = base
+ for (const [x, z] of [
+ [0, 0],
+ [2, 0],
+ ] as Array<[number, number]>) {
+ const patch = advanceStroke(active, x, z)
+ if (patch) result = applyHeightPatch(result, patch)
+ }
+ const beforeBreak = heightAt(result, 0, 0)
+ detachStrokeAnchor(active)
+ // Straight back over the start: a fresh stroke would deposit again.
+ const patch = advanceStroke(active, 0, 0)
+ if (patch) result = applyHeightPatch(result, patch)
+ expect(heightAt(result, 0, 0)).toBeCloseTo(beforeBreak, 6)
+ })
+})
+
+describe('flatten', () => {
+ test('converges on the absolute target without overshoot', () => {
+ const base = field()
+ let result = base
+ for (let pass = 0; pass < 12; pass++) {
+ result = stroke(
+ result,
+ { verb: 'flatten', target: 2.5, settings: { radius: 2, strength: 1, falloff: 0 } },
+ [[0, 0]],
+ )
+ // Never past the target, at any pass count — the property freehand
+ // raise/lower cannot offer.
+ expect(heightAt(result, 0, 0)).toBeLessThanOrEqual(2.5 + 1e-9)
+ }
+ expect(heightAt(result, 0, 0)).toBeCloseTo(2.5, 6)
+ })
+
+ test('a full-strength hard-edged pass lands exactly on the target', () => {
+ const base = field()
+ const result = stroke(
+ base,
+ { verb: 'flatten', target: 1.75, settings: { radius: 2, strength: 1, falloff: 0 } },
+ [[0, 0]],
+ )
+ expect(heightAt(result, 0, 0)).toBeCloseTo(1.75, 6)
+ })
+
+ test('two disjoint pads flattened to the same target are exactly equal', () => {
+ // This is the whole reason flatten-to-absolute exists: quantized heights
+ // plus an absolute target means `isFlatOver` is a true integer equality
+ // across pads that never touched each other.
+ let result = createTerrainField({ cols: 65, rows: 65, spacing: 0.5, origin: [-16, -16] })
+ const settings = { radius: 2, strength: 1, falloff: 0 }
+ result = stroke(result, { verb: 'flatten', target: 1.2, settings }, [[-8, 0]])
+ result = stroke(result, { verb: 'flatten', target: 1.2, settings }, [[8, 0]])
+
+ expect(isFlatOver(result, -9, -1, -7, 1)).toBe(true)
+ expect(isFlatOver(result, 7, -1, 9, 1)).toBe(true)
+ expect(quantize(result, heightAt(result, -8, 0))).toBe(quantize(result, heightAt(result, 8, 0)))
+ })
+
+ test('flatten pulls a raised hill back down as readily as it pushes up', () => {
+ const base = field()
+ const raised = stroke(
+ base,
+ { verb: 'raise', settings: { radius: 3, strength: 1, falloff: 0 } },
+ [[0, 0]],
+ )
+ const flattened = stroke(
+ raised,
+ { verb: 'flatten', target: 0, settings: { radius: 3, strength: 1, falloff: 0 } },
+ [[0, 0]],
+ )
+ expect(heightAt(flattened, 0, 0)).toBeCloseTo(0, 6)
+ })
+
+ test('sampleTarget reads the height the user clicked', () => {
+ const base = field()
+ const raised = stroke(
+ base,
+ { verb: 'raise', settings: { radius: 2, strength: 1, falloff: 0 } },
+ [[4, 0]],
+ )
+ expect(sampleTarget(raised, 4, 0)).toBeCloseTo(heightAt(raised, 4, 0), 6)
+ })
+})
+
+describe('smooth', () => {
+ test('reduces the peak of a spike without moving flat ground', () => {
+ const base = field()
+ const spiked = stroke(
+ base,
+ { verb: 'raise', settings: { radius: 1, strength: 1, falloff: 0 } },
+ [[0, 0]],
+ )
+ const peak = heightAt(spiked, 0, 0)
+ const smoothed = stroke(
+ spiked,
+ { verb: 'smooth', settings: { radius: 3, strength: 1, falloff: 0.2 } },
+ [[0, 0]],
+ )
+
+ expect(heightAt(smoothed, 0, 0)).toBeLessThan(peak)
+ // Far from the spike the ground was already flat, so diffusion has nothing
+ // to do — a smooth brush that dents flat ground is a broken smooth brush.
+ expect(heightAt(smoothed, 7, 7)).toBeCloseTo(0, 2)
+ })
+
+ test('repeated smoothing converges instead of oscillating', () => {
+ // λ = 0.25 on a 4-neighbour stencil is the stability bound. Above it the
+ // filter rings; this asserts monotone decay of the peak.
+ const base = field()
+ let result = stroke(base, { verb: 'raise', settings: { radius: 1, strength: 1, falloff: 0 } }, [
+ [0, 0],
+ ])
+ let previous = heightAt(result, 0, 0)
+ for (let pass = 0; pass < 6; pass++) {
+ result = stroke(
+ result,
+ { verb: 'smooth', settings: { radius: 3, strength: 1, falloff: 0.2 } },
+ [[0, 0]],
+ )
+ const current = heightAt(result, 0, 0)
+ expect(current).toBeLessThanOrEqual(previous + 1e-9)
+ previous = current
+ }
+ })
+})
+
+describe('field bounds', () => {
+ test('a stroke entirely off the field returns no patch', () => {
+ const active = beginStroke({ verb: 'raise', field: field(), settings: { radius: 1 } })
+ expect(advanceStroke(active, 500, 500)).toBeNull()
+ })
+
+ test('a stroke straddling the edge clips instead of throwing', () => {
+ const base = field()
+ // The field spans -8..8; a brush at the corner half-overhangs it.
+ const result = stroke(
+ base,
+ { verb: 'raise', settings: { radius: 2, strength: 1, falloff: 0 } },
+ [[8, 8]],
+ )
+ expect(heightAt(result, 8, 8)).toBeGreaterThan(0)
+ })
+})
+
+describe('minBrushRadius', () => {
+ /**
+ * The floor exists because a dab only moves samples *inside* its radius, so a
+ * brush narrower than the sample gap can land between four of them and do
+ * nothing — silently: no patch, no error, no mark. These assert the floor is
+ * actually above that cliff at every spacing, which matters because
+ * `fieldExtentForSite` stretches spacing on large lots, so the failure appears
+ * only on a big site.
+ */
+ const worstCaseDab = (spacing: number, radius: number) => {
+ const target = createTerrainField({
+ cols: 33,
+ rows: 33,
+ spacing,
+ origin: [-16 * spacing, -16 * spacing],
+ })
+ // Centred between four samples — the position that misses by the most.
+ const x = spacing / 2
+ const z = spacing / 2
+ const active = beginStroke({ verb: 'raise', field: target, settings: { radius } })
+ const patch = advanceStroke(active, x, z)
+ // Height, not the patch: a dab whose samples all weigh 0 still returns a
+ // patch (of unchanged heights), so "did the ground move" is the only question
+ // that matches what the user sees.
+ const moved = patch ? heightAt(applyHeightPatch(target, patch), x, z) : 0
+ return { moved, coverage: maxCoverage(active) }
+ }
+
+ // 0.5 is the default; the rest are spacings `fieldExtentForSite` produces once
+ // a lot outgrows 257² samples at the default.
+ const SPACINGS = [0.5, 1.18, 1.57, 3.13]
+
+ test('a brush at the floor still bites, at every spacing', () => {
+ for (const spacing of SPACINGS) {
+ const { moved, coverage } = worstCaseDab(spacing, minBrushRadius({ spacing }))
+ expect(moved).toBeGreaterThan(0)
+ // Not merely non-zero: the dab has to land with real authority, or the
+ // brush "works" only when it happens to straddle a sample.
+ expect(coverage).toBeGreaterThan(0.9)
+ }
+ })
+
+ test('below the floor the ground does not move at all', () => {
+ // The bug this guards: the radius minimum used to be a flat 0.5 m, which at
+ // stretched spacing is below this cliff — the user drags and *nothing*
+ // happens. Documenting the cliff's existence is the point: if the footprint
+ // math ever covers sub-spacing brushes, this failing is the signal that the
+ // floor can be lowered.
+ for (const spacing of SPACINGS) {
+ const { moved, coverage } = worstCaseDab(spacing, minBrushRadius({ spacing }) / 3)
+ expect(moved).toBe(0)
+ expect(coverage).toBe(0)
+ }
+ })
+
+ test('the floor scales with spacing rather than being a constant', () => {
+ expect(minBrushRadius({ spacing: 2 })).toBeCloseTo(2 * minBrushRadius({ spacing: 1 }), 6)
+ expect(minBrushRadius({ spacing: 1 })).toBe(MIN_BRUSH_RADIUS_IN_SPACINGS)
+ })
+})
+
+describe('highestOver', () => {
+ test('returns the tallest sample in the rect', () => {
+ const base = field()
+ const raised = stroke(
+ base,
+ { verb: 'raise', settings: { radius: 1.5, strength: 1, falloff: 0 } },
+ [[2, 2]],
+ )
+ expect(highestOver(raised, 0, 0, 4, 4)).toBeCloseTo(RAISE_METRES_PER_STROKE, 2)
+ // A rect away from the hill sees only the datum.
+ expect(highestOver(raised, -8, -8, -6, -6)).toBeCloseTo(0, 6)
+ })
+})
diff --git a/packages/core/src/lib/terrain-brush.ts b/packages/core/src/lib/terrain-brush.ts
new file mode 100644
index 000000000..5c2e4c6e0
--- /dev/null
+++ b/packages/core/src/lib/terrain-brush.ts
@@ -0,0 +1,436 @@
+/**
+ * The sculpt brush: pointer motion → `HeightPatch`.
+ *
+ * The obvious implementation — `h += strength * falloff` every tick — is wrong,
+ * and it is wrong in a way that is well documented by the people who ship it.
+ * Dwelling in one spot accumulates without bound, overlapping dabs double-apply,
+ * and a fast drag deposits less material than a slow one. That is exactly the
+ * "hash edges and jagged" result expert Sims builders complain about and route
+ * around. This module implements the model the mature tools converge on instead:
+ *
+ * 1. **Freeze a snapshot at pointer-down.** Every dab is evaluated against the
+ * snapshot, never against the running result.
+ * 2. **Saturating coverage mask.** A dab raises coverage to `max(mask, falloff)`
+ * rather than adding to it, and the height is always
+ * `snapshot + delta * mask`. Re-crossing your own stroke therefore cannot
+ * compound — the second pass recomputes the same value from the same
+ * snapshot.
+ * 3. **Arc-length dab spacing.** `advanceStroke` walks from the previous dab to
+ * the new pointer position in fixed metre steps, so stroke strength is a
+ * function of distance travelled, not of mouse speed or frame rate.
+ * 4. **Cosine-bell falloff.** Of the standard sculpt kernels this is one of only
+ * two that are C¹ at *both* centre and rim, which is what prevents a visible
+ * ridge at the brush boundary.
+ *
+ * The consequence users feel: one stroke is one bounded, repeatable edit. Hold
+ * still and nothing runs away; press again to go further.
+ *
+ * The `TerrainStroke` returned by `beginStroke` is a mutable accumulator — the
+ * one mutable thing in this file, and deliberately so. It is owned by exactly
+ * one in-flight gesture and dies with it, which is cheaper and clearer than
+ * rebuilding a coverage map per pointer-move.
+ */
+
+import {
+ type HeightPatch,
+ heightAt,
+ heightAtSample,
+ quantize,
+ type TerrainField,
+} from './terrain-field'
+
+export type BrushShape = 'round' | 'square'
+
+/**
+ * `raise`/`lower` are one verb with an inverted sign at the call site, matching
+ * every surveyed tool's modifier-inverts convention. `flatten` is absolute:
+ * it converges on a target height, which is the operation that makes two
+ * disjoint building pads provably equal — the thing freehand push/pull cannot do.
+ */
+export type TerrainVerb = 'raise' | 'lower' | 'flatten' | 'smooth'
+
+export type BrushSettings = {
+ /** Brush radius in metres. */
+ radius: number
+ /** 0–1. Scales how far one full stroke pass gets toward its goal. */
+ strength: number
+ /**
+ * 0–1 softness. 0 is a hard-edged stamp; 1 puts the cosine bell across the
+ * whole radius. The inner `1 - falloff` fraction stays at full coverage.
+ */
+ falloff: number
+ shape: BrushShape
+}
+
+export const DEFAULT_BRUSH_SETTINGS: BrushSettings = {
+ radius: 2,
+ strength: 0.6,
+ falloff: 0.6,
+ shape: 'round',
+}
+
+/**
+ * Metres a single raise/lower stroke moves the ground at full strength and full
+ * coverage. Bounding it per stroke is what makes the verb aimable: the user
+ * knows a pass is worth at most this much, and repeats for more.
+ */
+export const RAISE_METRES_PER_STROKE = 1
+
+/**
+ * Smallest brush radius that still bites, as a multiple of the field's spacing.
+ *
+ * A dab only moves samples that fall *inside* its radius, so a brush narrower
+ * than the gap between samples can land entirely between four of them and change
+ * nothing: `advanceStroke` returns `null`, and the user drags with no mark, no
+ * cursor change, and no error. 1.5 rather than the ~0.71 where a round brush
+ * first catches a corner, because a brush that only bites when it happens to
+ * straddle a sample is worse than one that is honestly too small — at 1.5 the
+ * worst-case coverage is 0.97, so the dab lands wherever it is aimed.
+ *
+ * A *multiple* of spacing rather than a constant because spacing is not fixed:
+ * `fieldExtentForSite` stretches it once a lot exceeds what 257² samples cover
+ * at the default, so on a 300 m lot the old flat 0.5 m floor sat below half the
+ * sample gap and the brush silently stopped working at the bottom of its range.
+ */
+export const MIN_BRUSH_RADIUS_IN_SPACINGS = 1.5
+
+/**
+ * Clamp a radius to what `field` can actually resolve.
+ *
+ * Lives here rather than in the editor's UI layer because it is a fact about the
+ * footprint math above, not a preference: every producer of a radius (slider,
+ * `[`/`]`, a future import preset) has to respect it or it produces a brush that
+ * does nothing.
+ */
+export function minBrushRadius(field: Pick): number {
+ return field.spacing * MIN_BRUSH_RADIUS_IN_SPACINGS
+}
+
+/** Dabs are placed every `radius * this` metres along the pointer path. */
+const DAB_SPACING_FRACTION = 0.25
+
+/**
+ * Jacobi iterations used to precompute the smooth target. Each is applied with
+ * λ = 0.25 on a 4-neighbour stencil, the stability bound for the discrete
+ * diffusion — above it the filter oscillates instead of converging.
+ */
+const SMOOTH_ITERATIONS = 8
+const SMOOTH_LAMBDA = 0.25
+
+export type TerrainStroke = {
+ readonly verb: TerrainVerb
+ readonly settings: BrushSettings
+ /** The field at pointer-down. Never mutated; every dab reads from it. */
+ readonly snapshot: TerrainField
+ /** Absolute target height in metres. Only meaningful for `flatten`. */
+ readonly target: number
+ /** Sample index → saturating coverage in 0–1. */
+ readonly mask: Map
+ /** Lazily computed diffused snapshot, for `smooth`. */
+ smoothed: Int16Array | null
+ /** Last dab centre, for arc-length spacing. Null until the first dab. */
+ lastX: number | null
+ lastZ: number | null
+}
+
+export function beginStroke(options: {
+ field: TerrainField
+ verb: TerrainVerb
+ settings?: Partial
+ /** Absolute height for `flatten`, in metres. Ignored by the other verbs. */
+ target?: number
+}): TerrainStroke {
+ return {
+ verb: options.verb,
+ settings: { ...DEFAULT_BRUSH_SETTINGS, ...options.settings },
+ snapshot: options.field,
+ target: options.target ?? 0,
+ mask: new Map(),
+ smoothed: null,
+ lastX: null,
+ lastZ: null,
+ }
+}
+
+/**
+ * Sample the height a `flatten` stroke should aim at, from the field itself.
+ *
+ * Sampling before the stroke starts (right-click / first click, per the surveyed
+ * tools) is what makes "flatten this pad to match that corner" a two-step
+ * gesture instead of a numeric-entry chore.
+ */
+export function sampleTarget(field: TerrainField, x: number, z: number): number {
+ return heightAt(field, x, z)
+}
+
+/**
+ * Forget where the last dab landed, without ending the stroke.
+ *
+ * Needed because dab spacing is a true *arc length*: `advanceStroke` interpolates
+ * from the previous centre, so any gap in the pointer stream is bridged with a
+ * line of dabs when it resumes. That is exactly right for a dropped frame, and
+ * exactly wrong when the caller deliberately skipped a stretch — a camera zoom
+ * mid-stroke moves the ground under a held brush, and bridging back to a centre
+ * from before the zoom paints a stripe across everything in between.
+ *
+ * A stroke resumed after this deposits its next dab at the pointer, like the
+ * first one. The mask is untouched, so the stroke stays saturating across the
+ * break and the whole gesture is still one undo step.
+ */
+export function detachStrokeAnchor(stroke: TerrainStroke): void {
+ stroke.lastX = null
+ stroke.lastZ = null
+}
+
+/**
+ * Extend the stroke to the pointer's new position and return the patch to apply.
+ *
+ * Returns `null` when the motion is shorter than the dab spacing (nothing
+ * changed yet) or when the footprint misses the field entirely. The patch is
+ * absolute heights over the union of the dabs placed by *this* call, recomputed
+ * from the snapshot and the saturated mask — which is why applying patches in
+ * order converges rather than compounding.
+ */
+export function advanceStroke(stroke: TerrainStroke, x: number, z: number): HeightPatch | null {
+ const dabs = dabPositions(stroke, x, z)
+ if (dabs.length === 0) return null
+
+ const field = stroke.snapshot
+ let minCol = field.cols
+ let minRow = field.rows
+ let maxCol = -1
+ let maxRow = -1
+
+ for (const [dx, dz] of dabs) {
+ const range = footprint(field, dx, dz, stroke.settings.radius)
+ if (!range) continue
+ if (range.col0 < minCol) minCol = range.col0
+ if (range.row0 < minRow) minRow = range.row0
+ if (range.col1 > maxCol) maxCol = range.col1
+ if (range.row1 > maxRow) maxRow = range.row1
+
+ for (let r = range.row0; r <= range.row1; r++) {
+ const sampleZ = field.origin[1] + r * field.spacing
+ for (let c = range.col0; c <= range.col1; c++) {
+ const sampleX = field.origin[0] + c * field.spacing
+ const coverage = weightAt(stroke.settings, sampleX - dx, sampleZ - dz)
+ if (coverage <= 0) continue
+ const index = r * field.cols + c
+ const previous = stroke.mask.get(index) ?? 0
+ if (coverage > previous) stroke.mask.set(index, coverage)
+ }
+ }
+ }
+
+ if (maxCol < minCol || maxRow < minRow) return null
+
+ const cols = maxCol - minCol + 1
+ const rows = maxRow - minRow + 1
+ const heights = new Int16Array(cols * rows)
+ for (let r = 0; r < rows; r++) {
+ const fieldRow = minRow + r
+ for (let c = 0; c < cols; c++) {
+ const fieldCol = minCol + c
+ const index = fieldRow * field.cols + fieldCol
+ heights[r * cols + c] = resolveSample(stroke, index, fieldCol, fieldRow)
+ }
+ }
+
+ return { col0: minCol, row0: minRow, cols, rows, heights }
+}
+
+/**
+ * The quantized height a sample should hold given the stroke's current mask.
+ *
+ * Every verb has the same shape — `snapshot + (goal - snapshot) * mask *
+ * strength` — which is what makes them all saturating. `raise`/`lower` express
+ * their goal as an offset from the snapshot, `flatten` as an absolute, and
+ * `smooth` as the diffused snapshot.
+ */
+function resolveSample(stroke: TerrainStroke, index: number, col: number, row: number): number {
+ const field = stroke.snapshot
+ const base = field.heights[index] ?? 0
+ const coverage = stroke.mask.get(index) ?? 0
+ if (coverage <= 0) return base
+
+ const amount = coverage * stroke.settings.strength
+ const baseMetres = base * field.step
+
+ switch (stroke.verb) {
+ case 'raise':
+ return quantize(field, baseMetres + RAISE_METRES_PER_STROKE * amount)
+ case 'lower':
+ return quantize(field, baseMetres - RAISE_METRES_PER_STROKE * amount)
+ case 'flatten':
+ return quantize(field, baseMetres + (stroke.target - baseMetres) * amount)
+ case 'smooth': {
+ const smoothed = smoothedSnapshot(stroke)
+ const goal = (smoothed[index] ?? base) * field.step
+ return quantize(field, baseMetres + (goal - baseMetres) * amount)
+ }
+ default:
+ // `col`/`row` are carried for future verbs that need position (terrace).
+ void col
+ void row
+ return base
+ }
+}
+
+/**
+ * The diffused snapshot every `smooth` dab lerps toward, computed once per
+ * stroke.
+ *
+ * Whole-field rather than stroke-local: a local blur would need its border
+ * samples pinned, and pinned borders are precisely where a smooth brush leaves
+ * the ridge it was supposed to remove. The cost is a few passes over an array
+ * that is 4 225 entries at the default 65² size.
+ */
+function smoothedSnapshot(stroke: TerrainStroke): Int16Array {
+ if (stroke.smoothed) return stroke.smoothed
+ const field = stroke.snapshot
+ let current = new Int16Array(field.heights)
+ let next = new Int16Array(current.length)
+ for (let iteration = 0; iteration < SMOOTH_ITERATIONS; iteration++) {
+ for (let r = 0; r < field.rows; r++) {
+ for (let c = 0; c < field.cols; c++) {
+ const index = r * field.cols + c
+ const centre = current[index] ?? 0
+ const laplacian =
+ sampleClamped(current, field, c - 1, r) +
+ sampleClamped(current, field, c + 1, r) +
+ sampleClamped(current, field, c, r - 1) +
+ sampleClamped(current, field, c, r + 1) -
+ 4 * centre
+ next[index] = Math.round(centre + SMOOTH_LAMBDA * laplacian)
+ }
+ }
+ const swap = current
+ current = next
+ next = swap
+ }
+ stroke.smoothed = current
+ return current
+}
+
+function sampleClamped(heights: Int16Array, field: TerrainField, col: number, row: number): number {
+ const c = Math.max(0, Math.min(field.cols - 1, col))
+ const r = Math.max(0, Math.min(field.rows - 1, row))
+ return heights[r * field.cols + c] ?? 0
+}
+
+/**
+ * Dab centres from the last one to `(x, z)`, spaced by arc length.
+ *
+ * The first dab of a stroke is placed exactly at the pointer, so a click with no
+ * drag still deposits one dab. After that, motion shorter than one spacing
+ * deposits nothing and the stroke keeps its old anchor — which is what makes the
+ * spacing a true arc length rather than a per-event budget.
+ */
+function dabPositions(stroke: TerrainStroke, x: number, z: number): Array<[number, number]> {
+ if (stroke.lastX === null || stroke.lastZ === null) {
+ stroke.lastX = x
+ stroke.lastZ = z
+ return [[x, z]]
+ }
+
+ const spacing = Math.max(1e-3, stroke.settings.radius * DAB_SPACING_FRACTION)
+ const dx = x - stroke.lastX
+ const dz = z - stroke.lastZ
+ const distance = Math.hypot(dx, dz)
+ if (distance < spacing) return []
+
+ const steps = Math.floor(distance / spacing)
+ const dabs: Array<[number, number]> = []
+ for (let step = 1; step <= steps; step++) {
+ const t = (step * spacing) / distance
+ dabs.push([stroke.lastX + dx * t, stroke.lastZ + dz * t])
+ }
+ const last = dabs[dabs.length - 1]
+ if (last) {
+ stroke.lastX = last[0]
+ stroke.lastZ = last[1]
+ }
+ return dabs
+}
+
+/** Sample range a dab of `radius` at `(x, z)` touches, clamped to the field. */
+function footprint(
+ field: TerrainField,
+ x: number,
+ z: number,
+ radius: number,
+): { col0: number; row0: number; col1: number; row1: number } | null {
+ const col0 = Math.max(0, Math.ceil((x - radius - field.origin[0]) / field.spacing))
+ const row0 = Math.max(0, Math.ceil((z - radius - field.origin[1]) / field.spacing))
+ const col1 = Math.min(field.cols - 1, Math.floor((x + radius - field.origin[0]) / field.spacing))
+ const row1 = Math.min(field.rows - 1, Math.floor((z + radius - field.origin[1]) / field.spacing))
+ if (col1 < col0 || row1 < row0) return null
+ return { col0, row0, col1, row1 }
+}
+
+/**
+ * Coverage in 0–1 for a sample at offset `(dx, dz)` from a dab centre.
+ *
+ * Round uses Euclidean distance, square uses Chebyshev — the same radius means
+ * the square brush's inscribed circle matches the round one, so switching shape
+ * mid-session does not change the brush's apparent size.
+ */
+export function weightAt(settings: BrushSettings, dx: number, dz: number): number {
+ const radius = Math.max(1e-6, settings.radius)
+ const distance =
+ settings.shape === 'square' ? Math.max(Math.abs(dx), Math.abs(dz)) : Math.hypot(dx, dz)
+ const normalized = distance / radius
+ if (normalized >= 1) return 0
+
+ const falloff = Math.min(1, Math.max(0, settings.falloff))
+ if (falloff <= 0) return 1
+
+ const inner = 1 - falloff
+ if (normalized <= inner) return 1
+ const t = (normalized - inner) / falloff
+ // Cosine bell: 1 at t=0, 0 at t=1, zero derivative at both ends.
+ return 0.5 * (1 + Math.cos(Math.PI * t))
+}
+
+/**
+ * The world-XZ height under a brush, for the readout the sculpt HUD shows.
+ * Thin, but it keeps the tool from importing `heightAt` just to render a number.
+ */
+export function brushHeightAt(field: TerrainField, x: number, z: number): number {
+ return heightAt(field, x, z)
+}
+
+/** Peak coverage the stroke reached, for tests and for the HUD's "pass" readout. */
+export function maxCoverage(stroke: TerrainStroke): number {
+ let max = 0
+ for (const value of stroke.mask.values()) if (value > max) max = value
+ return max
+}
+
+/**
+ * Highest sample height in metres under a world-XZ rect — the seed a `flatten`
+ * pad uses when the user asks for "level with the high corner" rather than
+ * typing a number.
+ */
+export function highestOver(
+ field: TerrainField,
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+): number {
+ const col0 = Math.max(0, Math.floor((minX - field.origin[0]) / field.spacing))
+ const row0 = Math.max(0, Math.floor((minZ - field.origin[1]) / field.spacing))
+ const col1 = Math.min(field.cols - 1, Math.ceil((maxX - field.origin[0]) / field.spacing))
+ const row1 = Math.min(field.rows - 1, Math.ceil((maxZ - field.origin[1]) / field.spacing))
+ if (col1 < col0 || row1 < row0) return heightAtSample(field, col0, row0)
+
+ let highest = Number.NEGATIVE_INFINITY
+ for (let r = row0; r <= row1; r++) {
+ for (let c = col0; c <= col1; c++) {
+ const value = (field.heights[r * field.cols + c] ?? 0) * field.step
+ if (value > highest) highest = value
+ }
+ }
+ return highest
+}
diff --git a/packages/core/src/lib/terrain-codec.test.ts b/packages/core/src/lib/terrain-codec.test.ts
new file mode 100644
index 000000000..89f6f357a
--- /dev/null
+++ b/packages/core/src/lib/terrain-codec.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, test } from 'bun:test'
+import type { TerrainData } from '../schema/terrain'
+import { decodeTerrainField, encodeTerrainField, isDatumField } from './terrain-codec'
+import { createTerrainField, heightAt, type TerrainField } from './terrain-field'
+
+function fieldWith(values: number[], cols: number, rows: number): TerrainField {
+ const field = createTerrainField({ cols, rows, spacing: 1, step: 0.01 })
+ return { ...field, heights: Int16Array.from(values) }
+}
+
+describe('encodeTerrainField / decodeTerrainField', () => {
+ test('round-trips every sample exactly', () => {
+ const source = fieldWith([0, 1, -1, 250, -250, 32767, -32768, 7, 8], 3, 3)
+ const decoded = decodeTerrainField(encodeTerrainField(source))
+ expect(decoded).not.toBeNull()
+ expect(Array.from(decoded?.heights ?? [])).toEqual(Array.from(source.heights))
+ })
+
+ test('round-trips the field metadata', () => {
+ const source: TerrainField = {
+ ...createTerrainField({ cols: 5, rows: 4, spacing: 0.25, step: 0.005 }),
+ origin: [-12.5, 33.25],
+ }
+ const decoded = decodeTerrainField(encodeTerrainField(source))
+ expect(decoded?.origin).toEqual([-12.5, 33.25])
+ expect(decoded?.spacing).toBe(0.25)
+ expect(decoded?.step).toBe(0.005)
+ expect(decoded?.cols).toBe(5)
+ expect(decoded?.rows).toBe(4)
+ })
+
+ test('a decoded field reads the same heights as the original', () => {
+ const cols = 9
+ const rows = 9
+ const heights = new Int16Array(cols * rows)
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ heights[r * cols + c] = Math.round(Math.sin(c * 0.4) * 120 + r * 13)
+ }
+ }
+ const source: TerrainField = {
+ ...createTerrainField({ cols, rows, spacing: 0.5, step: 0.01 }),
+ heights,
+ }
+ const decoded = decodeTerrainField(encodeTerrainField(source))
+ expect(decoded).not.toBeNull()
+ // Sampling, not just bytes: the point of the codec is that the read
+ // authority behaves identically after a save/load cycle.
+ for (let x = 0; x < 4; x += 0.37) {
+ for (let z = 0; z < 4; z += 0.41) {
+ expect(heightAt(decoded as TerrainField, x, z)).toBeCloseTo(heightAt(source, x, z), 9)
+ }
+ }
+ })
+
+ test('is JSON-safe — survives stringify/parse', () => {
+ const source = fieldWith([5, -5, 300, -300], 2, 2)
+ const wire = JSON.parse(JSON.stringify(encodeTerrainField(source)))
+ const decoded = decodeTerrainField(wire)
+ expect(Array.from(decoded?.heights ?? [])).toEqual([5, -5, 300, -300])
+ })
+
+ test('encodes byte-identically regardless of the source buffer offset', () => {
+ // An Int16Array view into a larger buffer must encode the same as a standalone
+ // one — `heights.buffer` would be the whole parent buffer, which is the classic
+ // typed-array footgun.
+ const parent = new Int16Array(10)
+ parent.set([11, 22, 33, 44], 6)
+ const view = parent.subarray(6, 10)
+ const a = encodeTerrainField({ ...fieldWith([11, 22, 33, 44], 2, 2), heights: view })
+ const b = encodeTerrainField(fieldWith([11, 22, 33, 44], 2, 2))
+ expect(a.heights).toBe(b.heights)
+ })
+
+ test('beats a naive JSON array on realistic terrain', () => {
+ // Base64 is a fixed 2.67 chars/sample. A JSON number array is variable: an
+ // all-zero field costs 2 chars/sample and *wins*, which is exactly why an
+ // untouched field is never persisted at all (`isDatumField`). Real terrain
+ // has multi-digit signed heights, and that is where the fixed cost pays.
+ const cols = 65
+ const rows = 65
+ const heights = new Int16Array(cols * rows)
+ for (let i = 0; i < heights.length; i++) {
+ heights[i] = Math.round(Math.sin(i * 0.11) * 1800)
+ }
+ const field: TerrainField = { ...createTerrainField({ cols, rows }), heights }
+ const encoded = encodeTerrainField(field)
+ const naive = JSON.stringify(Array.from(field.heights)).length
+ expect(encoded.heights.length).toBeLessThan(naive)
+ // And the absolute size is documented, since it lands in every saved scene:
+ // 65² samples at 2 bytes → 11 268 base64 chars, ~11 KB.
+ expect(encoded.heights.length).toBe(11_268)
+ })
+})
+
+describe('decodeTerrainField — hostile and corrupt input', () => {
+ test('rejects non-objects and wrong discriminators', () => {
+ expect(decodeTerrainField(null)).toBeNull()
+ expect(decodeTerrainField(undefined)).toBeNull()
+ expect(decodeTerrainField('heightfield')).toBeNull()
+ expect(decodeTerrainField(42)).toBeNull()
+ expect(decodeTerrainField({})).toBeNull()
+ expect(decodeTerrainField({ type: 'polygon' })).toBeNull()
+ })
+
+ test('rejects non-integer or non-positive dimensions', () => {
+ const base = encodeTerrainField(fieldWith([1, 2, 3, 4], 2, 2))
+ expect(decodeTerrainField({ ...base, cols: 2.5 })).toBeNull()
+ expect(decodeTerrainField({ ...base, cols: 0 })).toBeNull()
+ expect(decodeTerrainField({ ...base, rows: -1 })).toBeNull()
+ expect(decodeTerrainField({ ...base, cols: Number.NaN })).toBeNull()
+ })
+
+ test('rejects dimensions above the authoring ceiling before allocating', () => {
+ const base = encodeTerrainField(fieldWith([1], 1, 1))
+ expect(decodeTerrainField({ ...base, cols: 258, rows: 1 })).toBeNull()
+ expect(decodeTerrainField({ ...base, cols: 1, rows: 258 })).toBeNull()
+ expect(decodeTerrainField({ ...base, cols: 100_000, rows: 100_000 })).toBeNull()
+ })
+
+ test('rejects non-base64 characters instead of decoding garbage', () => {
+ const base = encodeTerrainField(fieldWith([1, 2, 3, 4], 2, 2))
+ expect(decodeTerrainField({ ...base, heights: 'not base64!!' })).toBeNull()
+ expect(decodeTerrainField({ ...base, heights: '\u{1F600}' })).toBeNull()
+ })
+
+ test('rejects a missing heights string', () => {
+ const base = encodeTerrainField(fieldWith([1], 1, 1))
+ expect(decodeTerrainField({ ...base, heights: undefined })).toBeNull()
+ expect(decodeTerrainField({ ...base, heights: [1, 2] })).toBeNull()
+ })
+
+ test('rejects truncated and oversized payloads', () => {
+ const source = fieldWith([10, 20, 30, 40, 50, 60, 70, 80, 90], 3, 3)
+ const encoded = encodeTerrainField(source)
+ const truncated: TerrainData = { ...encoded, heights: encoded.heights.slice(0, 8) }
+ expect(decodeTerrainField(truncated)).toBeNull()
+
+ const longer = encodeTerrainField(fieldWith([10, 20, 30, 40, 50], 5, 1))
+ expect(decodeTerrainField({ ...encoded, heights: longer.heights })).toBeNull()
+ })
+
+ test('rejects missing metadata instead of inventing a different field', () => {
+ const encoded = encodeTerrainField(fieldWith([1, 2, 3, 4], 2, 2))
+ expect(
+ decodeTerrainField({ type: 'heightfield', cols: 2, rows: 2, heights: encoded.heights }),
+ ).toBeNull()
+ })
+
+ test('rejects invalid spacing, step, and origin values', () => {
+ const base = encodeTerrainField(fieldWith([1, 2, 3, 4], 2, 2))
+ expect(decodeTerrainField({ ...base, spacing: 0 })).toBeNull()
+ expect(decodeTerrainField({ ...base, spacing: -1 })).toBeNull()
+ expect(decodeTerrainField({ ...base, spacing: Number.POSITIVE_INFINITY })).toBeNull()
+ expect(decodeTerrainField({ ...base, step: 0 })).toBeNull()
+ expect(decodeTerrainField({ ...base, step: Number.NaN })).toBeNull()
+ expect(decodeTerrainField({ ...base, origin: [0, Number.NaN] })).toBeNull()
+ })
+
+ test('rejects non-canonical base64 instead of accepting trailing garbage', () => {
+ const base = encodeTerrainField(fieldWith([1, 2, 3, 4], 2, 2))
+ expect(decodeTerrainField({ ...base, heights: `${base.heights}AAAA` })).toBeNull()
+ expect(decodeTerrainField({ ...base, heights: base.heights.replace(/=$/, '') })).toBeNull()
+ })
+})
+
+describe('isDatumField', () => {
+ test('true for a fresh field', () => {
+ expect(isDatumField(createTerrainField({ cols: 17, rows: 17 }))).toBe(true)
+ })
+
+ test('false as soon as one sample moves', () => {
+ const field = createTerrainField({ cols: 5, rows: 5 })
+ const heights = new Int16Array(field.heights)
+ heights[12] = 1
+ expect(isDatumField({ ...field, heights })).toBe(false)
+ })
+
+ test('false for a negative sample — a pit is not the datum', () => {
+ expect(isDatumField(fieldWith([0, 0, -1, 0], 2, 2))).toBe(false)
+ })
+})
diff --git a/packages/core/src/lib/terrain-codec.ts b/packages/core/src/lib/terrain-codec.ts
new file mode 100644
index 000000000..c266af517
--- /dev/null
+++ b/packages/core/src/lib/terrain-codec.ts
@@ -0,0 +1,164 @@
+/**
+ * The wire format for a `TerrainField`.
+ *
+ * The scene graph is persisted as JSON (`projects_models.scene_graph`, a jsonb
+ * column), so an `Int16Array` cannot be stored directly — `JSON.stringify` turns
+ * a typed array into an object with numeric string keys, which round-trips
+ * lossily and costs ~7 bytes per sample. This module is the one place the field
+ * crosses that boundary.
+ *
+ * Base64 of little-endian `Int16` was chosen over the alternatives:
+ *
+ * - **A plain number array** costs 4-6× the bytes and, worse, re-parses into a
+ * `number[]` that every consumer would then have to copy into an `Int16Array`.
+ * - **`Array.from(heights)`** has the same problem and silently drops the
+ * quantization invariant: nothing stops a hand-edited scene from putting
+ * `2.5` in the array, and then `isFlatOver`'s integer equality is meaningless.
+ * - **Compression** (RLE, deflate) is deliberately *not* here. Imported DEMs are
+ * noisy and compress worst, so a compressed format would fail only on the
+ * messiest scenes — the ones most likely to be real. Chunking (`diffToPatches`)
+ * is the size mitigation, not compression.
+ *
+ * Byte order is written explicitly via `DataView` rather than relying on
+ * `Int16Array`'s platform order. Every realistic platform is little-endian, but
+ * the wire format must not depend on that being true of the machine that saved
+ * the scene.
+ */
+
+import { MAX_TERRAIN_SIDE, type TerrainData } from '../schema/terrain'
+import type { TerrainField } from './terrain-field'
+
+const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
+
+function encodeBase64(bytes: Uint8Array): string {
+ let out = ''
+ for (let i = 0; i < bytes.length; i += 3) {
+ const b0 = bytes[i] ?? 0
+ const b1 = bytes[i + 1] ?? 0
+ const b2 = bytes[i + 2] ?? 0
+ const remaining = bytes.length - i
+ out += BASE64_ALPHABET[b0 >> 2]
+ out += BASE64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)]
+ out += remaining > 1 ? BASE64_ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] : '='
+ out += remaining > 2 ? BASE64_ALPHABET[b2 & 0x3f] : '='
+ }
+ return out
+}
+
+function decodeBase64(text: string): Uint8Array | null {
+ // Reverse lookup is built per call rather than at module scope: decode runs
+ // once per scene load, and a 64-entry loop is cheaper than the indirection
+ // of a module-level Map for a hot path that isn't hot.
+ const lookup = new Int8Array(128).fill(-1)
+ for (let i = 0; i < BASE64_ALPHABET.length; i++) {
+ lookup[BASE64_ALPHABET.charCodeAt(i)] = i
+ }
+
+ let clean = ''
+ for (let i = 0; i < text.length; i++) {
+ const code = text.charCodeAt(i)
+ if (code === 61 /* = */) break
+ if (code > 127 || lookup[code] === -1) return null
+ clean += text[i]
+ }
+ if (clean.length % 4 === 1) return null
+
+ const byteLength = Math.floor((clean.length * 3) / 4)
+ const bytes = new Uint8Array(byteLength)
+ let out = 0
+ for (let i = 0; i < clean.length; i += 4) {
+ const c0 = lookup[clean.charCodeAt(i)] ?? 0
+ const c1 = lookup[clean.charCodeAt(i + 1)] ?? 0
+ const c2 = i + 2 < clean.length ? (lookup[clean.charCodeAt(i + 2)] ?? 0) : 0
+ const c3 = i + 3 < clean.length ? (lookup[clean.charCodeAt(i + 3)] ?? 0) : 0
+ if (out < byteLength) bytes[out++] = (c0 << 2) | (c1 >> 4)
+ if (out < byteLength) bytes[out++] = ((c1 & 0x0f) << 4) | (c2 >> 2)
+ if (out < byteLength) bytes[out++] = ((c2 & 0x03) << 6) | c3
+ }
+ return bytes
+}
+
+export function encodeTerrainField(field: TerrainField): TerrainData {
+ const bytes = new Uint8Array(field.heights.length * 2)
+ const view = new DataView(bytes.buffer)
+ for (let i = 0; i < field.heights.length; i++) {
+ view.setInt16(i * 2, field.heights[i] ?? 0, true)
+ }
+ return {
+ type: 'heightfield',
+ origin: [field.origin[0], field.origin[1]],
+ spacing: field.spacing,
+ cols: field.cols,
+ rows: field.rows,
+ step: field.step,
+ heights: encodeBase64(bytes),
+ }
+}
+
+/**
+ * Rebuild a field from persisted data, or `null` if the data is unusable.
+ *
+ * Returns `null` rather than throwing, and rather than repairing: a scene whose
+ * terrain fails to decode must still load (with flat ground) instead of taking
+ * the whole project down, and silently substituting a *different* terrain would
+ * be worse than showing none. The caller treats `null` as "this site has no
+ * terrain".
+ *
+ * Metadata and payload length are strict. Repairing a truncated payload or
+ * inventing missing metadata would silently substitute a different surface.
+ */
+export function decodeTerrainField(data: unknown): TerrainField | null {
+ if (!data || typeof data !== 'object') return null
+ const d = data as Partial
+ if (d.type !== 'heightfield') return null
+ if (!Number.isInteger(d.cols) || !Number.isInteger(d.rows)) return null
+ const cols = d.cols as number
+ const rows = d.rows as number
+ if (cols < 1 || rows < 1) return null
+ if (cols > MAX_TERRAIN_SIDE || rows > MAX_TERRAIN_SIDE) return null
+ if (typeof d.heights !== 'string') return null
+ if (!Number.isFinite(d.spacing) || (d.spacing as number) <= 0) return null
+ if (!Number.isFinite(d.step) || (d.step as number) <= 0) return null
+ if (
+ !Array.isArray(d.origin) ||
+ d.origin.length !== 2 ||
+ !Number.isFinite(d.origin[0]) ||
+ !Number.isFinite(d.origin[1])
+ ) {
+ return null
+ }
+
+ const bytes = decodeBase64(d.heights)
+ if (!bytes) return null
+ if (encodeBase64(bytes) !== d.heights) return null
+ if (bytes.byteLength !== cols * rows * 2) return null
+
+ const heights = new Int16Array(cols * rows)
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+ for (let i = 0; i < heights.length; i++) {
+ heights[i] = view.getInt16(i * 2, true)
+ }
+
+ return {
+ origin: [d.origin[0] as number, d.origin[1] as number],
+ spacing: d.spacing as number,
+ cols,
+ rows,
+ step: d.step as number,
+ heights,
+ }
+}
+
+/**
+ * True when every sample is at the datum — the test for "this field is not worth
+ * persisting".
+ *
+ * Called before writing to the node so an untouched site keeps `terrain:
+ * undefined` instead of carrying ~11 KB of base64 zeroes in every saved scene.
+ */
+export function isDatumField(field: TerrainField): boolean {
+ for (let i = 0; i < field.heights.length; i++) {
+ if (field.heights[i] !== 0) return false
+ }
+ return true
+}
diff --git a/packages/core/src/lib/terrain-field.test.ts b/packages/core/src/lib/terrain-field.test.ts
new file mode 100644
index 000000000..aedc2946a
--- /dev/null
+++ b/packages/core/src/lib/terrain-field.test.ts
@@ -0,0 +1,404 @@
+import { describe, expect, test } from 'bun:test'
+import {
+ applyHeightPatch,
+ createTerrainField,
+ diffToPatches,
+ flattenPatch,
+ heightAt,
+ heightAtSample,
+ isFlatOver,
+ normalAt,
+ quantize,
+ slopeAt,
+ surfaceHeightAt,
+ type TerrainField,
+} from './terrain-field'
+
+/** A small field with a known ramp along +X: height = x * grade. */
+function rampField(grade: number, cols = 5, rows = 5, spacing = 1): TerrainField {
+ const field = createTerrainField({ cols, rows, spacing, step: 0.01 })
+ const heights = new Int16Array(cols * rows)
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ heights[r * cols + c] = Math.round((c * spacing * grade) / field.step)
+ }
+ }
+ return { ...field, heights }
+}
+
+describe('quantize', () => {
+ test('rounds onto the step ladder', () => {
+ const field = createTerrainField({ step: 0.01 })
+ expect(quantize(field, 1.004)).toBe(100)
+ expect(quantize(field, 1.006)).toBe(101)
+ expect(quantize(field, -0.5)).toBe(-50)
+ })
+
+ test('clamps to the Int16 range instead of overflowing', () => {
+ const field = createTerrainField({ step: 0.01 })
+ expect(quantize(field, 1e6)).toBe(32767)
+ expect(quantize(field, -1e6)).toBe(-32768)
+ })
+
+ test('non-finite input yields 0 rather than NaN in the buffer', () => {
+ const field = createTerrainField({ step: 0.01 })
+ expect(quantize(field, Number.NaN)).toBe(0)
+ expect(quantize(field, Number.POSITIVE_INFINITY)).toBe(0)
+ })
+})
+
+describe('heightAt', () => {
+ test('a flat field reads its height everywhere, including off-grid points', () => {
+ const field = applyHeightPatch(createTerrainField({ cols: 5, rows: 5, spacing: 1 }), {
+ col0: 0,
+ row0: 0,
+ cols: 5,
+ rows: 5,
+ heights: new Int16Array(25).fill(250),
+ })
+ expect(heightAt(field, 0, 0)).toBeCloseTo(2.5, 9)
+ expect(heightAt(field, 1.37, 2.9)).toBeCloseTo(2.5, 9)
+ })
+
+ test('interpolates linearly across a rendered triangle', () => {
+ const field = rampField(1)
+ // Ramp is 1 m rise per 1 m of +X, so the midpoint between x=1 and x=2 is 1.5.
+ expect(heightAt(field, 1.5, 0)).toBeCloseTo(1.5, 6)
+ expect(heightAt(field, 2, 0)).toBeCloseTo(2, 6)
+ })
+
+ test('clamps to the edge height outside the field rather than returning a hole', () => {
+ const field = rampField(1)
+ // Field spans x in [0,4]. Past the far edge, the last column's height holds.
+ expect(heightAt(field, 99, 0)).toBeCloseTo(4, 6)
+ expect(heightAt(field, -99, 0)).toBeCloseTo(0, 6)
+ })
+
+ test('respects a non-zero origin', () => {
+ const base = rampField(1)
+ const shifted: TerrainField = { ...base, origin: [10, 10] }
+ // The ramp now starts at x=10, so x=12 is 2 m up.
+ expect(heightAt(shifted, 12, 10)).toBeCloseTo(2, 6)
+ // And x=0 is off the low edge, clamped to the first column.
+ expect(heightAt(shifted, 0, 10)).toBeCloseTo(0, 6)
+ })
+})
+
+describe('heightAtSample', () => {
+ test('clamps out-of-range indices to the border', () => {
+ const field = rampField(1)
+ expect(heightAtSample(field, -5, 0)).toBeCloseTo(0, 6)
+ expect(heightAtSample(field, 999, 0)).toBeCloseTo(4, 6)
+ })
+})
+
+describe('surfaceHeightAt — explicit alias for the shared surface authority', () => {
+ /**
+ * A cell warped as much as it can be: opposite corners at 4 m, the other two at
+ * the datum. The two triangles both contain the diagonal, whose midpoint is
+ * 4 m on one diagonal pair and 0 m on the other.
+ */
+ function saddleField(): TerrainField {
+ const base = createTerrainField({ cols: 3, rows: 3, spacing: 2, step: 0.01 })
+ const heights = new Int16Array(9)
+ for (let r = 0; r < 3; r++) {
+ for (let c = 0; c < 3; c++) {
+ heights[r * 3 + c] = (c + r) % 2 === 0 ? 400 : 0
+ }
+ }
+ return { ...base, heights }
+ }
+
+ test('agrees with heightAt at every sample', () => {
+ const field = saddleField()
+ for (let r = 0; r < 3; r++) {
+ for (let c = 0; c < 3; c++) {
+ const x = c * 2
+ const z = r * 2
+ expect(surfaceHeightAt(field, x, z)).toBeCloseTo(heightAt(field, x, z), 6)
+ }
+ }
+ })
+
+ test('heightAt is planar within the rendered triangle', () => {
+ const field = saddleField()
+ // Cell [0,0] spans x,z ∈ [0,2]; the diagonal runs (2,0)–(0,2). The lower
+ // triangle is (0,0)=4, (2,0)=0, (0,2)=0, whose plane is 4 − 2x − 2z... in
+ // metres, 4·(1 − x/2 − z/2). Midpoint of the diagonal: x = z = 1 → 0.
+ expect(surfaceHeightAt(field, 1, 1)).toBeCloseTo(0, 6)
+ // The plane's value at a third of the way along each axis.
+ expect(surfaceHeightAt(field, 0.5, 0.5)).toBeCloseTo(2, 6)
+ expect(heightAt(field, 1, 1)).toBeCloseTo(0, 6)
+ })
+
+ test('picks the far triangle past the diagonal', () => {
+ const field = saddleField()
+ // Just inside cell [0,0] but past tc + tr = 1: the upper triangle is
+ // (2,2)=4, (0,2)=0, (2,0)=0, so its plane rises toward (2,2).
+ expect(surfaceHeightAt(field, 1.5, 1.5)).toBeCloseTo(2, 6)
+ })
+
+ test('the compatibility name always delegates to heightAt', () => {
+ const field = rampField(0.5, 5, 5)
+ for (const [x, z] of [
+ [0.3, 0.7],
+ [1.5, 2.5],
+ [3.9, 1.1],
+ ] as const) {
+ expect(surfaceHeightAt(field, x, z)).toBeCloseTo(heightAt(field, x, z), 6)
+ }
+ })
+
+ test('clamps outside the field to the border cell plane', () => {
+ const field = rampField(1, 5, 5)
+ // Past +X the ramp stops: the border sample is 4 m and stays 4 m.
+ expect(surfaceHeightAt(field, 99, 2)).toBeCloseTo(4, 6)
+ expect(surfaceHeightAt(field, -99, 2)).toBeCloseTo(0, 6)
+ })
+
+ test('a degenerate single-sample field falls back to the clamped read', () => {
+ const field = createTerrainField({ cols: 1, rows: 1, spacing: 1 })
+ expect(surfaceHeightAt(field, 5, 5)).toBe(0)
+ })
+})
+
+describe('slopeAt and normalAt', () => {
+ test('flat ground is zero slope with a straight-up normal', () => {
+ const field = createTerrainField({ cols: 5, rows: 5, spacing: 1 })
+ expect(slopeAt(field, 2, 2)).toBeCloseTo(0, 9)
+ const [nx, ny, nz] = normalAt(field, 2, 2)
+ expect(nx).toBeCloseTo(0, 9)
+ expect(ny).toBeCloseTo(1, 9)
+ expect(nz).toBeCloseTo(0, 9)
+ })
+
+ test('a 45-degree ramp reads back as 45 degrees', () => {
+ const field = rampField(1)
+ expect(slopeAt(field, 2, 2)).toBeCloseTo(Math.PI / 4, 4)
+ })
+
+ test('normal tilts against the uphill direction and stays unit length', () => {
+ const field = rampField(1)
+ const [nx, ny, nz] = normalAt(field, 2, 2)
+ // Uphill is +X, so the normal leans -X.
+ expect(nx).toBeLessThan(0)
+ expect(ny).toBeGreaterThan(0)
+ expect(nz).toBeCloseTo(0, 6)
+ expect(Math.hypot(nx, ny, nz)).toBeCloseTo(1, 9)
+ })
+})
+
+describe('isFlatOver — the placement predicate', () => {
+ test('true on untouched ground', () => {
+ const field = createTerrainField({ cols: 9, rows: 9, spacing: 1 })
+ expect(isFlatOver(field, 1, 1, 4, 4)).toBe(true)
+ })
+
+ test('false across a ramp', () => {
+ const field = rampField(1, 9, 9)
+ expect(isFlatOver(field, 1, 1, 4, 4)).toBe(false)
+ })
+
+ test('true over a flattened pad, false when the rect spills past it', () => {
+ const base = rampField(1, 9, 9)
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 3)
+ expect(patch).not.toBeNull()
+ const field = applyHeightPatch(base, patch as never)
+ expect(isFlatOver(field, 2.5, 2.5, 4.5, 4.5)).toBe(true)
+ // Reaching past the pad onto the ramp breaks flatness.
+ expect(isFlatOver(field, 2.5, 2.5, 8, 8)).toBe(false)
+ })
+
+ test('a one-step difference is detected — quantization makes this exact', () => {
+ const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, step: 0.01 })
+ const bumped = applyHeightPatch(field, {
+ col0: 2,
+ row0: 2,
+ cols: 1,
+ rows: 1,
+ heights: new Int16Array([1]),
+ })
+ // 1 unit = 1 cm. No epsilon can be tuned wrong here; it is integer equality.
+ expect(isFlatOver(bumped, 0, 0, 4, 4)).toBe(false)
+ })
+})
+
+describe('applyHeightPatch — the one write seam', () => {
+ test('does not mutate the input field', () => {
+ const before = createTerrainField({ cols: 4, rows: 4, spacing: 1 })
+ const after = applyHeightPatch(before, {
+ col0: 0,
+ row0: 0,
+ cols: 1,
+ rows: 1,
+ heights: new Int16Array([500]),
+ })
+ expect(before.heights[0]).toBe(0)
+ expect(after.heights[0]).toBe(500)
+ expect(after.heights).not.toBe(before.heights)
+ })
+
+ test('writes only the patch rect', () => {
+ const field = applyHeightPatch(createTerrainField({ cols: 4, rows: 4, spacing: 1 }), {
+ col0: 1,
+ row0: 1,
+ cols: 2,
+ rows: 2,
+ heights: new Int16Array([10, 20, 30, 40]),
+ })
+ expect(Array.from(field.heights)).toEqual([0, 0, 0, 0, 0, 10, 20, 0, 0, 30, 40, 0, 0, 0, 0, 0])
+ })
+
+ test('clips a patch that overhangs the field instead of throwing', () => {
+ const field = createTerrainField({ cols: 3, rows: 3, spacing: 1 })
+ const after = applyHeightPatch(field, {
+ col0: 2,
+ row0: 2,
+ cols: 3,
+ rows: 3,
+ heights: new Int16Array(9).fill(7),
+ })
+ // Only the single in-range corner sample lands.
+ expect(after.heights[8]).toBe(7)
+ expect(Array.from(after.heights).filter((h) => h === 7)).toHaveLength(1)
+ })
+
+ test('a fully out-of-range patch is a no-op', () => {
+ const field = createTerrainField({ cols: 3, rows: 3, spacing: 1 })
+ const after = applyHeightPatch(field, {
+ col0: 50,
+ row0: 50,
+ cols: 2,
+ rows: 2,
+ heights: new Int16Array(4).fill(9),
+ })
+ expect(Array.from(after.heights).every((h) => h === 0)).toBe(true)
+ })
+})
+
+describe('flattenPatch — the first brush', () => {
+ test('sets an absolute height under the rect', () => {
+ const base = rampField(1, 9, 9)
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 2.5)
+ expect(patch).not.toBeNull()
+ const field = applyHeightPatch(base, patch as never)
+ expect(heightAt(field, 3, 3)).toBeCloseTo(2.5, 6)
+ expect(heightAt(field, 4, 4)).toBeCloseTo(2.5, 6)
+ })
+
+ test('leaves ground outside the rect untouched', () => {
+ const base = rampField(1, 9, 9)
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 4, maxZ: 4 }, 0)
+ const field = applyHeightPatch(base, patch as never)
+ // x=7 is well past the pad; the ramp still reads 7 m.
+ expect(heightAt(field, 7, 7)).toBeCloseTo(7, 6)
+ })
+
+ test('returns null when the rect misses the field', () => {
+ const field = createTerrainField({ cols: 4, rows: 4, spacing: 1 })
+ expect(flattenPatch(field, { minX: 100, minZ: 100, maxX: 110, maxZ: 110 }, 1)).toBeNull()
+ })
+
+ test('the flattened pad satisfies isFlatOver — brush and predicate agree', () => {
+ const base = rampField(0.5, 17, 17)
+ const patch = flattenPatch(base, { minX: 3, minZ: 3, maxX: 9, maxZ: 9 }, 1.25)
+ const field = applyHeightPatch(base, patch as never)
+ expect(isFlatOver(field, 3.5, 3.5, 8.5, 8.5)).toBe(true)
+ // And the height is exactly what was asked for, on the ladder.
+ expect(heightAt(field, 6, 6)).toBeCloseTo(1.25, 9)
+ })
+})
+
+describe('diffToPatches — the 64 KiB transport contract', () => {
+ const MAX = 64 * 1024
+
+ test('no change yields no patches', () => {
+ const field = rampField(1, 9, 9)
+ expect(diffToPatches(field, field, MAX)).toEqual([])
+ })
+
+ test('a one-sample edit yields one small patch, not a full-field rewrite', () => {
+ const before = createTerrainField({ cols: 129, rows: 129, spacing: 0.5 })
+ const after = applyHeightPatch(before, {
+ col0: 64,
+ row0: 64,
+ cols: 1,
+ rows: 1,
+ heights: new Int16Array([123]),
+ })
+ const patches = diffToPatches(before, after, MAX)
+ expect(patches).toHaveLength(1)
+ expect(patches[0]?.rows).toBe(1)
+ expect(patches[0]?.row0).toBe(64)
+ })
+
+ test('round-trips: applying every patch reproduces the target field', () => {
+ const before = createTerrainField({ cols: 65, rows: 65, spacing: 0.5 })
+ const target = applyHeightPatch(
+ before,
+ flattenPatch(before, { minX: 4, minZ: 4, maxX: 20, maxZ: 20 }, 1.75) as never,
+ )
+ let rebuilt = before
+ for (const patch of diffToPatches(before, target, MAX)) {
+ rebuilt = applyHeightPatch(rebuilt, patch)
+ }
+ expect(Array.from(rebuilt.heights)).toEqual(Array.from(target.heights))
+ })
+
+ test('every patch stays under the byte cap, even for a full-field change', () => {
+ const before = createTerrainField({ cols: 129, rows: 129, spacing: 0.5 })
+ const after = applyHeightPatch(
+ before,
+ flattenPatch(before, { minX: -1e3, minZ: -1e3, maxX: 1e3, maxZ: 1e3 }, 5) as never,
+ )
+ const patches = diffToPatches(before, after, MAX)
+ expect(patches.length).toBeGreaterThan(0)
+ for (const patch of patches) {
+ // 2 bytes per sample, doubled because the op carries `from` and `to`.
+ expect(patch.heights.byteLength * 2).toBeLessThanOrEqual(MAX)
+ }
+ })
+
+ test('a full-field change still round-trips across the chunk boundary', () => {
+ const before = createTerrainField({ cols: 129, rows: 129, spacing: 0.5 })
+ const after = applyHeightPatch(
+ before,
+ flattenPatch(before, { minX: -1e3, minZ: -1e3, maxX: 1e3, maxZ: 1e3 }, 5) as never,
+ )
+ let rebuilt = before
+ for (const patch of diffToPatches(before, after, MAX)) {
+ rebuilt = applyHeightPatch(rebuilt, patch)
+ }
+ expect(Array.from(rebuilt.heights)).toEqual(Array.from(after.heights))
+ })
+
+ test('two separate dirty regions yield separate patches', () => {
+ const before = createTerrainField({ cols: 33, rows: 33, spacing: 1 })
+ let after = applyHeightPatch(before, {
+ col0: 2,
+ row0: 2,
+ cols: 1,
+ rows: 1,
+ heights: new Int16Array([5]),
+ })
+ after = applyHeightPatch(after, {
+ col0: 20,
+ row0: 20,
+ cols: 1,
+ rows: 1,
+ heights: new Int16Array([9]),
+ })
+ const patches = diffToPatches(before, after, MAX)
+ expect(patches).toHaveLength(2)
+ expect(patches[0]?.row0).toBe(2)
+ expect(patches[1]?.row0).toBe(20)
+ })
+
+ test('a shape change chunks the whole field rather than diffing mismatched buffers', () => {
+ const before = createTerrainField({ cols: 5, rows: 5, spacing: 1 })
+ const after = createTerrainField({ cols: 9, rows: 9, spacing: 1 })
+ const patches = diffToPatches(before, after, MAX)
+ const totalRows = patches.reduce((sum, p) => sum + p.rows, 0)
+ expect(totalRows).toBe(9)
+ })
+})
diff --git a/packages/core/src/lib/terrain-field.ts b/packages/core/src/lib/terrain-field.ts
new file mode 100644
index 000000000..edd8dc3c0
--- /dev/null
+++ b/packages/core/src/lib/terrain-field.ts
@@ -0,0 +1,321 @@
+/**
+ * The terrain heightfield: one regular grid of quantized heights, and the
+ * only place terrain geometry is read from.
+ *
+ * Two invariants carry the whole design:
+ *
+ * 1. **Quantized to `Int16` multiples of `step`.** Heights are integers, so
+ * "is this ground flat?" is exact integer equality rather than an epsilon
+ * comparison. That is what lets the support election treat terrain as one
+ * discrete candidate (see `slab-support.ts`) instead of a float soup that
+ * never ties. `step` is metres per unit; at 0.01 the Int16 range covers
+ * ±327 m of relief, far past any building site.
+ * 2. **Every read goes through `heightAt`.** Consumers never index `heights`
+ * directly. One authority means the mesh, the raycast, the placement
+ * predicate, and the tests cannot disagree about where the ground is.
+ *
+ * Pure data — no Three.js, per the core layer rule. The mesh built from this
+ * lives in `@pascal-app/nodes`.
+ */
+
+/**
+ * A regular grid of heights over the XZ plane.
+ *
+ * `origin` is the world XZ of sample [0,0]; sample [c,r] sits at
+ * `(origin[0] + c * spacing, origin[1] + r * spacing)`. Row-major, so index
+ * `r * cols + c`.
+ *
+ * Sized `2ⁿ+1` per side by convention (33, 65, 129, …) so a future LOD halving
+ * lands on existing samples and shares edge vertices with its neighbour rather
+ * than interpolating a seam.
+ */
+export type TerrainField = {
+ /** World XZ of sample [0,0]. */
+ readonly origin: readonly [number, number]
+ /** Metres between adjacent samples. */
+ readonly spacing: number
+ readonly cols: number
+ readonly rows: number
+ /** Metres per `heights` unit — the quantization ladder. */
+ readonly step: number
+ /** Row-major quantized heights, length `cols * rows`. */
+ readonly heights: Int16Array
+}
+
+/** A rectangular sub-block of samples — the unit of every terrain write. */
+export type HeightPatch = {
+ readonly col0: number
+ readonly row0: number
+ readonly cols: number
+ readonly rows: number
+ /** Row-major quantized heights, length `cols * rows`. */
+ readonly heights: Int16Array
+}
+
+export const DEFAULT_TERRAIN_STEP = 0.01
+export const DEFAULT_TERRAIN_SPACING = 0.5
+
+export function createTerrainField(options?: {
+ origin?: readonly [number, number]
+ spacing?: number
+ cols?: number
+ rows?: number
+ step?: number
+}): TerrainField {
+ const cols = options?.cols ?? 65
+ const rows = options?.rows ?? 65
+ return {
+ origin: options?.origin ?? [0, 0],
+ spacing: options?.spacing ?? DEFAULT_TERRAIN_SPACING,
+ cols,
+ rows,
+ step: options?.step ?? DEFAULT_TERRAIN_STEP,
+ heights: new Int16Array(cols * rows),
+ }
+}
+
+/** Quantize a metre height onto the field's ladder, clamped to Int16. */
+export function quantize(field: TerrainField, metres: number): number {
+ if (!Number.isFinite(metres)) return 0
+ const raw = Math.round(metres / field.step)
+ return Math.max(-32768, Math.min(32767, raw))
+}
+
+/** Sample [c,r] in metres. Out-of-range indices clamp to the edge, so a
+ * consumer reading just past the boundary gets the border height rather than
+ * a hole — the same clamp-don't-ask rule the vertical model uses. */
+export function heightAtSample(field: TerrainField, col: number, row: number): number {
+ const c = Math.max(0, Math.min(field.cols - 1, Math.trunc(col)))
+ const r = Math.max(0, Math.min(field.rows - 1, Math.trunc(row)))
+ return (field.heights[r * field.cols + c] ?? 0) * field.step
+}
+
+/**
+ * The ground height in metres at a world XZ, on the rendered triangle plane.
+ *
+ * This is the single read authority named in the plan. Each cell uses the same
+ * `(c+1,r)`–`(c,r+1)` diagonal as `nodes/src/site/terrain-geometry.ts`, so
+ * placement, picking, collision, draping, and pixels all answer the same height.
+ */
+export function heightAt(field: TerrainField, x: number, z: number): number {
+ if (field.cols < 2 || field.rows < 2) return heightAtSample(field, 0, 0)
+
+ const u = Math.min(field.cols - 1, Math.max(0, (x - field.origin[0]) / field.spacing))
+ const v = Math.min(field.rows - 1, Math.max(0, (z - field.origin[1]) / field.spacing))
+ const col = Math.min(field.cols - 2, Math.floor(u))
+ const row = Math.min(field.rows - 2, Math.floor(v))
+ const tc = u - col
+ const tr = v - row
+
+ const h00 = heightAtSample(field, col, row)
+ const h10 = heightAtSample(field, col + 1, row)
+ const h01 = heightAtSample(field, col, row + 1)
+ const h11 = heightAtSample(field, col + 1, row + 1)
+
+ if (tc + tr <= 1) return h00 + (h10 - h00) * tc + (h01 - h00) * tr
+ return h11 + (h01 - h11) * (1 - tc) + (h10 - h11) * (1 - tr)
+}
+
+/**
+ * Compatibility name for callers that explicitly describe a rendered surface.
+ * There is intentionally no second interpolation model: `heightAt` is the
+ * rendered triangle plane.
+ */
+export function surfaceHeightAt(field: TerrainField, x: number, z: number): number {
+ return heightAt(field, x, z)
+}
+
+/**
+ * Surface normal at a world XZ, from analytic central differences.
+ *
+ * Central differences rather than per-triangle face normals: the field is the
+ * authority, so the normal is a property of the *field* and stays continuous
+ * across triangle boundaries. Returns a unit vector, +Y up.
+ */
+export function normalAt(field: TerrainField, x: number, z: number): [number, number, number] {
+ const d = field.spacing
+ const dhdx = (heightAt(field, x + d, z) - heightAt(field, x - d, z)) / (2 * d)
+ const dhdz = (heightAt(field, x, z + d) - heightAt(field, x, z - d)) / (2 * d)
+ // Gradient (dh/dx, dh/dz) → normal (-dh/dx, 1, -dh/dz), normalized.
+ const len = Math.hypot(dhdx, 1, dhdz)
+ return [-dhdx / len, 1 / len, -dhdz / len]
+}
+
+/** Slope in radians from horizontal at a world XZ. 0 = flat, π/2 = vertical. */
+export function slopeAt(field: TerrainField, x: number, z: number): number {
+ const d = field.spacing
+ const dhdx = (heightAt(field, x + d, z) - heightAt(field, x - d, z)) / (2 * d)
+ const dhdz = (heightAt(field, x, z + d) - heightAt(field, x, z - d)) / (2 * d)
+ return Math.atan(Math.hypot(dhdx, dhdz))
+}
+
+/**
+ * True when every sample under the world-XZ rect is at the identical quantized
+ * height — the placement predicate for kinds that need level ground.
+ *
+ * Exact integer equality, which is only meaningful because heights are
+ * quantized (invariant 1). A float field would need a tolerance here, and that
+ * tolerance would then have to agree with the support election's own epsilon —
+ * two tunables that could drift apart. Instead there is one ladder and no
+ * tolerance.
+ */
+export function isFlatOver(
+ field: TerrainField,
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+): boolean {
+ const c0 = Math.max(0, Math.floor((minX - field.origin[0]) / field.spacing))
+ const r0 = Math.max(0, Math.floor((minZ - field.origin[1]) / field.spacing))
+ const c1 = Math.min(field.cols - 1, Math.ceil((maxX - field.origin[0]) / field.spacing))
+ const r1 = Math.min(field.rows - 1, Math.ceil((maxZ - field.origin[1]) / field.spacing))
+ if (c1 < c0 || r1 < r0) return true
+
+ const first = field.heights[r0 * field.cols + c0] ?? 0
+ for (let r = r0; r <= r1; r++) {
+ const rowBase = r * field.cols
+ for (let c = c0; c <= c1; c++) {
+ if ((field.heights[rowBase + c] ?? 0) !== first) return false
+ }
+ }
+ return true
+}
+
+/**
+ * The one write API. Every terrain mutation — brush stroke, DEM import, scan
+ * conversion, preset, MCP tool — lands here.
+ *
+ * Returns a new field with a new `heights` buffer, so a stroke's before/after
+ * pair can be diffed for undo and for `diffToPatches`. Patch samples outside
+ * the field are ignored rather than throwing: an imported DEM is allowed to
+ * overhang the site, and clipping is the sane response.
+ */
+export function applyHeightPatch(field: TerrainField, patch: HeightPatch): TerrainField {
+ const heights = new Int16Array(field.heights)
+ for (let pr = 0; pr < patch.rows; pr++) {
+ const row = patch.row0 + pr
+ if (row < 0 || row >= field.rows) continue
+ const destBase = row * field.cols
+ const srcBase = pr * patch.cols
+ for (let pc = 0; pc < patch.cols; pc++) {
+ const col = patch.col0 + pc
+ if (col < 0 || col >= field.cols) continue
+ heights[destBase + col] = patch.heights[srcBase + pc] ?? 0
+ }
+ }
+ return { ...field, heights }
+}
+
+/** The world-XZ sample range a rect covers, clamped to the field. Shared by
+ * the brushes so every tool agrees which samples a footprint touches. */
+export function sampleRangeOver(
+ field: TerrainField,
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+): { col0: number; row0: number; col1: number; row1: number } | null {
+ const col0 = Math.max(0, Math.floor((minX - field.origin[0]) / field.spacing))
+ const row0 = Math.max(0, Math.floor((minZ - field.origin[1]) / field.spacing))
+ const col1 = Math.min(field.cols - 1, Math.ceil((maxX - field.origin[0]) / field.spacing))
+ const row1 = Math.min(field.rows - 1, Math.ceil((maxZ - field.origin[1]) / field.spacing))
+ if (col1 < col0 || row1 < row0) return null
+ return { col0, row0, col1, row1 }
+}
+
+/**
+ * The only brush in the first slice: set every sample under a world-XZ rect to
+ * one absolute height.
+ *
+ * Absolute-height flatten before raise/lower is deliberate. A raise/lower brush
+ * answers "make this taller", which the user cannot aim; flatten-to-height
+ * answers "put the ground at 2.5 m here", which is the actual task when siting
+ * a building — and it is the one brush whose result is predictable enough to
+ * test. Returns null when the rect misses the field entirely.
+ */
+export function flattenPatch(
+ field: TerrainField,
+ rect: { minX: number; minZ: number; maxX: number; maxZ: number },
+ metres: number,
+): HeightPatch | null {
+ const range = sampleRangeOver(field, rect.minX, rect.minZ, rect.maxX, rect.maxZ)
+ if (!range) return null
+ const cols = range.col1 - range.col0 + 1
+ const rows = range.row1 - range.row0 + 1
+ const value = quantize(field, metres)
+ const heights = new Int16Array(cols * rows)
+ heights.fill(value)
+ return { col0: range.col0, row0: range.row0, cols, rows, heights }
+}
+
+/**
+ * Split the difference between two fields into patches that each stay under
+ * `maxBytes` when serialized.
+ *
+ * This exists because scene operations are capped at
+ * `MAX_SCENE_OPERATION_BYTES` (64 KiB) and each mutation carries both `from`
+ * and `to` — so a whole-field write is over budget the moment the grid gets
+ * interesting. Rows are the split unit: a patch is always whole rows, which
+ * keeps reassembly trivial and means a single-row edit costs one row.
+ *
+ * Returns the minimal dirty row span, so a small brush stroke on a large field
+ * produces a small patch rather than a full-field rewrite.
+ */
+export function diffToPatches(
+ before: TerrainField,
+ after: TerrainField,
+ maxBytes: number,
+): HeightPatch[] {
+ if (before.cols !== after.cols || before.rows !== after.rows) {
+ // Shape changed — the whole field is dirty; chunk it wholesale.
+ return chunkRows(after, 0, after.rows - 1, maxBytes)
+ }
+
+ const patches: HeightPatch[] = []
+ let runStart = -1
+ for (let r = 0; r < after.rows; r++) {
+ const base = r * after.cols
+ let dirty = false
+ for (let c = 0; c < after.cols; c++) {
+ if (before.heights[base + c] !== after.heights[base + c]) {
+ dirty = true
+ break
+ }
+ }
+ if (dirty && runStart < 0) runStart = r
+ if (!dirty && runStart >= 0) {
+ patches.push(...chunkRows(after, runStart, r - 1, maxBytes))
+ runStart = -1
+ }
+ }
+ if (runStart >= 0) patches.push(...chunkRows(after, runStart, after.rows - 1, maxBytes))
+ return patches
+}
+
+/** Slice a whole-row span into patches that each fit `maxBytes`. */
+function chunkRows(
+ field: TerrainField,
+ firstRow: number,
+ lastRow: number,
+ maxBytes: number,
+): HeightPatch[] {
+ // 2 bytes per Int16 sample, and the transport carries `from` + `to`, so a
+ // row costs twice its raw size. Halving the budget up front keeps the caller
+ // from having to know that.
+ const bytesPerRow = field.cols * 2 * 2
+ const rowsPerChunk = Math.max(1, Math.floor(maxBytes / Math.max(1, bytesPerRow)))
+ const patches: HeightPatch[] = []
+ for (let start = firstRow; start <= lastRow; start += rowsPerChunk) {
+ const end = Math.min(lastRow, start + rowsPerChunk - 1)
+ const rows = end - start + 1
+ patches.push({
+ col0: 0,
+ row0: start,
+ cols: field.cols,
+ rows,
+ heights: field.heights.slice(start * field.cols, (end + 1) * field.cols),
+ })
+ }
+ return patches
+}
diff --git a/packages/core/src/lib/terrain-raycast.test.ts b/packages/core/src/lib/terrain-raycast.test.ts
new file mode 100644
index 000000000..3e683ee05
--- /dev/null
+++ b/packages/core/src/lib/terrain-raycast.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, test } from 'bun:test'
+import {
+ applyHeightPatch,
+ createTerrainField,
+ flattenPatch,
+ heightAt,
+ type TerrainField,
+} from './terrain-field'
+import { raycastTerrain } from './terrain-raycast'
+
+/** A field sloping up along +X at `grade` (rise per metre). */
+function ramp(grade: number, cols = 33, rows = 33, spacing = 1): TerrainField {
+ const field = createTerrainField({ cols, rows, spacing, step: 0.01 })
+ const heights = new Int16Array(cols * rows)
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ heights[r * cols + c] = Math.round((c * spacing * grade) / field.step)
+ }
+ }
+ return { ...field, heights }
+}
+
+describe('raycastTerrain — flat ground', () => {
+ test('a straight-down ray hits Y=0 at its own XZ', () => {
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 1 })
+ const hit = raycastTerrain(field, [5, 10, 7], [0, -1, 0])
+ expect(hit).not.toBeNull()
+ expect(hit?.x).toBeCloseTo(5, 6)
+ expect(hit?.z).toBeCloseTo(7, 6)
+ expect(hit?.y).toBeCloseTo(0, 6)
+ expect(hit?.t).toBeCloseTo(10, 5)
+ })
+
+ test('t is in units of the direction vector, matching Ray.at', () => {
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 1 })
+ // Direction of length 2 -> t should be half the distance travelled.
+ const hit = raycastTerrain(field, [5, 10, 5], [0, -2, 0])
+ expect(hit?.t).toBeCloseTo(5, 5)
+ })
+
+ test('agrees with the flat-plane solve it replaces', () => {
+ const field = createTerrainField({ cols: 65, rows: 65, spacing: 0.5 })
+ // Field spans 0..32 in X and Z; the flat-plane hit must land inside it for
+ // the two to be comparable at all.
+ const origin: [number, number, number] = [3, 12, 4]
+ const dir: [number, number, number] = [0.4, -1, 0.25]
+ const hit = raycastTerrain(field, origin, dir)
+ // The legacy solve: t = -oy / dy.
+ const tPlane = -origin[1] / dir[1]
+ expect(hit?.t).toBeCloseTo(tPlane, 4)
+ expect(hit?.x).toBeCloseTo(origin[0] + dir[0] * tPlane, 4)
+ expect(hit?.z).toBeCloseTo(origin[2] + dir[2] * tPlane, 4)
+ })
+})
+
+describe('raycastTerrain — slopes', () => {
+ test('hits the analytic point on a 1:1 ramp', () => {
+ // Ground: y = x. Ray from (0, 4, 5) heading +X and down at 45°: y = 4 - x.
+ // Intersection where x = 4 - x -> x = 2, y = 2.
+ const hit = raycastTerrain(ramp(1), [0, 4, 5], [1, -1, 0])
+ expect(hit).not.toBeNull()
+ expect(hit?.x).toBeCloseTo(2, 4)
+ expect(hit?.y).toBeCloseTo(2, 4)
+ expect(hit?.z).toBeCloseTo(5, 6)
+ })
+
+ test('the hit is on the surface — y equals the field height there', () => {
+ const field = ramp(0.3, 65, 65, 0.5)
+ const hit = raycastTerrain(field, [2, 9, 3], [0.7, -1, 0.3])
+ expect(hit).not.toBeNull()
+ // The defining property: the returned point lies on the surface.
+ expect(hit?.y).toBeCloseTo(heightAt(field, hit?.x ?? 0, hit?.z ?? 0), 6)
+ })
+
+ test('a downhill ray does NOT report the flat-plane answer', () => {
+ // This is the bug the function exists to fix: on a slope, the flat solve
+ // lands at a different XZ than the real surface hit.
+ const field = ramp(0.5, 65, 65, 0.5)
+ const origin: [number, number, number] = [1, 8, 1]
+ const dir: [number, number, number] = [1, -1, 0]
+ const hit = raycastTerrain(field, origin, dir)
+ const tPlane = -origin[1] / dir[1]
+ const xPlane = origin[0] + dir[0] * tPlane
+ expect(Math.abs((hit?.x ?? 0) - xPlane)).toBeGreaterThan(1)
+ })
+
+ test('does not step over a single-cell spike', () => {
+ // One raised sample in an otherwise flat field. A ray aimed to pass just
+ // above the flat ground must hit the spike, not the ground beyond it.
+ const base = createTerrainField({ cols: 33, rows: 33, spacing: 1 })
+ const heights = new Int16Array(base.heights)
+ heights[16 * 33 + 16] = 500 // 5 m at step 0.01
+ const field: TerrainField = { ...base, heights }
+ // Sample [16,16] is at world (16, 16). Come in low from -X at that Z.
+ const hit = raycastTerrain(field, [10, 2.4, 16], [1, -0.05, 0])
+ expect(hit).not.toBeNull()
+ // Must land on the spike's rising flank, well before the far side.
+ expect(hit?.x).toBeLessThan(16)
+ expect(hit?.x).toBeGreaterThan(11)
+ expect(hit?.y).toBeGreaterThan(1)
+ })
+})
+
+describe('raycastTerrain — misses and edges', () => {
+ test('returns null for a ray pointing away from the field', () => {
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 1 })
+ expect(raycastTerrain(field, [5, 10, 5], [0, 1, 0])).toBeNull()
+ })
+
+ test('returns null when the ray misses the field in XZ', () => {
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 1 })
+ // Field spans x,z in [0,32]. Straight down at x = -50 is outside.
+ expect(raycastTerrain(field, [-50, 10, -50], [0, -1, 0])).toBeNull()
+ })
+
+ test('a ray from outside still hits when it crosses the field', () => {
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 1 })
+ const hit = raycastTerrain(field, [-20, 5, 16], [1, -0.2, 0])
+ expect(hit).not.toBeNull()
+ expect(hit?.x).toBeGreaterThanOrEqual(0)
+ expect(hit?.y).toBeCloseTo(0, 5)
+ })
+
+ test('a zero-length direction returns null instead of looping', () => {
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 1 })
+ expect(raycastTerrain(field, [5, 10, 5], [0, 0, 0])).toBeNull()
+ })
+
+ test('a horizontal ray inside the field terminates', () => {
+ // Worst case for the march: never descends, must exit via the XZ clip.
+ const field = createTerrainField({ cols: 129, rows: 129, spacing: 0.5 })
+ const hit = raycastTerrain(field, [1, 5, 1], [1, 0, 0])
+ expect(hit).toBeNull()
+ })
+
+ test('a ray fired upward from underground finds the surface above it', () => {
+ // The march tracks a sign change, not "starts above", so a basement-level
+ // ray still resolves the ground it exits through.
+ const field = ramp(0.4, 33, 33, 1)
+ const hit = raycastTerrain(field, [10, -2, 10], [0, 1, 0])
+ expect(hit).not.toBeNull()
+ expect(hit?.y).toBeCloseTo(4, 4)
+ expect(hit?.x).toBeCloseTo(10, 6)
+ })
+
+ test('a downward ray from underground misses — there is no surface below it', () => {
+ const field = ramp(0.4, 33, 33, 1)
+ expect(raycastTerrain(field, [10, -2, 10], [0, -1, 0])).toBeNull()
+ })
+
+ test('an origin exactly on the surface hits at t = 0', () => {
+ const field = ramp(0.5, 33, 33, 1)
+ // Ground at x = 10 is 5 m.
+ const hit = raycastTerrain(field, [10, 5, 10], [0, -1, 0])
+ expect(hit?.t).toBeCloseTo(0, 6)
+ expect(hit?.y).toBeCloseTo(5, 5)
+ })
+
+ test('respects a non-zero field origin', () => {
+ const field: TerrainField = {
+ ...createTerrainField({ cols: 33, rows: 33, spacing: 1 }),
+ origin: [100, 200],
+ }
+ // Inside the shifted field.
+ expect(raycastTerrain(field, [110, 5, 210], [0, -1, 0])).not.toBeNull()
+ // Where the field used to be.
+ expect(raycastTerrain(field, [10, 5, 10], [0, -1, 0])).toBeNull()
+ })
+})
+
+describe('raycastTerrain — consistency with the write path', () => {
+ test('picks up a flattened pad immediately after applyHeightPatch', () => {
+ const before = ramp(0.5, 33, 33, 1)
+ const patch = flattenPatch(before, { minX: 8, minZ: 8, maxX: 14, maxZ: 14 }, 3)
+ expect(patch).not.toBeNull()
+ const after = applyHeightPatch(before, patch as never)
+
+ const hitBefore = raycastTerrain(before, [11, 20, 11], [0, -1, 0])
+ const hitAfter = raycastTerrain(after, [11, 20, 11], [0, -1, 0])
+ expect(hitBefore?.y).toBeCloseTo(5.5, 5)
+ expect(hitAfter?.y).toBeCloseTo(3, 5)
+ })
+})
diff --git a/packages/core/src/lib/terrain-raycast.ts b/packages/core/src/lib/terrain-raycast.ts
new file mode 100644
index 000000000..bd3eb6a10
--- /dev/null
+++ b/packages/core/src/lib/terrain-raycast.ts
@@ -0,0 +1,216 @@
+/**
+ * Ray → terrain intersection.
+ *
+ * This is the function that replaces `t = -oy / dy` (the flat-ground plane
+ * solve) everywhere the editor asks "where on the ground is the pointer?".
+ * It lives in `core` with no Three.js so the 2D view, the tools, the placement
+ * coordinator, and the tests all call the same code — the failure mode this
+ * avoids is each surface having its own idea of where the ground is, which on
+ * flat ground is invisible and on a slope is a perspective-skewed offset that
+ * only some tools correct for.
+ *
+ * Method: march the ray in `spacing`-sized steps, watching the sign of
+ * `rayY - terrainY`, then bisect the bracketing interval. Marching (rather than
+ * per-triangle intersection over the whole grid) is what keeps this O(path
+ * length) instead of O(samples) — a 129² field is 32 768 triangles, and testing
+ * all of them per pointer-move is not affordable at 50 FPS.
+ *
+ * The bisection is exact to `spacing / 2^ITERATIONS`; at 0.5 m spacing and 20
+ * iterations that is under half a micrometre, i.e. far below the quantization
+ * ladder, so the returned point is on the same rendered triangle plane
+ * `heightAt` reports.
+ */
+
+import { heightAt, type TerrainField } from './terrain-field'
+
+export type TerrainHit = {
+ /** Distance along the (unnormalized) direction vector. */
+ readonly t: number
+ readonly x: number
+ readonly y: number
+ readonly z: number
+}
+
+/** Bisection depth. 20 halvings of one cell is sub-micrometre at any sane spacing. */
+const REFINE_ITERATIONS = 20
+
+/**
+ * How far to march before giving up, in multiples of `spacing`.
+ *
+ * A ray aimed just above a distant ridge can travel a long way before it dips
+ * below the surface, but an unbounded march is a hang risk if a caller passes a
+ * near-horizontal ray. The bound is generous relative to any site: at 0.5 m
+ * spacing this is 4 km of travel.
+ */
+const MAX_STEPS = 8192
+
+/**
+ * World-XZ bounds of the field, in metres. The march is clipped to this so a ray
+ * pointing away from the site terminates immediately instead of stepping
+ * MAX_STEPS times through empty space.
+ */
+function fieldBounds(field: TerrainField): {
+ minX: number
+ minZ: number
+ maxX: number
+ maxZ: number
+} {
+ return {
+ minX: field.origin[0],
+ minZ: field.origin[1],
+ maxX: field.origin[0] + (field.cols - 1) * field.spacing,
+ maxZ: field.origin[1] + (field.rows - 1) * field.spacing,
+ }
+}
+
+/**
+ * Min/max height in metres, cached per `heights` buffer.
+ *
+ * The vertical extent is what bounds the march for rays that never leave the
+ * field in XZ — a straight-down pointer ray, which is the single most common
+ * case, has `dx = dz = 0` and therefore an infinite XZ exit. Without a Y bound
+ * such a ray relies on `MAX_STEPS` to terminate, and an *upward* one burns all
+ * 8192 steps before returning null.
+ *
+ * The scan is O(samples), so it is cached on the buffer rather than repeated per
+ * ray. `applyHeightPatch` allocates a new `Int16Array` for every mutation, which
+ * makes the buffer identity a correct version key: a patched field misses the
+ * cache and rescans exactly once, and a stale entry is unreachable. `WeakMap`
+ * keeps it from retaining fields past their use.
+ */
+const heightRangeCache = new WeakMap()
+
+function heightRange(field: TerrainField): { minY: number; maxY: number } {
+ const cached = heightRangeCache.get(field.heights)
+ if (cached) return cached
+ let min = 0
+ let max = 0
+ if (field.heights.length > 0) {
+ min = field.heights[0] ?? 0
+ max = min
+ for (let i = 1; i < field.heights.length; i++) {
+ const h = field.heights[i] ?? 0
+ if (h < min) min = h
+ if (h > max) max = h
+ }
+ }
+ const range = { minY: min * field.step, maxY: max * field.step }
+ heightRangeCache.set(field.heights, range)
+ return range
+}
+
+/**
+ * Slab-method clip of the ray against the field's bounding box, returning the
+ * `[tEnter, tExit]` interval to march. Null when the ray misses the box.
+ *
+ * All three axes are clipped, not just XZ. The Y slab is what bounds a
+ * straight-down ray — `dx = dz = 0` makes both XZ slabs "parallel and inside",
+ * so an XZ-only clip returns an infinite exit and the march has to fall back on
+ * `MAX_STEPS`. The Y slab is padded because the surface is bilinear between
+ * samples and a tangent ray must not be clipped exactly at the extremum.
+ */
+function clipToField(
+ field: TerrainField,
+ origin: readonly [number, number, number],
+ direction: readonly [number, number, number],
+): { tEnter: number; tExit: number } | null {
+ const bounds = fieldBounds(field)
+ const { minY, maxY } = heightRange(field)
+ const pad = field.spacing
+ let tEnter = 0
+ let tExit = Number.POSITIVE_INFINITY
+
+ const axes: Array<[number, number, number, number]> = [
+ [origin[0], direction[0], bounds.minX, bounds.maxX],
+ [origin[1], direction[1], minY - pad, maxY + pad],
+ [origin[2], direction[2], bounds.minZ, bounds.maxZ],
+ ]
+
+ for (const [o, d, lo, hi] of axes) {
+ if (Math.abs(d) < 1e-12) {
+ // Parallel to this slab: either always inside or never.
+ if (o < lo || o > hi) return null
+ continue
+ }
+ const t1 = (lo - o) / d
+ const t2 = (hi - o) / d
+ tEnter = Math.max(tEnter, Math.min(t1, t2))
+ tExit = Math.min(tExit, Math.max(t1, t2))
+ if (tEnter > tExit) return null
+ }
+
+ return { tEnter, tExit }
+}
+
+/**
+ * First intersection of a ray with the terrain surface, or `null` if it misses.
+ *
+ * `direction` need not be normalized; `t` is in units of `direction`'s length,
+ * matching `Ray.at(t)` semantics so a viewer-side caller can pass a three.js ray
+ * through unchanged.
+ *
+ * The march tracks the *sign change* of `rayY - surfaceY` rather than assuming
+ * the ray starts above the ground, so a ray fired from inside a hill or from a
+ * basement finds the surface it exits through. A ray whose origin is already
+ * exactly on the surface returns `t = 0`.
+ */
+export function raycastTerrain(
+ field: TerrainField,
+ origin: readonly [number, number, number],
+ direction: readonly [number, number, number],
+): TerrainHit | null {
+ const dirLength = Math.hypot(direction[0], direction[1], direction[2])
+ if (dirLength < 1e-12) return null
+
+ const clip = clipToField(field, origin, direction)
+ if (!clip) return null
+ const { tEnter, tExit } = clip
+
+ // Step in world-space multiples of one cell, converted into t units. Half a
+ // cell, not a full one, so a ray crossing a single-cell spike cannot step over
+ // its peak and report the ground beyond it.
+ const stepT = field.spacing / 2 / dirLength
+ const at = (t: number) => ({
+ x: origin[0] + direction[0] * t,
+ y: origin[1] + direction[1] * t,
+ z: origin[2] + direction[2] * t,
+ })
+ const surfaceGap = (t: number) => {
+ const p = at(t)
+ return p.y - heightAt(field, p.x, p.z)
+ }
+ const hitAt = (t: number): TerrainHit => {
+ const p = at(t)
+ return { t, x: p.x, y: heightAt(field, p.x, p.z), z: p.z }
+ }
+
+ let prevT = tEnter
+ let prevGap = surfaceGap(tEnter)
+ // Entering exactly on the surface counts as a hit — a pointer ray grazing the
+ // ground plane must not fall through to the caller's flat-plane fallback.
+ if (prevGap === 0) return hitAt(tEnter)
+ const startedAbove = prevGap > 0
+
+ for (let step = 1; step <= MAX_STEPS; step++) {
+ const t = Math.min(tExit, tEnter + step * stepT)
+ const gap = surfaceGap(t)
+
+ // A crossing in either direction is a surface hit.
+ if (gap === 0 || gap > 0 !== startedAbove) {
+ let lo = prevT
+ let hi = t
+ for (let i = 0; i < REFINE_ITERATIONS; i++) {
+ const mid = (lo + hi) / 2
+ if (surfaceGap(mid) > 0 === startedAbove) lo = mid
+ else hi = mid
+ }
+ return hitAt(hi)
+ }
+
+ prevT = t
+ prevGap = gap
+ if (t >= tExit) break
+ }
+
+ return null
+}
diff --git a/packages/core/src/lib/terrain-source.test.ts b/packages/core/src/lib/terrain-source.test.ts
new file mode 100644
index 000000000..6a1579532
--- /dev/null
+++ b/packages/core/src/lib/terrain-source.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, test } from 'bun:test'
+import { encodeTerrainField } from './terrain-codec'
+import { applyHeightPatch, createTerrainField, flattenPatch, heightAt } from './terrain-field'
+import { commitTerrainField, terrainFieldForEdit, terrainFieldOf } from './terrain-source'
+
+function sculptedField() {
+ const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 })
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 2.5)
+ return applyHeightPatch(base, patch as never)
+}
+
+describe('terrainFieldOf', () => {
+ test('null for a site with no terrain — the flat-ground fast path', () => {
+ expect(terrainFieldOf(null)).toBeNull()
+ expect(terrainFieldOf(undefined)).toBeNull()
+ expect(terrainFieldOf({ terrain: undefined })).toBeNull()
+ })
+
+ test('decodes a site with terrain', () => {
+ const field = sculptedField()
+ const site = { terrain: encodeTerrainField(field) }
+ const resolved = terrainFieldOf(site)
+ expect(resolved).not.toBeNull()
+ expect(heightAt(resolved as never, 3, 3)).toBeCloseTo(2.5, 6)
+ })
+
+ test('returns the identical field object on repeat reads — decode runs once', () => {
+ const site = { terrain: encodeTerrainField(sculptedField()) }
+ const a = terrainFieldOf(site)
+ const b = terrainFieldOf(site)
+ expect(a).toBe(b)
+ })
+
+ test('a new terrain object misses the cache — object identity is the version key', () => {
+ const first = sculptedField()
+ const second = applyHeightPatch(
+ first,
+ flattenPatch(
+ first,
+ {
+ minX: 2,
+ minZ: 2,
+ maxX: 5,
+ maxZ: 5,
+ },
+ 7,
+ ) as never,
+ )
+
+ const before = terrainFieldOf({ terrain: encodeTerrainField(first) })
+ const after = terrainFieldOf({ terrain: encodeTerrainField(second) })
+ expect(heightAt(before as never, 3, 3)).toBeCloseTo(2.5, 6)
+ expect(heightAt(after as never, 3, 3)).toBeCloseTo(7, 6)
+ })
+
+ test('null for corrupt terrain, and the failed decode is not retried', () => {
+ const site = { terrain: { type: 'heightfield', cols: 2, rows: 2, heights: '!!!' } as never }
+ expect(terrainFieldOf(site)).toBeNull()
+ // Second call takes the WeakSet path; asserting it still returns null is the
+ // observable half — that it skipped the decode is the point of the WeakSet.
+ expect(terrainFieldOf(site)).toBeNull()
+ })
+})
+
+describe('terrainFieldForEdit', () => {
+ test('returns the existing field when the site has terrain', () => {
+ const site = { terrain: encodeTerrainField(sculptedField()) }
+ const field = terrainFieldForEdit(site)
+ expect(heightAt(field, 3, 3)).toBeCloseTo(2.5, 6)
+ })
+
+ test('returns a fresh flat field when the site has none', () => {
+ const field = terrainFieldForEdit(null, { cols: 17, rows: 17, spacing: 0.25 })
+ expect(field.cols).toBe(17)
+ expect(field.spacing).toBe(0.25)
+ expect(heightAt(field, 1, 1)).toBe(0)
+ })
+
+ test('does not fabricate terrain on the site — the caller decides to persist', () => {
+ const site: { terrain?: never } = {}
+ terrainFieldForEdit(site)
+ expect(site.terrain).toBeUndefined()
+ })
+})
+
+describe('commitTerrainField', () => {
+ test('the encoded data reads back as the same field, without decoding', () => {
+ const field = sculptedField()
+ const data = commitTerrainField(field)
+ // Cache primed: the very next read is the identical object, so a stroke never
+ // pays for base64 at all.
+ expect(terrainFieldOf({ terrain: data })).toBe(field)
+ })
+
+ test('the encoded data is still independently decodable', () => {
+ const field = sculptedField()
+ const data = commitTerrainField(field)
+ // Priming must not be load-bearing: a fresh process (no cache) must get the
+ // same heights back from the bytes alone.
+ const roundTripped = JSON.parse(JSON.stringify(data))
+ const decoded = terrainFieldOf({ terrain: roundTripped })
+ expect(decoded).not.toBe(field)
+ expect(Array.from(decoded?.heights ?? [])).toEqual(Array.from(field.heights))
+ })
+})
diff --git a/packages/core/src/lib/terrain-source.ts b/packages/core/src/lib/terrain-source.ts
new file mode 100644
index 000000000..759d9b2e2
--- /dev/null
+++ b/packages/core/src/lib/terrain-source.ts
@@ -0,0 +1,100 @@
+/**
+ * Scene → `TerrainField`, cached.
+ *
+ * Every consumer that needs the ground — the mesh builder, the raycast, the
+ * placement predicate, the collider, the 2D view — starts here. It exists to
+ * solve one problem: the scene stores terrain as base64 (`site.terrain`), and
+ * decoding that per frame, or per pointer-move, would be absurd.
+ *
+ * The cache is a `WeakMap` keyed on the **`TerrainData` object identity**, which
+ * is a correct version key because the scene store is immutable-by-convention:
+ * `updateNode` produces a new node object with a new `terrain` object, so a
+ * changed terrain necessarily misses the cache and a stale entry is unreachable.
+ * No invalidation call, no version counter, nothing to forget to bump.
+ *
+ * `commitTerrainField` closes the loop: it encodes a field *and* primes the cache
+ * with the field it just encoded. That is what makes a sculpt stroke free — after
+ * the commit, the next read is a cache hit, so a stroke never decodes base64 even
+ * once, let alone per dab.
+ */
+
+import type { SiteNode } from '../schema/nodes/site'
+import type { TerrainData } from '../schema/terrain'
+import useLiveTerrain from '../store/use-live-terrain'
+import { decodeTerrainField, encodeTerrainField } from './terrain-codec'
+import { createTerrainField, type TerrainField } from './terrain-field'
+
+const fieldCache = new WeakMap()
+
+/**
+ * Sites whose `terrain` failed to decode. Prevents re-attempting a doomed decode
+ * on every frame — the corrupt-scene path must be cheap, not just survivable.
+ */
+const failedDecodes = new WeakSet()
+
+/**
+ * The terrain field for a site, or `null` when the site has no sculpted terrain.
+ *
+ * `null` means flat ground at the datum. It is deliberately not "a flat field":
+ * callers use `null` to take their existing flat-ground fast path, which keeps
+ * every scene that has never touched terrain — the overwhelming majority —
+ * running exactly the code it ran before terrain existed. That property is worth
+ * more than the uniformity of always having a field.
+ *
+ * An in-flight sculpt stroke wins over the persisted data. Resolving that *here*
+ * rather than per caller is what makes a stroke consistent: the mesh, the pointer
+ * raycast, the placement predicate, the collider, and the 2D view all see the
+ * same ground mid-drag without any of them knowing a stroke exists. The
+ * alternative — each consumer checking the live store — is how a drag ends up
+ * showing a hill the raycast cannot hit.
+ */
+export function terrainFieldOf(
+ site: (Pick & { id?: string }) | null | undefined,
+): TerrainField | null {
+ if (site?.id) {
+ const live = useLiveTerrain.getState().fieldOf(site.id)
+ if (live) return live
+ }
+ const data = site?.terrain
+ if (!data) return null
+ const cached = fieldCache.get(data)
+ if (cached) return cached
+ if (failedDecodes.has(data)) return null
+
+ const field = decodeTerrainField(data)
+ if (!field) {
+ failedDecodes.add(data)
+ return null
+ }
+ fieldCache.set(data, field)
+ return field
+}
+
+/**
+ * The field a site *would* sculpt into: its existing terrain, or a fresh flat
+ * field sized to `options`.
+ *
+ * Separate from `terrainFieldOf` because the read path wants `null` for "no
+ * terrain" and the write path wants something to write into. Collapsing them
+ * would force every reader to allocate a field it does not need.
+ */
+export function terrainFieldForEdit(
+ site: Pick | null | undefined,
+ options?: Parameters[0],
+): TerrainField {
+ return terrainFieldOf(site) ?? createTerrainField(options)
+}
+
+/**
+ * Encode a field for persistence and prime the read cache with it.
+ *
+ * Call this instead of `encodeTerrainField` whenever the result is going into the
+ * scene. The returned data is what to write to `site.terrain`; the cache priming
+ * means the very next `terrainFieldOf` on the updated node returns this exact
+ * field object without decoding.
+ */
+export function commitTerrainField(field: TerrainField): TerrainData {
+ const data = encodeTerrainField(field)
+ fieldCache.set(data, field)
+ return data
+}
diff --git a/packages/core/src/lib/terrain-support.test.ts b/packages/core/src/lib/terrain-support.test.ts
new file mode 100644
index 000000000..07fd0b46b
--- /dev/null
+++ b/packages/core/src/lib/terrain-support.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, test } from 'bun:test'
+import type { AnyNode } from '../schema'
+import { encodeTerrainField } from './terrain-codec'
+import { applyHeightPatch, createTerrainField, flattenPatch } from './terrain-field'
+import { isSiteDatum, SITE_DATUM_Y, terrainSupportLift } from './terrain-support'
+
+const LEVEL_ID = 'level_ground'
+const BUILDING_ID = 'building_test'
+
+/** A 2.5 m plateau over x,z ∈ [2,5], flat at 0 elsewhere. */
+function sculptedField() {
+ const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 2.5)
+ return applyHeightPatch(base, patch as never)
+}
+
+function makeSite(withTerrain = true): AnyNode {
+ return {
+ id: 'site_test',
+ type: 'site',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [BUILDING_ID],
+ ...(withTerrain ? { terrain: encodeTerrainField(sculptedField()) } : {}),
+ } as unknown as AnyNode
+}
+
+function makeBuilding(overrides: Record = {}): AnyNode {
+ return {
+ id: BUILDING_ID,
+ type: 'building',
+ object: 'node',
+ parentId: 'site_test',
+ visible: true,
+ metadata: {},
+ children: [LEVEL_ID],
+ position: [0, 0, 0],
+ rotation: [0, 0, 0],
+ ...overrides,
+ } as unknown as AnyNode
+}
+
+function makeLevel(ordinal: number, id = LEVEL_ID): AnyNode {
+ return {
+ id,
+ type: 'level',
+ object: 'node',
+ parentId: BUILDING_ID,
+ visible: true,
+ metadata: {},
+ children: [],
+ level: ordinal,
+ height: 3,
+ } as unknown as AnyNode
+}
+
+function scene(...nodes: AnyNode[]): Record {
+ return Object.fromEntries(nodes.map((node) => [node.id, node]))
+}
+
+describe('isSiteDatum', () => {
+ test('the datum is ground; a slab top and a storey base are not', () => {
+ expect(isSiteDatum(SITE_DATUM_Y)).toBe(true)
+ // Float drift through a world matrix still reads as ground.
+ expect(isSiteDatum(1e-9)).toBe(true)
+ // A 5 cm ground slab is a built surface, not terrain.
+ expect(isSiteDatum(0.05)).toBe(false)
+ expect(isSiteDatum(3)).toBe(false)
+ })
+})
+
+describe('terrainSupportLift', () => {
+ test('null without terrain — every flat scene keeps its existing path', () => {
+ const nodes = scene(makeSite(false), makeBuilding(), makeLevel(0))
+ expect(terrainSupportLift(nodes, LEVEL_ID, 3, 3)).toBeNull()
+ })
+
+ test('samples the ground under a node on the storey at grade', () => {
+ const nodes = scene(makeSite(), makeBuilding(), makeLevel(0))
+ expect(terrainSupportLift(nodes, LEVEL_ID, 3, 3)).toBeCloseTo(2.5, 6)
+ expect(terrainSupportLift(nodes, LEVEL_ID, -3, -3)).toBeCloseTo(0, 6)
+ })
+
+ test('null on an upper storey — it has a real floor, not ground', () => {
+ const upper = 'level_upper'
+ const nodes = scene(makeSite(), makeBuilding(), makeLevel(0), makeLevel(1, upper))
+ expect(terrainSupportLift(nodes, upper, 3, 3)).toBeNull()
+ })
+
+ test('follows the level stack, not the ordinal, when a level sits below 0', () => {
+ // `getLevelElevations` anchors the *lowest ordinal* at 0, so adding an ordinal
+ // -1 level moves grade down to it and pushes the ground floor a storey up.
+ // Terrain follows that, which is the point: whichever storey the renderer put
+ // at the datum is the one draped, so the two can never disagree.
+ const below = 'level_below'
+ const nodes = scene(makeSite(), makeBuilding(), makeLevel(-1, below), makeLevel(0))
+ expect(terrainSupportLift(nodes, below, 3, 3)).toBeCloseTo(2.5, 6)
+ expect(terrainSupportLift(nodes, LEVEL_ID, 3, 3)).toBeNull()
+ })
+
+ test('null for an unknown level', () => {
+ const nodes = scene(makeSite(), makeBuilding(), makeLevel(0))
+ expect(terrainSupportLift(nodes, 'level_missing', 3, 3)).toBeNull()
+ })
+
+ test("the building's own datum offsets the sample instead of double-counting", () => {
+ // A building sited at y = 2.5 has its ground floor 2.5 m up, so its base is
+ // not the datum and terrain does not support it.
+ const nodes = scene(makeSite(), makeBuilding({ position: [0, 2.5, 0] }), makeLevel(0))
+ expect(terrainSupportLift(nodes, LEVEL_ID, 3, 3)).toBeNull()
+ })
+
+ test('level-local XZ goes through the building transform', () => {
+ // Translated: local (0,0) sits over world (3,3) — on the plateau.
+ const translated = scene(makeSite(), makeBuilding({ position: [3, 0, 3] }), makeLevel(0))
+ expect(terrainSupportLift(translated, LEVEL_ID, 0, 0)).toBeCloseTo(2.5, 6)
+
+ // Rotated a quarter turn: local (3,-3) maps to world (3,3).
+ const rotated = scene(makeSite(), makeBuilding({ rotation: [0, Math.PI / 2, 0] }), makeLevel(0))
+ expect(terrainSupportLift(rotated, LEVEL_ID, 3, -3)).toBeCloseTo(0, 6)
+ expect(terrainSupportLift(rotated, LEVEL_ID, -3, 3)).toBeCloseTo(2.5, 6)
+ })
+
+ test('a level with no building still resolves against the legacy stack', () => {
+ const orphan = { ...(makeLevel(0) as Record), parentId: null } as AnyNode
+ const nodes = scene(makeSite(), orphan)
+ expect(terrainSupportLift(nodes, LEVEL_ID, 3, 3)).toBeCloseTo(2.5, 6)
+ })
+})
diff --git a/packages/core/src/lib/terrain-support.ts b/packages/core/src/lib/terrain-support.ts
new file mode 100644
index 000000000..fecfed108
--- /dev/null
+++ b/packages/core/src/lib/terrain-support.ts
@@ -0,0 +1,174 @@
+/**
+ * Terrain as the level base — the third and last place flat ground was assumed.
+ *
+ * `getFloorPlacedElevation` returns a *lift* added to a node's level-local Y, and
+ * two of its `0`-returns mean "supported by the level base". This module is what
+ * those two branches call so that "the level base" can mean the sculpted ground
+ * instead of the plane `y = 0`. The other `0`-returns are `+0` no-ops for
+ * wall/ceiling-attached items, non-`level` parents, and broken graphs; substituting
+ * terrain there would lift sconces off walls, so they stay `0` forever.
+ *
+ * The whole file is pure — nodes in, metres out — which is what lets the stacking
+ * matrix be tested per kind without a canvas.
+ */
+
+import type { AnyNode, AnyNodeId } from '../schema'
+import type { BuildingNode } from '../schema/nodes/building'
+import type { SiteNode } from '../schema/nodes/site'
+import { getLevelElevations } from '../services/storey'
+import { heightAt } from './terrain-field'
+import { terrainFieldOf } from './terrain-source'
+
+/**
+ * World Y of the site datum — the plane terrain heights are measured from.
+ *
+ * Terrain vertices are absolute world coordinates (the site group renders at the
+ * origin and `terrain-geometry` writes `origin + col * spacing` straight into the
+ * buffer), so the datum is a constant rather than a site property. It is named
+ * because the *comparison* against it is load-bearing, not the value: it is what
+ * separates "the ground" from every other horizontal surface in the scene.
+ */
+export const SITE_DATUM_Y = 0
+
+/**
+ * How close a surface must sit to the datum to count as the ground.
+ *
+ * Loose enough to absorb float drift through a level's world matrix, tight enough
+ * that a ground slab (5 cm) still reads as a built surface rather than as terrain.
+ */
+export const SITE_DATUM_EPSILON = 1e-4
+
+/** True when a world-space height is the site datum — i.e. the terrain's plane. */
+export function isSiteDatum(worldY: number): boolean {
+ return Math.abs(worldY - SITE_DATUM_Y) < SITE_DATUM_EPSILON
+}
+
+/**
+ * `getLevelElevations` memoized on the `nodes` record identity.
+ *
+ * Without this the stacking resolver is O(N) per node and therefore O(N²) per
+ * frame, since `FloorElevationSystem` calls it for every dirty floor-placed node.
+ * The scene store is immutable-by-convention, so the record identity is a correct
+ * version key — the same reasoning (and the same `WeakMap` shape) as
+ * `terrain-source`'s field cache and the spatial grid's `levelWallsCache`.
+ */
+const levelElevationCache = new WeakMap