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>() + +function cachedLevelElevations( + nodes: Record, +): ReturnType { + const cached = levelElevationCache.get(nodes) + if (cached) return cached + const elevations = getLevelElevations(nodes as Record) + levelElevationCache.set(nodes, elevations) + return elevations +} + +function siteOf(nodes: Record): SiteNode | null { + for (const node of Object.values(nodes)) { + if (node?.type === 'site') return node as SiteNode + } + return null +} + +/** + * Level-local XZ → world XZ, through the building's transform. + * + * A level group carries only a Y offset (`LevelSystem` writes `position.y` and + * nothing else), so level-local XZ is building-local XZ; the building carries the + * site-space placement, and the site renders at the origin. Only the Y rotation + * matters — a building tipped in X or Z is not a thing the editor can produce. + */ +function levelLocalToWorldXZ( + building: BuildingNode | null, + x: number, + z: number, +): [number, number] { + if (!building) return [x, z] + const angle = building.rotation?.[1] ?? 0 + const [px, , pz] = building.position ?? [0, 0, 0] + if (angle === 0) return [x + px, z + pz] + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [cos * x + sin * z + px, -sin * x + cos * z + pz] +} + +/** + * Where a level sits relative to the datum, for the levels terrain can support. + * + * **Only the storey the level stack puts at the datum follows terrain.** An upper + * storey has a real floor, and draping it would sink its contents into the + * hillside under the building. The test is that storey's own base height against + * the datum — deliberately asked of `getLevelElevations`, the same function the + * renderer positions level groups with, so placement can never hold a second + * opinion about which storey is on the ground. + * + * Which storey that is follows from the stack, not from the ordinal: today the + * lowest ordinal anchors at 0, so a building with an ordinal `-1` level has *that* + * level at grade and its ground floor a storey up. When genuine below-grade + * stacking lands, this rule tracks it with no change here — an excavated basement + * will stop being at the datum, and stop being draped, on its own. + */ +function levelDatum( + nodes: Record, + levelId: string, +): { building: BuildingNode | null; baseWorldY: number } | null { + const elevation = cachedLevelElevations(nodes).get(levelId) + if (!elevation) return null + + const building = elevation.buildingId + ? ((nodes[elevation.buildingId] ?? null) as BuildingNode | null) + : null + const datum = building?.position?.[1] ?? 0 + const baseWorldY = datum + elevation.baseY + if (!isSiteDatum(baseWorldY)) return null + + return { building, baseWorldY } +} + +/** + * Whether the sculpted ground is what a node on `levelId` stands on — i.e. + * whether moving the terrain moves that node. + * + * Split out of `terrainSupportLift` so the invalidation rule that re-elevates + * nodes after a sculpt (`markTerrainSupportDependents`) tests grade-ness with + * the *same* predicate the resolver does. Two copies of this test would drift, + * and the failure is silent: a level the resolver drapes but the dirty rule + * skips leaves its contents frozen at the height the ground used to be. + * + * Deliberately independent of whether a field exists. A stroke that clears the + * terrain has to re-elevate exactly the nodes a stroke that creates it does, and + * asking about the field here would mark none of them on the way back down. + */ +export function isLevelAtSiteDatum(nodes: Record, levelId: string): boolean { + return levelDatum(nodes, levelId) !== null +} + +/** + * The lift, in level-local metres, that the sculpted ground provides to a node on + * `levelId` at level-local `x`/`z`. Null when terrain does not support this node — + * no sculpted ground, an unresolvable level, or a storey whose floor is not at + * grade. + * + * Null rather than 0 so callers keep their existing flat-ground path verbatim. + * That is what makes this change invisible to every scene that never touched + * terrain, which is nearly all of them. + */ +export function terrainSupportLift( + nodes: Record, + levelId: string, + x: number, + z: number, +): number | null { + const site = siteOf(nodes) + const field = site ? terrainFieldOf(site) : null + if (!field) return null + + const datum = levelDatum(nodes, levelId) + if (!datum) return null + + const [worldX, worldZ] = levelLocalToWorldXZ(datum.building, x, z) + // The lift is measured from the storey's own base, so a building sited on a + // datum other than 0 does not double-count it. + return heightAt(field, worldX, worldZ) - datum.baseWorldY +} diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 09f55805e..434adf4aa 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -158,6 +158,12 @@ export type LinearResizeHandle = { * by `apply`. */ onDrag?: (node: N, sceneApi: SceneApi) => void + /** + * Cleanup companion to {@link onDrag}. Called for commit, cancellation, and + * unmount so a descriptor can release transient feedback it owns without the + * generic renderer knowing which guide store produced it. + */ + onDragEnd?: (node: N, sceneApi: SceneApi) => void /** * Cross-node redirect. By default the drag's live override + the * committed write both land on the SELECTED node. When this returns diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 7374b7de8..0f6edbdd2 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -268,6 +268,7 @@ export { } from './nodes/window' export { ZoneNode } from './nodes/zone' export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material' +export { MAX_TERRAIN_SIDE, TerrainData } from './terrain' export type { AnyNodeId, AnyNodeType } from './types' // Union types export { AnyNode } from './types' diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index 26ebff777..f0d3ec9fa 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -35,6 +35,9 @@ export const FenceNode = BaseNode.extend({ // Persisted slab-support host — the fence sits on that slab's walking // surface (see ItemNode.supportSlabId for the host rules). supportSlabId: z.string().optional(), + // Manual vertical offset from the elected slab or level support. This moves + // the complete fence body without changing its height. + supportOffset: z.number().finite().optional(), curveOffset: z.number().optional(), baseHeight: z.number().default(0.22), postSpacing: z.number().default(2), @@ -58,6 +61,7 @@ export const FenceNode = BaseNode.extend({ - tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent - height/thickness: overall fence dimensions in meters - supportSlabId: optional slab host; the fence stands on that slab's walking surface (elevation) + - supportOffset: manual vertical offset from the elected support surface - curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set) - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - groundClearance/edgeInset/baseStyle: fence support and inset configuration diff --git a/packages/core/src/schema/nodes/site.test.ts b/packages/core/src/schema/nodes/site.test.ts new file mode 100644 index 000000000..776c706e3 --- /dev/null +++ b/packages/core/src/schema/nodes/site.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { encodeTerrainField } from '../../lib/terrain-codec' +import { createTerrainField } from '../../lib/terrain-field' +import { SiteNode } from './site' + +describe('SiteNode.terrain', () => { + test('a scene saved before terrain existed still parses', () => { + const parsed = SiteNode.parse({ id: 'site_1', type: 'site' }) + expect(parsed.terrain).toBeUndefined() + // And the default polygon is untouched by the new field. + expect(parsed.polygon.points).toHaveLength(4) + }) + + test('accepts what the encoder produces, unchanged', () => { + const field = createTerrainField({ cols: 5, rows: 5 }) + const heights = new Int16Array(field.heights) + heights[12] = 250 + const data = encodeTerrainField({ ...field, heights }) + + const parsed = SiteNode.parse({ id: 'site_1', type: 'site', terrain: data }) + expect(parsed.terrain).toEqual(data) + }) + + test('survives a JSON round-trip, which is how it is actually persisted', () => { + const data = encodeTerrainField(createTerrainField({ cols: 3, rows: 3 })) + const node = SiteNode.parse({ id: 'site_1', type: 'site', terrain: data }) + const reparsed = SiteNode.parse(JSON.parse(JSON.stringify(node))) + expect(reparsed.terrain).toEqual(data) + }) + + test('rejects terrain with a zero or negative spacing', () => { + const data = encodeTerrainField(createTerrainField({ cols: 3, rows: 3 })) + expect( + SiteNode.safeParse({ id: 'site_1', type: 'site', terrain: { ...data, spacing: 0 } }).success, + ).toBe(false) + expect( + SiteNode.safeParse({ id: 'site_1', type: 'site', terrain: { ...data, step: -1 } }).success, + ).toBe(false) + }) + + test('rejects non-integer dimensions and the wrong discriminator', () => { + const data = encodeTerrainField(createTerrainField({ cols: 3, rows: 3 })) + expect( + SiteNode.safeParse({ id: 'site_1', type: 'site', terrain: { ...data, cols: 3.5 } }).success, + ).toBe(false) + expect( + SiteNode.safeParse({ id: 'site_1', type: 'site', terrain: { ...data, type: 'polygon' } }) + .success, + ).toBe(false) + }) + + test('rejects non-finite metadata and dimensions above the supported ceiling', () => { + const data = encodeTerrainField(createTerrainField({ cols: 3, rows: 3 })) + expect( + SiteNode.safeParse({ id: 'site_1', type: 'site', terrain: { ...data, cols: 258 } }).success, + ).toBe(false) + expect( + SiteNode.safeParse({ + id: 'site_1', + type: 'site', + terrain: { ...data, origin: [Number.POSITIVE_INFINITY, 0] }, + }).success, + ).toBe(false) + }) +}) diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts index 6cb33d681..7f09fa591 100644 --- a/packages/core/src/schema/nodes/site.ts +++ b/packages/core/src/schema/nodes/site.ts @@ -3,6 +3,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { TerrainData } from '../terrain' // 2D Polygon const PropertyLineData = z.object({ @@ -10,12 +11,6 @@ const PropertyLineData = z.object({ points: z.array(z.tuple([z.number(), z.number()])), }) -// 3D Polygon/Mesh -// const TerrainData = z.object({ -// type: z.literal('terrain'), -// points: z.array(z.tuple([z.number(), z.number(), z.number()])), -// }) - export const SiteNode = BaseNode.extend({ id: objectId('site'), type: nodeType('site'), @@ -30,12 +25,18 @@ export const SiteNode = BaseNode.extend({ [-15, 15], ], }), - // terrain: TerrainData, + /** + * Sculpted ground. Absent means flat ground at the datum — the state every + * scene that predates terrain is in, and the state an untouched site stays in + * so ~11 KB of base64 zeroes does not land in every saved scene. + */ + terrain: TerrainData.optional(), children: z.array(z.string()).default([]), }).describe( dedent` Site node - used to represent a site - polygon: polygon data + - terrain: optional sculpted heightfield; absent means flat ground - children: array of child node ids (buildings, items) `, ) diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index ec8e99b74..813ba5089 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -24,6 +24,8 @@ export const SlabNode = BaseNode.extend({ elevation: z.number().default(0.05), // Walking surface (slab top), meters above the level plane thickness: z.number().default(0.05), // Grows downward from the surface recessed: z.boolean().default(false), + recessedRimElevation: z.number().finite().optional(), + fillToTerrain: z.boolean().optional(), autoFromWalls: z.boolean().default(false), }).describe( dedent` @@ -33,7 +35,9 @@ export const SlabNode = BaseNode.extend({ - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - elevation: the walking surface (slab top), in meters above the level plane - thickness: grows downward from the surface; the solid occupies [elevation - thickness, elevation] - - recessed: open recess (pool) whose floor sits at elevation (< 0); the shell walls rise to the level plane + - recessed: open recess (pool) whose floor sits at elevation + - recessedRimElevation: optional rim anchor for a raised/lowered recess; absent means the level plane + - fillToTerrain: extends a solid slab's perimeter downward to terrain without changing its flat top or authored thickness - autoFromWalls: whether the slab is automatically generated from a closed wall loop `, ) diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 4306d0936..a508b0f81 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -13,10 +13,30 @@ import { WALL_SKIRTING_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WallFaceBandConfig, + WallNode, type WallNode as WallNodeType, WallTrimConfig, } from './wall' +describe('wall support offset', () => { + test('stores a finite offset without defaulting it onto ordinary walls', () => { + expect(WallNode.parse({ start: [0, 0], end: [4, 0] }).supportOffset).toBeUndefined() + expect(WallNode.parse({ start: [0, 0], end: [4, 0], supportOffset: 1.75 }).supportOffset).toBe( + 1.75, + ) + expect( + WallNode.safeParse({ start: [0, 0], end: [4, 0], supportOffset: Number.NaN }).success, + ).toBe(false) + }) + + test('stores terrain infill only when explicitly enabled', () => { + expect(WallNode.parse({ start: [0, 0], end: [4, 0] }).fillToTerrain).toBeUndefined() + expect(WallNode.parse({ start: [0, 0], end: [4, 0], fillToTerrain: true }).fillToTerrain).toBe( + true, + ) + }) +}) + describe('wall face bands', () => { test('defaults to one band while preserving legacy enabled scenes as three bands', () => { expect(WALL_FACE_BAND_DEFAULT.count).toBe(1) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index a01a98ada..d4afd49ff 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -153,6 +153,12 @@ export const WallNode = BaseNode.extend({ curveOffset: z.number().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), + // Vertical offset from the elected support surface. Ground-hosted chained + // walls use this to preserve one construction plane without copying terrain. + supportOffset: z.number().finite().optional(), + // Extend downward from the authored wall base to the terrain while keeping + // the wall body height and top unchanged. + fillToTerrain: z.boolean().optional(), faceBands: WallFaceBandConfig.optional(), skirting: WallTrimConfig.optional(), crown: WallTrimConfig.optional(), @@ -168,6 +174,7 @@ export const WallNode = BaseNode.extend({ Wall node - used to represent a wall in the building - thickness: thickness in meters - height: height in meters + - fillToTerrain: extends the wall downward to the terrain without changing its authored height - curveOffset: midpoint sagitta offset used to bend the wall into an arc - start: start point of the wall in level coordinate system - end: end point of the wall in level coordinate system diff --git a/packages/core/src/schema/terrain.ts b/packages/core/src/schema/terrain.ts new file mode 100644 index 000000000..57c0a8e88 --- /dev/null +++ b/packages/core/src/schema/terrain.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' + +/** Current authoring and decoding ceiling. */ +export const MAX_TERRAIN_SIDE = 257 + +/** + * The persisted shape of a terrain heightfield. + * + * Kept in `schema/` rather than beside the codec because this is the contract + * the scene graph validates on load, and `lib/terrain-codec.ts` imports the type + * from here so there is exactly one definition of the wire format. + * + * `heights` is base64 of little-endian Int16 — see `lib/terrain-codec.ts` for why + * that beats a JSON number array, and why it is not compressed. The schema is + * intentionally loose about the *contents* of that string: validating base64 + * here would duplicate the decoder, and `decodeTerrainField` already returns + * `null` for anything it cannot read, which is the behaviour a corrupt scene + * needs (load flat, don't fail the project). + */ +export const TerrainData = z.object({ + type: z.literal('heightfield'), + /** World XZ of sample [0,0]. */ + origin: z.tuple([z.number().finite(), z.number().finite()]), + /** Metres between adjacent samples. */ + spacing: z.number().finite().positive(), + cols: z.number().int().positive().max(MAX_TERRAIN_SIDE), + rows: z.number().int().positive().max(MAX_TERRAIN_SIDE), + /** Metres per height unit — the quantization ladder. */ + step: z.number().finite().positive(), + /** Base64 of `cols * rows` little-endian Int16 samples, row-major. */ + heights: z.string(), +}) + +export type TerrainData = z.infer diff --git a/packages/core/src/store/use-live-terrain.test.ts b/packages/core/src/store/use-live-terrain.test.ts new file mode 100644 index 000000000..a282204bd --- /dev/null +++ b/packages/core/src/store/use-live-terrain.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { encodeTerrainField } from '../lib/terrain-codec' +import { + applyHeightPatch, + createTerrainField, + flattenPatch, + heightAt, + type TerrainField, +} from '../lib/terrain-field' +import { terrainFieldOf } from '../lib/terrain-source' +import useLiveTerrain from './use-live-terrain' + +function flatten(field: TerrainField, metres: number) { + const patch = flattenPatch(field, { minX: 1, minZ: 1, maxX: 4, maxZ: 4 }, metres) + return { patch: patch as never, field: applyHeightPatch(field, patch as never) } +} + +beforeEach(() => { + useLiveTerrain.getState().endAll() +}) + +describe('useLiveTerrain', () => { + test('begin / advance / end lifecycle', () => { + const store = useLiveTerrain.getState() + const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + + expect(store.fieldOf('site_1')).toBeUndefined() + + store.begin('site_1', base) + expect(useLiveTerrain.getState().fieldOf('site_1')).toBe(base) + + const dab = flatten(base, 2) + useLiveTerrain.getState().advance('site_1', dab.field, dab.patch) + expect(heightAt(useLiveTerrain.getState().fieldOf('site_1') as never, 2, 2)).toBeCloseTo(2, 6) + + useLiveTerrain.getState().end('site_1') + expect(useLiveTerrain.getState().fieldOf('site_1')).toBeUndefined() + }) + + test('the snapshot survives every dab — the anti-compounding invariant', () => { + const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + useLiveTerrain.getState().begin('site_1', base) + + let field = base + for (const height of [1, 2, 3, 4]) { + const dab = flatten(field, height) + field = dab.field + useLiveTerrain.getState().advance('site_1', field, dab.patch) + } + + // Four dabs later, the snapshot is still the field the stroke started from. + // A brush computes `snapshot + delta * mask` from this, which is why + // re-crossing your own stroke cannot compound. + expect(useLiveTerrain.getState().strokeOf('site_1')?.snapshot).toBe(base) + expect(heightAt(useLiveTerrain.getState().fieldOf('site_1') as never, 2, 2)).toBeCloseTo(4, 6) + }) + + test('lastPatch is what the renderer uploads, and is null right after begin', () => { + const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + useLiveTerrain.getState().begin('site_1', base) + expect(useLiveTerrain.getState().strokeOf('site_1')?.lastPatch).toBeNull() + + const dab = flatten(base, 3) + useLiveTerrain.getState().advance('site_1', dab.field, dab.patch) + expect(useLiveTerrain.getState().strokeOf('site_1')?.lastPatch).toBe(dab.patch) + }) + + test('a dab with no stroke is ignored rather than inventing a snapshot', () => { + const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const dab = flatten(base, 5) + useLiveTerrain.getState().advance('site_1', dab.field, dab.patch) + expect(useLiveTerrain.getState().fieldOf('site_1')).toBeUndefined() + }) + + test('a second begin adopts the dabs so far as its snapshot — the hazard callers own', () => { + // Pinned rather than left to the doc comment because the shape reads like + // harmless idempotence. Mid-stroke the field a caller is reading *is* the live + // one, dabs included, so re-beginning promotes uncommitted dabs to the + // saturating baseline: they survive the next commit while never appearing in + // history. Callers `end` first (see `abandonLiveStroke` in the editor's + // `terrain-sculpt`), and this asserts why they must. + const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + useLiveTerrain.getState().begin('site_1', base) + const dab = flatten(base, 5) + useLiveTerrain.getState().advance('site_1', dab.field, dab.patch) + + const live = useLiveTerrain.getState().fieldOf('site_1') as TerrainField + useLiveTerrain.getState().begin('site_1', live) + + // Not `base`: the 5 m dab is now the floor the next stroke computes from. + expect( + heightAt(useLiveTerrain.getState().strokeOf('site_1')?.snapshot as never, 2, 2), + ).toBeCloseTo(5, 6) + }) + + test('strokes are per site — one does not leak into another', () => { + const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + useLiveTerrain.getState().begin('site_1', base) + const dab = flatten(base, 7) + useLiveTerrain.getState().advance('site_1', dab.field, dab.patch) + + expect(useLiveTerrain.getState().fieldOf('site_2')).toBeUndefined() + useLiveTerrain.getState().end('site_1') + expect(useLiveTerrain.getState().fieldOf('site_1')).toBeUndefined() + }) +}) + +describe('terrainFieldOf sees the live stroke', () => { + test('a stroke wins over the persisted terrain', () => { + const persisted = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const site = { id: 'site_1', terrain: encodeTerrainField(persisted) } + expect(heightAt(terrainFieldOf(site) as never, 2, 2)).toBeCloseTo(0, 6) + + const dab = flatten(persisted, 6) + useLiveTerrain.getState().begin('site_1', persisted) + useLiveTerrain.getState().advance('site_1', dab.field, dab.patch) + + // Every consumer that goes through terrainFieldOf now sees the in-progress + // ground — that is what keeps the mesh and the raycast from disagreeing. + expect(heightAt(terrainFieldOf(site) as never, 2, 2)).toBeCloseTo(6, 6) + }) + + test('ending the stroke reverts readers to the persisted terrain', () => { + const persisted = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const site = { id: 'site_1', terrain: encodeTerrainField(persisted) } + const dab = flatten(persisted, 9) + useLiveTerrain.getState().begin('site_1', persisted) + useLiveTerrain.getState().advance('site_1', dab.field, dab.patch) + useLiveTerrain.getState().end('site_1') + + expect(heightAt(terrainFieldOf(site) as never, 2, 2)).toBeCloseTo(0, 6) + }) + + test('a stroke on a site with no persisted terrain is still visible', () => { + // Sculpting a virgin site: there is no `site.terrain` yet, so without the + // live lookup the first stroke would render nothing until commit. + const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const dab = flatten(base, 4) + useLiveTerrain.getState().begin('site_1', base) + useLiveTerrain.getState().advance('site_1', dab.field, dab.patch) + + const site = { id: 'site_1', terrain: undefined } + expect(terrainFieldOf(site)).not.toBeNull() + expect(heightAt(terrainFieldOf(site) as never, 2, 2)).toBeCloseTo(4, 6) + }) + + test('a site with no id and no terrain is still null — no crash', () => { + expect(terrainFieldOf({ terrain: undefined })).toBeNull() + }) +}) diff --git a/packages/core/src/store/use-live-terrain.ts b/packages/core/src/store/use-live-terrain.ts new file mode 100644 index 000000000..54ad09e07 --- /dev/null +++ b/packages/core/src/store/use-live-terrain.ts @@ -0,0 +1,104 @@ +import { create } from 'zustand' +import type { HeightPatch, TerrainField } from '../lib/terrain-field' + +/** + * In-progress terrain edits, held as a live `TerrainField` rather than as node + * data. + * + * This is the sculpting analogue of `useLiveNodeOverrides`, and it exists because + * that store cannot be reused here. An override stores *node* values, so a live + * terrain override would have to be a `TerrainData` — meaning every dab of a + * brush would base64-encode the whole field, ~11 KB at 65² and ~44 KB at 129², + * dozens of times a second. Holding the decoded field instead makes a dab cost + * one `applyHeightPatch` (a typed-array copy) and nothing else. + * + * The store also carries the **stroke's starting field**, which is what makes the + * saturating stroke model possible: a brush computes `snapshot + delta · mask` + * rather than accumulating onto the current field, so re-crossing your own stroke + * cannot compound. Without a snapshot the same drag deposits material twice + * wherever it overlaps itself, which is the single most common way a sculpt tool + * feels wrong. + * + * `lastPatch` is how the renderer knows what to upload: it takes the patch, + * pushes exactly those rows, and never diffs the whole field. + */ + +export type LiveTerrainStroke = { + /** The field as it was at pointer-down. Never mutated during the stroke. */ + readonly snapshot: TerrainField + /** The field as of the most recent dab — what the renderer and readers see. */ + readonly field: TerrainField + /** + * The most recent dab's patch, or null right after `begin`. The renderer + * consumes this for the dirty-rect upload; a null means "nothing new to push". + */ + readonly lastPatch: HeightPatch | null +} + +type LiveTerrainState = { + /** Keyed by site node id — a scene can hold more than one site. */ + strokes: Map + /** + * Start a stroke from `field`. + * + * A second `begin` for the same site *replaces* the stroke rather than being + * rejected — and that is a hazard, not idempotence, which is why it is spelled + * out here: the caller passes the field it is currently reading, and mid-stroke + * that field is the live one, dabs included. So a re-begin adopts uncommitted + * dabs as the new stroke's snapshot, where they become the saturating baseline + * and get persisted by the next commit — visible on screen the whole time, yet + * absent from history and unreachable by undo. + * + * `advance` takes the opposite stance for the same reason (a dab with no stroke + * is ignored rather than inventing a snapshot): the invariant is that a snapshot + * only ever comes from a deliberate stroke boundary. Callers own the boundary — + * `end` first, or don't begin. + */ + begin(siteId: string, field: TerrainField): void + /** Record a dab. `field` must be the result of applying `patch`. */ + advance(siteId: string, field: TerrainField, patch: HeightPatch): void + /** The live field for a site, or undefined when no stroke is in flight. */ + fieldOf(siteId: string): TerrainField | undefined + strokeOf(siteId: string): LiveTerrainStroke | undefined + /** End the stroke. The caller persists the field first if it wants to keep it. */ + end(siteId: string): void + endAll(): void +} + +const useLiveTerrain = create((set, get) => ({ + strokes: new Map(), + + begin: (siteId, field) => + set((state) => { + const next = new Map(state.strokes) + next.set(siteId, { snapshot: field, field, lastPatch: null }) + return { strokes: next } + }), + + advance: (siteId, field, patch) => + set((state) => { + const current = state.strokes.get(siteId) + // A dab with no stroke is a bug in the caller, not something to paper over + // by inventing a snapshot — that would silently disable the saturating + // model and reintroduce compounding. + if (!current) return state + const next = new Map(state.strokes) + next.set(siteId, { snapshot: current.snapshot, field, lastPatch: patch }) + return { strokes: next } + }), + + fieldOf: (siteId) => get().strokes.get(siteId)?.field, + strokeOf: (siteId) => get().strokes.get(siteId), + + end: (siteId) => + set((state) => { + if (!state.strokes.has(siteId)) return state + const next = new Map(state.strokes) + next.delete(siteId) + return { strokes: next } + }), + + endAll: () => set((state) => (state.strokes.size === 0 ? state : { strokes: new Map() })), +})) + +export default useLiveTerrain diff --git a/packages/core/src/systems/slab/slab-placement.test.ts b/packages/core/src/systems/slab/slab-placement.test.ts new file mode 100644 index 000000000..48868209a --- /dev/null +++ b/packages/core/src/systems/slab/slab-placement.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test' +import { SlabNode } from '../../schema' +import { resolveSlabPlacementElevation } from './slab-placement' + +describe('resolveSlabPlacementElevation', () => { + test('places the default slab underside on the captured base', () => { + const slab = SlabNode.parse({ polygon: [] }) + const elevation = resolveSlabPlacementElevation(slab, 1.2) + + expect(elevation).toBeCloseTo(1.25) + expect(elevation - slab.thickness).toBeCloseTo(1.2) + }) + + test('preserves authored clearance and thickness', () => { + const slab = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon: [] }) + const elevation = resolveSlabPlacementElevation(slab, 1.2) + + expect(elevation).toBeCloseTo(1.5) + expect(elevation - slab.thickness).toBeCloseTo(1.4) + expect(slab.thickness).toBe(0.1) + }) + + test('keeps recessed slabs level-relative', () => { + const slab = SlabNode.parse({ elevation: -0.8, polygon: [], recessed: true }) + + expect(resolveSlabPlacementElevation(slab, 1.2)).toBe(-0.8) + }) + + test('keeps authored elevation when no base was resolved', () => { + const slab = SlabNode.parse({ elevation: 0.12, polygon: [] }) + + expect(resolveSlabPlacementElevation(slab, null)).toBe(0.12) + }) +}) diff --git a/packages/core/src/systems/slab/slab-placement.ts b/packages/core/src/systems/slab/slab-placement.ts new file mode 100644 index 000000000..ea96748fa --- /dev/null +++ b/packages/core/src/systems/slab/slab-placement.ts @@ -0,0 +1,19 @@ +import type { SlabNode } from '../../schema' + +/** + * Translate a solid slab's authored vertical interval onto a chosen base plane. + * + * `slab.elevation` is the authored top relative to the placement plane and + * `thickness` grows downward. Adding the base therefore preserves both the + * thickness and any intentional clearance in a preset. Recessed slabs keep + * their level-relative depth because their rim is defined by the level plane. + */ +export function resolveSlabPlacementElevation( + slab: Pick, + baseElevation: number | null | undefined, +): number { + if (slab.recessed || baseElevation == null || !Number.isFinite(baseElevation)) { + return slab.elevation + } + return slab.elevation + baseElevation +} diff --git a/packages/core/src/systems/wall/wall-top.test.ts b/packages/core/src/systems/wall/wall-top.test.ts index e452dbfe5..a743dcad2 100644 --- a/packages/core/src/systems/wall/wall-top.test.ts +++ b/packages/core/src/systems/wall/wall-top.test.ts @@ -14,6 +14,13 @@ describe('resolveWallTop', () => { expect(resolveWallTop({ height: 2.5 }, 3, -0.4)).toBe(2.5) }) + test('ground-hosted explicit height remains body-relative in a terrain depression', () => { + expect(resolveWallTop({ height: 2.5, supportSlabId: 'ground' }, 3, -0.4)).toBeCloseTo(2.1) + expect( + resolveWallEffectiveHeight({ height: 2.5, supportSlabId: 'ground' }, 3, -0.4), + ).toBeCloseTo(2.5) + }) + test('plane-bound wall tops out at the storey plane regardless of base', () => { expect(resolveWallTop({}, 3, 0)).toBe(3) expect(resolveWallTop({}, 3, 0.6)).toBe(3) diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts index 208ed4bd8..2bbf475da 100644 --- a/packages/core/src/systems/wall/wall-top.ts +++ b/packages/core/src/systems/wall/wall-top.ts @@ -16,17 +16,20 @@ export const MIN_WALL_HEIGHT = 0.5 * the wall shorter, never taller, and no gap can open at the top of a * level. A wall WITH `height` is an explicit exception (half wall, * parapet) and keeps the legacy semantics: the top rides a raised elected - * base (`electedBase + height`), while a zero or sunken base leaves the - * top at `height` (the legacy negative-slab constraint). + * base (`electedBase + height`), while a zero or sunken slab base leaves + * the top at `height` (the legacy negative-slab constraint). Explicit + * ground-hosted walls are the terrain exception: `height` is always body + * height, including below datum, so sculpting cannot stretch the wall. * * Returns the top in level-local Y (same frame as `electedBase`). */ export function resolveWallTop( - wall: Pick, + wall: Pick, storeyHeight: number, electedBase: number, ): number { if (wall.height == null) return storeyHeight + if (wall.supportSlabId === 'ground') return electedBase + wall.height return electedBase > 0 ? electedBase + wall.height : wall.height } @@ -45,7 +48,7 @@ export function resolveWallTop( * policy. */ export function resolveWallEffectiveHeight( - wall: Pick, + wall: Pick, storeyHeight: number, electedBase: number, ): number { diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index fcbb802ff..aa0d694e8 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -31,6 +31,7 @@ import { withCameraPoseDistance, } from '../../lib/camera-pose' import { EDITOR_LAYER } from '../../lib/constants' +import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority' import { publishCameraPose } from '../../store/camera-pose-store' import useEditor from '../../store/use-editor' import { @@ -698,11 +699,11 @@ export const CustomCameraControls = () => { // Touch gestures (mobile / trackpad). // - One finger drag → rotate by default (much easier on a phone), but - // falls back to NONE while the user is actively - // placing/moving something OR in box-select mode, - // so the editor's pointer handlers (place tool, - // drag-to-move endpoint, marquee selection drag) - // keep priority over the camera. + // falls back to NONE while the editor owns the drag — + // placing/moving something, box-select, or a brush mode + // — so the editor's pointer handlers (place tool, + // drag-to-move endpoint, marquee selection drag, paint + // and sculpt strokes) keep priority over the camera. // In preview mode it's TOUCH_TRUCK (pan), matching // preview's left = SCREEN_PAN. // - Two finger pinch → zoom + pan together (TOUCH_DOLLY_TRUCK for @@ -717,9 +718,16 @@ export const CustomCameraControls = () => { const endpointReshape = useEndpointReshape() const activeHandleDrag = useActiveHandleDrag() const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee' - const isInteracting = Boolean( - tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive, - ) + // The mode term lives in `editorOwnsOneFingerDrag` rather than in this OR: a + // brush mode owns the drag the way an armed tool does, but none of the + // transient terms notice it (entering paint or sculpt *clears* `tool`, and its + // scope is `painting`/`sculpting`, not `handle-drag`). + const isInteracting = editorOwnsOneFingerDrag({ + mode, + activeGesture: Boolean( + tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive, + ), + }) const touches = useMemo(() => { const twoFingerAction = cameraMode === 'orthographic' diff --git a/packages/editor/src/components/editor/elevation-3d-guide-layer.tsx b/packages/editor/src/components/editor/elevation-3d-guide-layer.tsx new file mode 100644 index 000000000..f46580461 --- /dev/null +++ b/packages/editor/src/components/editor/elevation-3d-guide-layer.tsx @@ -0,0 +1,120 @@ +'use client' + +import { sceneRegistry } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useFrame } from '@react-three/fiber' +import { memo, useMemo, useRef } from 'react' +import { BoxGeometry, CircleGeometry, type Group } from 'three' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' +import useElevationGuides from '../../store/use-elevation-guides' +import { formatMeasurement } from './measurement-pill' + +const LINE_COLOR = 0x81_8c_f8 +const PILL_COLOR = '#6366f1' +const GUIDE_LIFT = 0.025 +const GUIDE_LENGTH = 2.4 +const DASH_LENGTH = 0.16 +const DASH_GAP = 0.1 +const LINE_WIDTH = 0.035 +const DOT_RADIUS = 0.07 + +const guideMaterial = new MeshBasicNodeMaterial({ + color: LINE_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.9, +}) +const dotMaterial = new MeshBasicNodeMaterial({ + color: LINE_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.95, +}) +const DASH_GEOMETRY = new BoxGeometry(1, 1, 1) +const DOT_GEOMETRY = new CircleGeometry(1, 24) + +type Vec3 = [number, number, number] + +export const Elevation3DGuideLayer = memo(function Elevation3DGuideLayer() { + const guide = useElevationGuides((state) => state.guide) + const unit = useViewer((state) => state.unit) + const groupRef = useRef(null) + + useFrame(() => { + const group = groupRef.current + if (!(group && guide)) return + const levelObject = sceneRegistry.nodes.get(guide.levelId) + group.position.y = (levelObject?.position.y ?? 0) + guide.elevation + GUIDE_LIFT + }) + + const dashes = useMemo(() => { + if (!guide) return [] as Vec3[] + const [cx, cz] = guide.center + const [dx, dz] = guide.direction + const start = -GUIDE_LENGTH / 2 + const period = DASH_LENGTH + DASH_GAP + const centers: Vec3[] = [] + for (let offset = start + DASH_LENGTH / 2; offset < GUIDE_LENGTH / 2; offset += period) { + centers.push([cx + dx * offset, 0, cz + dz * offset]) + } + return centers + }, [guide]) + + if (!guide) return null + + const angleY = -Math.atan2(guide.direction[1], guide.direction[0]) + const labelPosition: Vec3 = [ + guide.center[0] - guide.direction[1] * 0.65, + 0.16, + guide.center[1] + guide.direction[0] * 0.65, + ] + const elevationText = + Math.abs(guide.elevation) < 1e-6 + ? formatMeasurement(0, unit) + : `${guide.elevation < 0 ? '-' : '+'}${formatMeasurement(Math.abs(guide.elevation), unit)}` + + return ( + + {dashes.map((position, index) => ( + + ))} + + +
+ {`${guide.label} · Y ${elevationText}`} +
+ +
+ ) +}) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index 610618cda..ed398e69c 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -2,10 +2,14 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeDefinition, + applyHeightPatch, BuildingNode, CeilingNode, ColumnNode, + createTerrainField, ElevatorNode, + encodeTerrainField, + flattenPatch, LevelNode, nodeRegistry, registerNode, @@ -14,7 +18,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three' +import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world' function registerColliderDefinition( @@ -209,4 +213,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { expect(world?.bounds?.max.z).toBeCloseTo(1000) world?.dispose() }) + + test('a sculpted site walks on its terrain instead of the flat ground slab', () => { + 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) + const field = applyHeightPatch(base, patch as never) + const site = SiteNode.parse({ id: 'site_test', terrain: encodeTerrainField(field) }) + setSceneNodes([site]) + mountRegistryGroup(site) + + const world = buildFirstPersonColliderWorldFromRegistry() + expect(world).not.toBeNull() + if (!world) return + + // The hill is in the collider: a flat slab would top out at 0. + expect(world.bounds?.max.y).toBeCloseTo(2.5) + // And it still reaches past the site so stepping out does not drop the player. + expect(world.bounds?.min.x).toBeCloseTo(-1008) + expect(world.bounds?.max.x).toBeCloseTo(1008) + + // What the player actually stands on, on the plateau and off it. + const raycaster = new Raycaster() + const standOn = (x: number, z: number) => { + raycaster.set(new Vector3(x, 500, z), new Vector3(0, -1, 0)) + return raycaster.intersectObject(world.mesh, false)[0]?.point.y ?? null + } + expect(standOn(3, 3)).toBeCloseTo(2.5, 4) + expect(standOn(-3, -3)).toBeCloseTo(0, 4) + + world.dispose() + }) }) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 14267ecc9..eb8474d87 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -7,6 +7,7 @@ import { isOperationDoorType, nodeRegistry, sceneRegistry, + terrainFieldOf, useInteractive, useScene, } from '@pascal-app/core' @@ -14,6 +15,7 @@ import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { computeSceneBoundsXZ } from '../../../lib/scene-bounds' +import { createTerrainColliderGeometry } from './terrain-collider' const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh']) const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish']) @@ -153,12 +155,27 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) { // is effectively unbounded (not sized to the site polygon): the ground plane must // keep holding the player up even after they step past the site boundary, // otherwise they fall below the ground plane into the void. +// +// A sculpted site takes the terrain path instead: the flat slab would hold the +// player at the datum inside an excavation and bury them inside a hill. Derived +// from the field rather than the rendered terrain mesh for the same +// mount-timing reason as the flat slab. function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) { if (site.visible === false) return null const siteObject = sceneRegistry.nodes.get(site.id) if (!siteObject?.visible) return null + const field = terrainFieldOf(site) + if (field) { + const terrainGeometry = createTerrainColliderGeometry(field) + if (terrainGeometry) { + siteObject.updateWorldMatrix(true, false) + terrainGeometry.applyMatrix4(siteObject.matrixWorld) + return terrainGeometry + } + } + const bounds = computeSceneBoundsXZ(nodes) const [centerX, centerZ] = bounds?.center ?? [0, 0] const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0] diff --git a/packages/editor/src/components/editor/first-person/terrain-collider.test.ts b/packages/editor/src/components/editor/first-person/terrain-collider.test.ts new file mode 100644 index 000000000..ab699aa2e --- /dev/null +++ b/packages/editor/src/components/editor/first-person/terrain-collider.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from 'bun:test' +import { + applyHeightPatch, + createTerrainField, + flattenPatch, + heightAt, + type TerrainField, +} from '@pascal-app/core' +import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { createTerrainColliderGeometry } from './terrain-collider' + +const DOWN = new Vector3(0, -1, 0) +const UP = new Vector3(0, 1, 0) + +/** A 2.5 m plateau over x,z ∈ [2,5] on an otherwise flat field. */ +function plateauField(): TerrainField { + 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) +} + +/** A field sloping 0.1 m per metre along +x, so every sample differs. */ +function slopedField(): TerrainField { + const field = createTerrainField({ cols: 9, rows: 9, spacing: 1, origin: [-4, -4] }) + const heights = new Int16Array(field.heights) + for (let r = 0; r < field.rows; r++) { + for (let c = 0; c < field.cols; c++) { + heights[r * field.cols + c] = Math.round((c * 0.1) /* m */ / field.step) + } + } + return { ...field, heights } +} + +function colliderMesh(field: TerrainField) { + const geometry = createTerrainColliderGeometry(field) + if (!geometry) throw new Error('no collider geometry') + const mesh = new Mesh(geometry, new MeshBasicMaterial()) + mesh.updateMatrixWorld(true) + return mesh +} + +/** The height a downward ray finds — what the walkthrough player stands on. */ +function groundUnder(mesh: Mesh, x: number, z: number): number | null { + const raycaster = new Raycaster() + raycaster.set(new Vector3(x, 500, z), DOWN) + const hits = raycaster.intersectObject(mesh, false) + const floor = hits.find((hit) => { + if (!hit.face) return true + return hit.face.normal.clone().transformDirection(mesh.matrixWorld).dot(UP) > 0.2 + }) + return floor ? floor.point.y : null +} + +describe('createTerrainColliderGeometry', () => { + test('the walkable surface is the field, not a plane', () => { + const field = plateauField() + const mesh = colliderMesh(field) + + // On the plateau, off it, and on the slope between: the collider has to agree + // with `heightAt` at each, or what you see is not what you stand on. + for (const [x, z] of [ + [3, 3], + [-3, -3], + [1.5, 3], + [4.5, 4.5], + ] as Array<[number, number]>) { + expect(groundUnder(mesh, x, z)).toBeCloseTo(heightAt(field, x, z), 4) + } + }) + + test('a sloped field is sampled per vertex, not averaged', () => { + const field = slopedField() + const mesh = colliderMesh(field) + + // Sample midpoints, where a flat or decimated collider would disagree most. + for (const x of [-3.5, -0.5, 1.5, 3.5]) { + expect(groundUnder(mesh, x, 0)).toBeCloseTo(heightAt(field, x, 0), 4) + } + }) + + test('the skirt keeps holding the player far past the field', () => { + const field = slopedField() + const mesh = colliderMesh(field) + + // Past every edge and past a corner: `heightAt` clamps to the border there, so + // the collider must too. Without the skirt these are null and the player falls. + for (const [x, z] of [ + [400, 0], + [-400, 0], + [0, 400], + [0, -400], + [400, 400], + [-400, -400], + ] as Array<[number, number]>) { + expect(groundUnder(mesh, x, z)).toBeCloseTo(heightAt(field, x, z), 4) + } + }) + + test('every triangle carries a normal, and the surface faces up', () => { + const geometry = createTerrainColliderGeometry(plateauField()) + if (!geometry) throw new Error('no collider geometry') + + const position = geometry.getAttribute('position') + const normal = geometry.getAttribute('normal') + expect(normal).toBeDefined() + expect(normal.count).toBe(position.count) + // Non-indexed triangles — `mergeGeometries` needs every contributor to match. + expect(geometry.index).toBeNull() + expect(position.count % 3).toBe(0) + + for (let i = 0; i < normal.count; i++) { + expect(normal.getY(i)).toBeGreaterThan(0) + } + }) + + test('decimates rather than exploding on an absurd field', () => { + // 1025² is inside the schema's decode-bomb guard but 1M cells — an exact + // collider would be 2M triangles for a surface nobody can walk that far across. + const field = createTerrainField({ cols: 1025, rows: 1025, spacing: 0.5 }) + const geometry = createTerrainColliderGeometry(field) + if (!geometry) throw new Error('no collider geometry') + + const triangles = geometry.getAttribute('position').count / 3 + expect(triangles).toBeLessThan(200_000) + // Still a real surface, not an empty one. + expect(triangles).toBeGreaterThan(1000) + }) + + test('returns null for a degenerate field', () => { + expect(createTerrainColliderGeometry(createTerrainField({ cols: 1, rows: 1 }))).toBeNull() + }) +}) diff --git a/packages/editor/src/components/editor/first-person/terrain-collider.ts b/packages/editor/src/components/editor/first-person/terrain-collider.ts new file mode 100644 index 000000000..2d29fe909 --- /dev/null +++ b/packages/editor/src/components/editor/first-person/terrain-collider.ts @@ -0,0 +1,212 @@ +/** + * The sculpted ground as walkthrough collision geometry. + * + * The first-person collider world is a single merged BVH mesh, and before terrain + * the ground contributed one flat 2 km box to it. That box is wrong in both + * directions on a sculpted site: it holds the player up at the datum inside an + * excavation, and buries them inside a hill. This module replaces it. + * + * Two properties it has to hold, because the whole point is that walking agrees + * with every other consumer of the field: + * + * - **The surface is the field**, sampled at every vertex — the same + * `heightAtSample`/`normalAt` the rendered mesh uses (`terrain-geometry.ts`), + * so what you see is what you stand on. It is deliberately not a decimated + * proxy at realistic field sizes. + * - **It extends past the field**, because `heightAt` clamps to the border + * outside it. A collider that stopped at the field edge would drop the player + * into the void one step past the site, which is exactly the bug the flat box + * existed to prevent. The skirt extrudes each border sample outward at its own + * height, so the collider and `heightAt` agree everywhere, not just inside. + * + * Non-indexed triangles with position + normal, matching what the other collider + * contributors emit — `mergeGeometries` requires every input to agree on both. + */ + +import { heightAtSample, normalAt, type TerrainField } from '@pascal-app/core' +import * as THREE from 'three' + +/** + * How far past the field the ground keeps holding the player, in metres. Matches + * the flat box's half-extent so a site's collider bounds do not change size when + * it gains terrain. + */ +const SKIRT_REACH = 1000 + +/** + * Cell budget before the collider decimates. + * + * 66k cells is a 257² field — the largest anything realistic produces, and ~131k + * triangles, which the BVH builds in well under a frame's worth of budget. The + * schema allows up to 4097² (a decode-bomb guard, not a real terrain); at that + * size an exact collider would be 100M triangles, so past the budget this steps + * the sample grid and the collider becomes a faithful-but-coarser version of the + * same surface rather than an out-of-memory crash. + */ +const MAX_COLLIDER_CELLS = 66_000 + +function colliderStride(field: TerrainField): number { + const cells = Math.max(1, (field.cols - 1) * (field.rows - 1)) + if (cells <= MAX_COLLIDER_CELLS) return 1 + return Math.ceil(Math.sqrt(cells / MAX_COLLIDER_CELLS)) +} + +/** The sample indices the collider walks: every `stride`-th, always including the border. */ +function sampleLine(count: number, stride: number): number[] { + const line: number[] = [] + for (let i = 0; i < count - 1; i += stride) line.push(i) + line.push(count - 1) + return line +} + +type Vertex = { x: number; y: number; z: number } + +class TriangleSink { + private readonly positions: number[] = [] + private readonly normals: number[] = [] + + constructor(private readonly field: TerrainField) {} + + private push(v: Vertex): void { + this.positions.push(v.x, v.y, v.z) + const [nx, ny, nz] = normalAt(this.field, v.x, v.z) + this.normals.push(nx, ny, nz) + } + + /** + * One cell, given its four corners named by axis order. Winding matches + * `buildTerrainMesh`: `(p00, p01, p10)` and `(p10, p01, p11)` faces +Y, which is + * what the spawn resolver's `normal.dot(UP) > 0.2` floor test needs. + */ + quad(p00: Vertex, p10: Vertex, p01: Vertex, p11: Vertex): void { + this.push(p00) + this.push(p01) + this.push(p10) + this.push(p10) + this.push(p01) + this.push(p11) + } + + build(): THREE.BufferGeometry | null { + if (this.positions.length === 0) return null + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.BufferAttribute(new Float32Array(this.positions), 3), + ) + geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(this.normals), 3)) + return geometry + } +} + +/** + * Terrain collision geometry in the site's own frame — the caller applies the + * site's world matrix, exactly as it did for the flat box it replaces. + */ +export function createTerrainColliderGeometry(field: TerrainField): THREE.BufferGeometry | null { + if (field.cols < 2 || field.rows < 2) return null + + const stride = colliderStride(field) + const cols = sampleLine(field.cols, stride) + const rows = sampleLine(field.rows, stride) + const sink = new TriangleSink(field) + + const xAt = (col: number) => field.origin[0] + col * field.spacing + const zAt = (row: number) => field.origin[1] + row * field.spacing + const at = (col: number, row: number): Vertex => ({ + x: xAt(col), + y: heightAtSample(field, col, row), + z: zAt(row), + }) + + for (let ri = 0; ri < rows.length - 1; ri++) { + const r0 = rows[ri] as number + const r1 = rows[ri + 1] as number + for (let ci = 0; ci < cols.length - 1; ci++) { + const c0 = cols[ci] as number + const c1 = cols[ci + 1] as number + sink.quad(at(c0, r0), at(c1, r0), at(c0, r1), at(c1, r1)) + } + } + + // The skirt: each border sample extruded outward at its own height, so stepping + // off the field lands on the border height rather than on nothing. This is the + // collider spelling of `heightAt`'s edge clamp. + const firstCol = 0 + const lastCol = field.cols - 1 + const firstRow = 0 + const lastRow = field.rows - 1 + const westX = xAt(firstCol) - SKIRT_REACH + const eastX = xAt(lastCol) + SKIRT_REACH + const northZ = zAt(firstRow) - SKIRT_REACH + const southZ = zAt(lastRow) + SKIRT_REACH + + for (let ci = 0; ci < cols.length - 1; ci++) { + const c0 = cols[ci] as number + const c1 = cols[ci + 1] as number + const north0 = at(c0, firstRow) + const north1 = at(c1, firstRow) + sink.quad( + { x: north0.x, y: north0.y, z: northZ }, + { x: north1.x, y: north1.y, z: northZ }, + north0, + north1, + ) + const south0 = at(c0, lastRow) + const south1 = at(c1, lastRow) + sink.quad( + south0, + south1, + { x: south0.x, y: south0.y, z: southZ }, + { + x: south1.x, + y: south1.y, + z: southZ, + }, + ) + } + + for (let ri = 0; ri < rows.length - 1; ri++) { + const r0 = rows[ri] as number + const r1 = rows[ri + 1] as number + const west0 = at(firstCol, r0) + const west1 = at(firstCol, r1) + sink.quad( + { x: westX, y: west0.y, z: west0.z }, + west0, + { x: westX, y: west1.y, z: west1.z }, + west1, + ) + const east0 = at(lastCol, r0) + const east1 = at(lastCol, r1) + sink.quad(east0, { x: eastX, y: east0.y, z: east0.z }, east1, { + x: eastX, + y: east1.y, + z: east1.z, + }) + } + + // Corner quads, flat at the corner sample's height — the diagonal region no edge + // strip covers, and where `heightAt` also reports the corner value. + const corners: Array<[Vertex, number, number]> = [ + [at(firstCol, firstRow), westX, northZ], + [at(lastCol, firstRow), eastX, northZ], + [at(firstCol, lastRow), westX, southZ], + [at(lastCol, lastRow), eastX, southZ], + ] + for (const [corner, farX, farZ] of corners) { + const minX = Math.min(corner.x, farX) + const maxX = Math.max(corner.x, farX) + const minZ = Math.min(corner.z, farZ) + const maxZ = Math.max(corner.z, farZ) + const y = corner.y + sink.quad( + { x: minX, y, z: minZ }, + { x: maxX, y, z: minZ }, + { x: minX, y, z: maxZ }, + { x: maxX, y, z: maxZ }, + ) + } + + return sink.build() +} diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.test.ts b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts index 41381c8ed..fd96c2fbb 100644 --- a/packages/editor/src/components/editor/floorplan-camera-sync.test.ts +++ b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts @@ -177,6 +177,34 @@ describe('floorplan camera sync', () => { expect(applied).toEqual([]) }) + test('applies the north-alignment pose used by the compass in 3D view', () => { + const applied: CameraPose[] = [] + const bridge = createFloorplanCameraSyncBridge({ + applyCameraPose: (pose) => applied.push(pose), + publishNavigationPose: () => {}, + }) + const currentPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4]) + + bridge.receiveCameraPose(currentPose) + bridge.receiveNavigationPose({ + source: '2d', + revision: 1, + target: [...currentPose.target], + azimuth: 0, + viewWidth: currentPose.viewWidth!, + }) + + expect(applied).toHaveLength(1) + expect(applied[0]).toMatchObject({ + target: currentPose.target, + projection: currentPose.projection, + viewWidth: currentPose.viewWidth, + }) + expect(applied[0]?.position[0]).toBeCloseTo(currentPose.target[0]) + expect(applied[0]?.position[1]).toBeCloseTo(currentPose.position[1]) + expect(applied[0]?.position[2]).toBeCloseTo(currentPose.target[2] + 5) + }) + test('applies 2D-first navigation on initial load without a 3D interaction', () => { const applied: CameraPose[] = [] const bridge = createFloorplanCameraSyncBridge({ diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.ts b/packages/editor/src/components/editor/floorplan-camera-sync.ts index 80ff42950..448428e67 100644 --- a/packages/editor/src/components/editor/floorplan-camera-sync.ts +++ b/packages/editor/src/components/editor/floorplan-camera-sync.ts @@ -4,10 +4,7 @@ import { type CameraPose, emitter } from '@pascal-app/core' import { useEffect, useRef } from 'react' import { subscribeCameraPose } from '../../store/camera-pose-store' import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store' -import useEditor, { - type NavigationSyncPose, - type NavigationSyncPoseInput, -} from '../../store/use-editor' +import type { NavigationSyncPose, NavigationSyncPoseInput } from '../../store/use-editor' const POSITION_EPSILON = 0.001 const AZIMUTH_EPSILON = 1e-4 @@ -198,19 +195,18 @@ export function createFloorplanCameraSyncBridge({ } export function useFloorplanCameraSyncBridge() { - const active = useEditor((state) => state.viewMode !== '3d') + // The compass is portalled into the always-visible viewer area, so its + // orientation-only 2D pose must still reach the camera while the floorplan + // itself is hidden in 3D view. const bridgeRef = useRef(null) if (!bridgeRef.current) { bridgeRef.current = createFloorplanCameraSyncBridge({ - active, applyCameraPose: (pose) => emitter.emit('camera-controls:apply-pose', pose), publishNavigationPose: (pose) => liveCameraNavigation.publish(pose), }) } const bridge = bridgeRef.current - useEffect(() => bridge.setActive(active), [active, bridge]) - useEffect(() => subscribeCameraPose(bridge.receiveCameraPose), [bridge]) useEffect(() => subscribeNavigationSyncPose(bridge.receiveNavigationPose), [bridge]) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 14f51a193..4e2be01a4 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -32,6 +32,7 @@ import { type Point2D, type RoofNode, type RoofSegmentNode, + resolveSlabPlacementElevation, type SiteNode, type SlabNode, SlabNode as SlabNodeSchema, @@ -41,6 +42,8 @@ import { sampleWallCenterline, sceneRegistry, snapPointAlongAngleRay, + spatialGridManager, + terrainSupportLift, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -85,11 +88,12 @@ import { type FloorplanNodeTransform as SharedFloorplanNodeTransform, worldToFloorplanLocalPoint, } from '../../lib/floorplan' +import { groundHeightAt } from '../../lib/ground-surface' import { guideEmitter } from '../../lib/guide-events' import { measurementHint, parseMeasurement } from '../../lib/measurement-parser' import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements' import { sfxEmitter } from '../../lib/sfx-bus' -import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' +import { SITE_BOUNDARY_DRAG_LABEL, siteBoundaryHandlesEnabled } from '../../lib/site-boundary' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { cn } from '../../lib/utils' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' @@ -179,6 +183,7 @@ import { chainEndJoinsExistingWall, createWallOnCurrentLevel, isSegmentLongEnough, + resolveTerrainWallConstructionOptions, snapWallDraftPoint, snapWallDraftPointDetailed, snapPointToGrid as snapWallPointToGrid, @@ -5133,6 +5138,8 @@ export function FloorplanPanel({ // Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged. const [draftStart, setDraftStart] = useState(null) const [wallChainFirstVertex, setWallChainFirstVertex] = useState(null) + const wallConstructionOptionsRef = + useRef>(undefined) // Walls committed by the current 2D-only chain — exclusion set for the // T-junction chain-termination test (mirrors the 3D tool's `chainWallIds`). const wallChainWallIdsRef = useRef([]) @@ -5161,6 +5168,7 @@ export function FloorplanPanel({ ) const [ceilingDraftPoints, setCeilingDraftPoints] = useState([]) const [slabDraftPoints, setSlabDraftPoints] = useState([]) + const slabPlacementBaseRef = useRef(null) // Mirror the per-click draft START anchors into the shared draft store so // out-of-tree consumers (such as host-owned preview publishers) see the whole open // segment without subscribing to this panel's state. @@ -6056,7 +6064,7 @@ export function FloorplanPanel({ isFloorplanItemContextActive const visibleSitePolygon = displaySitePolygon const canUseSiteBoundaryVertexHandles = - visibleSitePolygon !== null && (isSiteEditActive || mode === 'select') + visibleSitePolygon !== null && siteBoundaryHandlesEnabled({ mode, phase }) const isSiteBoundaryHighlighted = isSiteEditActive || siteVertexDragState !== null const shouldShowSiteEdgeLabels = Boolean(visibleSitePolygon) && @@ -7831,6 +7839,7 @@ export function FloorplanPanel({ const clearWallPlacementDraft = useCallback(() => { setDraftStart(null) setWallChainFirstVertex(null) + wallConstructionOptionsRef.current = undefined wallChainWallIdsRef.current = [] setDraftEnd(null) useSegmentDraftChain.getState().clear('wall') @@ -7848,6 +7857,7 @@ export function FloorplanPanel({ }, []) const clearSlabPlacementDraft = useCallback(() => { setSlabDraftPoints([]) + slabPlacementBaseRef.current = null }, []) const clearZonePlacementDraft = useCallback(() => { setZoneDraftPoints([]) @@ -7970,18 +7980,22 @@ export function FloorplanPanel({ // the tools' own `commitSlab/CeilingDrawing`) are called ONLY in 2D-only view // so split / 3D keep their single-owner tool commit (no double-create). const createSlabOnCurrentLevel = useCallback( - (points: WallPlanPoint[]) => { + (points: WallPlanPoint[], baseElevation: number | null) => { if (!levelId) { return null } const { createNode, nodes } = useScene.getState() const slabCount = Object.values(nodes).filter((node) => node.type === 'slab').length const defaults = useEditor.getState().toolDefaults.slab ?? {} - const slab = SlabNodeSchema.parse({ + const authored = SlabNodeSchema.parse({ ...defaults, name: `Slab ${slabCount + 1}`, polygon: points.map(([x, z]) => [x, z] as [number, number]), }) + const slab = { + ...authored, + elevation: resolveSlabPlacementElevation(authored, baseElevation), + } createNode(slab, levelId) sfxEmitter.emit('sfx:structure-build') setSelection({ selectedIds: [slab.id] }) @@ -8949,10 +8963,20 @@ export function FloorplanPanel({ const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos + // On sculpted ground the grid Y is the terrain height under the plan point, + // not the flat storey plane. There is no XZ correction to make here — the + // plan view's ray is vertical by construction, so the skew the 3D path has + // to march for cannot happen — but the Y still has to be the ground's, or a + // wall drafted in 2D lands at the datum while the same wall drafted in 3D + // lands on the hillside. + const groundY = groundHeightAt(worldX, worldZ, floorplanGridWorldY) + const worldY = groundY ?? floorplanGridWorldY + const localY = groundY === null ? floorplanGridLocalY : groundY - buildingPosition[1] + emitter.emit(`grid:${eventType}` as any, { nativeEvent: nativeEvent.nativeEvent as any, - position: [worldX, floorplanGridWorldY, worldZ], - localPosition: [planPoint[0], floorplanGridLocalY, planPoint[1]], + position: [worldX, worldY, worldZ], + localPosition: [planPoint[0], localY, planPoint[1]], }) }, [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY], @@ -9554,16 +9578,32 @@ export function FloorplanPanel({ if (firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) { // 2D-only view: the 3D tool can't commit, so close the polygon here. if (useEditor.getState().viewMode === '2d') { - createSlabOnCurrentLevel(slabDraftPoints) + createSlabOnCurrentLevel(slabDraftPoints, slabPlacementBaseRef.current) } clearDraft() return } + if (slabDraftPoints.length === 0) { + const recessed = useEditor.getState().toolDefaults.slab?.recessed === true + if (!levelId || recessed) { + slabPlacementBaseRef.current = null + } else { + const support = spatialGridManager.getPointedSupportSurface( + levelId, + [point[0], 1_000_000, point[1]], + [0, -1, 0], + ) + slabPlacementBaseRef.current = + support.slabId != null + ? support.elevation + : terrainSupportLift(useScene.getState().nodes, levelId, point[0], point[1]) + } + } setSlabDraftPoints((currentPoints) => [...currentPoints, point]) setCursorPoint(point) }, - [clearDraft, createSlabOnCurrentLevel, slabDraftPoints, setCursorPoint], + [clearDraft, createSlabOnCurrentLevel, levelId, slabDraftPoints, setCursorPoint], ) const handleSlabPlacementConfirm = useCallback( (point?: WallPlanPoint) => { @@ -9588,7 +9628,7 @@ export function FloorplanPanel({ // 2D-only view: the 3D tool can't commit, so create the slab here. if (useEditor.getState().viewMode === '2d') { - createSlabOnCurrentLevel(nextPoints) + createSlabOnCurrentLevel(nextPoints, slabPlacementBaseRef.current) } clearDraft() }, @@ -9695,6 +9735,14 @@ export function FloorplanPanel({ const handleWallPlacementPoint = useCallback( (point: WallPlanPoint) => { if (!draftStart) { + wallConstructionOptionsRef.current = levelId + ? resolveTerrainWallConstructionOptions( + useScene.getState().nodes, + levelId, + point, + useEditor.getState().toolDefaults.wall, + ) + : undefined setDraftStart(point) setWallChainFirstVertex(point) setDraftEnd(point) @@ -9721,7 +9769,14 @@ export function FloorplanPanel({ // ceiling 2D-only committers: create locally here, gated on the // view, so split / 3D keep their single-owner tool commit. const viewIs2DOnly = useEditor.getState().viewMode === '2d' - const createdWall = viewIs2DOnly ? createWallOnCurrentLevel(draftStart, point) : null + let createdWall: WallNode | null = null + if (viewIs2DOnly) { + createdWall = createWallOnCurrentLevel( + draftStart, + point, + wallConstructionOptionsRef.current, + ) + } if (createdWall) { wallChainWallIdsRef.current.push(createdWall.id) } diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index 9572367db..2a413d54f 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -29,6 +29,7 @@ import useAlignmentGuides from '../../store/use-alignment-guides' import useDeleteConfirmation from '../../store/use-delete-confirmation' import useEditor, { isAlignmentGuideActive, + isBrushMode, isGridSnapActive, isMagneticSnapActive, } from '../../store/use-editor' @@ -490,6 +491,15 @@ export async function pasteSelectionAndPickUp(targetLevelId?: AnyNodeId): Promis if (activeScope.kind === 'placing' || activeScope.kind === 'moving') { emitter.emit('tool:cancel') } + // Paint and sculpt hold their scope for the whole *mode*, so a pick-up would + // `begin({kind: 'handle-drag'})` over it — scopes are single-owner, so the brush + // would stay armed and painting while the editor believed a group move was + // running, and the mode's own release would then end someone else's scope. + // ⌘V is reachable from either mode (it reads the clipboard, not the selection, + // which is why clearing the selection on mode entry does not cover it), so drop + // the brush first: carrying clones is the newer intent, and unlike the cancels + // above there is nothing to abandon. + if (isBrushMode(useEditor.getState().mode)) useEditor.getState().setMode('select') const result = await pasteSystemEditorClipboardToLevel(targetLevelId) if (!result || result.pastedIds.length === 0) return false diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index f33e7f509..b350351c0 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -247,7 +247,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { onCancel() } - dragCleanupRef.current = cleanup + dragCleanupRef.current = onCancel window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 612766334..8c8b3bbbd 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -751,6 +751,9 @@ function LinearArrow({ }, onEnd: () => { useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') + if (descriptor.kind === 'linear-resize') { + descriptor.onDragEnd?.(initialNode as never, sceneApi) + } if (onDrag) useOpeningGuides.getState().clear() for (const previewId of previewOverrideIds) { useLiveNodeOverrides.getState().clear(previewId) @@ -1580,7 +1583,10 @@ function CornerPickerShape({ position={position} renderOrder={1001} /> - + { if (polygon.length < 2) return [] return polygon.map(([x1, z1], i) => { const [x2, z2] = polygon[(i + 1) % polygon.length]! const midX = (x1! + x2) / 2 const midZ = (z1! + z2) / 2 + // Length stays the *plan* length. A property line is a horizontal measurement + // — a deed says 30 m whether the lot is flat or a hillside — and reporting the + // slope distance would make the same lot appear to grow as it is sculpted. + // Only the label's Y follows the ground. const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2) - return { midX, midZ, dist } + // `?? 0` covers both no-terrain cases, and 0 *is* the datum, so this reduces + // to the flat constant it replaced whenever the lot is unsculpted. + const groundY = groundHeightAt(midX, midZ, 0) ?? 0 + return { midX, midY: groundY + LABEL_LIFT, midZ, dist } }) }, [polygon]) @@ -99,7 +121,7 @@ export function SiteEdgeLabels() { calculatePosition={calculateLabelPosition} key={`${cameraMode}-${camera.uuid}-edge-${i}`} occlude - position={[edge.midX, 0.5, edge.midZ]} + position={[edge.midX, edge.midY, edge.midZ]} style={{ pointerEvents: 'none', userSelect: 'none' }} zIndexRange={[10, 0]} > diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 3ae9b8a10..6e6165d33 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -1,8 +1,10 @@ 'use client' import { + type AnyNode, type AnyNodeId, type FenceNode, + getWallBaseElevationForNodes, getWallCurveFrameAt, getWallEffectiveHeightForNodes, getWallThickness, @@ -33,19 +35,27 @@ import { } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' +import { + clearStructuralElevationGuide, + publishStructuralElevationGuide, + resolveStructuralElevationSnap, +} from '../../lib/elevation-guides' import { isHistoryShortcut } from '../../lib/history' import { endpointReshapeScope } from '../../lib/interaction/scope' import { sfxEmitter } from '../../lib/sfx-bus' -import useEditor from '../../store/use-editor' +import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' import useInteractionScope, { useEndpointReshape, useIsCurveReshape, useMovingNode, } from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' +import { resolveResizeSnapValue } from './handles/resize-snap' +import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' import { createArrowHitAreaGeometry, createEndpointHitAreaGeometry, + HandleArrow, InvisibleHandleHitArea, NO_RAYCAST, useInvisibleHitAreaMaterial, @@ -154,6 +164,7 @@ export function WallMoveSideHandles() { } function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { + const nodes = useScene((state) => state.nodes) // Merge the in-flight drag override so every handle (side-move arrows, // height arrow, corner leaders) tracks the live height in real time // during a height drag — the scene store stays at the pre-drag value @@ -197,24 +208,34 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { } }, [wall.parentId]) - const handles = useMemo(() => getWallMoveHandles(effectiveWall), [effectiveWall]) + const baseElevation = getWallBaseElevationForNodes(effectiveWall, nodes) + const handles = useMemo(() => getWallMoveHandles(effectiveWall, nodes), [effectiveWall, nodes]) if (!levelObject || handles.length === 0) return null return createPortal( - - {handles.map((handle) => ( - - ))} - - - - , + <> + + {handles.map((handle) => ( + + ))} + + + + + + , levelObject, ) } function buildDashedVerticalGeometry(height: number) { + if (!(Number.isFinite(height) && height > 0)) return new BufferGeometry() + // Build each dash as a thin cylinder section so thickness is // controllable — native `lineSegments` lock to 1px on WebGL/WebGPU. const dashes: BufferGeometry[] = [] @@ -227,7 +248,10 @@ function buildDashedVerticalGeometry(height: number) { dashes.push(cylinder) y = end + CORNER_GAP_SIZE } - const merged = mergeGeometries(dashes, false) ?? new BufferGeometry() + const merged = + dashes.length > 0 + ? (mergeGeometries(dashes, false) ?? new BufferGeometry()) + : new BufferGeometry() for (const dash of dashes) dash.dispose() return merged } @@ -381,6 +405,155 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: ) } +function WallBaseElevationHandle({ + wall, + baseElevation, + levelObject, +}: { + wall: WallNode + baseElevation: number + levelObject: Object3D +}) { + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const { camera } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null + const midpoint: [number, number] = curveFrame + ? [curveFrame.point.x, curveFrame.point.y] + : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] + const elevationGuideSource = { + nodeId: wall.id, + levelId: wall.parentId, + anchor: midpoint, + } + const leaderBottom = Math.min(0, baseElevation) + const leaderHeight = Math.abs(baseElevation) + const dashedGeometry = useMemo( + () => (leaderHeight > 0.0001 ? buildDashedVerticalGeometry(leaderHeight) : null), + [leaderHeight], + ) + const dashMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + transparent: true, + opacity: 0.85, + depthTest: false, + depthWrite: false, + }), + [], + ) + + const isActive = isHovered || isDragging + useEffect(() => { + dashMaterial.color.set(isActive ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [dashMaterial, isActive]) + useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry]) + useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) + const dragControls = useMemo( + () => ({ + onStart: () => {}, + onEnd: () => {}, + }), + [], + ) + + const activateElevationMove = useHandleDrag({ + kind: 'drag', + cursor: 'ns-resize', + dragControls, + handleIndex: 0, + node: wall, + rideObject: levelObject, + setIsDragging, + onStart: ({ event, initialNode, intersectPlane, nodeId, sceneApi }) => { + if (initialNode.type !== 'wall') return null + + levelObject.updateWorldMatrix(true, false) + const initialBase = getWallBaseElevationForNodes(initialNode, sceneApi.nodes()) + const supportBase = initialBase - (initialNode.supportOffset ?? 0) + const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes()) + const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4( + levelObject.matrixWorld, + ) + const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) + if (planeNormal.lengthSq() === 0) return null + planeNormal.normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) + const initialHit = new Vector3() + if ( + !intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, initialHit) + ) { + return null + } + const initialPointerY = levelObject.worldToLocal(initialHit).y + + return { + onBegin: () => { + useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId, handle: 'elevation' }) + }, + onEnd: () => { + useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag') + clearStructuralElevationGuide(initialNode.id) + }, + move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { + const hit = new Vector3() + if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null + const pointerY = levelObject.worldToLocal(hit).y + const nextBase = resolveResizeSnapValue({ + rawValue: initialBase + pointerY - initialPointerY, + gridSnapEnabled: true, + gridSnapActive: isGridSnapActive(), + gridSnapStep: useEditor.getState().gridSnapStep, + magneticSnapActive: isMagneticSnapActive(), + magneticSnap: (value) => + resolveStructuralElevationSnap(elevationGuideSource, value, sceneApi.nodes()), + }) + publishStructuralElevationGuide(elevationGuideSource, nextBase, sceneApi.nodes()) + if (Math.abs(nextBase - initialBase) < 1e-6) { + return { + supportOffset: initialNode.supportOffset, + height: initialNode.height, + } + } + const nextOffset = nextBase - supportBase + return { + supportOffset: Math.abs(nextOffset) < 1e-6 ? undefined : nextOffset, + height: initialHeight, + } + }, + } + }, + }) + + return ( + <> + {dashedGeometry ? ( + + ) : null} + + + ) +} + function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const [isHovered, setIsHovered] = useState(false) const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) @@ -760,7 +933,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal ) } -function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { +function getWallMoveHandles(wall: WallNode, nodes: Record): WallMoveHandle[] { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] const length = Math.hypot(dx, dz) @@ -776,7 +949,7 @@ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { const midpoint: [number, number] = frame ? [frame.point.x, frame.point.y] : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] - const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) + const wallHeight = getWallEffectiveHeightForNodes(wall, nodes) const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) diff --git a/packages/editor/src/components/tools/shared/horizontal-construction-plane.ts b/packages/editor/src/components/tools/shared/horizontal-construction-plane.ts new file mode 100644 index 000000000..f61856be9 --- /dev/null +++ b/packages/editor/src/components/tools/shared/horizontal-construction-plane.ts @@ -0,0 +1,123 @@ +import { + type AnyNodeId, + GROUND_SUPPORT_ID, + type GridEvent, + sceneRegistry, + terrainSupportLift, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Vector3 } from 'three' +import { publishPlacementSurface } from '../../../lib/active-placement-surface' +import type { PointerSupportSurface } from './pointer-support-cap' + +const SURFACE_UP = new Vector3(0, 1, 0) +const worldScratch = new Vector3() +const localScratch = new Vector3() +const surfacePointScratch = new Vector3() + +export type HorizontalConstructionPlane = { + /** Building-local Y used by the cursor and draft preview. */ + localY: number + /** World Y used by the shared placement grid. */ + worldY: number + /** Level-local base elevation used when the node is committed. */ + elevation: number | null + /** Support source selected at the first click. */ + supportSlabId: string | null + /** Optional scene node from which this frozen plane was derived. */ + sourceNodeId: AnyNodeId | null +} + +export function resolveEventConstructionPlane( + event: GridEvent, + pointed: PointerSupportSurface | null, +): HorizontalConstructionPlane { + if (pointed) { + return { + localY: pointed.localPoint?.[1] ?? event.localPosition[1], + worldY: pointed.worldY, + elevation: pointed.elevation, + supportSlabId: pointed.supportSlabId, + sourceNodeId: pointed.sourceNodeId, + } + } + + let elevation: number | null = null + const levelId = useViewer.getState().selection.levelId + const levelMesh = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : undefined + if (levelMesh) { + localScratch.set(event.position[0], event.position[1], event.position[2]) + levelMesh.worldToLocal(localScratch) + elevation = localScratch.y + } + + return { + localY: event.localPosition[1], + worldY: event.position[1], + elevation, + supportSlabId: null, + sourceNodeId: null, + } +} + +export function resolveLevelConstructionPlane(): HorizontalConstructionPlane | null { + const { buildingId, levelId } = useViewer.getState().selection + if (!levelId) return null + + const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId) + worldScratch.set(0, 0, 0) + if (levelMesh) levelMesh.localToWorld(worldScratch) + + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined + localScratch.copy(worldScratch) + if (buildingMesh) buildingMesh.worldToLocal(localScratch) + + return { + localY: localScratch.y, + worldY: worldScratch.y, + elevation: 0, + supportSlabId: null, + sourceNodeId: null, + } +} + +export function resampleTerrainConstructionPlane( + plane: HorizontalConstructionPlane, + point: readonly [number, number], +): HorizontalConstructionPlane { + if (plane.supportSlabId !== GROUND_SUPPORT_ID || plane.sourceNodeId) return plane + + const levelId = useViewer.getState().selection.levelId + if (!levelId) return plane + const elevation = terrainSupportLift(useScene.getState().nodes, levelId, point[0], point[1]) + if (elevation == null) return plane + + const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId) + worldScratch.set(point[0], elevation, point[1]) + if (levelMesh) levelMesh.localToWorld(worldScratch) + + const buildingId = useViewer.getState().selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined + localScratch.copy(worldScratch) + if (buildingMesh) buildingMesh.worldToLocal(localScratch) + + return { + localY: localScratch.y, + worldY: worldScratch.y, + elevation, + supportSlabId: GROUND_SUPPORT_ID, + sourceNodeId: null, + } +} + +export function publishHorizontalConstructionPlane( + event: GridEvent, + plane: HorizontalConstructionPlane, +): void { + publishPlacementSurface( + surfacePointScratch.set(event.position[0], plane.worldY, event.position[2]), + SURFACE_UP, + 'fixed-plane', + ) +} diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts new file mode 100644 index 000000000..97fc1f531 --- /dev/null +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + sceneRegistry, + spatialGridManager, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera } from 'three' +import { resolvePointerSupportSurface } from './pointer-support-cap' + +const LEVEL_ID = 'level_test' as AnyNodeId +const WALL_ID = 'wall_test' as AnyNodeId + +describe('resolvePointerSupportSurface node tops', () => { + beforeEach(() => { + spatialGridManager.clear() + sceneRegistry.clear() + useViewer.setState({ + selection: { + buildingId: null, + levelId: LEVEL_ID, + zoneId: null, + selectedIds: [], + }, + } as never) + useScene.setState({ + nodes: { + [LEVEL_ID]: { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [WALL_ID], + level: 0, + } as AnyNode, + [WALL_ID]: { + id: WALL_ID, + type: 'wall', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + assemblyLayers: [], + start: [-1, 0], + end: [1, 0], + frontSide: 'unknown', + backSide: 'unknown', + } as AnyNode, + }, + }) + }) + + afterEach(() => { + sceneRegistry.clear() + }) + + test('prefers the nearest upward-facing registered node surface over the ground', () => { + const wallMesh = new Mesh(new BoxGeometry(2, 2, 0.2), new MeshBasicMaterial()) + wallMesh.position.y = 1 + wallMesh.updateMatrixWorld(true) + sceneRegistry.nodes.set(WALL_ID, wallMesh) + sceneRegistry.byType.wall!.add(WALL_ID) + + const camera = new PerspectiveCamera() + camera.position.set(0, 5, 0) + camera.updateMatrixWorld(true) + + const support = resolvePointerSupportSurface(camera, [0, 0, 0], { + includeNodeTopSurfaces: true, + }) + + expect(support?.sourceNodeId).toBe(WALL_ID) + expect(support?.elevation).toBeCloseTo(2) + expect(support?.worldPoint).toEqual([0, 2, 0]) + }) + + test('keeps the ground result when node-top surfaces are not requested', () => { + const wallMesh = new Mesh(new BoxGeometry(2, 2, 0.2), new MeshBasicMaterial()) + wallMesh.position.y = 1 + wallMesh.updateMatrixWorld(true) + sceneRegistry.nodes.set(WALL_ID, wallMesh) + sceneRegistry.byType.wall!.add(WALL_ID) + + const camera = new PerspectiveCamera() + camera.position.set(0, 5, 0) + camera.updateMatrixWorld(true) + + const support = resolvePointerSupportSurface(camera, [0, 0, 0]) + + expect(support?.sourceNodeId).toBeNull() + expect(support?.elevation).toBe(0) + }) +}) diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index b0bc8398e..b01803bd8 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -1,15 +1,36 @@ -import { type AnyNodeId, sceneRegistry, spatialGridManager } from '@pascal-app/core' +import { + type AnyNodeId, + canHostOnTop, + GROUND_SUPPORT_ID, + type ItemNode, + isLowProfileItemSurface, + sceneRegistry, + spatialGridManager, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { type Camera, Vector3 } from 'three' +import { type Camera, Matrix3, type Object3D, Raycaster, Vector3 } from 'three' +import { resolveTerrainGroundHit } from '../../../lib/ground-surface' const originScratch = new Vector3() const hitScratch = new Vector3() const worldScratch = new Vector3() const pointScratch = new Vector3() +const worldRayOrigin = new Vector3() +const worldRayDirection = new Vector3() +const nodeTopRaycaster = new Raycaster() +const nodeTopNormal = new Vector3() +const nodeTopNormalMatrix = new Matrix3() + +const NODE_TOP_SURFACE_KINDS = ['wall', 'item', 'column'] as const export type PointerSupportSurface = { /** Level-local elevation of the pointed surface — the election cap. */ elevation: number + /** Slab host, or the explicit ground sentinel when the level base won. */ + supportSlabId: string | null + /** Node whose upward-facing geometry supplied this surface, when applicable. */ + sourceNodeId: AnyNodeId | null /** World-space Y of the same surface, for grid-plane / preview placement. */ worldY: number /** @@ -45,17 +66,28 @@ export type PointerSupportSurface = { * the plane sits on a different storey than the pointed surface, so * callers should place at `localPoint` / `worldPoint`, not the event hit. * + * When the ray crosses no slab, the surface is the level base — and on the + * storey that sits on the ground, that base is the sculpted terrain rather + * than a plane, so `elevation` varies with XZ. Everything downstream already + * treats it as a varying scalar, which is why terrain needs no new axis here. + * * Returns null when no level is active (callers fall back to the * uncapped max election and the raw event hit). */ export function resolvePointerSupportSurface( camera: Camera, worldHit: readonly [number, number, number], + options?: { includeNodeTopSurfaces?: boolean }, ): PointerSupportSurface | null { const levelId = useViewer.getState().selection.levelId if (!levelId) return null - camera.getWorldPosition(originScratch) + // The world ray, kept before the level conversion below: the terrain field is + // world-space (site geometry, not level-local), so the march needs this frame. + camera.getWorldPosition(worldRayOrigin) + worldRayDirection.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin) + + originScratch.copy(worldRayOrigin) hitScratch.set(worldHit[0], worldHit[1], worldHit[2]) // Slab polygons/elevations live in the level frame; the level mesh // carries the storey Y offset and any building rotation. @@ -67,11 +99,43 @@ export function resolvePointerSupportSurface( hitScratch.sub(originScratch) if (hitScratch.lengthSq() < 1e-12) return null - const { elevation, point } = spatialGridManager.getPointedSupportSurface( + const pointed = spatialGridManager.getPointedSupportSurface( levelId, [originScratch.x, originScratch.y, originScratch.z], [hitScratch.x, hitScratch.y, hitScratch.z], ) + let elevation = pointed.elevation + let point = pointed.point + + // The level base is the second flat plane terrain has to displace (the first is + // `useGridEvents`'). The manager solves `t = -oy / dy` for it — a plane — which + // is still right for every storey whose base is a built floor, and wrong for the + // storey sitting on the ground, where the base IS the terrain. + // + // Substituted here rather than inside the manager for two reasons: the + // discrimination ("is this plane the site ground?") needs the level's *world* + // height, and this function is what owns the world↔level conversion; and + // `ground-surface` is meant to hold that rule once, shared with `useGridEvents`, + // rather than have `core` grow a terrain dependency it cannot express. + // + // Only the no-slab branch is replaced. A ray that crossed a slab inside its + // rendered polygon is aimed at a built floor, and terrain does not compete with + // one — that is the flat-floor invariant. + if (pointed.slabId === null) { + const baseWorldY = levelMesh ? levelMesh.localToWorld(worldScratch.set(0, 0, 0)).y : 0 + const groundHit = resolveTerrainGroundHit( + [worldRayOrigin.x, worldRayOrigin.y, worldRayOrigin.z], + [worldRayDirection.x, worldRayDirection.y, worldRayDirection.z], + baseWorldY, + ) + if (groundHit) { + pointScratch.set(groundHit.x, groundHit.y, groundHit.z) + if (levelMesh) levelMesh.worldToLocal(pointScratch) + elevation = pointScratch.y + point = [pointScratch.x, pointScratch.z] + } + } + const worldY = levelMesh ? levelMesh.localToWorld(worldScratch.set(0, elevation, 0)).y : elevation let worldPoint: [number, number, number] | null = null @@ -87,7 +151,117 @@ export function resolvePointerSupportSurface( localPoint = [pointScratch.x, pointScratch.y, pointScratch.z] } - return { elevation, worldY, worldPoint, localPoint } + if (options?.includeNodeTopSurfaces) { + nodeTopRaycaster.set(worldRayOrigin, worldRayDirection.clone().normalize()) + const nodes = useScene.getState().nodes + const registeredOwners = new Map( + [...sceneRegistry.nodes.entries()].map(([nodeId, object]) => [object, nodeId as AnyNodeId]), + ) + const belongsToActiveLevel = (nodeId: AnyNodeId) => { + let current = nodes[nodeId] + const visited = new Set() + while (current && !visited.has(current.id)) { + if (current.id === levelId) return true + visited.add(current.id) + current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined + } + return false + } + + const pointedDistance = worldPoint + ? worldRayOrigin.distanceTo(pointScratch.set(...worldPoint)) + : Number.POSITIVE_INFINITY + let nearest: + | { + distance: number + nodeId: AnyNodeId + point: Vector3 + } + | undefined + + for (const kind of NODE_TOP_SURFACE_KINDS) { + for (const rawId of sceneRegistry.byType[kind] ?? []) { + const nodeId = rawId as AnyNodeId + const node = nodes[nodeId] + const object = sceneRegistry.nodes.get(nodeId) + if (!(node?.visible && object?.visible && belongsToActiveLevel(nodeId))) continue + if ( + node.type === 'item' && + (!canHostOnTop(node) || isLowProfileItemSurface(node as ItemNode)) + ) { + continue + } + + for (const intersection of nodeTopRaycaster.intersectObject(object, true)) { + if ( + intersection.distance >= (nearest?.distance ?? pointedDistance) || + !intersection.face + ) { + continue + } + let ownerObject: Object3D | null = intersection.object + let belongsToNestedNode = false + while (ownerObject && ownerObject !== object) { + const ownerId = registeredOwners.get(ownerObject) + if (ownerId && ownerId !== nodeId) { + belongsToNestedNode = true + break + } + ownerObject = ownerObject.parent + } + if (belongsToNestedNode) continue + nodeTopNormal + .copy(intersection.face.normal) + .applyNormalMatrix(nodeTopNormalMatrix.getNormalMatrix(intersection.object.matrixWorld)) + .normalize() + if (nodeTopNormal.y < 0.75) continue + nearest = { + distance: intersection.distance, + nodeId, + point: intersection.point.clone(), + } + } + } + } + + if (nearest) { + const sourceNode = nodes[nearest.nodeId] + pointScratch.copy(nearest.point) + const nodeWorldPoint: [number, number, number] = [ + pointScratch.x, + pointScratch.y, + pointScratch.z, + ] + if (levelMesh) levelMesh.worldToLocal(pointScratch) + elevation = pointScratch.y + + pointScratch.copy(nearest.point) + const buildingId = useViewer.getState().selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + if (buildingMesh) buildingMesh.worldToLocal(pointScratch) + + return { + elevation, + supportSlabId: + sourceNode && 'supportSlabId' in sourceNode + ? ((sourceNode.supportSlabId as string | undefined) ?? null) + : null, + sourceNodeId: nearest.nodeId, + worldY: nearest.point.y, + worldPoint: nodeWorldPoint, + localPoint: [pointScratch.x, pointScratch.y, pointScratch.z], + } + } + } + + return { + elevation, + supportSlabId: pointed.slabId ?? GROUND_SUPPORT_ID, + sourceNodeId: null, + worldY, + worldPoint, + localPoint, + } } /** {@link resolvePointerSupportSurface}, elevation only — the election cap. */ diff --git a/packages/editor/src/components/tools/site/site-boundary-editor.tsx b/packages/editor/src/components/tools/site/site-boundary-editor.tsx index a3d942e89..230d3f544 100644 --- a/packages/editor/src/components/tools/site/site-boundary-editor.tsx +++ b/packages/editor/src/components/tools/site/site-boundary-editor.tsx @@ -447,9 +447,12 @@ export const SiteBoundaryEditor: React.FC = () => { const activateSiteEditing = useCallback(() => { isDraggingSiteBoundaryRef.current = true setIsDraggingSiteBoundary(true) - if (useEditor.getState().phase !== 'site') { - useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null }) - } + const editor = useEditor.getState() + // A boundary drag takes ownership away from the terrain brush. Going + // through setMode releases the sustained sculpting scope before the + // PolygonEditor begins its handle-drag scope. + if (editor.mode !== 'select') editor.setMode('select') + if (editor.phase !== 'site') editor.setPhase('site') selectSiteFloorplanContext() }, []) @@ -461,10 +464,14 @@ export const SiteBoundaryEditor: React.FC = () => { }, []) useEffect(() => { - if (!isSiteEditing) return + // Terrain sculpting also lives in the site phase so the 3D flags stay + // available, but its ordinary terrain clicks belong to the brush. Only + // select mode treats an empty site-grid click as "leave site editing". + if (!isSiteEditing || mode !== 'select') return const onGridClick = () => { - if (useEditor.getState().phase !== 'site') return + const editor = useEditor.getState() + if (editor.phase !== 'site' || editor.mode !== 'select') return exitSiteEditing() } @@ -472,7 +479,7 @@ export const SiteBoundaryEditor: React.FC = () => { return () => { emitter.off('grid:click', onGridClick) } - }, [exitSiteEditing, isSiteEditing]) + }, [exitSiteEditing, isSiteEditing, mode]) const renderSiteFlagVertex = useCallback( ({ diff --git a/packages/editor/src/components/tools/site/stroke-pointer-ownership.test.ts b/packages/editor/src/components/tools/site/stroke-pointer-ownership.test.ts new file mode 100644 index 000000000..f176a69e6 --- /dev/null +++ b/packages/editor/src/components/tools/site/stroke-pointer-ownership.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'bun:test' +import { + type StrokePointerAction, + type StrokePointerEvent, + strokePointerAction, +} from './stroke-pointer-ownership' + +/** + * A stroke carries uncommitted state, so every one of these has a silent failure + * mode rather than a visible one — which is why the matrix is asserted rather + * than left to the four call sites. + */ +describe('strokePointerAction', () => { + const OWNER = 1 + const OTHER = 2 + const act = (event: StrokePointerEvent, pointerId: number, ownerPointerId: number | null) => + strokePointerAction({ event, pointerId, ownerPointerId }) + + describe('with no stroke in flight', () => { + test('a down starts one', () => { + expect(act('down', OWNER, null)).toBe('begin') + }) + + test('every other event is ignored', () => { + // Notably `up`: the pointerup of a click that never began a stroke (over the + // eyedropper, or off the field) must not commit anything. + for (const event of ['move', 'up', 'cancel'] as StrokePointerEvent[]) { + expect(act(event, OWNER, null)).toBe('ignore') + } + }) + }) + + describe('the owning pointer drives the stroke', () => { + test('move paints, up commits', () => { + expect(act('move', OWNER, OWNER)).toBe('apply') + expect(act('up', OWNER, OWNER)).toBe('commit') + }) + + test('cancel abandons where up commits', () => { + // The browser fires `cancel` when it takes the gesture away, so the user + // never finished the stroke. These two must not be the same handler — they + // were, and a cancelled drag persisted. + expect(act('cancel', OWNER, OWNER)).toBe('abandon') + expect(act('cancel', OWNER, OWNER)).not.toBe(act('up', OWNER, OWNER)) + }) + }) + + describe('a second pointer', () => { + test('down abandons the stroke in flight', () => { + // Two fingers is a pinch-zoom. Abandoning restores the ground exactly, + // because nothing was committed yet. + expect(act('down', OTHER, OWNER)).toBe('abandon') + }) + + test('never touches the owner’s stroke any other way', () => { + for (const event of ['move', 'up', 'cancel'] as StrokePointerEvent[]) { + expect(act(event, OTHER, OWNER)).toBe('ignore') + } + }) + + test('lifting it does not commit — the bug this replaced', () => { + // The old handler keyed off `strokeRef` alone, so a resting finger lifting + // committed a stroke the other finger was still drawing. + expect(act('up', OTHER, OWNER)).not.toBe('commit') + }) + }) + + test('only a down can begin, and only an owning up can commit', () => { + // Swept over the whole matrix rather than spot-checked: these are the two + // actions that write to the scene, so anything else reaching them is a bug. + const events: StrokePointerEvent[] = ['down', 'move', 'up', 'cancel'] + const owners: Array = [null, OWNER] + const seen: Array<[StrokePointerEvent, number, number | null, StrokePointerAction]> = [] + for (const event of events) { + for (const pointerId of [OWNER, OTHER]) { + for (const ownerPointerId of owners) { + seen.push([event, pointerId, ownerPointerId, act(event, pointerId, ownerPointerId)]) + } + } + } + expect(seen.filter(([, , , action]) => action === 'begin').map(([event]) => event)).toEqual([ + 'down', + 'down', + ]) + expect( + seen + .filter(([, , , action]) => action === 'commit') + .map(([event, pointerId, ownerPointerId]) => [event, pointerId === ownerPointerId]), + ).toEqual([['up', true]]) + }) +}) diff --git a/packages/editor/src/components/tools/site/stroke-pointer-ownership.ts b/packages/editor/src/components/tools/site/stroke-pointer-ownership.ts new file mode 100644 index 000000000..d44a2f4b7 --- /dev/null +++ b/packages/editor/src/components/tools/site/stroke-pointer-ownership.ts @@ -0,0 +1,57 @@ +/** + * Which pointer a terrain stroke belongs to, and what every other one does. + * + * A stroke is a sustained drag with uncommitted state, so "who owns the pointer" + * is a real decision rather than plumbing — and each wrong answer fails silently + * in its own way. The tool used to have no answer at all: every branch below fell + * through to the owning-pointer path. + * + * Extracted as a pure function because the interesting part is the *matrix*, which + * cannot be asserted through the component — reproducing it needs a WebGL canvas, + * a camera, and synthetic multi-touch. The tool keeps the DOM plumbing. + */ +export type StrokePointerAction = + /** Start a stroke owned by this pointer. */ + | 'begin' + /** Route the event into the active stroke. */ + | 'apply' + /** Drop the stroke, restoring the pre-stroke ground. */ + | 'abandon' + /** Finish and persist the stroke. */ + | 'commit' + /** Not our pointer, or nothing in flight. */ + | 'ignore' + +export type StrokePointerEvent = 'down' | 'move' | 'up' | 'cancel' + +/** + * `ownerPointerId` is `null` when no stroke is in flight. + * + * The rules, in the order they matter: + * + * - **A second `down` abandons.** Two fingers on the glass is a pinch-zoom, and by + * then the first finger has already deposited a dab; abandoning is what makes + * "pinch to look closer" leave no accidental bump. Falling through was the worst + * of the options: it orphaned an uncommitted stroke whose dabs were already + * baked into the live field's snapshot, so they survived on screen while being + * unreachable by undo. + * - **A non-owning `up` is ignored, not a commit.** Lifting a resting second finger + * would otherwise persist a stroke the other finger is still drawing. + * - **`cancel` abandons where `up` commits.** The browser fires `cancel` when it + * takes the gesture away (OS edge swipe, palm rejection), meaning the user never + * finished — committing there persists a half-drag nobody released. + */ +export function strokePointerAction(args: { + event: StrokePointerEvent + pointerId: number + ownerPointerId: number | null +}): StrokePointerAction { + const { event, pointerId, ownerPointerId } = args + + if (event === 'down') { + return ownerPointerId === null ? 'begin' : 'abandon' + } + if (ownerPointerId === null || pointerId !== ownerPointerId) return 'ignore' + if (event === 'move') return 'apply' + return event === 'cancel' ? 'abandon' : 'commit' +} diff --git a/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx b/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx new file mode 100644 index 000000000..18c3e0f44 --- /dev/null +++ b/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx @@ -0,0 +1,247 @@ +'use client' + +import { + minBrushRadius, + raycastTerrain, + type SiteNode, + surfaceHeightAt, + type TerrainField, + terrainFieldOf, + useLiveTerrain, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import { type MutableRefObject, useEffect, useMemo, useRef } from 'react' +import { + BufferGeometry, + Float32BufferAttribute, + type Group, + type Line, + Raycaster, + Vector2, +} from 'three' +import { EDITOR_LAYER } from '../../../lib/constants' +import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt' +import { brushRingColor } from '../../../lib/terrain-verb-color' +import useEditor from '../../../store/use-editor' +import { formatMeasurement } from '../../editor/measurement-pill' + +/** Segments in the cursor ring. 64 reads as a circle at any usable brush size. */ +const RING_SEGMENTS = 64 + +/** + * Lift above the surface, so the ring is not z-fighting the ground it traces. + * + * Two centimetres only works because the ring samples `surfaceHeightAt` — the + * plane of the triangle under each point — and not the bilinear `heightAt`. Those + * differ by up to a quarter of a cell's warp, which on steep ground is decimetres, + * so a bilinear ring would submerge on half of every cell no matter how large this + * gets. `depthTest={false}` hides that on screen but not in the exported GLB. + */ +const RING_LIFT = 0.02 + +/** The ring is decoration; it must never intercept a pointer ray. */ +const NO_RAYCAST = () => null + +export type TerrainBrushFocus = { + radius: number + x: number + z: number +} + +/** + * The brush ring, drawn *on the terrain surface* rather than as a flat disc. + * + * Following the surface is the whole point: a flat ring on sloped ground tells + * the user nothing about what the brush will touch, because the footprint is a + * cylinder in XZ and the interesting question is which part of the slope falls + * inside it. Sampling the ring's height per segment answers that directly. + * + * Updated in `useFrame` from a ref, never through React state — the ring tracks + * the pointer, and routing that through a re-render would re-run every hook in + * the tool subtree at pointer rate. + * + * On `EDITOR_LAYER`, non-negotiably: an overlay mesh left on the scene layer + * poisons the MRT scene pass and makes FrontSide geometry render see-through. + */ +export const TerrainBrushCursor: React.FC<{ + focusRef: MutableRefObject + site: SiteNode +}> = ({ focusRef, site }) => { + const { camera, gl } = useThree() + const radius = useEditor((state) => state.terrainBrush.radius) + const shape = useEditor((state) => state.terrainBrush.shape) + // The ring is the only thing at the point of action that can say what the next + // drag will do — its geometry is identical for all four verbs, so colour is the + // one channel left. Keep the mapping centralized in `lib/terrain-verb-color`. + const verb = useEditor((state) => state.terrainVerb) + const sampling = useEditor((state) => state.terrainSampling) + const color = brushRingColor(verb, sampling) + const unit = useViewer((state) => state.unit) + + const lineRef = useRef(null) + const centerRef = useRef(null) + const heightRef = useRef(null) + const lastHeightLabelRef = useRef('') + const pointerRef = useRef<{ x: number; y: number } | null>(null) + const raycaster = useRef(new Raycaster()) + const ndc = useRef(new Vector2()) + + const geometry = useMemo(() => { + const positions = new Float32Array((RING_SEGMENTS + 1) * 3) + const buffer = new BufferGeometry() + buffer.setAttribute('position', new Float32BufferAttribute(positions, 3)) + return buffer + }, []) + useEffect(() => () => geometry.dispose(), [geometry]) + + useEffect(() => { + const canvas = gl.domElement + const track = (event: PointerEvent) => { + const rect = canvas.getBoundingClientRect() + pointerRef.current = { + x: ((event.clientX - rect.left) / rect.width) * 2 - 1, + y: -((event.clientY - rect.top) / rect.height) * 2 + 1, + } + } + const freeze = () => { + pointerRef.current = null + } + canvas.addEventListener('pointermove', track) + canvas.addEventListener('pointerleave', freeze) + return () => { + canvas.removeEventListener('pointermove', track) + canvas.removeEventListener('pointerleave', freeze) + } + }, [gl]) + + useFrame(() => { + const line = lineRef.current + const center = centerRef.current + const pointer = pointerRef.current + if (!(line && center)) return + if (!(pointer || focusRef.current)) { + line.visible = false + center.visible = false + if (heightRef.current) heightRef.current.style.display = 'none' + return + } + + // Two fields, deliberately. Aiming uses the stroke's *snapshot* so the ring + // sits exactly where the next dab will land — the tool raycasts the snapshot + // too, and a cursor that disagreed with the brush by even a few centimetres + // would read as lag. Drawing uses the live field so the ring drapes over the + // hill as it rises. Outside a stroke the two are the same field. + const stroke = useLiveTerrain.getState().strokeOf(site.id) + const surface: TerrainField = stroke?.field ?? terrainFieldOf(site) ?? sculptFieldForSite(site) + const aim: TerrainField = stroke?.snapshot ?? surface + + let hit: { x: number; z: number } | null = null + if (pointer) { + ndc.current.set(pointer.x, pointer.y) + raycaster.current.setFromCamera(ndc.current, camera) + const { origin, direction } = raycaster.current.ray + hit = raycastTerrain( + aim, + [origin.x, origin.y, origin.z], + [direction.x, direction.y, direction.z], + ) + if (hit && terrainPointInsideSite(site, hit.x, hit.z)) { + focusRef.current = { radius: Math.max(radius, minBrushRadius(aim)), x: hit.x, z: hit.z } + } else { + hit = null + focusRef.current = null + } + } else if (focusRef.current) { + hit = terrainPointInsideSite(site, focusRef.current.x, focusRef.current.z) + ? focusRef.current + : null + } + if (!hit) { + line.visible = false + center.visible = false + if (heightRef.current) heightRef.current.style.display = 'none' + return + } + line.visible = true + center.visible = true + + const attribute = line.geometry.getAttribute('position') + const array = attribute.array as Float32Array + // The same floor the tool applies at pointer-down. Drawing the stored radius + // instead would draw a ring smaller than the footprint the next dab actually + // gets — on a coarse lot the ring would be the one thing insisting the brush + // is 2 m wide while the stroke is 1.5 spacings wide. + const drawn = Math.max(radius, minBrushRadius(aim)) + focusRef.current = { radius: drawn, x: hit.x, z: hit.z } + for (let index = 0; index <= RING_SEGMENTS; index++) { + const angle = (index / RING_SEGMENTS) * Math.PI * 2 + // The square brush uses Chebyshev distance, so its rim is a square: scale + // the unit circle out to the box so the ring shows the actual footprint + // rather than an inscribed circle that under-reports it. + const cos = Math.cos(angle) + const sin = Math.sin(angle) + const scale = + shape === 'square' ? 1 / Math.max(Math.abs(cos), Math.abs(sin), Number.EPSILON) : 1 + const x = hit.x + cos * drawn * scale + const z = hit.z + sin * drawn * scale + array[index * 3] = x + array[index * 3 + 1] = surfaceHeightAt(surface, x, z) + RING_LIFT + array[index * 3 + 2] = z + } + attribute.needsUpdate = true + line.geometry.computeBoundingSphere() + + const centerHeight = surfaceHeightAt(surface, hit.x, hit.z) + center.position.set(hit.x, centerHeight + RING_LIFT, hit.z) + const heightLabel = `H ${formatMeasurement(centerHeight, unit)}` + if (heightRef.current) { + heightRef.current.style.display = '' + if (heightLabel !== lastHeightLabelRef.current) { + heightRef.current.textContent = heightLabel + lastHeightLabelRef.current = heightLabel + } + } + }) + + return ( + <> + element conflicts with SVG type + ref={lineRef} + renderOrder={13} + > + + + + + + + + + + + + + ) +} + +export default TerrainBrushCursor diff --git a/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx b/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx new file mode 100644 index 000000000..746049351 --- /dev/null +++ b/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx @@ -0,0 +1,192 @@ +'use client' + +import { + type HeightPatch, + type SiteNode, + type TerrainField, + terrainFieldOf, + useLiveTerrain, + useScene, +} from '@pascal-app/core' +import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' +import { type MutableRefObject, useEffect, useMemo, useRef } from 'react' +import { BufferAttribute, BufferGeometry, type LineSegments, Vector2 } from 'three' +import { float, positionLocal, smoothstep, uniform } from 'three/tsl' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt' +import type { TerrainBrushFocus } from './terrain-brush-cursor' + +const GRID_LIFT = 0.012 +const NO_RAYCAST = () => null + +function writeGridHeights( + attribute: BufferAttribute, + field: TerrainField, + patch: HeightPatch | null, +): void { + const col0 = patch ? Math.max(0, patch.col0) : 0 + const row0 = patch ? Math.max(0, patch.row0) : 0 + const col1 = patch ? Math.min(field.cols, patch.col0 + patch.cols) : field.cols + const row1 = patch ? Math.min(field.rows, patch.row0 + patch.rows) : field.rows + if (col0 >= col1 || row0 >= row1) return + + const positions = attribute.array as Float32Array + for (let row = row0; row < row1; row += 1) { + for (let col = col0; col < col1; col += 1) { + const sampleIndex = row * field.cols + col + positions[sampleIndex * 3 + 1] = (field.heights[sampleIndex] ?? 0) * field.step + GRID_LIFT + } + } + + attribute.clearUpdateRanges() + const first = (row0 * field.cols + col0) * 3 + const last = ((row1 - 1) * field.cols + (col1 - 1)) * 3 + 2 + attribute.addUpdateRange(first, last - first + 1) + attribute.needsUpdate = true +} + +function createGridGeometry(field: TerrainField, site: Pick): BufferGeometry { + const geometry = new BufferGeometry() + const positions = new Float32Array(field.cols * field.rows * 3) + const indices: number[] = [] + + for (let row = 0; row < field.rows; row += 1) { + for (let col = 0; col < field.cols; col += 1) { + const sampleIndex = row * field.cols + col + positions[sampleIndex * 3] = field.origin[0] + col * field.spacing + positions[sampleIndex * 3 + 2] = field.origin[1] + row * field.spacing + } + } + + let indexOffset = 0 + for (let row = 0; row < field.rows; row += 1) { + for (let col = 0; col < field.cols - 1; col += 1) { + const sampleIndex = row * field.cols + col + const x = field.origin[0] + col * field.spacing + const z = field.origin[1] + row * field.spacing + if ( + terrainPointInsideSite(site, x, z) && + terrainPointInsideSite(site, x + field.spacing, z) + ) { + indices[indexOffset++] = sampleIndex + indices[indexOffset++] = sampleIndex + 1 + } + } + } + for (let col = 0; col < field.cols; col += 1) { + for (let row = 0; row < field.rows - 1; row += 1) { + const sampleIndex = row * field.cols + col + const x = field.origin[0] + col * field.spacing + const z = field.origin[1] + row * field.spacing + if ( + terrainPointInsideSite(site, x, z) && + terrainPointInsideSite(site, x, z + field.spacing) + ) { + indices[indexOffset++] = sampleIndex + indices[indexOffset++] = sampleIndex + field.cols + } + } + } + + const positionAttribute = new BufferAttribute(positions, 3) + geometry.setAttribute('position', positionAttribute) + geometry.setIndex(indices) + writeGridHeights(positionAttribute, field, null) + geometry.computeBoundingSphere() + return geometry +} + +/** + * A terrain-following lattice shown for the entire lifetime of sculpt mode. + * + * The ordinary editor grid is a planar snapping aid, so making it visible here + * would leave it cutting through hills and hiding below excavations. This grid + * shares the terrain samples instead: every row and column visibly bends with + * the surface, and live dabs rewrite only the affected height span. It stays on + * `GRID_LAYER` so scene geometry depth-occludes it instead of the overlay pass + * compositing it through walls and objects. + */ +export function TerrainSculptGrid({ + focusRef, + site, +}: { + focusRef: MutableRefObject + site: SiteNode +}) { + const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance) + const targetRef = useRef(null) + + const field = useMemo(() => sculptFieldForSite(site), [site]) + const geometry = useMemo(() => createGridGeometry(field, site), [field, site]) + useEffect(() => () => geometry.dispose(), [geometry]) + + const focusCenter = useMemo(() => uniform(new Vector2()), []) + const focusRadius = useMemo(() => uniform(1), []) + const focusVisible = useMemo(() => uniform(0), []) + const material = useMemo(() => { + const baseOpacity = appearance === 'dark' ? 0.07 : 0.05 + const focusBoost = appearance === 'dark' ? 0.11 : 0.09 + const distance = positionLocal.xz.sub(focusCenter).length() + const focus = float(1) + .sub(smoothstep(focusRadius.mul(1.05), focusRadius.mul(2.5), distance)) + .mul(focusVisible) + + return new LineBasicNodeMaterial({ + color: appearance === 'dark' ? '#cbd5e1' : '#475569', + depthTest: true, + depthWrite: false, + opacityNode: float(baseOpacity).add(focus.mul(focusBoost)), + transparent: true, + }) + }, [appearance, focusCenter, focusRadius, focusVisible]) + useEffect(() => () => material.dispose(), [material]) + + useFrame(() => { + const focus = focusRef.current + focusVisible.value = focus ? 1 : 0 + if (!focus) return + focusCenter.value.set(focus.x, focus.z) + focusRadius.value = focus.radius + }) + + useEffect(() => { + let lastPatch = useLiveTerrain.getState().strokeOf(site.id)?.lastPatch ?? null + + return useLiveTerrain.subscribe((state) => { + const target = targetRef.current + if (!target) return + const attribute = target.geometry.getAttribute('position') as BufferAttribute + const stroke = state.strokes.get(site.id) + + if (stroke?.lastPatch && stroke.lastPatch !== lastPatch) { + lastPatch = stroke.lastPatch + writeGridHeights(attribute, stroke.field, stroke.lastPatch) + target.geometry.computeBoundingSphere() + return + } + + if (!stroke && lastPatch) { + lastPatch = null + const current = useScene.getState().nodes[site.id] + if (current?.type !== 'site') return + const restored = terrainFieldOf(current) ?? sculptFieldForSite(current) + if (restored.cols !== field.cols || restored.rows !== field.rows) return + writeGridHeights(attribute, restored, null) + target.geometry.computeBoundingSphere() + } + }) + }, [field.cols, field.rows, site.id]) + + return ( + + ) +} diff --git a/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx b/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx new file mode 100644 index 000000000..a2c8b434f --- /dev/null +++ b/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx @@ -0,0 +1,356 @@ +'use client' + +import { + advanceStroke, + applyHeightPatch, + beginStroke, + detachStrokeAnchor, + emitter, + minBrushRadius, + raycastTerrain, + type SiteNode, + type TerrainField, + type TerrainStroke, + useLiveTerrain, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { Raycaster, Vector2 } from 'three' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { sfxEmitter } from '../../../lib/sfx-bus' +import { + clipTerrainPatchToSite, + commitStroke, + resolveFlattenTarget, + sculptFieldForSite, + terrainPointInsideSite, +} from '../../../lib/terrain-sculpt' +import useEditor from '../../../store/use-editor' +import { isBoxSelectPointerSuppressed } from '../select/box-select-state' +import { strokePointerAction } from './stroke-pointer-ownership' +import { TerrainBrushCursor, type TerrainBrushFocus } from './terrain-brush-cursor' +import { TerrainSculptGrid } from './terrain-sculpt-grid' + +/** + * The sculpt tool: pointer motion → terrain, while `mode === 'terrain-sculpt'`. + * + * Pointer handling is DOM-level on the canvas rather than R3F mesh events, for + * the same reason `useGridEvents` is: a mesh handler can be swallowed by any + * geometry that calls `stopPropagation`, and the ground is precisely the surface + * that everything else sits on top of. Sculpting must reach it even with a wall + * under the cursor. + * + * Four properties are what keep this from fighting the rest of the editor: + * + * - It is a *mode*, so it is mutually exclusive with select/build/delete/paint + * by construction. Nothing else can be armed at the same time. + * - The selection managers all early-return unless `mode === 'select'`, so while + * the brush is armed a click cannot select, move, or reshape a node — no + * matter what geometry is under it. + * - The `sculpting` interaction scope is held by the mode, not by this + * component's drag, so every other object's handles and the floating action + * menu stay stepped back between dabs rather than flickering back. + * - Camera drags are respected: `cameraDragging` suppresses dabs at pointer-down + * *and* per dab, so neither starting a drag as an orbit nor zooming mid-stroke + * carves the terrain. The stroke commits once, as one undo step, on pointer-up. + */ +export const TerrainSculptTool: React.FC = () => { + const { camera, gl } = useThree() + const brushFocusRef = useRef(null) + const nodes = useScene((state) => state.nodes) + const rootNodeIds = useScene((state) => state.rootNodeIds) + const verb = useEditor((state) => state.terrainVerb) + const brush = useEditor((state) => state.terrainBrush) + const flattenTarget = useEditor((state) => state.terrainFlattenTarget) + const sampling = useEditor((state) => state.terrainSampling) + + const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null + const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null + + // Everything the pointer handlers need, mirrored into refs so the listener + // effect depends only on the canvas and camera. Re-attaching listeners because + // the brush radius changed would drop an in-flight stroke. + const latest = useRef({ site, verb, brush, flattenTarget, sampling }) + latest.current = { site, verb, brush, flattenTarget, sampling } + + const strokeRef = useRef<{ + stroke: TerrainStroke + field: TerrainField + siteId: string + /** The pointer that owns this stroke; every other one is ignored. */ + pointerId: number + } | null>(null) + const raycaster = useRef(new Raycaster()) + const pointer = useRef(new Vector2()) + + useEffect(() => { + const canvas = gl.domElement + + /** + * Where the pointer meets the ground, through the terrain raycast. + * + * `field` is passed in rather than read from the store so a stroke keeps + * hitting the surface it started on. Using the live (already-sculpted) field + * would make the cursor climb the hill it is building — raise the ground and + * the aim point walks toward the camera, which reads as the brush sliding + * out from under the mouse. + */ + const groundPoint = ( + event: PointerEvent | MouseEvent, + field: TerrainField, + ): [number, number] | null => { + const rect = canvas.getBoundingClientRect() + pointer.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + pointer.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 + raycaster.current.setFromCamera(pointer.current, camera) + const { origin, direction } = raycaster.current.ray + const hit = raycastTerrain( + field, + [origin.x, origin.y, origin.z], + [direction.x, direction.y, direction.z], + ) + const target = latest.current.site + return hit && target && terrainPointInsideSite(target, hit.x, hit.z) ? [hit.x, hit.z] : null + } + + /** Which pointer owns the stroke in flight, for `strokePointerAction`. */ + const ownerPointerId = () => strokeRef.current?.pointerId ?? null + + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + if (useEditor.getState().mode !== 'terrain-sculpt') return + // Polygon handles run through R3F on this same canvas. They mark the + // pointer before this DOM listener sees it, so a flag/midpoint press can + // switch cleanly into boundary editing without also depositing a dab. + if (isBoxSelectPointerSuppressed(event)) return + // A second pointer means the gesture was navigation, not sculpting: one + // finger paints (`editorOwnsOneFingerDrag` gives the editor one-finger + // priority in this mode) but two fingers always pinch-zoom, and by the time + // the second lands the first has already deposited a dab. Abandoning is what + // makes "pinch to look closer" free of an accidental bump — the dabs so far + // were never committed, so dropping the live stroke restores the ground + // exactly, the same as Escape. + if ( + strokePointerAction({ + event: 'down', + pointerId: event.pointerId, + ownerPointerId: ownerPointerId(), + }) === 'abandon' + ) { + abandonStroke() + return + } + // Orbiting over the ground must not carve it. `cameraDragging` is the same + // flag the grid events and node events gate on, so a middle/right-drag or a + // modifier orbit behaves identically here. + if (useViewer.getState().cameraDragging) return + const { + site: target, + verb: activeVerb, + brush: settings, + flattenTarget: explicit, + } = latest.current + if (!target) return + + const field = sculptFieldForSite(target) + const point = groundPoint(event, field) + if (!point) return + + // Eyedropper: sample and consume the click. Sampling *is* the gesture, so + // it must not also deposit a dab. + if (latest.current.sampling) { + useEditor.getState().setTerrainFlattenTarget(resolveFlattenTarget(field, null, ...point)) + return + } + + // Last line of defence on the radius. The controls clamp to the same floor, + // but the *stored default* is 2 m and the floor scales with the field's + // spacing — so a coarse lot (`fieldExtentForSite` stretches it past 1.3 m + // once a lot passes ~300 m) would meet a first-ever stroke that resolves to + // no samples and paints nothing, before the user has touched any control. + // Clamping against the field in hand rather than the node means this is + // right even for an imported DEM at its own resolution. + const stroke = beginStroke({ + field, + verb: activeVerb, + settings: { ...settings, radius: Math.max(settings.radius, minBrushRadius(field)) }, + target: + activeVerb === 'flatten' ? resolveFlattenTarget(field, explicit, ...point) : undefined, + }) + strokeRef.current = { + stroke, + field, + siteId: target.id, + pointerId: event.pointerId, + } + useLiveTerrain.getState().begin(target.id, field) + sfxEmitter.emit('sfx:terrain-sculpt-start', activeVerb) + + // Capture so a stroke that leaves the canvas still ends. Without this a + // drag released outside the viewport would leave the live stroke armed and + // the terrain uncommitted. + canvas.setPointerCapture(event.pointerId) + applyDab(event) + } + + const applyDab = (event: PointerEvent) => { + const active = strokeRef.current + if (!active) return + // Re-checked per dab, not just at pointer-down: a wheel or trackpad zoom + // mid-stroke sets `cameraDragging` and releases it on a 500 ms timer + // (`scheduleEnd`), all while the pointer is still down and painting. The + // camera moves under a held brush, so every dab in that window lands + // somewhere the user did not aim — a smear across the lot from one scroll. + // Skipping the dab rather than ending the stroke keeps zoom-then-continue + // working, which is how anyone sculpts a slope they need to see closer. + // + // Dropping the anchor is the other half, and skipping alone would be worse + // than not skipping: dab spacing is an arc length, so `advanceStroke` would + // bridge from the pre-zoom centre to wherever the pointer resurfaced and + // paint the entire smear in one call. + if (useViewer.getState().cameraDragging) { + detachStrokeAnchor(active.stroke) + return + } + const point = groundPoint(event, active.field) + if (!point) return + const brushPatch = advanceStroke(active.stroke, point[0], point[1]) + if (!brushPatch) return + const target = latest.current.site + if (!target || target.id !== active.siteId) return + const patch = clipTerrainPatchToSite(active.field, brushPatch, target) + const next = applyHeightPatch(active.field, patch) + // The stroke's *snapshot* stays the pointer-down field (that is what makes + // it saturating); `active.field` is only the running result the mesh shows. + active.field = next + useLiveTerrain.getState().advance(active.siteId, next, patch) + } + + const handlePointerMove = (event: PointerEvent) => { + // Only the owning pointer paints. A second finger resting on the glass + // still emits `pointermove`, and letting those through would drag the + // stroke between two contact points on every frame. + const action = strokePointerAction({ + event: 'move', + pointerId: event.pointerId, + ownerPointerId: ownerPointerId(), + }) + if (action !== 'apply') return + applyDab(event) + } + + const handlePointerUp = (event: PointerEvent) => { + const active = strokeRef.current + // Lifting a non-owning finger must not commit the owner's stroke, which is + // still in progress under the finger that is still down. + if ( + !active || + strokePointerAction({ + event: 'up', + pointerId: event.pointerId, + ownerPointerId: ownerPointerId(), + }) !== 'commit' + ) { + return + } + strokeRef.current = null + if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId) + sfxEmitter.emit('sfx:terrain-sculpt-stop') + + // Commit before ending the live stroke: `terrainFieldOf` prefers the live + // field, so ending first would flash one frame of the pre-stroke ground + // between the two writes. + commitStroke(active.siteId as SiteNode['id'], active.field) + useLiveTerrain.getState().end(active.siteId) + } + + /** + * Drop the in-flight stroke, leaving the scene untouched — nothing was + * committed, so ending the live stroke restores the pre-stroke ground exactly. + * + * Shared by Escape/⌘Z and by a second pointer arriving. Releasing the capture + * matters as much as clearing the ref: the canvas keeps routing that pointer's + * events here until it is released, and the owning `pointerup` now early- + * returns on a null ref, so without this the capture outlived the gesture. + */ + const abandonStroke = () => { + const active = strokeRef.current + if (!active) return false + strokeRef.current = null + if (canvas.hasPointerCapture(active.pointerId)) { + canvas.releasePointerCapture(active.pointerId) + } + sfxEmitter.emit('sfx:terrain-sculpt-stop') + useLiveTerrain.getState().end(active.siteId) + return true + } + + /** + * On the `tool:cancel` bus rather than a raw `keydown`, and that detail is + * the fix rather than a style choice: the global Escape handler also listens + * on `window`, is registered first (it mounts at the app root, this tool + * mounts later), and its fall-through is `setMode('select')`. A window + * listener here would therefore run *after* the mode had already been + * exited. `tool:cancel` is emitted synchronously *before* that check, so + * `markToolCancelConsumed` still lands in time. + * + * The result is the two meanings of Escape stay separate: mid-stroke it + * abandons the dab and keeps the brush armed; with no stroke live it falls + * through and leaves terrain mode. Same contract every drafting tool uses + * (`slab/tool.tsx`). It also covers ⌘Z mid-stroke, which routes through the + * same emit — undoing a stroke in flight cancels it rather than jumping + * history to a baseline the stroke was never part of. + */ + const onCancel = () => { + // Only claim the key when there was something to abandon, or Escape with no + // stroke live would be swallowed and never exit the mode. + if (abandonStroke()) markToolCancelConsumed() + } + + // `pointercancel` abandons rather than commits, unlike `pointerup`: the + // browser fires it when it takes the gesture away (an OS edge swipe, palm + // rejection, the pointer being captured elsewhere), which means the user never + // finished the stroke. Committing there would persist a half-drag nobody + // released — and it was wired to `handlePointerUp` before, so it did. + const handlePointerCancel = (event: PointerEvent) => { + const action = strokePointerAction({ + event: 'cancel', + pointerId: event.pointerId, + ownerPointerId: ownerPointerId(), + }) + if (action === 'abandon') abandonStroke() + } + + canvas.addEventListener('pointerdown', handlePointerDown) + canvas.addEventListener('pointermove', handlePointerMove) + canvas.addEventListener('pointerup', handlePointerUp) + canvas.addEventListener('pointercancel', handlePointerCancel) + emitter.on('tool:cancel', onCancel) + + return () => { + canvas.removeEventListener('pointerdown', handlePointerDown) + canvas.removeEventListener('pointermove', handlePointerMove) + canvas.removeEventListener('pointerup', handlePointerUp) + canvas.removeEventListener('pointercancel', handlePointerCancel) + emitter.off('tool:cancel', onCancel) + // Unmounting mid-stroke (mode switch, hot reload) must not leave the live + // stroke armed — every reader prefers it over the persisted terrain, so a + // leaked stroke is a scene that shows ground the scene graph does not have. + abandonStroke() + sfxEmitter.emit('sfx:terrain-sculpt-stop') + } + }, [camera, gl]) + + if (!site) return null + + return ( + <> + + + + ) +} + +export default TerrainSculptTool diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 3b1b8068f..fed4e66d9 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -9,12 +9,14 @@ import { movingAlignmentAnchors, type NodeEvent, resolveAlignment, + resolveSupportSlabPatch, StairNode, StairSegmentNode, syncAutoStairOpenings, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -34,6 +36,7 @@ import useFacingPose from '../../../store/use-facing-pose' import { useStairBuildPreview } from '../../../store/use-stair-build-preview' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' +import { resolvePointerSupportSurface } from '../shared/pointer-support-cap' import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' import { DEFAULT_CURVED_STAIR_INNER_RADIUS, @@ -59,6 +62,7 @@ const GRID_OFFSET = 0.02 /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 type ClickTriggerEvent = GridEvent | NodeEvent +type MoveTriggerEvent = GridEvent | NodeEvent const CLICK_TRIGGER_KINDS = [ 'shelf', @@ -173,6 +177,7 @@ function commitStairPlacement( levelId: LevelNode['id'], position: [number, number, number], rotation: number, + supportElevationCap: number | null, ): void { const { createNodes, nodes } = useScene.getState() const placementLevelId = resolveStairPlacementLevelId( @@ -193,13 +198,27 @@ function commitStairPlacement( }) const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId - const stair = createDefaultStairNode({ - name, - levelId: placementLevelId, - nextLevelId, - position, - rotation, - segmentId: segment.id, + const stair = StairNode.parse({ + ...createDefaultStairNode({ + name, + levelId: placementLevelId, + nextLevelId, + position, + rotation, + segmentId: segment.id, + }), + parentId: placementLevelId, + }) + const prospectiveNodes = { + ...nodes, + [stair.id]: stair, + [segment.id]: { ...segment, parentId: stair.id }, + } as Record + const committedStair = StairNode.parse({ + ...stair, + ...resolveSupportSlabPatch(stair, prospectiveNodes, { + maxElevation: supportElevationCap, + }), }) const createdLevel = destinationPlan?.createdLevel @@ -210,17 +229,21 @@ function commitStairPlacement( createNodes([ ...levelCreateOps, - { node: stair, parentId: placementLevelId }, - { node: segment, parentId: stair.id }, + { node: committedStair, parentId: placementLevelId }, + { node: segment, parentId: committedStair.id }, ]) sfxEmitter.emit('sfx:structure-build') } export const StairTool: React.FC = () => { + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera const cursorRef = useRef(null) const previewRef = useRef(null) const rotationRef = useRef(0) + const supportCapRef = useRef(null) const previousGridPosRef = useRef<[number, number] | null>(null) const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null) const currentLevelId = useViewer((state) => state.selection.levelId) @@ -240,6 +263,7 @@ export const StairTool: React.FC = () => { useStairBuildPreview.getState().reset() if (previewRef.current) previewRef.current.rotation.y = 0 lastCanonicalPositionRef.current = null + supportCapRef.current = null const buildPreviewScene = (position: [number, number, number], rotation: number) => { const nodes = useScene.getState().nodes @@ -286,8 +310,12 @@ export const StairTool: React.FC = () => { // cheap because it has no opening sync). let lastPreviewKey: string | null = null - const applyDraftPreview = (position: [number, number, number], rotation: number) => { - const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}` + const applyDraftPreview = ( + position: [number, number, number], + rotation: number, + supportElevationCap: number | null, + ) => { + const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportElevationCap?.toFixed(3) ?? 'none'}` if (key === lastPreviewKey) return lastPreviewKey = key useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation) @@ -299,6 +327,7 @@ export const StairTool: React.FC = () => { rotation, levelId: preview.placementLevelId, nodes: preview.previewNodes, + maxElevation: supportElevationCap, }) : position if (cursorRef.current) { @@ -394,25 +423,34 @@ export const StairTool: React.FC = () => { return [x, z] } - const onGridMove = (event: GridEvent) => { + const resolveStairPosition = (event: MoveTriggerEvent): [number, number, number] | null => { + const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) + supportCapRef.current = pointed?.elevation ?? null + const fallbackPosition = + 'node' in event ? lastCanonicalPositionRef.current : event.localPosition + if (!pointed?.localPoint && !fallbackPosition) return null + const rawX = pointed?.localPoint?.[0] ?? fallbackPosition![0] + const rawZ = pointed?.localPoint?.[2] ?? fallbackPosition![2] // Grid snap follows the global mode (live step so the HUD chip is // honest); Off keeps the raw cursor. Shift cycles the mode centrally. const step = useEditor.getState().gridSnapStep const [gridX, gridZ] = alignPoint( - isGridSnapActive() - ? Math.round(event.localPosition[0] / step) * step - : event.localPosition[0], - isGridSnapActive() - ? Math.round(event.localPosition[2] / step) * step - : event.localPosition[2], - event.localPosition[0], - event.localPosition[2], + isGridSnapActive() ? Math.round(rawX / step) * step : rawX, + isGridSnapActive() ? Math.round(rawZ / step) * step : rawZ, + rawX, + rawZ, !isAlignmentGuideActive(), isMagneticSnapActive(), ) - const position: [number, number, number] = [gridX, 0, gridZ] + return [gridX, 0, gridZ] + } + + const onPointerMove = (event: MoveTriggerEvent) => { + const position = resolveStairPosition(event) + if (!position) return + const [gridX, , gridZ] = position lastCanonicalPositionRef.current = position - applyDraftPreview(position, rotationRef.current) + applyDraftPreview(position, rotationRef.current, supportCapRef.current) if ( (isGridSnapActive() || isMagneticSnapActive()) && @@ -425,23 +463,6 @@ export const StairTool: React.FC = () => { previousGridPosRef.current = [gridX, gridZ] } - const getAlignedGridPosition = (event: GridEvent): [number, number, number] => { - const step = useEditor.getState().gridSnapStep - const [gridX, gridZ] = alignPoint( - isGridSnapActive() - ? Math.round(event.localPosition[0] / step) * step - : event.localPosition[0], - isGridSnapActive() - ? Math.round(event.localPosition[2] / step) * step - : event.localPosition[2], - event.localPosition[0], - event.localPosition[2], - !isAlignmentGuideActive(), - isMagneticSnapActive(), - ) - return [gridX, 0, gridZ] - } - const commitAtCursor = (event: ClickTriggerEvent) => { if (!currentLevelId) return // One physical click can reach here twice (node click synthesized on @@ -460,12 +481,10 @@ export const StairTool: React.FC = () => { swallowFollowUpBrowserClick() } - const position = nodeEvent - ? lastCanonicalPositionRef.current - : getAlignedGridPosition(event as GridEvent) + const position = resolveStairPosition(event) if (!position) return - commitStairPlacement(currentLevelId, position, rotationRef.current) + commitStairPlacement(currentLevelId, position, rotationRef.current, supportCapRef.current) openingPreview.clear() // Commit cleared the opening preview, so force the next hover (even on the // same cell) to rebuild rather than dedupe against the just-placed key. @@ -504,29 +523,38 @@ export const StairTool: React.FC = () => { sfxEmitter.emit('sfx:item-rotate') rotationRef.current += rotationDelta if (lastCanonicalPositionRef.current) { - applyDraftPreview(lastCanonicalPositionRef.current, rotationRef.current) + applyDraftPreview( + lastCanonicalPositionRef.current, + rotationRef.current, + supportCapRef.current, + ) } else if (previewRef.current) { previewRef.current.rotation.y = rotationRef.current } } } - emitter.on('grid:move', onGridMove) + emitter.on('grid:move', onPointerMove) emitter.on('grid:click', commitAtCursor) type SuffixedKey = `${K}:${EventSuffix}` type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]> + type MoveKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]> for (const kind of CLICK_TRIGGER_KINDS) { const key = `${kind}:click` as ClickKey emitter.on(key, commitAtCursor as never) + const moveKey = `${kind}:move` as MoveKey + emitter.on(moveKey, onPointerMove as never) } window.addEventListener('keydown', onKeyDown) return () => { - emitter.off('grid:move', onGridMove) + emitter.off('grid:move', onPointerMove) emitter.off('grid:click', commitAtCursor) for (const kind of CLICK_TRIGGER_KINDS) { const key = `${kind}:click` as ClickKey emitter.off(key, commitAtCursor as never) + const moveKey = `${kind}:move` as MoveKey + emitter.off(moveKey, onPointerMove as never) } window.removeEventListener('keydown', onKeyDown) useAlignmentGuides.getState().clear() diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 80d0a8d7e..08259b6f4 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -11,6 +11,7 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense, useMemo } from 'react' +import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary' import useEditor, { type Phase, type Tool } from '../../store/use-editor' import { useControlPointReshape, @@ -24,6 +25,7 @@ import { useTangentReshape, } from '../../store/use-interaction-scope' import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' +import { Elevation3DGuideLayer } from '../editor/elevation-3d-guide-layer' import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer' import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' import { ElevatorTool } from './elevator/elevator-tool' @@ -32,6 +34,7 @@ import { RoofTool } from './roof/roof-tool' import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { FacingPoseIndicator } from './shared/facing-pose-indicator' import { SiteBoundaryEditor } from './site/site-boundary-editor' +import { TerrainSculptTool } from './site/terrain-sculpt-tool' import { StairTool } from './stair/stair-tool' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneTool } from './zone/zone-tool' @@ -162,9 +165,14 @@ export const ToolManager: React.FC = () => { | CeilingNode['id'] | undefined - // Keep the site vertex flags available in select mode; the editor component - // switches to full polygon editing only after a flag activates site mode. - const showSiteBoundaryEditor = phase === 'site' || mode === 'select' + // Site boundary handles normally share one 2D/3D rule. Sculpt is the deliberate + // 3D exception: the brush only owns this canvas, where PolygonEditor can hand + // off its pointer before a boundary drag starts. + const sculpting = mode === 'terrain-sculpt' + // Sculpt keeps the 3D property controls visible. PolygonEditor marks its + // pointer before the canvas-level brush listener runs, and activating one + // exits sculpt mode before starting the boundary drag. + const showSiteBoundaryEditor = sculpting || siteBoundaryHandlesEnabled({ mode, phase }) // A multi-selection is manipulated as one rigid group (drag / R / T), so // per-node reshape chrome — the slab / ceiling boundary editors' vertex and @@ -256,6 +264,10 @@ export const ToolManager: React.FC = () => { <> {/* World-space tools: site boundary and building movement operate in world coordinates */} {showSiteBoundaryEditor && } + {/* Terrain sculpting is a mode rather than a `tools[phase][tool]` entry — + it places no node — so it gets its own gate here. World-space, because + the ground is not building-local. */} + {sculpting && } {showMover && movingNode?.type === 'building' && ( )} @@ -387,6 +399,9 @@ export const ToolManager: React.FC = () => { {/* Wall-plane proximity / sill / equal-spacing guides for openings, published by the door/window move tools in the same world frame. */} + {/* Structural Y-datum feedback for slab, ceiling, wall, and fence + elevation handles. Ephemeral editor chrome; never scene data. */} + {/* "Magnetic" beacon at the active wall-draft snap point. */} diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 40703ca04..f2e91c1a9 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -2,8 +2,14 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, + applyHeightPatch, + createTerrainField, DoorNode as DoorSchema, + encodeTerrainField, + flattenPatch, + GROUND_SUPPORT_ID, runAsSingleSceneHistoryStep, + spatialGridManager, useScene, type WallNode, WallNode as WallSchema, @@ -14,6 +20,7 @@ import useInteractionScope from '../../../store/use-interaction-scope' import { createWallOnCurrentLevel, resolveEndpointWallSplit, + resolveTerrainWallConstructionOptions, snapWallDraftPointDetailed, } from './wall-drafting' import type { WallPlanPoint } from './wall-snap-geometry' @@ -102,6 +109,83 @@ describe('createWallOnCurrentLevel', () => { expect(levelWalls()).toHaveLength(2) }) + test('committed wall preserves the ghost construction elevation on ground', () => { + const created = createWallOnCurrentLevel([2, 2], [3, 2], { + supportCap: 1.75, + preferredSupportSlabId: GROUND_SUPPORT_ID, + constructionElevation: 1.75, + constructionHeight: 2.5, + }) + + expect(created?.supportSlabId).toBe(GROUND_SUPPORT_ID) + expect(created?.supportOffset).toBe(1.75) + expect(created?.height).toBe(2.5) + const support = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + created?.start ?? [0, 0], + created?.end ?? [0, 0], + created?.curveOffset, + created?.thickness, + created?.supportSlabId, + undefined, + created?.supportOffset, + ) + expect(support.elevation).toBe(1.75) + }) + + test('2D terrain construction options freeze the first-point elevation and wall height', () => { + const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [-2, -2] }) + const patch = flattenPatch(field, { minX: -2, minZ: -2, maxX: 2, maxZ: 2 }, 1.5) + if (!patch) throw new Error('Expected terrain patch') + const terrain = applyHeightPatch(field, patch) + const site = { + id: 'site_test', + type: 'site', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + terrain: encodeTerrainField(terrain), + } as unknown 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 = { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: building.id, + visible: true, + metadata: {}, + children: [], + level: 0, + height: 3, + } as AnyNode + const nodes = Object.fromEntries([site, building, level].map((node) => [node.id, node])) + + expect(resolveTerrainWallConstructionOptions(nodes, LEVEL_ID, [0, 0])).toEqual({ + constructionElevation: 1.5, + constructionHeight: 3, + supportCap: 1.5, + }) + expect( + resolveTerrainWallConstructionOptions(nodes, LEVEL_ID, [0, 0], { height: 2.25 }), + ).toEqual({ + constructionElevation: 1.5, + constructionHeight: 2.25, + supportCap: 1.5, + }) + }) + test('endpoint near the host start corner snaps there without splitting', () => { const created = createWallOnCurrentLevel([2, 2], [0.015, 0]) @@ -326,6 +410,7 @@ describe('snapWallDraftPointDetailed', () => { expect(result.point).toEqual([3.99, 0.03]) expect(result.snap).toBeNull() + expect(result.targetWallIds).toEqual([]) }) // Endpoint-move regression: walls attached to the moving corner keep their @@ -347,6 +432,7 @@ describe('snapWallDraftPointDetailed', () => { }) expect(captured.point).toEqual([2, 0.03]) expect(captured.snap).toBe('endpoint') + expect(captured.targetWallIds).toEqual(['wall_d']) const freed = snapWallDraftPointDetailed({ point: [2, 0], diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index e588ce5a8..60d5c44de 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -2,12 +2,16 @@ import { type AnyNode, type AnyNodeId, DEFAULT_ANGLE_STEP, + DEFAULT_LEVEL_HEIGHT, type DoorNode, + GROUND_SUPPORT_ID, getScaledDimensions, type ItemNode, resolveWallSupportSlabPatch, runAsSingleSceneHistoryStep, snapPointAlongAngleRay, + spatialGridManager, + terrainSupportLift, useScene, type WallNode, WallNode as WallSchema, @@ -27,6 +31,7 @@ import { type WallDraftSnapResult, type WallPlanPoint, type WallSnapRadii, + wallIdsAtSnapPoint, } from './wall-snap-geometry' // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so @@ -73,7 +78,11 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)] } -function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] { +function splitWallAtPoint( + wall: WallNode, + splitPoint: WallPlanPoint, + nodes: ReturnType['nodes'], +): [WallNode, WallNode] { const { id: _id, parentId: _parentId, children, ...rest } = wall const first = WallSchema.parse({ @@ -89,7 +98,24 @@ function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, children: [], }) - return [first, second] + if (wall.supportSlabId !== GROUND_SUPPORT_ID || !wall.parentId) { + return [first, second] + } + + const levelId = wall.parentId + const originalElevation = + (terrainSupportLift(nodes, levelId, wall.start[0], wall.start[1]) ?? 0) + + (wall.supportOffset ?? 0) + const rebase = (segment: WallNode): WallNode => { + const terrainElevation = + terrainSupportLift(nodes, levelId, segment.start[0], segment.start[1]) ?? 0 + const supportOffset = originalElevation - terrainElevation + return { + ...segment, + supportOffset: Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, + } + } + return [rebase(first), rebase(second)] } function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean { @@ -288,7 +314,7 @@ function splitWallIfNeeded( return { walls, point: intersection.point } } - const [first, second] = splitWallAtPoint(wallToSplit, intersection.point) + const [first, second] = splitWallAtPoint(wallToSplit, intersection.point, nodes) const attachmentUpdates = buildAttachmentMigrationPlan( wallToSplit, intersection.point, @@ -394,7 +420,7 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn snapRadii, } = args - if (bypassSnap) return { point, snap: null } + if (bypassSnap) return { point, snap: null, targetWallIds: [] } // Discrete special points (corner / midpoint / crossing) are taken from the // raw cursor so an interim grid snap can't mask them. A corner always wins, @@ -420,8 +446,14 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn ignoreWallIds, radius: snapRadii?.wall, }) - if (wallSnap) return { point: wallSnap, snap: 'wall' } - return { point: basePoint, snap: null } + if (wallSnap) { + return { + point: wallSnap, + snap: 'wall', + targetWallIds: wallIdsAtSnapPoint(wallSnap, walls, ignoreWallIds), + } + } + return { point: basePoint, snap: null, targetWallIds: [] } } // Non-magnetic modes (grid / off / angles): connectivity still sticks so a @@ -441,9 +473,15 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn ignoreWallIds, radius: WALL_CONNECT_SNAP_RADIUS, }) - if (connectWall) return { point: connectWall, snap: 'wall' } + if (connectWall) { + return { + point: connectWall, + snap: 'wall', + targetWallIds: wallIdsAtSnapPoint(connectWall, walls, ignoreWallIds), + } + } - return { point: basePoint, snap: null } + return { point: basePoint, snap: null, targetWallIds: [] } } export function snapWallDraftPoint(args: SnapWallDraftArgs): WallPlanPoint { @@ -454,20 +492,45 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH } +export type WallConstructionOptions = { + /** Pointer-decided maximum support elevation in level-local metres. */ + supportCap?: number | null + /** Support source selected by the first click or inherited from a snapped wall. */ + preferredSupportSlabId?: string | null + /** Frozen level-local Y shown by the draft ghost. */ + constructionElevation?: number | null + /** Height shown by the draft ghost. */ + constructionHeight?: number | null +} + +export function resolveTerrainWallConstructionOptions( + nodes: Record, + levelId: string, + point: WallPlanPoint, + defaults?: Record, +): WallConstructionOptions | undefined { + const constructionElevation = terrainSupportLift(nodes, levelId, point[0], point[1]) + if (constructionElevation == null) return undefined + + const level = nodes[levelId] + const constructionHeight = + typeof defaults?.height === 'number' + ? defaults.height + : level?.type === 'level' + ? (level.height ?? DEFAULT_LEVEL_HEIGHT) + : DEFAULT_LEVEL_HEIGHT + + return { + constructionElevation, + constructionHeight, + supportCap: constructionElevation, + } +} + export function createWallOnCurrentLevel( start: WallPlanPoint, end: WallPlanPoint, - options?: { - /** - * Pointer-decided support cap (level-local Y) from - * `resolvePointerSupportSurface` — the 3D tool passes the elevation of - * the surface the commit click actually aimed at, so the persisted - * host reproduces what the preview showed (floor under a deck vs deck - * top). Omitted by 2D floor-plan commits (no camera ray): those keep - * the uncapped max election. - */ - supportCap?: number | null - }, + options?: WallConstructionOptions, ): WallNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, createNodes, deleteNode, nodes } = useScene.getState() @@ -555,12 +618,46 @@ export function createWallOnCurrentLevel( createNode(wall, currentLevelId) const createdWall = useScene.getState().nodes[wall.id] if (createdWall?.type === 'wall') { - useScene.getState().updateNode( - createdWall.id, - resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, { - maxElevation: options?.supportCap ?? null, - }), + const terrainBase = terrainSupportLift( + useScene.getState().nodes, + currentLevelId, + createdWall.start[0], + createdWall.start[1], + ) + const preferredSupportSlabId = + options?.preferredSupportSlabId ?? + (options?.constructionElevation != null && terrainBase != null ? GROUND_SUPPORT_ID : null) + const supportPatch = resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, { + maxElevation: options?.supportCap ?? null, + preferredSlabId: preferredSupportSlabId, + }) + const supportSlabId = supportPatch.supportSlabId + const sourceSupport = spatialGridManager.getSlabSupportForWall( + currentLevelId, + createdWall.start, + createdWall.end, + createdWall.curveOffset, + createdWall.thickness, + supportSlabId, + options?.supportCap ?? null, ) + const supportOffset = + options?.constructionElevation == null + ? undefined + : options.constructionElevation - sourceSupport.elevation + const preserveDraftHeight = + createdWall.height == null && + options?.constructionHeight != null && + options.constructionElevation != null && + (terrainBase != null || Math.abs(options.constructionElevation) > 1e-6) + useScene.getState().updateNode(createdWall.id, { + ...supportPatch, + height: preserveDraftHeight + ? (options?.constructionHeight ?? createdWall.height) + : createdWall.height, + supportOffset: + supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, + }) } sfxEmitter.emit('sfx:structure-build') diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts index bf2526fdc..b707fefcf 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts @@ -48,6 +48,14 @@ describe('findWallSpecialPointSnap', () => { expect(result?.snap).toBe('intersection') expect(result?.point[0]).toBeCloseTo(1, 6) expect(result?.point[1]).toBeCloseTo(0, 6) + expect(result?.targetWallIds).toEqual(['a', 'b']) + }) + + test('keeps every wall that shares a snapped endpoint as provenance', () => { + const walls = [makeWall([0, 0], [4, 0], 'a'), makeWall([0, 0], [0, 4], 'b')] + const result = findWallSpecialPointSnap([0.1, 0.1], walls) + expect(result?.point).toEqual([0, 0]) + expect(result?.targetWallIds).toEqual(['a', 'b']) }) test('corner wins over a midpoint when both are in range', () => { diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index 5a6ae7cb6..7ef593bc4 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -25,6 +25,12 @@ export type WallDraftSnapResult = { * the "magnetic" snap the beacon visualises; `null` for grid/angle-only. */ snap: WallDraftSnapKind | null + /** + * Walls whose geometry produced the snap. Kept separate from `snap` so a + * caller can use geometry for XZ alignment while independently deciding + * whether the target is allowed to transfer its construction plane. + */ + targetWallIds: string[] } export const WALL_JOIN_SNAP_RADIUS = 0.35 @@ -118,6 +124,53 @@ export function findWallSnapTarget( return bestTarget } +/** + * Wall ids that contain an already-resolved snap point. + * + * This is provenance, not another snap pass: the tolerance only absorbs float + * drift around a point the snap pipeline already chose. + */ +export function wallIdsAtSnapPoint( + point: WallPlanPoint, + walls: WallNode[], + ignoreWallIds?: string[], + tolerance = 1e-6, +): string[] { + const ignored = new Set(ignoreWallIds ?? []) + const toleranceSquared = tolerance * tolerance + const ids: string[] = [] + + for (const wall of walls) { + if (ignored.has(wall.id)) continue + if ( + distanceSquared(point, wall.start) <= toleranceSquared || + distanceSquared(point, wall.end) <= toleranceSquared + ) { + ids.push(wall.id) + continue + } + + if (isCurvedWall(wall)) { + const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) + for (let index = 1; index < sampleCount; index += 1) { + const frame = getWallCurveFrameAt(wall, index / sampleCount) + if (distanceSquared(point, [frame.point.x, frame.point.y]) <= toleranceSquared) { + ids.push(wall.id) + break + } + } + continue + } + + const projected = projectPointOntoWall(point, wall) + if (projected && distanceSquared(point, projected) <= toleranceSquared) { + ids.push(wall.id) + } + } + + return ids +} + /** * Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a * generous radius. Use this before `findWallSnapTarget` so the strong @@ -320,12 +373,26 @@ export function findWallSpecialPointSnap( radii?: WallSnapRadii, ): WallDraftSnapResult | null { const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds, radii?.endpoint) - if (endpoint) return { point: endpoint, snap: 'endpoint' } + if (endpoint) { + return { + point: endpoint, + snap: 'endpoint', + targetWallIds: wallIdsAtSnapPoint(endpoint, walls, ignoreWallIds), + } + } const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds, radii?.midpoint) const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds, radii?.intersection) return nearestCandidate(point, [ - midpoint && { point: midpoint, snap: 'midpoint' }, - intersection && { point: intersection, snap: 'intersection' }, + midpoint && { + point: midpoint, + snap: 'midpoint', + targetWallIds: wallIdsAtSnapPoint(midpoint, walls, ignoreWallIds), + }, + intersection && { + point: intersection, + snap: 'intersection', + targetWallIds: wallIdsAtSnapPoint(intersection, walls, ignoreWallIds), + }, ]) } diff --git a/packages/editor/src/components/ui/action-menu/control-modes.tsx b/packages/editor/src/components/ui/action-menu/control-modes.tsx index 746890eea..597e81c29 100644 --- a/packages/editor/src/components/ui/action-menu/control-modes.tsx +++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx @@ -72,7 +72,10 @@ export function ControlModes() { } const handleClick = (id: ControlId) => { - // Exit site editing first if needed + // Exit site editing first if needed. Sculpting is a site-phase mode, so + // leaving the phase is exactly the right way to leave the brush — but the + // order matters: `setPhase` resets the mode, and setting the mode first + // would have it overwritten below. if (isSiteEditing) { setPhase('structure') setStructureLayer('elements') diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 300cb4298..a68c2b224 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -22,6 +22,7 @@ import { Minimize2, Moon, MousePointer2, + Mountain, Package, PaintBucket, PencilLine, @@ -168,6 +169,17 @@ export function EditorCommands() { setMode('material-paint') }), }, + { + id: 'editor.mode.terrain-sculpt', + label: 'Sculpt Terrain', + group: 'Scene', + icon: , + keywords: ['terrain', 'ground', 'elevation', 'sculpt', 'hill', 'slope', 'grade', 'dig'], + shortcut: ['G'], + // No `setPhase`: `setMode` moves to the site phase itself, and doing it + // here would set the phase twice with a mode reset in between. + execute: () => run(() => setMode('terrain-sculpt')), + }, // ── Levels ─────────────────────────────────────────────────────────── { diff --git a/packages/editor/src/components/ui/controls/terrain-sculpt-panel.tsx b/packages/editor/src/components/ui/controls/terrain-sculpt-panel.tsx new file mode 100644 index 000000000..ec39f5be3 --- /dev/null +++ b/packages/editor/src/components/ui/controls/terrain-sculpt-panel.tsx @@ -0,0 +1,193 @@ +'use client' + +import { + type AnyNodeId, + RAISE_METRES_PER_STROKE, + type SiteNode, + type TerrainVerb, + useScene, +} from '@pascal-app/core' +import { Mountain, Pipette } from 'lucide-react' +import { brushRadiusRange, flattenSite, resetSiteTerrain } from '../../../lib/terrain-sculpt' +import useEditor from '../../../store/use-editor' +import { Button } from '../primitives/button' +import { SegmentedControl } from './segmented-control' +import { SliderControl } from './slider-control' + +const VERB_OPTIONS: Array<{ value: TerrainVerb; iconSrc: string; hint: string }> = [ + { value: 'raise', iconSrc: '/icons/terrain-raise.webp', hint: 'Raise' }, + { value: 'lower', iconSrc: '/icons/terrain-lower.webp', hint: 'Lower' }, + { value: 'flatten', iconSrc: '/icons/terrain-flatten.webp', hint: 'Flatten' }, + { value: 'smooth', iconSrc: '/icons/terrain-smooth.webp', hint: 'Smooth' }, +] + +const VERB_HINTS: Record = { + raise: `Drag to raise the ground. One pass moves it up to ${RAISE_METRES_PER_STROKE} m — release and drag again to go further.`, + lower: `Drag to lower the ground. One pass moves it down to ${RAISE_METRES_PER_STROKE} m.`, + flatten: 'Drag to level the ground toward the target height. It never overshoots.', + smooth: 'Drag to soften slopes and remove ridges. Flat ground stays flat.', +} + +/** + * Sculpt controls for terrain mode — verb, brush, and the two lot-wide actions. + * + * A panel rather than a floating HUD because the brush settings are the sort of + * thing a user adjusts between strokes and then leaves alone, and because + * sculpting already owns the whole viewport pointer: putting controls over the + * canvas would put them over the surface being sculpted. + * + * Embedders mount this wherever their sculpt controls belong (the community + * editor puts it in the Build sidebar while sculpt mode is active), exactly like + * `MaterialPaintPanel`. + */ +export function TerrainSculptPanel() { + const verb = useEditor((state) => state.terrainVerb) + const setTerrainVerb = useEditor((state) => state.setTerrainVerb) + const brush = useEditor((state) => state.terrainBrush) + const setTerrainBrush = useEditor((state) => state.setTerrainBrush) + const flattenTarget = useEditor((state) => state.terrainFlattenTarget) + const setTerrainFlattenTarget = useEditor((state) => state.setTerrainFlattenTarget) + const sampling = useEditor((state) => state.terrainSampling) + const setTerrainSampling = useEditor((state) => state.setTerrainSampling) + + const nodes = useScene((state) => state.nodes) + const rootNodeIds = useScene((state) => state.rootNodeIds) + const siteId = rootNodeIds[0] + const siteNode = siteId ? nodes[siteId as AnyNodeId] : undefined + const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null + const [minRadius, maxRadius] = brushRadiusRange(site) + + return ( +
+
+ setTerrainVerb(next)} + options={VERB_OPTIONS.map(({ value, iconSrc, hint }) => ({ + value, + label: ( + + + {hint} + + ), + }))} + value={verb} + /> +

{VERB_HINTS[verb]}

+
+ +
+ {/* + Range from `brushRadiusRange`, shared with the `[`/`]` keys. The low end + is not a preference: it tracks the field's sample spacing, and a brush + under it lands between samples and paints nothing at all. + */} + setTerrainBrush({ radius })} + precision={1} + step={0.5} + unit="m" + value={brush.radius} + /> + setTerrainBrush({ strength })} + precision={2} + step={0.05} + value={brush.strength} + /> + setTerrainBrush({ falloff })} + precision={2} + step={0.05} + value={brush.falloff} + /> + setTerrainBrush({ shape })} + options={[ + { value: 'round', label: 'Round' }, + { value: 'square', label: 'Square' }, + ]} + value={brush.shape} + /> +
+ + {verb === 'flatten' && ( +
+
+
+ +
+ +
+

+ {sampling + ? 'Click the ground to pick its height as the target.' + : flattenTarget === null + ? 'No target yet — the first click samples the ground under it.' + : 'Every flatten stroke levels toward this height.'} +

+
+ )} + +
+ + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 83b1c2e3b..ff1f2af62 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, nodeRegistry, + type TerrainVerb, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -53,6 +54,35 @@ function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] { ] } +// Sculpt mode's HUD. The verb is named rather than described because it is the one +// piece of state a user loses track of between strokes. This stays even though the +// brush ring now carries the verb in colour (`brushRingColor`): the ring says +// *which* verb only to someone who already knows the mapping, and the HUD is what +// teaches it. +function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualShortcutHint[] { + if (sampling) { + return [ + { keys: ['Click'], label: 'Pick target height' }, + { keys: ['Esc'], label: 'Cancel picking' }, + ] + } + const action = + verb === 'raise' + ? 'Raise ground' + : verb === 'lower' + ? 'Lower ground' + : verb === 'flatten' + ? 'Level ground' + : 'Smooth ground' + return [ + { keys: ['Drag'], label: action }, + // Nested, so the two render as alternatives ("[ / ]") rather than a chord — + // a flat `['[', ']']` joins with "+" and would read as "press both". + { keys: [['[', ']']], label: 'Brush size' }, + { keys: ['Esc'], label: 'Cancel stroke' }, + ] +} + type ActiveModifierKeys = { command: boolean shift: boolean @@ -95,6 +125,8 @@ function useActiveModifierKeys(): ActiveModifierKeys { export function HelperManager() { const mode = useEditor((s) => s.mode) const tool = useEditor((s) => s.tool) + const terrainVerb = useEditor((s) => s.terrainVerb) + const terrainSampling = useEditor((s) => s.terrainSampling) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind) const workspaceMode = useEditor((s) => s.workspaceMode) @@ -214,6 +246,15 @@ export function HelperManager() { return } + // Sculpt mode. The HUD is what makes a sustained brush mode legible: it names + // the active verb (the same word the panel's segmented control shows, so the + // two never disagree), and it advertises the two keys whose behaviour is + // mode-specific — bracket resize, and an Esc that abandons the stroke rather + // than exiting the mode. + if (mode === 'terrain-sculpt') { + return + } + // Idle select only — an active scope (handle-drag, box-select, …) must not show // the idle selection hints. if (mode === 'select' && scope.kind === 'idle') { diff --git a/packages/editor/src/hooks/use-grid-events.ts b/packages/editor/src/hooks/use-grid-events.ts index 9c1a22c85..a93533d07 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -9,6 +9,8 @@ import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { Plane, Raycaster, Vector2, Vector3 } from 'three' +import { getPlacementSurface } from '../lib/active-placement-surface' +import { resolveTerrainGroundHit } from '../lib/ground-surface' /** * Custom grid events hook that uses manual raycasting instead of mesh events. @@ -38,6 +40,28 @@ export function useGridEvents(gridY: number) { // Update raycaster raycaster.current.setFromCamera(pointer.current, camera) + // Sculpted ground wins over the plane, but only while the plane IS the + // ground (see `isSiteGroundPlane`): a plane riding a storey base or a slab + // top is a real flat surface and must stay planar. The march is what removes + // the perspective skew — a ray intersected at the datum instead of at the + // hillside reports an XZ that slides along the ray, so every tool consuming + // `position`/`localPosition` was placing short of the cursor on a slope. + // + // The plane's own height is the query height, not the `gridY` argument: this + // effect is keyed on `[camera, gl]` (re-attaching listeners mid-drag would + // drop the gesture), so the argument in this closure is the value from mount. + // `constant = -gridY` by construction in the effect above. + const { origin, direction } = raycaster.current.ray + const fixedConstructionPlane = getPlacementSurface()?.projection === 'fixed-plane' + const hit = fixedConstructionPlane + ? null + : resolveTerrainGroundHit( + [origin.x, origin.y, origin.z], + [direction.x, direction.y, direction.z], + -groundPlane.current.constant, + ) + if (hit) return intersectionPoint.current.set(hit.x, hit.y, hit.z).clone() + // Intersect with ground plane if (raycaster.current.ray.intersectPlane(groundPlane.current, intersectionPoint.current)) { return intersectionPoint.current.clone() diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 1eaafd691..990a1e098 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -31,6 +31,7 @@ import { runRedo, runUndo } from '../lib/history' import { isActive } from '../lib/interaction/scope' import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard' import { sfxEmitter } from '../lib/sfx-bus' +import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt' import { toggleWindowOpenState } from '../lib/window-interaction' import useDeleteConfirmation from '../store/use-delete-confirmation' import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor' @@ -227,6 +228,34 @@ export const useKeyboard = ({ return } + // Brush size, on the keys every sculpting tool in the industry uses. Gated + // on sculpt mode so `[`/`]` stay free everywhere else. Key-repeat is + // allowed (unlike the cycles above) because holding to resize is the + // expected feel, and the step is multiplicative so one press is a + // proportional change at both the floor and 20 m rather than 40× coarser at + // the bottom of the range. The range comes from `brushRadiusRange` so this + // and the panel's slider cannot disagree about it — and so the low end + // tracks the field's sample spacing, below which a dab paints nothing. + if ( + (e.key === '[' || e.key === ']') && + !e.metaKey && + !e.ctrlKey && + useEditor.getState().mode === 'terrain-sculpt' + ) { + e.preventDefault() + const { terrainBrush, setTerrainBrush } = useEditor.getState() + const factor = e.key === ']' ? 1.25 : 1 / 1.25 + const radius = clampBrushRadius( + activeSiteNode(), + Math.round(terrainBrush.radius * factor * 10) / 10, + ) + if (radius !== terrainBrush.radius) { + setTerrainBrush({ radius }) + sfxEmitter.emit('sfx:grid-snap') + } + return + } + if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) { // Cycle the global snapping mode (grid → lines → angles → off). // `'off'` is the snap bypass now, so Shift no longer holds-to-bypass. @@ -364,6 +393,12 @@ export const useKeyboard = ({ useEditor.getState().setPhase('structure') useEditor.getState().setStructureLayer('elements') useEditor.getState().setMode('material-paint') + } else if (e.key === 'g' && !e.metaKey && !e.ctrlKey) { + if (isVersionPreviewMode) return + e.preventDefault() + // G for ground. No `setPhase` — `setMode` moves to the site phase itself, + // and doing it here would set the phase twice with a mode reset between. + useEditor.getState().setMode('terrain-sculpt') } else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { if (isVersionPreviewMode) return e.preventDefault() diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d5c9651db..05ffecf80 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -136,6 +136,13 @@ export { CursorSphere } from './components/tools/shared/cursor-sphere' export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility' +export { + type HorizontalConstructionPlane, + publishHorizontalConstructionPlane, + resampleTerrainConstructionPlane, + resolveEventConstructionPlane, + resolveLevelConstructionPlane, +} from './components/tools/shared/horizontal-construction-plane' export { PlacementBox } from './components/tools/shared/placement-box' // Pointer-decided support surface (deck top vs floor underneath) — the // draw tools (wall / fence) ride their grid plane and commit cap on it. @@ -225,6 +232,7 @@ export { MetricControl } from './components/ui/controls/metric-control' export { PanelSection } from './components/ui/controls/panel-section' export { SegmentedControl } from './components/ui/controls/segmented-control' export { SliderControl } from './components/ui/controls/slider-control' +export { TerrainSculptPanel } from './components/ui/controls/terrain-sculpt-panel' export { ToggleControl } from './components/ui/controls/toggle-control' export { FloatingLevelSelector } from './components/ui/floating-level-selector' export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items' @@ -310,6 +318,17 @@ export { continuationContextOf, nextContinuation, } from './lib/continuation' +export { + clearStructuralElevationGuide, + collectElevationSnapTargets, + ELEVATION_ALIGNMENT_THRESHOLD_M, + type ElevationGuideSource, + type ElevationSnapMatch, + type ElevationSnapTarget, + publishStructuralElevationGuide, + resolveElevationSnapMatch, + resolveStructuralElevationSnap, +} from './lib/elevation-guides' export { resolveCurrentBuildingId, resolveElevatorNodeSupportY, @@ -480,6 +499,13 @@ export { type SurfacePlanSnapInput, type SurfacePlanSnapResult, } from './lib/surface-plan-snap' +export { + fieldExtentForSite, + flattenSite, + resetSiteTerrain, + resolveFlattenTarget, + sculptFieldForSite, +} from './lib/terrain-sculpt' // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ // nodes` so they don't need their own copy / their own tailwind-merge // dependency. diff --git a/packages/editor/src/lib/active-placement-surface.test.ts b/packages/editor/src/lib/active-placement-surface.test.ts new file mode 100644 index 000000000..d234ee7fe --- /dev/null +++ b/packages/editor/src/lib/active-placement-surface.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { Vector3 } from 'three' +import { + clearPlacementSurface, + getPlacementSurface, + publishPlacementSurface, +} from './active-placement-surface' + +describe('active placement surface', () => { + afterEach(() => clearPlacementSurface()) + + test('marks a frozen construction plane so terrain raycasting can bypass it', () => { + publishPlacementSurface(new Vector3(1, 0, 2), new Vector3(0, 1, 0), 'fixed-plane') + + expect(getPlacementSurface()?.projection).toBe('fixed-plane') + }) + + test('ordinary surface publishes reset the projection mode', () => { + publishPlacementSurface(new Vector3(), new Vector3(0, 1, 0), 'fixed-plane') + publishPlacementSurface(new Vector3(), new Vector3(0, 1, 0)) + + expect(getPlacementSurface()?.projection).toBe('surface') + }) +}) diff --git a/packages/editor/src/lib/active-placement-surface.ts b/packages/editor/src/lib/active-placement-surface.ts index c9cf9a3ce..207881eb9 100644 --- a/packages/editor/src/lib/active-placement-surface.ts +++ b/packages/editor/src/lib/active-placement-surface.ts @@ -12,17 +12,24 @@ import { Vector3 } from 'three' export type PlacementSurface = { point: Vector3 normal: Vector3 + projection: 'surface' | 'fixed-plane' } const surface: PlacementSurface = { point: new Vector3(), normal: new Vector3(0, 1, 0), + projection: 'surface', } let active = false -export function publishPlacementSurface(point: Vector3, normal: Vector3): void { +export function publishPlacementSurface( + point: Vector3, + normal: Vector3, + projection: PlacementSurface['projection'] = 'surface', +): void { surface.point.copy(point) surface.normal.copy(normal) + surface.projection = projection active = true } diff --git a/packages/editor/src/lib/elevation-guides.test.ts b/packages/editor/src/lib/elevation-guides.test.ts new file mode 100644 index 000000000..c0192aaea --- /dev/null +++ b/packages/editor/src/lib/elevation-guides.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core' +import useElevationGuides from '../store/use-elevation-guides' +import { + clearStructuralElevationGuide, + collectElevationSnapTargets, + publishStructuralElevationGuide, + resolveElevationSnapMatch, + resolveStructuralElevationSnap, +} from './elevation-guides' + +function structuralScene() { + const rawLevel = LevelNode.parse({ level: 0, height: 3 }) + const slab = SlabNode.parse({ + parentId: rawLevel.id, + polygon: [ + [2, -1], + [4, -1], + [4, 1], + [2, 1], + ], + elevation: 0.6, + thickness: 0.2, + }) + const ceiling = CeilingNode.parse({ + parentId: rawLevel.id, + polygon: [ + [-4, -1], + [-2, -1], + [-2, 1], + [-4, 1], + ], + height: 2.4, + }) + const level = { ...rawLevel, children: [slab.id, ceiling.id] } + const nodes: Record = { + [level.id]: level, + [slab.id]: slab, + [ceiling.id]: ceiling, + } + return { ceiling, level, nodes, slab } +} + +describe('elevation guides', () => { + test('collects the level, slab faces, and ceiling plane on the source level', () => { + const { level, nodes, slab } = structuralScene() + const targets = collectElevationSnapTargets( + { nodeId: 'wall_moving', levelId: level.id, anchor: [0, 0] }, + nodes, + ) + + expect(targets.map((target) => [target.label, target.elevation])).toEqual([ + ['Level', 0], + ['Slab top', 0.6], + ['Slab underside', 0.39999999999999997], + ['Ceiling', 2.4], + ]) + + const withoutSelf = collectElevationSnapTargets( + { nodeId: slab.id, levelId: level.id, anchor: [0, 0] }, + nodes, + ) + expect(withoutSelf.some((target) => target.id.startsWith(slab.id))).toBe(false) + }) + + test('snaps inside the Y tolerance and leaves values outside it alone', () => { + const { level, nodes } = structuralScene() + const source = { nodeId: 'wall_moving', levelId: level.id, anchor: [0, 0] } as const + + expect(resolveStructuralElevationSnap(source, 0.54, nodes)).toBe(0.6) + expect(resolveStructuralElevationSnap(source, 0.49, nodes)).toBe(0.49) + expect(resolveStructuralElevationSnap(source, 2.34, nodes)).toBe(2.4) + }) + + test('uses plan distance to disambiguate datums at the same elevation', () => { + const match = resolveElevationSnapMatch( + 1, + [0, 0], + [ + { id: 'far', elevation: 1, anchor: [10, 0], label: 'Far' }, + { id: 'near', elevation: 1, anchor: [2, 0], label: 'Near' }, + ], + ) + + expect(match?.target.id).toBe('near') + }) + + test('publishes one owner-scoped guide only while exactly aligned', () => { + const { level, nodes } = structuralScene() + const source = { nodeId: 'wall_moving', levelId: level.id, anchor: [0, 0] } as const + useElevationGuides.setState({ guide: null }) + + publishStructuralElevationGuide(source, 0.6, nodes) + expect(useElevationGuides.getState().guide).toMatchObject({ + ownerId: 'wall_moving', + elevation: 0.6, + label: 'Slab top', + direction: [1, 0], + }) + + clearStructuralElevationGuide('someone_else') + expect(useElevationGuides.getState().guide).not.toBeNull() + + publishStructuralElevationGuide(source, 0.7, nodes) + expect(useElevationGuides.getState().guide).toBeNull() + }) +}) diff --git a/packages/editor/src/lib/elevation-guides.ts b/packages/editor/src/lib/elevation-guides.ts new file mode 100644 index 000000000..021afbf4e --- /dev/null +++ b/packages/editor/src/lib/elevation-guides.ts @@ -0,0 +1,236 @@ +import { + type AnyNode, + type AnyNodeId, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, + resolveCeilingHeight, +} from '@pascal-app/core' +import useElevationGuides from '../store/use-elevation-guides' + +export const ELEVATION_ALIGNMENT_THRESHOLD_M = 0.08 +const GUIDE_MATCH_EPSILON_M = 1e-4 + +export type ElevationGuideSource = { + nodeId: string + levelId: string | null | undefined + anchor: readonly [number, number] +} + +export type ElevationSnapTarget = { + id: string + elevation: number + anchor: readonly [number, number] + label: string +} + +export type ElevationSnapMatch = { + target: ElevationSnapTarget + elevation: number +} + +function polygonCenter(polygon: ReadonlyArray): [number, number] { + if (polygon.length === 0) return [0, 0] + let x = 0 + let z = 0 + for (const point of polygon) { + x += point[0] + z += point[1] + } + return [x / polygon.length, z / polygon.length] +} + +function segmentCenter( + start: readonly [number, number], + end: readonly [number, number], +): [number, number] { + return [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] +} + +function fenceBaseElevation(node: AnyNode, nodes: Record): number { + if (node.type !== 'fence') return 0 + const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined + const hostElevation = + host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null) + ? (host.elevation ?? 0) + : 0 + return hostElevation + (node.supportOffset ?? 0) +} + +/** + * Structural Y datums on the source node's level. This is editor-runtime + * collection over authoritative vertical resolvers; matching remains a pure + * scalar operation in {@link resolveElevationSnapMatch}. + */ +export function collectElevationSnapTargets( + source: ElevationGuideSource, + nodes: Record, +): ElevationSnapTarget[] { + if (!source.levelId) return [] + + const targets: ElevationSnapTarget[] = [ + { + id: `${source.levelId}:level`, + elevation: 0, + anchor: source.anchor, + label: 'Level', + }, + ] + const level = nodes[source.levelId as AnyNodeId] + if (level?.type !== 'level') return targets + + for (const childId of level.children) { + if (childId === source.nodeId) continue + const node = nodes[childId as AnyNodeId] + if (!node) continue + + if (node.type === 'slab') { + const center = polygonCenter(node.polygon) + const top = node.elevation ?? 0.05 + targets.push({ + id: `${node.id}:top`, + elevation: top, + anchor: center, + label: 'Slab top', + }) + if (!node.recessed) { + targets.push({ + id: `${node.id}:base`, + elevation: top - (node.thickness ?? 0.05), + anchor: center, + label: 'Slab underside', + }) + } + continue + } + + if (node.type === 'ceiling') { + targets.push({ + id: `${node.id}:ceiling`, + elevation: resolveCeilingHeight(node, nodes as Record), + anchor: polygonCenter(node.polygon), + label: 'Ceiling', + }) + continue + } + + if (node.type === 'wall') { + const base = getWallBaseElevationForNodes(node, nodes) + const center = segmentCenter(node.start, node.end) + targets.push({ + id: `${node.id}:base`, + elevation: base, + anchor: center, + label: 'Wall base', + }) + targets.push({ + id: `${node.id}:top`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes), + anchor: center, + label: 'Wall top', + }) + continue + } + + if (node.type === 'fence') { + const base = fenceBaseElevation(node, nodes) + const center = segmentCenter(node.start, node.end) + targets.push({ + id: `${node.id}:base`, + elevation: base, + anchor: center, + label: 'Fence base', + }) + targets.push({ + id: `${node.id}:top`, + elevation: base + (node.height ?? 1.8), + anchor: center, + label: 'Fence top', + }) + } + } + + return targets +} + +export function resolveElevationSnapMatch( + proposedElevation: number, + sourceAnchor: readonly [number, number], + targets: readonly ElevationSnapTarget[], + threshold = ELEVATION_ALIGNMENT_THRESHOLD_M, +): ElevationSnapMatch | null { + let best: ElevationSnapTarget | null = null + let bestDelta = Number.POSITIVE_INFINITY + let bestPlanDistance = Number.POSITIVE_INFINITY + + for (const target of targets) { + const delta = Math.abs(target.elevation - proposedElevation) + if (delta > threshold) continue + const dx = target.anchor[0] - sourceAnchor[0] + const dz = target.anchor[1] - sourceAnchor[1] + const planDistance = dx * dx + dz * dz + if ( + delta < bestDelta - 1e-9 || + (Math.abs(delta - bestDelta) <= 1e-9 && planDistance < bestPlanDistance) + ) { + best = target + bestDelta = delta + bestPlanDistance = planDistance + } + } + + return best ? { target: best, elevation: best.elevation } : null +} + +export function resolveStructuralElevationSnap( + source: ElevationGuideSource, + proposedElevation: number, + nodes: Record, +): number { + return ( + resolveElevationSnapMatch( + proposedElevation, + source.anchor, + collectElevationSnapTargets(source, nodes), + )?.elevation ?? proposedElevation + ) +} + +export function publishStructuralElevationGuide( + source: ElevationGuideSource, + elevation: number, + nodes: Record, +): void { + if (!source.levelId) { + clearStructuralElevationGuide(source.nodeId) + return + } + + const match = resolveElevationSnapMatch( + elevation, + source.anchor, + collectElevationSnapTargets(source, nodes), + GUIDE_MATCH_EPSILON_M, + ) + if (!match) { + clearStructuralElevationGuide(source.nodeId) + return + } + + const dx = match.target.anchor[0] - source.anchor[0] + const dz = match.target.anchor[1] - source.anchor[1] + const length = Math.hypot(dx, dz) + const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0] + + useElevationGuides.getState().publish({ + ownerId: source.nodeId, + levelId: source.levelId, + center: source.anchor, + direction, + elevation: match.elevation, + label: match.target.label, + }) +} + +export function clearStructuralElevationGuide(ownerId: string): void { + useElevationGuides.getState().clear(ownerId) +} diff --git a/packages/editor/src/lib/ground-surface.ts b/packages/editor/src/lib/ground-surface.ts new file mode 100644 index 000000000..6137d77f3 --- /dev/null +++ b/packages/editor/src/lib/ground-surface.ts @@ -0,0 +1,95 @@ +// The one place that answers "where is the ground under this pointer ray?". +// +// Before terrain there was no question to answer: the ground was `y = 0` and +// every consumer intersected its own horizontal plane. Terrain makes those +// planes wrong in a way that is invisible on flat ground and badly wrong on a +// slope — a ray intersected at the datum instead of at the surface reports an XZ +// that slides along the ray, further the more oblique the camera. Item placement +// already repairs this locally (`resolvePointerSupportSurface`); this module +// exists so the repair is shared instead of copied into column, wall, fence, and +// the sculpt tool. +// +// It lives in `editor` rather than `core` because the decision needs one piece of +// editor knowledge — which plane a consumer is asking about — while the geometry +// it delegates to (`raycastTerrain`, `heightAt`) is pure core. + +import { + heightAt, + isSiteDatum, + raycastTerrain, + type SiteNode, + type TerrainField, + type TerrainHit, + terrainFieldOf, + useScene, +} from '@pascal-app/core' + +/** The active site's terrain field, or null when the scene has no sculpted ground. */ +export function siteTerrainField(): TerrainField | null { + const { nodes, rootNodeIds } = useScene.getState() + const siteId = rootNodeIds[0] + const site = siteId ? nodes[siteId] : null + if (site?.type !== 'site') return null + return terrainFieldOf(site as SiteNode) +} + +/** + * True when a horizontal query plane at `planeY` is the *site ground*, as opposed + * to a built flat surface that happens to be horizontal. + * + * This is the whole scoping rule, and getting it wrong in either direction is + * worse than not having terrain at all: + * + * - Treat every horizontal plane as ground and a second-storey wall draft snaps + * to the hillside under the building. + * - Treat none of them as ground and every ground tool keeps the perspective skew + * terrain was supposed to remove. + * + * The datum is exactly the right discriminator because terrain *is* the surface at + * the datum: the site renderer removes the flat ground fill when terrain mounts, + * so at the datum there is nothing else to hit. Every other horizontal plane — + * a storey base, a slab top, a shelf — is a real flat surface built by the user, + * and a plane is the correct model for it. + * + * `isSiteDatum` comes from core so this rule and the one the support resolver + * applies (`terrain-support.ts`) cannot drift into disagreeing about which + * surfaces are ground. + */ +export function isSiteGroundPlane(planeY: number): boolean { + return isSiteDatum(planeY) +} + +/** + * Where a pointer ray meets the sculpted ground, or null when the flat-plane path + * should be used instead — no terrain, the ray misses the field, or the query + * plane is a built surface rather than the ground. + * + * Returning null rather than a fallback hit is deliberate: the caller already owns + * a correct plane intersection for its own frame (world for grid events, + * level-local for support election), and duplicating that here would mean two + * definitions of the fallback to keep in sync. + */ +export function resolveTerrainGroundHit( + origin: readonly [number, number, number], + direction: readonly [number, number, number], + planeY: number, +): TerrainHit | null { + if (!isSiteGroundPlane(planeY)) return null + const field = siteTerrainField() + if (!field) return null + return raycastTerrain(field, origin, direction) +} + +/** + * Ground height at a world XZ, for consumers with no ray to march. + * + * The 2D plan view is the case this exists for: an orthographic top-down camera + * makes the pointer ray vertical, so the march degenerates to a single sample and + * there is no XZ skew to correct — only a Y to report. + */ +export function groundHeightAt(x: number, z: number, planeY: number): number | null { + if (!isSiteGroundPlane(planeY)) return null + const field = siteTerrainField() + if (!field) return null + return heightAt(field, x, z) +} diff --git a/packages/editor/src/lib/interaction/overlay-policy.test.ts b/packages/editor/src/lib/interaction/overlay-policy.test.ts index aa7860767..08560c856 100644 --- a/packages/editor/src/lib/interaction/overlay-policy.test.ts +++ b/packages/editor/src/lib/interaction/overlay-policy.test.ts @@ -20,6 +20,7 @@ const ACTIVE_SCOPES: ActiveInteractionScope[] = [ { kind: 'reshaping', nodeId: 's1', reshape: 'hole', driver: 'tool', holeIndex: 0 }, { kind: 'box-select' }, { kind: 'painting' }, + { kind: 'sculpting' }, ] describe('resolveOverlayPolicy', () => { diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index a8a8cd5fb..03e0db7b1 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -59,6 +59,13 @@ export type InteractionScope = | { kind: 'box-select' } // Material paint application. | { kind: 'painting' } + // Terrain sculpting. Held for the whole time the sculpt tool is armed, not + // just while the pointer is down — the same lifetime as `painting`, and for + // the same reason: an *active* scope is what makes every other object's + // handles, the floating action menu, and the zone labels step back + // (`resolveOverlayPolicy`). A brush that only claimed the scope between + // pointer-down and -up would flicker all of that back on between dabs. + | { kind: 'sculpting' } export type InteractionKind = InteractionScope['kind'] diff --git a/packages/editor/src/lib/sfx-bus.ts b/packages/editor/src/lib/sfx-bus.ts index 7186b86a3..236c0c9ae 100644 --- a/packages/editor/src/lib/sfx-bus.ts +++ b/packages/editor/src/lib/sfx-bus.ts @@ -1,5 +1,14 @@ +import type { TerrainVerb } from '@pascal-app/core' import mitt from 'mitt' -import { disposeSFX, playSFX } from './sfx-player' +import useAudio from '../store/use-audio' +import { + disposeSFX, + type LoopSFXName, + playSFX, + startLoopSFX, + stopLoopSFX, + updateSFXVolumes, +} from './sfx-player' /** * SFX-specific events that tools can trigger @@ -18,8 +27,14 @@ type SFXEvents = { 'sfx:menu-hover': undefined 'sfx:menu-click': undefined 'sfx:paint-apply': undefined + 'sfx:terrain-sculpt-start': TerrainVerb + 'sfx:terrain-sculpt-stop': undefined } +type TriggerSFXEvent = { + [Event in keyof SFXEvents]: SFXEvents[Event] extends undefined ? Event : never +}[keyof SFXEvents] + /** * Dedicated event emitter for SFX * Tools should use this to trigger sound effects @@ -41,6 +56,15 @@ const handleSnapshotCapture = () => playSFX('snapshotCapture') const handleMenuHover = () => playSFX('menuHover') const handleMenuClick = () => playSFX('menuClick') const handlePaintApply = () => playSFX('paintApply') +const TERRAIN_LOOP_BY_VERB = { + raise: 'terrainRaise', + lower: 'terrainLower', + flatten: 'terrainFlatten', + smooth: 'terrainSmooth', +} as const satisfies Record +const handleTerrainSculptStart = (verb: TerrainVerb) => startLoopSFX(TERRAIN_LOOP_BY_VERB[verb]) +const handleTerrainSculptStop = () => stopLoopSFX() +let unsubscribeAudio: (() => void) | null = null /** * Initialize SFX Bus - connects SFX events to actual sound playback. @@ -62,6 +86,9 @@ export function initSFXBus() { sfxEmitter.on('sfx:menu-hover', handleMenuHover) sfxEmitter.on('sfx:menu-click', handleMenuClick) sfxEmitter.on('sfx:paint-apply', handlePaintApply) + sfxEmitter.on('sfx:terrain-sculpt-start', handleTerrainSculptStart) + sfxEmitter.on('sfx:terrain-sculpt-stop', handleTerrainSculptStop) + unsubscribeAudio = useAudio.subscribe(updateSFXVolumes) } export function disposeSFXBus() { @@ -79,6 +106,10 @@ export function disposeSFXBus() { sfxEmitter.off('sfx:menu-hover', handleMenuHover) sfxEmitter.off('sfx:menu-click', handleMenuClick) sfxEmitter.off('sfx:paint-apply', handlePaintApply) + sfxEmitter.off('sfx:terrain-sculpt-start', handleTerrainSculptStart) + sfxEmitter.off('sfx:terrain-sculpt-stop', handleTerrainSculptStop) + unsubscribeAudio?.() + unsubscribeAudio = null sfxBusInitialized = false } disposeSFX() @@ -89,7 +120,7 @@ export function disposeSFXBus() { * @example * triggerSFX('sfx:item-place') */ -export function triggerSFX(event: keyof SFXEvents) { +export function triggerSFX(event: TriggerSFXEvent) { sfxEmitter.emit(event) } diff --git a/packages/editor/src/lib/sfx-player.test.ts b/packages/editor/src/lib/sfx-player.test.ts index 53125f734..da50715cb 100644 --- a/packages/editor/src/lib/sfx-player.test.ts +++ b/packages/editor/src/lib/sfx-player.test.ts @@ -4,17 +4,25 @@ type FakeContext = { id: string } let activeContext: FakeContext = { id: 'first' } let initialState: 'loaded' | 'loading' = 'loaded' +let muted = false let throwOnPlay = false const instances: FakeHowl[] = [] class FakeHowl { + loadCallbacks: Array<() => void> = [] + loop = false + src: string | undefined stateValue: 'loaded' | 'loading' | 'unloaded' = initialState unloadCount = 0 playCount = 0 + stopCount = 0 + fadeCalls: Array<[number, number, number, number | undefined]> = [] stereoCalls: Array<[number, number | undefined]> = [] volumeCalls: Array<[number, number | undefined]> = [] - constructor(_options: unknown) { + constructor(options: { loop?: boolean; src?: string[] }) { + this.loop = options.loop ?? false + this.src = options.src?.[0] instances.push(this) } @@ -29,6 +37,34 @@ class FakeHowl { return this } + fade(from: number, to: number, duration: number, id?: number) { + this.fadeCalls.push([from, to, duration, id]) + return this + } + + once(event: string, callback: () => void) { + if (event === 'load') { + this.loadCallbacks.push(callback) + return this + } + callback() + return this + } + + finishLoading() { + this.stateValue = 'loaded' + for (const callback of this.loadCallbacks.splice(0)) callback() + } + + playing() { + return this.playCount > this.stopCount + } + + stop() { + this.stopCount++ + return this + } + stereo(value: number, id?: number) { this.stereoCalls.push([value, id]) return this @@ -58,17 +94,20 @@ const fakeHowler = { mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler })) mock.module('../store/use-audio', () => ({ default: { - getState: () => ({ masterVolume: 100, muted: false, sfxVolume: 100 }), + getState: () => ({ masterVolume: 100, muted, sfxVolume: 100 }), + subscribe: () => () => {}, }, })) -const { disposeSFX, playSFX, preloadSFX } = await import('./sfx-player') -const { disposeSFXBus, initSFXBus, triggerSFX } = await import('./sfx-bus') +const { disposeSFX, playSFX, preloadSFX, startLoopSFX, stopLoopSFX, updateSFXVolumes } = + await import('./sfx-player') +const { disposeSFXBus, initSFXBus, sfxEmitter, triggerSFX } = await import('./sfx-bus') beforeEach(() => { disposeSFXBus() activeContext = { id: 'first' } initialState = 'loaded' + muted = false throwOnPlay = false instances.length = 0 }) @@ -157,4 +196,71 @@ describe('SFX audio context lifecycle', () => { expect(instances.reduce((total, sound) => total + sound.playCount, 0)).toBe(2) }) + + test('keeps one faded terrain loop active and stops it on release', () => { + startLoopSFX('terrainRaise') + const raise = instances.find((sound) => sound.loop && sound.playCount === 1) + + expect(raise?.volumeCalls[0]).toEqual([0, 1]) + expect(raise?.fadeCalls).toEqual([[0, 0.2, 90, 1]]) + + startLoopSFX('terrainRaise') + expect(raise?.playCount).toBe(1) + + stopLoopSFX() + expect(raise?.stopCount).toBe(1) + expect(raise?.fadeCalls.at(-1)).toEqual([0.2, 0, 90, 1]) + }) + + test('maps each terrain verb to a distinct loop through the SFX bus', () => { + initSFXBus() + + for (const verb of ['raise', 'lower', 'flatten', 'smooth'] as const) { + sfxEmitter.emit('sfx:terrain-sculpt-start', verb) + sfxEmitter.emit('sfx:terrain-sculpt-stop') + } + + expect( + instances.filter((sound) => sound.loop && sound.playCount === 1).map((sound) => sound.src), + ).toEqual([ + '/audios/sfx/terrain_raise.mp3', + '/audios/sfx/terrain_lower.mp3', + '/audios/sfx/terrain_flatten.mp3', + '/audios/sfx/terrain_smooth.mp3', + ]) + }) + + test('fades an active terrain loop out when sound is muted', () => { + startLoopSFX('terrainLower') + const lower = instances.find((sound) => sound.loop && sound.playCount === 1) + + muted = true + updateSFXVolumes() + + expect(lower?.fadeCalls.at(-1)).toEqual([0.2, 0, 90, 1]) + expect(lower?.stopCount).toBe(1) + }) + + test('starts a requested terrain loop when its asset finishes loading', () => { + initialState = 'loading' + startLoopSFX('terrainRaise') + const raise = instances.find((sound) => sound.src === '/audios/sfx/terrain_raise.mp3') + + expect(raise?.playCount).toBe(0) + + raise?.finishLoading() + + expect(raise?.playCount).toBe(1) + }) + + test('does not start a terrain loop after the sculpt press was released', () => { + initialState = 'loading' + startLoopSFX('terrainLower') + const lower = instances.find((sound) => sound.src === '/audios/sfx/terrain_lower.mp3') + + stopLoopSFX() + lower?.finishLoading() + + expect(lower?.playCount).toBe(0) + }) }) diff --git a/packages/editor/src/lib/sfx-player.ts b/packages/editor/src/lib/sfx-player.ts index 130457865..ca968c361 100644 --- a/packages/editor/src/lib/sfx-player.ts +++ b/packages/editor/src/lib/sfx-player.ts @@ -16,8 +16,16 @@ type SFXConfig = { minIntervalMs?: number } +type LoopSFXConfig = { + src: string + volumeMultiplier: number + fadeInMs?: number + fadeOutMs?: number +} + const DEFAULT_MIN_INTERVAL_MS = 30 const SFX_FAILURE_BACKOFF_MS = 5_000 +const DEFAULT_LOOP_FADE_MS = 90 // SFX sound definitions export const SFX: Record = { @@ -108,6 +116,32 @@ export const SFX: Record = { export type SFXName = keyof typeof SFX +export const LOOP_SFX = { + terrainRaise: { + src: '/audios/sfx/terrain_raise.mp3', + volumeMultiplier: 0.2, + }, + terrainLower: { + src: '/audios/sfx/terrain_lower.mp3', + volumeMultiplier: 0.2, + }, + terrainFlatten: { + src: '/audios/sfx/terrain_flatten.mp3', + volumeMultiplier: 0.2, + }, + terrainSmooth: { + src: '/audios/sfx/terrain_smooth.mp3', + volumeMultiplier: 0.2, + }, +} as const satisfies Record + +export type LoopSFXName = keyof typeof LOOP_SFX +type CachedSFXName = SFXName | LoopSFXName + +function loopConfig(name: LoopSFXName): LoopSFXConfig { + return LOOP_SFX[name] +} + export type SFXPlaybackOptions = { source?: 'local' | 'remote' stereo?: number @@ -118,13 +152,43 @@ function randomInRange([min, max]: [number, number]): number { return min + Math.random() * (max - min) } -let sfxCache = new Map() +let sfxCache = new Map() let sfxAudioContext: AudioContext | null = null let sfxRetryAfter = 0 const lastPlayedAt = new Map() const lastVariation = new Map() +let activeLoop: { + id: number + name: LoopSFXName + sound: Howl + volume: number +} | null = null +let pendingLoop: { name: LoopSFXName; sound: Howl } | null = null +let requestedLoop: LoopSFXName | null = null + +function loopVolume(name: LoopSFXName): number { + const { masterVolume, sfxVolume } = useAudio.getState() + return (masterVolume / 100) * (sfxVolume / 100) * LOOP_SFX[name].volumeMultiplier +} + +function stopActiveLoop(fadeMs: number) { + const active = activeLoop + if (!active) return + activeLoop = null + + if (fadeMs <= 0) { + active.sound.stop(active.id) + return + } + + active.sound.once('fade', () => active.sound.stop(active.id), active.id) + active.sound.fade(active.volume, 0, fadeMs, active.id) +} function unloadCachedSounds(resetPlaybackState: boolean) { + requestedLoop = null + pendingLoop = null + stopActiveLoop(0) for (const sounds of sfxCache.values()) { for (const sound of sounds) { try { @@ -154,13 +218,15 @@ export function preloadSFX() { if (!cacheNeedsRebuild()) return unloadCachedSounds(false) - for (const [name, config] of Object.entries(SFX)) { + const definitions = { ...SFX, ...LOOP_SFX } + for (const [name, config] of Object.entries(definitions)) { const sources = Array.isArray(config.src) ? config.src : [config.src] sfxCache.set( - name as SFXName, + name as CachedSFXName, sources.map( (src) => new Howl({ + loop: name in LOOP_SFX, src: [src], preload: true, volume: 0.5, @@ -175,6 +241,58 @@ export function disposeSFX() { unloadCachedSounds(true) } +export function startLoopSFX(name: LoopSFXName) { + const { muted } = useAudio.getState() + if (muted) { + stopLoopSFX() + return + } + + requestedLoop = name + const current = activeLoop + if (current?.name === name && current.sound.playing(current.id)) return + if (current) stopActiveLoop(loopConfig(current.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS) + + const now = performance.now() + if (now < sfxRetryAfter) return + + try { + preloadSFX() + // A fresh Howler context rebuilds the cache and clears stale playback + // state. Restore this current press after that cleanup so a first-use loop + // can still begin when its asset finishes loading. + requestedLoop = name + const sound = sfxCache.get(name)?.[0] + if (!sound) return + if (sound.state() !== 'loaded') { + if (pendingLoop?.name === name && pendingLoop.sound === sound) return + pendingLoop = { name, sound } + sound.once('load', () => { + if (pendingLoop?.sound === sound) pendingLoop = null + if (requestedLoop === name) startLoopSFX(name) + }) + return + } + + const config = loopConfig(name) + const volume = loopVolume(name) + const id = sound.play() + sound.volume(0, id) + activeLoop = { id, name, sound, volume } + sound.fade(0, volume, config.fadeInMs ?? DEFAULT_LOOP_FADE_MS, id) + } catch { + unloadCachedSounds(false) + sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS + } +} + +export function stopLoopSFX() { + requestedLoop = null + pendingLoop = null + const fadeMs = activeLoop ? (loopConfig(activeLoop.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS) : 0 + stopActiveLoop(fadeMs) +} + /** * Play a sound effect with volume based on audio settings */ @@ -240,7 +358,11 @@ export function playSFX(name: SFXName, options: SFXPlaybackOptions = {}) { * Update all cached SFX volumes (useful when settings change) */ export function updateSFXVolumes() { - const { masterVolume, sfxVolume } = useAudio.getState() + const { masterVolume, muted, sfxVolume } = useAudio.getState() + if (muted) { + stopLoopSFX() + return + } const finalVolume = (masterVolume / 100) * (sfxVolume / 100) try { @@ -251,6 +373,10 @@ export function updateSFXVolumes() { sound.volume(finalVolume) }) }) + if (activeLoop) { + activeLoop.volume = loopVolume(activeLoop.name) + activeLoop.sound.volume(activeLoop.volume, activeLoop.id) + } } catch { unloadCachedSounds(false) sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS diff --git a/packages/editor/src/lib/site-boundary.test.ts b/packages/editor/src/lib/site-boundary.test.ts new file mode 100644 index 000000000..82fd1dbf7 --- /dev/null +++ b/packages/editor/src/lib/site-boundary.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import type { Mode, Phase } from '../store/use-editor' +import { siteBoundaryHandlesEnabled } from './site-boundary' + +/** + * The site handles are the one affordance both views draw from the same state, so + * they are also the one place a 2D/3D parity break hides: each pane used to derive + * this rule locally, and the sculpt exclusion existed in the 3D copy only. + */ + +const MODES: Mode[] = ['select', 'edit', 'delete', 'build', 'material-paint', 'terrain-sculpt'] +const PHASES: Phase[] = ['site', 'structure', 'furnish'] + +describe('siteBoundaryHandlesEnabled', () => { + test('sculpt hides the handles in every phase', () => { + // Including `site` — which is the case that matters, because arming sculpt + // *promotes* the phase to `site`, so this is the only combination the brush is + // ever actually in. + for (const phase of PHASES) { + expect(siteBoundaryHandlesEnabled({ mode: 'terrain-sculpt', phase })).toBe(false) + } + }) + + test('the site phase offers them in every other mode', () => { + for (const mode of MODES.filter((m) => m !== 'terrain-sculpt')) { + expect(siteBoundaryHandlesEnabled({ mode, phase: 'site' })).toBe(true) + } + }) + + test('select mode offers them outside the site phase', () => { + // The flags double as the affordance that *enters* site editing, so select mode + // has to show them from structure/furnish or the lot becomes unreachable. + expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'structure' })).toBe(true) + expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'furnish' })).toBe(true) + }) + + test('a drafting or painting mode outside the site phase hides them', () => { + for (const mode of ['edit', 'delete', 'build', 'material-paint'] as Mode[]) { + expect(siteBoundaryHandlesEnabled({ mode, phase: 'structure' })).toBe(false) + expect(siteBoundaryHandlesEnabled({ mode, phase: 'furnish' })).toBe(false) + } + }) + + test('sculpt is excluded before the phase term, not alongside it', () => { + // Ordering is the whole fix: `phase === 'site' || mode === 'select'` alone is + // *true* while sculpting, so an implementation that ORs the sculpt check in + // rather than early-returning would still show the handles. + const sculptInSite = siteBoundaryHandlesEnabled({ mode: 'terrain-sculpt', phase: 'site' }) + const selectInSite = siteBoundaryHandlesEnabled({ mode: 'select', phase: 'site' }) + expect(sculptInSite).toBe(false) + expect(selectInSite).toBe(true) + }) +}) diff --git a/packages/editor/src/lib/site-boundary.ts b/packages/editor/src/lib/site-boundary.ts index 2e68718dc..bded45a5e 100644 --- a/packages/editor/src/lib/site-boundary.ts +++ b/packages/editor/src/lib/site-boundary.ts @@ -1 +1,28 @@ +import type { Mode, Phase } from '../store/use-editor' + export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary' + +/** + * Whether the site's vertex/midpoint handles are grabbable right now. + * + * Shared by the 3D boundary editor (`tool-manager.tsx`) and the floorplan's SVG + * handles (`floorplan-panel.tsx`) because they must agree, and until this existed + * they did not: each derived the rule from `phase`/`mode` locally, and the sculpt + * clause was added to only one of them. + * + * The disagreement was reachable in exactly one configuration, which is why it was + * easy to miss: arming sculpt from the 2D view promotes `viewMode` to `split` *and* + * the phase to `site`, so the floorplan's `phase === 'site'` term went true at the + * same moment the 3D editor's `!sculpting` term went false — handles gone in one + * pane, live in the other. Grabbing one then calls `begin({kind: 'handle-drag'})`, + * which atomically replaces the held `sculpting` scope: the brush stays armed and + * still paints, while the rest of the editor believes a boundary drag is running. + * + * Sculpt is excluded rather than merely deprioritised because the flags sit *on* the + * ground the brush is aimed at — leaving them mounted puts a grabbable handle under + * every dab near the lot line, in either view. + */ +export function siteBoundaryHandlesEnabled(args: { mode: Mode; phase: Phase }): boolean { + if (args.mode === 'terrain-sculpt') return false + return args.phase === 'site' || args.mode === 'select' +} diff --git a/packages/editor/src/lib/terrain-sculpt.test.ts b/packages/editor/src/lib/terrain-sculpt.test.ts new file mode 100644 index 000000000..3d6b39f94 --- /dev/null +++ b/packages/editor/src/lib/terrain-sculpt.test.ts @@ -0,0 +1,429 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + createTerrainField, + DEFAULT_TERRAIN_SPACING, + heightAt, + minBrushRadius, + type SiteNode, + terrainFieldOf, + useLiveTerrain, + useScene, +} from '@pascal-app/core' +import { + brushRadiusRange, + clampBrushRadius, + clipTerrainPatchToSite, + fieldExtentForSite, + flattenSite, + resetSiteTerrain, + resolveFlattenTarget, + sculptFieldForSite, + terrainPointInsideSite, +} from './terrain-sculpt' + +// `updateNode` batches its dirty-node flush through rAF, which bun's runtime does +// not provide. Running the callback synchronously is what the other scene-writing +// unit tests do (`handle-drag-history.test.ts`). +type RafFn = (callback: (time: number) => void) => number +;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {} + +function site(points: Array<[number, number]>): SiteNode { + return { + id: 'site_1', + type: 'site', + children: [], + polygon: { type: 'polygon', points }, + } as unknown as SiteNode +} + +describe('fieldExtentForSite', () => { + test('covers the whole polygon with padding to spare', () => { + const extent = fieldExtentForSite( + site([ + [-10, -10], + [10, -10], + [10, 10], + [-10, 10], + ]), + ) + const maxX = extent.origin[0] + (extent.cols - 1) * extent.spacing + const maxZ = extent.origin[1] + (extent.rows - 1) * extent.spacing + + // Strictly outside the lot on all four sides: the field must not end at the + // property line, or the terrain mesh stops exactly where the boundary is + // drawn and leaves a visible cliff. + expect(extent.origin[0]).toBeLessThan(-10) + expect(extent.origin[1]).toBeLessThan(-10) + expect(maxX).toBeGreaterThan(10) + expect(maxZ).toBeGreaterThan(10) + }) + + test('is square and 2ⁿ+1 so a future LOD halving lands on real samples', () => { + for (const half of [4, 12, 30, 80]) { + const extent = fieldExtentForSite( + site([ + [-half, -half], + [half, -half], + [half, half], + [-half, half], + ]), + ) + expect(extent.cols).toBe(extent.rows) + expect([33, 65, 129, 257]).toContain(extent.cols) + } + }) + + test('centres on the lot even when the lot is off-origin', () => { + const extent = fieldExtentForSite( + site([ + [100, 40], + [120, 40], + [120, 60], + [100, 60], + ]), + ) + const centerX = extent.origin[0] + ((extent.cols - 1) / 2) * extent.spacing + const centerZ = extent.origin[1] + ((extent.rows - 1) / 2) * extent.spacing + expect(centerX).toBeCloseTo(110, 6) + expect(centerZ).toBeCloseTo(50, 6) + }) + + test('holds the default spacing while the lot fits in 257 samples', () => { + const extent = fieldExtentForSite( + site([ + [-20, -20], + [20, -20], + [20, 20], + [-20, 20], + ]), + ) + expect(extent.spacing).toBeCloseTo(DEFAULT_TERRAIN_SPACING, 6) + }) + + test('stretches spacing rather than stopping short on a huge lot', () => { + // A 400 m lot needs 801 samples at 0.5 m. Capping the sample count and + // coarsening is the right trade: coarse ground everywhere beats fine ground + // that ends mid-lot. + const extent = fieldExtentForSite( + site([ + [-200, -200], + [200, -200], + [200, 200], + [-200, 200], + ]), + ) + expect(extent.cols).toBe(257) + expect(extent.spacing).toBeGreaterThan(DEFAULT_TERRAIN_SPACING) + const maxX = extent.origin[0] + (extent.cols - 1) * extent.spacing + expect(maxX).toBeGreaterThan(200) + }) + + test('a degenerate polygon still yields a usable field', () => { + for (const points of [[], [[0, 0]] as Array<[number, number]>]) { + const extent = fieldExtentForSite(site(points as Array<[number, number]>)) + expect(extent.cols).toBe(65) + expect(extent.spacing).toBeCloseTo(DEFAULT_TERRAIN_SPACING, 6) + } + }) + + test('a null site does not throw', () => { + expect(fieldExtentForSite(null).cols).toBe(65) + expect(fieldExtentForSite(undefined).cols).toBe(65) + }) +}) + +describe('sculptFieldForSite', () => { + test('a virgin site gets a fresh flat field sized to its lot', () => { + const target = site([ + [-10, -10], + [10, -10], + [10, 10], + [-10, 10], + ]) + const field = sculptFieldForSite(target) + expect(field.cols).toBe(fieldExtentForSite(target).cols) + expect(heightAt(field, 0, 0)).toBeCloseTo(0, 6) + }) +}) + +describe('site footprint', () => { + const concaveSite = site([ + [0, 0], + [4, 0], + [4, 2], + [2, 2], + [2, 4], + [0, 4], + ]) + + test('includes the boundary but excludes the padded field and concave notch', () => { + expect(terrainPointInsideSite(concaveSite, 0, 2)).toBe(true) + expect(terrainPointInsideSite(concaveSite, 1, 3)).toBe(true) + expect(terrainPointInsideSite(concaveSite, 3, 3)).toBe(false) + expect(terrainPointInsideSite(concaveSite, -1, 2)).toBe(false) + }) + + test('a brush patch preserves samples outside the property polygon', () => { + const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [0, 0] }) + const heights = new Int16Array(25) + heights.fill(100) + const clipped = clipTerrainPatchToSite( + field, + { col0: 0, row0: 0, cols: 5, rows: 5, heights }, + concaveSite, + ) + + expect(clipped.heights[1 * 5 + 1]).toBe(100) + expect(clipped.heights[3 * 5 + 3]).toBe(0) + expect(clipped.heights[2 * 5 + 0]).toBe(100) + expect(clipped.heights[2 * 5 + 4]).toBe(100) + }) +}) + +describe('brushRadiusRange', () => { + const hugeLot = site([ + [-200, -200], + [200, -200], + [200, 200], + [-200, 200], + ]) + const smallLot = site([ + [-10, -10], + [10, -10], + [10, 10], + [-10, 10], + ]) + + test('the low end is the field floor, not a constant', () => { + // The bug: both the slider and the `[`/`]` keys hardcoded 0.5 m. On a lot big + // enough that `fieldExtentForSite` stretches spacing, 0.5 m is under the + // sample gap and the brush silently paints nothing. The floor has to move + // with the lot. + const [smallMin] = brushRadiusRange(smallLot) + const [hugeMin] = brushRadiusRange(hugeLot) + expect(smallMin).toBeCloseTo(minBrushRadius({ spacing: DEFAULT_TERRAIN_SPACING }), 6) + expect(hugeMin).toBeGreaterThan(smallMin) + expect(hugeMin).toBeCloseTo(minBrushRadius(sculptFieldForSite(hugeLot)), 6) + }) + + test('reads the spacing of terrain that already exists', () => { + // A lot whose field was created earlier (or imported from a DEM at its own + // resolution) must be measured by *that* field, not by what a fresh one + // would get — otherwise the floor is wrong for exactly the sites that have + // been sculpted. + const existing = { ...smallLot, terrain: { spacing: 4 } } as unknown as SiteNode + const [min] = brushRadiusRange(existing) + expect(min).toBeCloseTo(minBrushRadius({ spacing: 4 }), 6) + }) + + test('a site with no lot yet still yields a usable range', () => { + for (const target of [null, undefined]) { + const [min, max] = brushRadiusRange(target) + expect(min).toBeGreaterThan(0) + expect(max).toBeGreaterThan(min) + } + }) + + test('the range never inverts', () => { + // Reachable: a lot coarse enough that the floor passes MAX_BRUSH_RADIUS would + // otherwise give min > max, and a slider whose min exceeds its max clamps + // every value to a different number depending on which bound is applied last. + for (const spacing of [0.5, 4, 20, 100]) { + const target = { ...smallLot, terrain: { spacing } } as unknown as SiteNode + const [min, max] = brushRadiusRange(target) + expect(max).toBeGreaterThanOrEqual(min) + expect(clampBrushRadius(target, 1)).toBeGreaterThanOrEqual(min) + expect(clampBrushRadius(target, 1e6)).toBeLessThanOrEqual(max) + } + }) +}) + +describe('clampBrushRadius', () => { + const lot = site([ + [-10, -10], + [10, -10], + [10, 10], + [-10, 10], + ]) + + test('holds a radius already in range untouched', () => { + expect(clampBrushRadius(lot, 5)).toBe(5) + }) + + test('the `[`/`]` ladder stays inside the range and reverses', () => { + // The keys step multiplicatively (×1.25) and quantize to decimetres, which is + // where a ratchet would hide: if `]` then `[` did not return, the pair would + // walk the radius away from where the user left it. Only the clamp at the top + // is allowed to be one-way. + const step = (radius: number, up: boolean) => + clampBrushRadius(lot, Math.round(radius * (up ? 1.25 : 1 / 1.25) * 10) / 10) + const [min, max] = brushRadiusRange(lot) + + let radius = min + const visited: number[] = [] + for (let i = 0; i < 40 && radius < max; i++) { + const next = step(radius, true) + if (next === radius) break + visited.push(next) + radius = next + } + // The ladder has to actually traverse the range, or `]` is a dead key + // somewhere in the middle. + expect(visited.at(-1)).toBe(max) + expect(visited.length).toBeGreaterThan(8) + + for (const value of visited) { + expect(value).toBeGreaterThanOrEqual(min) + expect(value).toBeLessThanOrEqual(max) + // Below the top, up-then-down returns. At the ceiling it cannot: one step + // down from the max is a genuine 20% reduction, which is correct. + if (step(value, true) !== max) expect(step(step(value, true), false)).toBe(value) + } + }) +}) + +describe('resolveFlattenTarget', () => { + test('an explicit target wins over the ground under the cursor', () => { + const field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5, origin: [-8, -8] }) + expect(resolveFlattenTarget(field, 3.25, 0, 0)).toBeCloseTo(3.25, 6) + }) + + test('with no explicit target the first click samples the ground', () => { + // This is what makes flatten usable without ever opening a number field. + const field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5, origin: [-8, -8] }) + const raised = { ...field, heights: field.heights.slice() } + // Sample [16,16] is world (0,0) — put it at 1.5 m. + raised.heights[16 * 33 + 16] = Math.round(1.5 / raised.step) + expect(resolveFlattenTarget(raised, null, 0, 0)).toBeCloseTo(1.5, 6) + }) + + test('an explicit target of 0 is honoured, not treated as absent', () => { + // `?? ` rather than `||` — flatten-to-datum is the single most common + // explicit target, and `||` would silently resample instead. + const field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5, origin: [-8, -8] }) + const raised = { ...field, heights: field.heights.slice() } + raised.heights[16 * 33 + 16] = Math.round(2 / raised.step) + expect(resolveFlattenTarget(raised, 0, 0, 0)).toBe(0) + }) +}) + +/** + * What Escape mid-stroke actually promises. + * + * The tool's `tool:cancel` handler ends the live stroke *without* calling + * `commitStroke`, which is the whole mechanism: the live stroke is what every reader + * prefers over the persisted terrain, so dropping it un-committed reverts the scene + * by construction. The half worth asserting is that it reverts to the *right* place — + * a site sculpted before the abandoned stroke must keep that earlier work — and that + * the abandon leaves no live stroke behind for the next reader to pick up. + */ +describe('abandoning a stroke — the Escape contract', () => { + const siteId = 'site_cancel' as SiteNode['id'] + + function fieldAt(metres: number): ReturnType { + const field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5, origin: [-8, -8] }) + const heights = field.heights.slice() + heights.fill(Math.round(metres / field.step)) + return { ...field, heights } + } + + test('ends the live stroke and commits nothing', () => { + const committed = fieldAt(1.5) + useLiveTerrain.getState().begin(siteId, committed) + useLiveTerrain.getState().advance(siteId, fieldAt(9), null as never) + expect(useLiveTerrain.getState().fieldOf(siteId)).toBeDefined() + + // Exactly what the tool's `onCancel` does: end, no `commitStroke`. + useLiveTerrain.getState().end(siteId) + + // No stroke left armed — a leaked one shows ground the scene graph never had. + expect(useLiveTerrain.getState().strokes.has(siteId)).toBe(false) + expect(useLiveTerrain.getState().fieldOf(siteId)).toBeUndefined() + }) + + test('the snapshot taken at begin is never mutated by the dabs it survives', () => { + // The anti-compounding invariant, from the cancel side: if a dab could write + // through to the snapshot, Escape would revert to a *partially* sculpted field + // rather than the baseline, and the user's earlier work would drift every stroke. + const committed = fieldAt(1.5) + const baseline = Array.from(committed.heights) + useLiveTerrain.getState().begin(siteId, committed) + useLiveTerrain.getState().advance(siteId, fieldAt(9), null as never) + useLiveTerrain.getState().end(siteId) + expect(Array.from(committed.heights)).toEqual(baseline) + }) +}) + +/** + * The lot-wide writes have to win over a stroke in flight. + * + * Both replace the entire field, and every reader prefers the live stroke over the + * persisted terrain — so landing one *under* a stroke is invisible twice over: the + * ground keeps showing the stroke, and the stroke's commit (computed from a snapshot + * predating the write) then overwrites it. A button that silently does nothing. + * + * Not a hypothetical race. On touch one finger holds a stroke while the other taps + * the panel; with a mouse a stroke survives a wheel zoom's 500 ms `cameraDragging` + * window and a held pointer that has left the canvas. + */ +describe('lot-wide terrain writes vs a stroke in flight', () => { + const lotSiteId = 'site_lotwide' as SiteNode['id'] + + function siteNodeWithTerrain(terrain: SiteNode['terrain']): SiteNode { + return { + id: lotSiteId, + type: 'site', + children: [], + polygon: { + type: 'polygon', + points: [ + [-10, -10], + [10, -10], + [10, 10], + [-10, 10], + ], + }, + terrain, + } as unknown as SiteNode + } + + beforeEach(() => { + useLiveTerrain.getState().endAll() + useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never) + useScene.temporal.getState().clear() + useScene.temporal.getState().resume() + }) + + test('levelling the lot drops the stroke instead of hiding behind it', () => { + const site = siteNodeWithTerrain(undefined) + useScene.setState({ nodes: { [lotSiteId]: site }, rootNodeIds: [lotSiteId] } as never) + useLiveTerrain.getState().begin(lotSiteId, sculptFieldForSite(site)) + + flattenSite(site, 3) + + // The assertion that fails without the abandon: with a stroke still live, + // `terrainFieldOf` — and so the mesh, the raycast and the next stroke's + // snapshot — would keep reading the pre-flatten ground. + expect(useLiveTerrain.getState().fieldOf(lotSiteId)).toBeUndefined() + const written = useScene.getState().nodes[lotSiteId] as SiteNode + expect(heightAt(terrainFieldOf(written) as never, 0, 0)).toBeCloseTo(3, 6) + }) + + test('clearing terrain drops the stroke even when there is nothing to clear', () => { + // The early-return path: a first-ever stroke on a virgin site leaves + // `site.terrain` undefined, so `resetSiteTerrain` returns before writing — + // and a live stroke surviving that shows ground the scene graph has none of. + const site = siteNodeWithTerrain(undefined) + useScene.setState({ nodes: { [lotSiteId]: site }, rootNodeIds: [lotSiteId] } as never) + useLiveTerrain.getState().begin(lotSiteId, sculptFieldForSite(site)) + + resetSiteTerrain(site) + + expect(useLiveTerrain.getState().fieldOf(lotSiteId)).toBeUndefined() + expect(terrainFieldOf(useScene.getState().nodes[lotSiteId] as SiteNode)).toBeNull() + }) +}) diff --git a/packages/editor/src/lib/terrain-sculpt.ts b/packages/editor/src/lib/terrain-sculpt.ts new file mode 100644 index 000000000..65ddef605 --- /dev/null +++ b/packages/editor/src/lib/terrain-sculpt.ts @@ -0,0 +1,301 @@ +// Sculpt-mode helpers that are pure enough to test without a canvas: sizing the +// field to a site, resolving the flatten target, and the commit. +// +// The tool component owns pointer plumbing; everything here is a function of +// data, which is what lets the interesting decisions (grid extent, undo +// granularity, when a stroke is worth persisting) be asserted in unit tests +// instead of e2e. + +import { + type BrushSettings, + commitTerrainField, + createTerrainField, + DEFAULT_TERRAIN_SPACING, + type HeightPatch, + isDatumField, + minBrushRadius, + pointInPolygon2D, + quantize, + runAsSingleSceneHistoryStep, + type SiteNode, + sampleTarget, + type TerrainField, + type TerrainVerb, + terrainFieldOf, + useLiveTerrain, + useScene, +} from '@pascal-app/core' + +/** + * Field sizes we allow, all `2ⁿ+1` so a future LOD halving lands on existing + * samples instead of interpolating a seam. + */ +const FIELD_SIZES = [33, 65, 129, 257] as const + +/** + * Sample count a fresh field gets, chosen to cover the site polygon at roughly + * `DEFAULT_TERRAIN_SPACING` and never exceeding 257² (66 049 samples — about + * four furnished rooms' worth of vertices). + * + * Sizing from the polygon rather than a fixed extent matters because the extent + * is baked at creation: the field is the ground for the whole session, and a + * grid that stops short of the lot line leaves a visible cliff where the terrain + * mesh ends and the flat fill resumes. Padding by one spacing keeps the boundary + * line itself on interpolated ground rather than on the clamped edge row. + */ +export function fieldExtentForSite(site: Pick | null | undefined): { + origin: [number, number] + cols: number + rows: number + spacing: number +} { + const points = site?.polygon?.points ?? [] + if (points.length < 3) { + const half = ((65 - 1) / 2) * DEFAULT_TERRAIN_SPACING + return { origin: [-half, -half], cols: 65, rows: 65, spacing: DEFAULT_TERRAIN_SPACING } + } + + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const [x, z] of points) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (z < minZ) minZ = z + if (z > maxZ) maxZ = z + } + + // The grid is square: a rectangular field would need two spacings or two + // sample counts, and both make every downstream index calculation branchier + // for no visual gain. + const pad = DEFAULT_TERRAIN_SPACING * 2 + const span = Math.max(maxX - minX, maxZ - minZ) + pad * 2 + const centerX = (minX + maxX) / 2 + const centerZ = (minZ + maxZ) / 2 + + const wanted = Math.ceil(span / DEFAULT_TERRAIN_SPACING) + 1 + const size = FIELD_SIZES.find((candidate) => candidate >= wanted) ?? FIELD_SIZES.at(-1)! + // Spacing stretches when the lot is bigger than 257 samples can cover at the + // default: coarser ground beats ground that stops at the property line. + const spacing = Math.max(DEFAULT_TERRAIN_SPACING, span / (size - 1)) + const half = ((size - 1) / 2) * spacing + + return { + origin: [centerX - half, centerZ - half], + cols: size, + rows: size, + spacing, + } +} + +/** + * The field a sculpt stroke on `site` should start from: its existing terrain, + * or a fresh flat one sized to the lot. + * + * Not `terrainFieldForEdit` — that takes explicit options, and the whole point + * here is that the tool must not have to know how to size a grid. + */ +export function sculptFieldForSite(site: SiteNode): TerrainField { + return terrainFieldOf(site) ?? createTerrainField(fieldExtentForSite(site)) +} + +/** Whether an XZ point belongs to the editable property footprint. */ +export function terrainPointInsideSite( + site: Pick, + x: number, + z: number, +): boolean { + const polygon = site.polygon?.points ?? [] + return polygon.length >= 3 && pointInPolygon2D([x, z], polygon, { includeBoundary: true }) +} + +/** + * Keep a rectangular brush patch inside the property footprint. + * + * The heightfield stays padded and square for compact storage and predictable + * partial uploads, but that implementation extent is not editable land. Samples + * outside the polygon retain their current values even when a brush overlaps the + * property line. + */ +export function clipTerrainPatchToSite( + field: TerrainField, + patch: HeightPatch, + site: Pick, +): HeightPatch { + let heights: Int16Array | null = null + + for (let row = 0; row < patch.rows; row += 1) { + for (let col = 0; col < patch.cols; col += 1) { + const fieldCol = patch.col0 + col + const fieldRow = patch.row0 + row + const x = field.origin[0] + fieldCol * field.spacing + const z = field.origin[1] + fieldRow * field.spacing + if (terrainPointInsideSite(site, x, z)) continue + + const patchIndex = row * patch.cols + col + const previous = field.heights[fieldRow * field.cols + fieldCol] ?? 0 + if ((patch.heights[patchIndex] ?? 0) === previous) continue + heights ??= patch.heights.slice() + heights[patchIndex] = previous + } + } + + return heights ? { ...patch, heights } : patch +} + +/** + * The site node the sculpt tool acts on, read straight from the scene. + * + * The tool and panel get this from a subscribed selector; the keyboard handler + * cannot (it is one imperative listener, not a component), and open-coding + * "root[0], is it a site?" a third time is how the three drift apart. + */ +export function activeSiteNode(): SiteNode | null { + const { nodes, rootNodeIds } = useScene.getState() + const root = rootNodeIds[0] + const node = root ? nodes[root] : undefined + return node?.type === 'site' ? (node as SiteNode) : null +} + +/** + * Largest brush radius offered, in metres. + * + * A taste call, unlike the minimum: 20 m covers a building pad in one pass and is + * about where a bigger brush stops being aimable and starts being `flattenSite`. + */ +export const MAX_BRUSH_RADIUS = 20 + +/** + * The legal brush-radius range for `site`, low end first. + * + * Exists because two controls set this value — the panel's Size slider and the + * `[`/`]` keys — and both used to carry their own hardcoded `0.5`/`20`, with a + * comment in the keyboard handler asserting they matched. They did, but nothing + * held them to it, and the shared minimum was wrong anyway: it was a flat 0.5 m + * while the real floor scales with the field's spacing, which + * `fieldExtentForSite` stretches on large lots. Below that floor a drag paints + * nothing at all (see `MIN_BRUSH_RADIUS_IN_SPACINGS`). + * + * The max is clamped to stay above the min so the range never inverts on a lot + * big enough that the minimum passes 20 m — the brush ends up pinned to one + * usable size, which is honest, rather than to an empty range. + */ +export function brushRadiusRange(site: SiteNode | null | undefined): [number, number] { + // Spacing, not the field: this runs on every render of the panel, and + // `sculptFieldForSite` would decode or allocate up to 257² samples to read one + // number off the result. An existing field's spacing is on the node; a lot with + // no terrain yet gets the spacing it *would* be given. + const spacing = site + ? (site.terrain?.spacing ?? fieldExtentForSite(site).spacing) + : DEFAULT_TERRAIN_SPACING + const min = minBrushRadius({ spacing }) + return [min, Math.max(min, MAX_BRUSH_RADIUS)] +} + +/** `radius` clamped into `brushRadiusRange(site)`. */ +export function clampBrushRadius(site: SiteNode | null | undefined, radius: number): number { + const [min, max] = brushRadiusRange(site) + return Math.min(max, Math.max(min, radius)) +} + +/** + * The absolute height a `flatten` stroke aims at. + * + * `null` target means the user never picked one, so the first click samples the + * ground under the cursor — flatten stays usable without ever opening a number + * field, and "flatten this pad to match that corner" is a two-step gesture. + */ +export function resolveFlattenTarget( + field: TerrainField, + explicitTarget: number | null, + x: number, + z: number, +): number { + return explicitTarget ?? sampleTarget(field, x, z) +} + +export type SculptCommit = { + verb: TerrainVerb + settings: BrushSettings +} + +/** + * Drop any stroke in flight on `site` before a write that replaces the whole field. + * + * Every reader prefers the live stroke over the persisted terrain (that is what + * makes a drag consistent), so a lot-wide write landing *under* a live stroke is + * invisible: the ground keeps showing the stroke, and the stroke's own commit — + * computed from a snapshot taken before the write — then overwrites it. The user + * sees a button that did nothing, with no error and nothing in history to undo. + * + * Reachable because the brush and these controls are usable at the same moment. + * On touch it takes no timing at all: one finger holds a stroke while the other + * taps the panel. With a mouse a stroke stays live across a wheel zoom's 500 ms + * `cameraDragging` window, and a held pointer that leaves the canvas keeps its + * capture until release. + * + * Abandoning rather than committing first: the pending dabs are the thing the + * user is replacing, so keeping them would mean "level the lot, except where I + * happened to be mid-drag". Ending the live stroke without committing reverts it + * exactly, the same as Escape. + */ +function abandonLiveStroke(site: Pick): void { + useLiveTerrain.getState().end(site.id) +} + +/** + * Flatten the whole site to one height in a single step. + * + * The brush cannot do this well and should not have to: covering a lot with dabs + * is dozens of strokes, and any sample the brush misses leaves a ridge that is + * invisible until a slab lands on it. Sizing from `fieldExtentForSite` means the + * result covers the padded extent, so the property line stays on flat ground too. + */ +export function flattenSite(site: SiteNode, metres: number): void { + abandonLiveStroke(site) + const field = sculptFieldForSite(site) + const value = quantize(field, metres) + const heights = new Int16Array(field.heights.length) + heights.fill(value) + commitStroke(site.id, { ...field, heights }) +} + +/** + * Drop a site's terrain entirely, back to the implicit flat ground. + * + * Distinct from flattening to 0: this removes the `terrain` field, so the scene + * returns to the exact state it had before terrain was ever touched rather than + * carrying a field of zeroes. Undo-able like any other sculpt. + */ +export function resetSiteTerrain(site: SiteNode): void { + // Before the `site.terrain` guard, not after: a stroke on a site that has no + // persisted terrain yet is exactly the case that reaches the early return, and + // leaving it live would keep showing sculpted ground the scene graph has none of. + abandonLiveStroke(site) + if (!site.terrain) return + runAsSingleSceneHistoryStep(useScene, () => { + useScene.getState().updateNode(site.id, { terrain: undefined }) + }) +} + +/** + * Persist a finished stroke as exactly one undo step. + * + * `runAsSingleSceneHistoryStep` collapses the write, which matters because a + * stroke is one user action even though it produced dozens of dabs — without it, + * undo would walk back through the stroke dab by dab, which is both surprising + * and unbounded in length. + * + * A stroke that leaves the field at the datum writes `undefined` rather than + * ~11 KB of base64 zeroes: sculpting up and then flattening back down should + * return the scene to the state it would have had if terrain were never touched, + * not merely to a visually identical one. + */ +export function commitStroke(siteId: SiteNode['id'], field: TerrainField): void { + runAsSingleSceneHistoryStep(useScene, () => { + useScene.getState().updateNode(siteId, { + terrain: isDatumField(field) ? undefined : commitTerrainField(field), + }) + }) +} diff --git a/packages/editor/src/lib/terrain-verb-color.test.ts b/packages/editor/src/lib/terrain-verb-color.test.ts new file mode 100644 index 000000000..f8368f789 --- /dev/null +++ b/packages/editor/src/lib/terrain-verb-color.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test' +import type { TerrainVerb } from '@pascal-app/core' +import { brushRingColor } from './terrain-verb-color' + +/** + * The brush ring is the only thing at the point of action that can say what the + * next drag will do. Sculpt is a *sustained* mode, so the verb a user armed a + * minute ago is still armed — and the ring's geometry is identical for all four + * verbs, which leaves colour as the only channel carrying that state. + * + * These assert the property rather than the palette: which hex is taste, but "no + * two states look alike" is correctness, and it is the thing that silently breaks + * when a fifth verb is added (terrace is planned). + */ + +const VERBS: TerrainVerb[] = ['raise', 'lower', 'flatten', 'smooth'] + +describe('brushRingColor', () => { + test('no two verbs share a colour', () => { + const colors = VERBS.map((verb) => brushRingColor(verb, false)) + expect(new Set(colors).size).toBe(VERBS.length) + }) + + test('raise and lower are the pair that must never be confused', () => { + // The original bug: one amber ring for both, so the only way to know which way + // the ground would move was to look away from the cursor at the panel. + expect(brushRingColor('raise', false)).not.toBe(brushRingColor('lower', false)) + }) + + test('flatten and smooth differ too', () => { + // Both are "neither up nor down", but one drives ground to a target height you + // can set wrongly and the other only softens what is there. + expect(brushRingColor('flatten', false)).not.toBe(brushRingColor('smooth', false)) + }) + + test('sampling overrides every verb with one distinct colour', () => { + // While the eyedropper is armed the click does not apply the verb at all, so + // the ring must not read as any of them. + const sampled = VERBS.map((verb) => brushRingColor(verb, true)) + expect(new Set(sampled).size).toBe(1) + for (const verb of VERBS) { + expect(brushRingColor(verb, true)).not.toBe(brushRingColor(verb, false)) + } + }) + + test('every verb resolves to a colour, so a new verb cannot fall through', () => { + for (const verb of VERBS) { + expect(brushRingColor(verb, false)).toMatch(/^#[0-9a-f]{6}$/) + } + }) +}) diff --git a/packages/editor/src/lib/terrain-verb-color.ts b/packages/editor/src/lib/terrain-verb-color.ts new file mode 100644 index 000000000..014946f8a --- /dev/null +++ b/packages/editor/src/lib/terrain-verb-color.ts @@ -0,0 +1,58 @@ +// The verb→colour mapping, shared by the brush ring on the canvas and the verb +// picker in the panel. +// +// Its own module because the two consumers cannot import each other: the ring is an +// R3F component (`useFrame`, `three`) and the panel is a plain sidebar surface that +// must not drag a render loop into its module graph. Duplicating four hex strings +// across them would be the version that silently drifts — and drift here is not +// cosmetic, since the whole point is that the two surfaces agree. + +import type { TerrainVerb } from '@pascal-app/core' + +/** + * One colour per verb, because the brush ring's geometry is identical for all four. + * + * A single colour made raise and lower visually indistinguishable: same circle, same + * amber, opposite effect — so the only way to know which way the next drag moves the + * ground was to look away from the cursor at the panel. The verb is also the one piece + * of sculpt state a user loses track of between strokes, since the mode is sustained + * and an intent set a minute ago is still armed. + * + * Values are the repo's existing palette (`ui/primitives/color-dot.tsx`) used with its + * established meanings — green for additive, red for destructive (`#ef4444` is already + * the delete cursor badge and the 2D delete stroke), blue and violet for the two + * operations that move ground neither up nor down. Amber is deliberately retired rather + * than kept for one verb: reusing the old colour for `raise` alone would leave every + * existing screenshot ambiguous. + * + * All four differ, including flatten vs smooth. Giving those two the same blue would + * reproduce the gap this closes, one pair over: both are "neither up nor down", but one + * drives ground to a *target height* you can set wrongly and the other only softens + * what is already there — confusing them costs a stroke. + */ +export const TERRAIN_VERB_COLOR: Record = { + raise: '#22c55e', + lower: '#ef4444', + flatten: '#3b82f6', + smooth: '#a855f7', +} + +/** + * Picking a height, not applying a verb — so the ring must not read as any of them. + * The eyedropper is a one-shot arm whose UI lives in the panel; the cursor is the only + * thing at the point of action that can say "this click samples rather than sculpts". + */ +export const TERRAIN_SAMPLING_COLOR = '#f59e0b' + +/** + * The brush ring's colour for a given brush state. + * + * A function rather than a lookup at the call site because sampling *outranks* the + * verb — while the eyedropper is armed the click does not apply the verb at all, so + * showing the verb's colour would promise the wrong action. Keeping that precedence in + * one place also makes it assertable: "no two states look alike" is a property, and one + * worth checking rather than re-eyeballing every time a verb is added. + */ +export function brushRingColor(verb: TerrainVerb, sampling: boolean): string { + return sampling ? TERRAIN_SAMPLING_COLOR : TERRAIN_VERB_COLOR[verb] +} diff --git a/packages/editor/src/lib/touch-gesture-priority.test.ts b/packages/editor/src/lib/touch-gesture-priority.test.ts new file mode 100644 index 000000000..8f59294e3 --- /dev/null +++ b/packages/editor/src/lib/touch-gesture-priority.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import type { Mode } from '../store/use-editor' +import { editorOwnsOneFingerDrag } from './touch-gesture-priority' + +/** + * One finger on a touch screen is one pointer, so the camera and the editor + * cannot both have it. These lock which side wins, because the failure is silent: + * the loser's handlers simply never fire. + */ +describe('editorOwnsOneFingerDrag', () => { + const ALL_MODES: Mode[] = [ + 'select', + 'edit', + 'delete', + 'build', + 'material-paint', + 'terrain-sculpt', + ] + + test('the camera keeps one finger when nothing is going on', () => { + expect(editorOwnsOneFingerDrag({ mode: 'select', activeGesture: false })).toBe(false) + }) + + test('both brush modes claim it with no transient gesture at all', () => { + // The regression: a brush mode arms nothing that the transient terms can see, + // so before this the camera orbited under a one-finger sculpt or paint drag. + expect(editorOwnsOneFingerDrag({ mode: 'terrain-sculpt', activeGesture: false })).toBe(true) + expect(editorOwnsOneFingerDrag({ mode: 'material-paint', activeGesture: false })).toBe(true) + }) + + test('a live gesture claims it in every mode', () => { + for (const mode of ALL_MODES) { + expect(editorOwnsOneFingerDrag({ mode, activeGesture: true })).toBe(true) + } + }) + + test('the non-brush modes leave it to the camera when idle', () => { + // Asserted as the complement of the brush set rather than a list, so adding a + // third brush mode cannot quietly land here as "camera wins". + const idle = ALL_MODES.filter( + (mode) => !editorOwnsOneFingerDrag({ mode, activeGesture: false }), + ) + expect(idle).toEqual(['select', 'edit', 'delete', 'build']) + }) +}) diff --git a/packages/editor/src/lib/touch-gesture-priority.ts b/packages/editor/src/lib/touch-gesture-priority.ts new file mode 100644 index 000000000..aa5b681be --- /dev/null +++ b/packages/editor/src/lib/touch-gesture-priority.ts @@ -0,0 +1,30 @@ +import type { Mode } from '../store/use-editor' +import { isBrushMode } from '../store/use-editor' + +/** + * Whether the editor — rather than the camera — owns a one-finger touch drag. + * + * On touch there is exactly one pointer, so the camera and the editor cannot both + * have it: `custom-camera-controls` maps one finger to `TOUCH_ROTATE` by default + * (orbiting a phone with one thumb is the common case) and to `NONE` while an + * editor gesture is live, which lets the tool's own pointer handlers through. + * Two fingers always pinch and three always orbit, so the camera is never + * unreachable — that is what makes yielding one finger safe. + * + * Extracted from the component because the terms are not obviously exhaustive and + * one was missing. A *brush mode* owns the drag exactly the way an armed tool + * does, but none of the other terms notice it: entering paint or sculpt **clears** + * `tool`, and the scope it holds is `painting`/`sculpting`, not `handle-drag`. So + * on a tablet a one-finger drag over the ground orbited the camera instead of + * sculpting — and set `cameraDragging`, which the sculpt tool gates pointer-down + * on, so the stroke could not start either. The brush was simply inert on touch, + * in both brush modes. + * + * `activeGesture` is the OR of the transient signals the component already reads + * (an armed tool, a moving node, an endpoint reshape, a handle drag, marquee + * box-select); they stay in the component because each is its own subscription, + * while what is worth pinning down here is that a sustained *mode* counts too. + */ +export function editorOwnsOneFingerDrag(args: { mode: Mode; activeGesture: boolean }): boolean { + return args.activeGesture || isBrushMode(args.mode) +} diff --git a/packages/editor/src/store/terrain-sculpt-mode.test.ts b/packages/editor/src/store/terrain-sculpt-mode.test.ts new file mode 100644 index 000000000..3d8b0722b --- /dev/null +++ b/packages/editor/src/store/terrain-sculpt-mode.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import useEditor, { isBrushMode, type Mode, normalizePersistedEditorUiState } from './use-editor' +import useInteractionScope from './use-interaction-scope' + +/** + * The mode-lifecycle contract for terrain sculpting. + * + * These are not tests of the brush — that lives in `core`'s terrain-brush tests. + * They cover the part the user experiences as "does the tool fight the rest of + * the editor": entering sculpt mode must not leave conflicting affordances + * armed, and leaving it must not leak sculpt-only state into select mode. + */ + +function reset() { + // These come first: each one forces `mode: 'select'` on entry, so leaving one + // latched would make the next test's `setMode('terrain-sculpt')` a no-op. + useEditor.getState().setPreviewMode(false) + useEditor.getState().setFirstPersonMode(false) + useEditor.getState().setWorkspaceMode('edit') + // Before the mode: `setViewMode('2d')` disarms sculpt, and leaving a stale view + // behind would silently change what the next test's `setMode` does. + useEditor.getState().setViewMode('3d') + useEditor.getState().setMode('select') + useEditor.getState().setTerrainVerb('flatten') + useEditor.getState().setTerrainSampling(false) + useInteractionScope.getState().end() +} +afterEach(reset) + +describe('terrain-sculpt mode lifecycle', () => { + test('entering claims the sculpting scope for the whole mode', () => { + useEditor.getState().setMode('terrain-sculpt') + // Held by the mode, not by a pointer gesture: an active scope is what makes + // every other node's handles and the floating action menu step back. + expect(useInteractionScope.getState().scope.kind).toBe('sculpting') + }) + + test('leaving releases it, so selection works again', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setMode('select') + expect(useInteractionScope.getState().scope.kind).toBe('idle') + }) + + test('arming sculpt from another phase moves to the site phase', () => { + useEditor.getState().setPhase('structure') + useEditor.getState().setMode('terrain-sculpt') + // Otherwise `normalizeModeForPhase` rejects the mode on the next rehydrate + // and the UI shows a mode the store does not hold. + expect(useEditor.getState().phase).toBe('site') + expect(useEditor.getState().mode).toBe('terrain-sculpt') + }) + + test('a phase switch out of site drops the scope', () => { + useEditor.getState().setMode('terrain-sculpt') + // This path rewrites `mode` without going through `setMode`, which is + // exactly why the scope sync is its own function. + useEditor.getState().setPhase('structure') + expect(useInteractionScope.getState().scope.kind).toBe('idle') + }) +}) + +describe('the eyedropper arm is not allowed to outlive sculpt mode', () => { + test('leaving via setMode disarms it', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setTerrainSampling(true) + useEditor.getState().setMode('select') + // A one-shot arm that survived would swallow the user's first click back in + // select mode, with the panel that showed it as armed already unmounted. + expect(useEditor.getState().terrainSampling).toBe(false) + }) + + test('leaving via a phase switch disarms it too', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setTerrainSampling(true) + useEditor.getState().setPhase('furnish') + expect(useEditor.getState().terrainSampling).toBe(false) + }) + + test('switching away from flatten disarms it', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setTerrainSampling(true) + useEditor.getState().setTerrainVerb('raise') + expect(useEditor.getState().terrainSampling).toBe(false) + }) + + test('staying on flatten keeps it armed', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setTerrainSampling(true) + useEditor.getState().setTerrainVerb('flatten') + expect(useEditor.getState().terrainSampling).toBe(true) + }) + + test('picking a target consumes the arm', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setTerrainSampling(true) + useEditor.getState().setTerrainFlattenTarget(2.5) + expect(useEditor.getState().terrainFlattenTarget).toBe(2.5) + expect(useEditor.getState().terrainSampling).toBe(false) + }) +}) + +describe('the brush and the 3D canvas travel together', () => { + // In `2d` the 3D pane is still mounted but `display: none`, so nothing about an + // armed brush is observable and no pointer event can reach it. Every path that + // could pair the two has to resolve it, and they resolve it in opposite + // directions on purpose: arming the mode asks for the ground (so the view + // moves), while choosing the floorplan asks for the floorplan (so the mode + // yields). + test('arming from the 2D floorplan opens a 3D pane beside it', () => { + useEditor.getState().setViewMode('2d') + useEditor.getState().setMode('terrain-sculpt') + // `split`, not `3d`: the floorplan was a deliberate choice, so keep it. + expect(useEditor.getState().viewMode).toBe('split') + expect(useEditor.getState().isFloorplanOpen).toBe(true) + expect(useEditor.getState().mode).toBe('terrain-sculpt') + }) + + test('switching to the 2D floorplan disarms the brush', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setViewMode('2d') + expect(useEditor.getState().mode).toBe('select') + // And the scope goes with it — otherwise selection stays suppressed in the + // floorplan the user just asked for, with no visible way out. + expect(useInteractionScope.getState().scope.kind).toBe('idle') + }) + + test('split keeps it armed — the canvas is right there', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setViewMode('split') + expect(useEditor.getState().mode).toBe('terrain-sculpt') + }) + + test('rehydrating the pair resolves it to select', () => { + // Mode and view persist independently and rehydrate runs neither setter, so + // this is the one reconciliation the setters cannot cover. + const state = normalizePersistedEditorUiState({ + phase: 'site', + mode: 'terrain-sculpt', + viewMode: '2d', + }) + expect(state.mode).toBe('select') + }) + + test('rehydrating sculpt beside a visible canvas restores the mode', () => { + const state = normalizePersistedEditorUiState({ + phase: 'site', + mode: 'terrain-sculpt', + viewMode: 'split', + }) + expect(state.mode).toBe('terrain-sculpt') + }) +}) + +describe('the modes that hide the editing canvas release the brush', () => { + // Preview / walkthrough / studio all unmount `ToolManager` (via `noEditing`), + // so the sculpt tool that would release the scope on unmount is gone. Each one + // resets `mode` with a raw `set` rather than `setMode`, which is exactly the + // bug class `setPhase` had: a `sculpting` scope leaked here disables selection + // across the whole editor with nothing left mounted to clear it. + test('entering preview releases it', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setPreviewMode(true) + expect(useEditor.getState().mode).toBe('select') + expect(useInteractionScope.getState().scope.kind).toBe('idle') + }) + + test('entering the first-person walkthrough releases it', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setFirstPersonMode(true) + expect(useInteractionScope.getState().scope.kind).toBe('idle') + }) + + test('entering the studio workspace releases it', () => { + useEditor.getState().setMode('terrain-sculpt') + useEditor.getState().setWorkspaceMode('studio') + expect(useInteractionScope.getState().scope.kind).toBe('idle') + }) + + test('a structure-layer switch releases it', () => { + useEditor.getState().setMode('terrain-sculpt') + // Reachable from the layer toggle while sculpt is armed, and it takes the + // same raw-`set` path — the mode resets, so the scope has to follow. + useEditor.getState().setStructureLayer('zones') + expect(useEditor.getState().mode).toBe('select') + expect(useInteractionScope.getState().scope.kind).toBe('idle') + }) +}) + +describe('entering sculpt mode stands other affordances down', () => { + test('the armed build tool is cleared', () => { + useEditor.getState().setPhase('structure') + useEditor.getState().setMode('build') + useEditor.getState().setTool('wall') + useEditor.getState().setMode('terrain-sculpt') + // A ghost still armed under the brush would place a wall on the first dab. + expect(useEditor.getState().tool).toBeNull() + }) +}) + +describe('isBrushMode', () => { + /** + * The set of modes that hold their interaction scope for the whole mode rather + * than for a pointer gesture. It matters outside the store because a scope is + * single-owner: anything calling `begin()` while a brush mode holds its scope + * evicts it silently, leaving the brush armed and painting while the editor + * believes a drag is running. Nearly every producer needs a selection — which + * entering a brush mode clears — but the clipboard paths read the clipboard, so + * `pasteSelectionAndPickUp` asks this before picking clones up. + */ + test('is exactly the two modes that hold a scope for their duration', () => { + const modes: Mode[] = ['select', 'edit', 'delete', 'build', 'material-paint', 'terrain-sculpt'] + const held: Mode[] = [] + for (const mode of modes) { + useEditor.getState().setMode(mode) + const kind = useInteractionScope.getState().scope.kind + if (kind === 'painting' || kind === 'sculpting') held.push(mode) + } + // Derived from observed scope behaviour rather than restating the literal, so + // a third brush mode that forgets `isBrushMode` fails here. + expect(held.filter(isBrushMode)).toEqual(held) + expect(modes.filter(isBrushMode)).toEqual(held) + }) +}) diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index c9ec6e3f6..651d01001 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -4,6 +4,7 @@ import type { AssetInput } from '@pascal-app/core' import { type AnyNode, type AnyNodeId, + type BrushSettings, type BuildingNode, type CabinetModuleNode, type CabinetNode, @@ -11,6 +12,7 @@ import { type ChimneyMaterialRole, type ChimneyNode, type ColumnNode, + DEFAULT_BRUSH_SETTINGS, type DoorNode, type DormerNode, type DormerSurfaceMaterialRole, @@ -28,6 +30,7 @@ import { type StairNode, type StairSegmentNode, type StairSurfaceMaterialRole, + type TerrainVerb, useScene, type WallNode, type WallSurfaceSide, @@ -138,7 +141,21 @@ export type CaptureMode = export type Phase = 'site' | 'structure' | 'furnish' -export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' +/** + * `terrain-sculpt` is a mode, not a build tool, and that is the whole answer to + * "how does terrain editing avoid conflicting with everything else". + * + * A build tool places a node and hands the pointer back. Sculpting is a + * sustained brush over the *ground* — the one surface every other tool uses as + * its reference plane — so while it is armed, clicks must not select a wall, + * drag a window, or arm a ghost. Modeling it as a mode gets that for free: it is + * mutually exclusive with `build`/`select`/`delete` by construction, every + * selection manager already early-returns unless `mode === 'select'`, and it + * holds a `sculpting` interaction scope for its whole lifetime so conflicting + * controls stay stepped back. `material-paint` is the existing precedent for + * exactly this shape. + */ +export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' | 'terrain-sculpt' // Structure mode tools (building elements) type BuiltInStructureTool = @@ -368,6 +385,29 @@ type EditorState = { paintEraser: boolean setPaintEraser: (eraser: boolean) => void primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot + /** + * Terrain sculpt state. Lives here rather than in the tool component so the + * bottom-bar HUD, the keyboard shortcuts, and the brush all read one source — + * the same reason the paint mode's material/scope/eraser live here. + */ + terrainVerb: TerrainVerb + setTerrainVerb: (verb: TerrainVerb) => void + terrainBrush: BrushSettings + setTerrainBrush: (settings: Partial) => void + /** + * Absolute height in metres the `flatten` verb aims at. Sampled by clicking + * the ground with the eyedropper, or typed. `null` means "sample on first + * click", which is what makes flatten usable without ever opening a number + * field. + */ + terrainFlattenTarget: number | null + setTerrainFlattenTarget: (metres: number | null) => void + /** + * True while the next click should sample a flatten target instead of + * sculpting. One-shot: sampling clears it. + */ + terrainSampling: boolean + setTerrainSampling: (sampling: boolean) => void // What the cursor is over in paint mode: the scopes it offers + labels for the // HUD chip. `null` when not over a paintable surface (drives the "hover a // surface" hint). Set by the selection-manager paint hover; not persisted. @@ -549,8 +589,12 @@ type SelectDefaultBuildingAndLevelOptions = { } function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode { + // The site phase used to hard-return `select`, which made a site-phase brush + // mode unrepresentable. It is an allowlist now rather than a free-for-all: + // `delete` and `material-paint` still have nothing to act on at site scope, so + // letting them survive here would restore a mode with no targets. if (phase === 'site') { - return 'select' + return mode === 'terrain-sculpt' ? mode : 'select' } return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select' @@ -568,7 +612,7 @@ export function normalizePersistedEditorUiState( state: Partial | null | undefined, ): PersistedEditorUiState { const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site' - const mode = normalizeModeForPhase(phase, state?.mode) + let mode = normalizeModeForPhase(phase, state?.mode) // Migrate old isFloorplanOpen to viewMode let viewMode: ViewMode = '3d' @@ -579,6 +623,13 @@ export function normalizePersistedEditorUiState( } const isFloorplanOpen = viewMode !== '3d' + // Both are persisted independently, and rehydrate goes through neither setter, + // so this is the third place the sculpt/2D pair has to be reconciled. The view + // wins here for the same reason it does in `setViewMode`: a brush armed over a + // hidden canvas is a mode the user cannot use, and reviving one on load is + // worse than reviving it mid-session — nothing on screen explains it. + if (mode === 'terrain-sculpt' && viewMode === '2d') mode = 'select' + if (phase === 'site') { return { ...DEFAULT_PERSISTED_EDITOR_UI_STATE, @@ -829,6 +880,54 @@ export function selectSiteFloorplanContext() { // floorplan panes render nothing meaningful for a thumbnail. let viewModeBeforeCapture: ViewMode | null = null +/** + * Hold the interaction scope that belongs to a sustained brush mode. + * + * Paint and sculpt are the two modes whose scope lifetime is the *mode*, not a + * pointer gesture. Both must be released whenever the mode changes for any + * reason — including the phase switch that resets the mode without going through + * `setMode`. A stuck `sculpting` scope would leave selection disabled across the + * whole editor, so this is one function called from every mode transition rather + * than a rule each transition remembers. + * + * The eyedropper arm rides along for the same reason: it is one-shot state whose + * UI is unmounted the moment sculpt mode ends, so it has to be cleared on every + * exit path and not just the one through `setMode`. + * + * Every `set({ mode: ... })` in this store must be followed by a call to this — + * see the callers in `setPhase`, `setStructureLayer`, `setPreviewMode`, + * `setFirstPersonMode` and `setWorkspaceMode`. The four view-swapping ones are + * the dangerous class: they unmount `ToolManager` (via `noEditing`), so the + * sculpt tool that would otherwise release the scope on unmount is gone, and a + * scope leaked there is unrecoverable without a reload. + */ +function syncBrushModeScope(mode: Mode): void { + const scope = useInteractionScope.getState() + if (mode === 'material-paint') scope.begin({ kind: 'painting' }) + else if (mode === 'terrain-sculpt') scope.begin({ kind: 'sculpting' }) + // `isBrushMode` is the same set as the two branches above — kept as one + // predicate so an added brush mode cannot be handled here and missed there. + else { + scope.endIf((s) => s.kind === 'painting' || s.kind === 'sculpting') + if (useEditor.getState().terrainSampling) useEditor.getState().setTerrainSampling(false) + } +} + +/** + * Whether `mode` is one of the two sustained brush modes. + * + * Named because callers *outside* this store need the same test: a scope is + * single-owner, so anything that calls `begin()` while a brush mode holds its + * scope silently evicts it — the brush stays armed and painting while the rest of + * the editor believes a drag is running. Almost every producer is unreachable + * under a brush mode because it needs a selection and entering the mode clears + * one, but the clipboard paths read the clipboard instead (see + * `pasteSelectionAndPickUp`), so they have to ask. + */ +export function isBrushMode(mode: Mode): boolean { + return mode === 'material-paint' || mode === 'terrain-sculpt' +} + const useEditor = create()( persist( (set, get) => ({ @@ -857,6 +956,11 @@ const useEditor = create()( set({ mode: 'select', tool: null, catalogCategory: null }) } + // Leaving the site phase must drop a held sculpt scope: this branch + // rewrites `mode` without going through `setMode`, so without this a + // stuck `sculpting` scope would disable selection in structure phase. + syncBrushModeScope(get().mode) + switch (phase) { case 'site': selectSiteFloorplanContext() @@ -875,6 +979,23 @@ const useEditor = create()( }, mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode, setMode: (mode) => { + // Sculpting is a site-phase mode. Arming it from structure/furnish moves + // the phase rather than failing silently: the user asked for the ground, + // and `normalizeModeForPhase` would otherwise reject the mode on the next + // rehydrate, leaving the UI showing a mode the store does not hold. + if (mode === 'terrain-sculpt' && get().phase !== 'site') { + get().setPhase('site') + } + + // Same promotion, one axis over. The brush needs the 3D canvas: in `2d` + // the pane is only `display: none`, so the mode would arm, hold its + // scope and show its HUD while no pointer event could ever reach it. + // `split` is the smallest move that satisfies it — a plain `3d` would + // throw away a floorplan the user deliberately opened. + if (mode === 'terrain-sculpt' && get().viewMode === '2d') { + set({ viewMode: 'split', isFloorplanOpen: true }) + } + set({ mode }) const { phase, structureLayer, tool } = get() @@ -898,9 +1019,14 @@ const useEditor = create()( set({ tool: null }) } - const scope = useInteractionScope.getState() - if (mode === 'material-paint') scope.begin({ kind: 'painting' }) - else scope.endIf((s) => s.kind === 'painting') + if (mode === 'terrain-sculpt') { + // Sculpting acts on the ground, never on a node. Clearing the + // selection on entry is what stops the selected wall's gizmo from + // sitting under the brush, competing for the same clicks. + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + } + + syncBrushModeScope(mode) }, tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool, setTool: (tool) => set({ tool }), @@ -926,6 +1052,7 @@ const useEditor = create()( set({ structureLayer: layer, tool }) } else { set({ structureLayer: layer, mode: 'select', tool: null }) + syncBrushModeScope('select') } const viewer = useViewer.getState() @@ -1045,6 +1172,21 @@ const useEditor = create()( }, paintHover: null, setPaintHover: (info) => set({ paintHover: info }), + terrainVerb: 'flatten', + setTerrainVerb: (verb) => + set({ + terrainVerb: verb, + // Switching away from flatten drops the sampling arm — an eyedropper + // that survives into the raise brush would swallow its first click. + terrainSampling: verb === 'flatten' ? get().terrainSampling : false, + }), + terrainBrush: DEFAULT_BRUSH_SETTINGS, + setTerrainBrush: (settings) => set({ terrainBrush: { ...get().terrainBrush, ...settings } }), + terrainFlattenTarget: null, + setTerrainFlattenTarget: (metres) => + set({ terrainFlattenTarget: metres, terrainSampling: false }), + terrainSampling: false, + setTerrainSampling: (sampling) => set({ terrainSampling: sampling }), canFindNode: false, setCanFindNode: (canFind) => set({ canFindNode: canFind }), selectedReferenceId: null, @@ -1095,6 +1237,7 @@ const useEditor = create()( setPreviewMode: (preview) => { if (preview) { set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null }) + syncBrushModeScope('select') // Clear zone/item selection for clean viewer drill-down hierarchy useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) } else { @@ -1137,7 +1280,14 @@ const useEditor = create()( }) }, viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode, - setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }), + setViewMode: (mode) => { + set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }) + // Going the other way, the view wins and the mode yields. Hiding the 3D + // pane leaves the brush unreachable, and a held `sculpting` scope would + // then keep selection suppressed in a floorplan the user is trying to + // work in — a mode you cannot use and cannot see how to leave. + if (mode === '2d' && get().mode === 'terrain-sculpt') get().setMode('select') + }, splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation, setSplitOrientation: (orientation) => set({ splitOrientation: orientation }), isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen, @@ -1235,6 +1385,7 @@ const useEditor = create()( tool: null, catalogCategory: null, }) + syncBrushModeScope('select') } else { const prevMode = get()._viewModeBeforeFirstPerson set({ @@ -1259,6 +1410,7 @@ const useEditor = create()( tool: null, catalogCategory: null, }) + syncBrushModeScope('select') // Clear selection so no edit affordances bleed into the clean canvas. useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) } else { @@ -1302,6 +1454,13 @@ const useEditor = create()( : {}), } }, + // `mode` is persisted, but the interaction scope a brush mode holds is not + // — it lives in a separate, non-persisted store. Rehydrating into + // `terrain-sculpt` (or paint) without re-claiming the scope would restore + // the brush with selection still enabled, so every dab could grab a wall. + onRehydrateStorage: () => (state) => { + if (state) syncBrushModeScope(state.mode) + }, partialize: (state) => ({ phase: state.phase, mode: state.mode, diff --git a/packages/editor/src/store/use-elevation-guides.ts b/packages/editor/src/store/use-elevation-guides.ts new file mode 100644 index 000000000..98d105032 --- /dev/null +++ b/packages/editor/src/store/use-elevation-guides.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand' + +export type ElevationGuide3D = { + ownerId: string + levelId: string + center: readonly [number, number] + direction: readonly [number, number] + elevation: number + label: string +} + +type ElevationGuidesState = { + guide: ElevationGuide3D | null + publish(guide: ElevationGuide3D): void + clear(ownerId: string): void +} + +const useElevationGuides = create((set) => ({ + guide: null, + publish: (guide) => + set((state) => { + const current = state.guide + if ( + current?.ownerId === guide.ownerId && + current.levelId === guide.levelId && + current.elevation === guide.elevation && + current.label === guide.label && + current.center[0] === guide.center[0] && + current.center[1] === guide.center[1] && + current.direction[0] === guide.direction[0] && + current.direction[1] === guide.direction[1] + ) { + return state + } + return { guide } + }), + clear: (ownerId) => set((state) => (state.guide?.ownerId === ownerId ? { guide: null } : state)), +})) + +export default useElevationGuides diff --git a/packages/editor/src/store/use-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts index 594e77c57..c3b9a1035 100644 --- a/packages/editor/src/store/use-interaction-scope.test.ts +++ b/packages/editor/src/store/use-interaction-scope.test.ts @@ -193,6 +193,7 @@ describe('derived flag views are leak-free (no parallel flags)', () => { { kind: 'drafting', tool: 'wall' }, { kind: 'box-select' }, { kind: 'painting' }, + { kind: 'sculpting' }, ] for (const k of kinds) { s.begin(k) diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index fc8b86359..f362f8316 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -8,6 +8,11 @@ import { type SceneApi, useScene, } from '@pascal-app/core' +import { + clearStructuralElevationGuide, + publishStructuralElevationGuide, + resolveStructuralElevationSnap, +} from '@pascal-app/editor' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' import { buildCeilingFloorplan } from './floorplan' import { @@ -49,6 +54,14 @@ function ceilingPolygonCenter(n: CeilingNodeType): [number, number] { return [cx / polygon.length, cz / polygon.length] } +function ceilingElevationGuideSource(n: CeilingNodeType) { + return { + nodeId: n.id, + levelId: n.parentId, + anchor: ceilingPolygonCenter(n), + } +} + // Ceiling height arrow — vertical chevron at the polygon centroid, // hovering just above the ceiling plane. Drags the `height` field // (the Y position of the ceiling surface). `anchor: 'min'` so dragging @@ -71,6 +84,15 @@ function ceilingHeightHandle(): HandleDescriptor { min: MIN_CEILING_HEIGHT, max: ceilingHeightBound, currentValue: (n) => resolveCeilingHeight(n, useScene.getState().nodes), + magneticSnap: (n, newValue, sceneApi) => + resolveStructuralElevationSnap(ceilingElevationGuideSource(n), newValue, sceneApi.nodes()), + onDrag: (n, sceneApi) => + publishStructuralElevationGuide( + ceilingElevationGuideSource(n), + resolveCeilingHeight(n, sceneApi.nodes()), + sceneApi.nodes(), + ), + onDragEnd: (n) => clearStructuralElevationGuide(n.id), apply: (_n, newValue) => ({ height: newValue }), placement: { position: (n) => { diff --git a/packages/nodes/src/fence/__tests__/lift.test.ts b/packages/nodes/src/fence/__tests__/lift.test.ts index 53d6f935f..de578936b 100644 --- a/packages/nodes/src/fence/__tests__/lift.test.ts +++ b/packages/nodes/src/fence/__tests__/lift.test.ts @@ -18,12 +18,17 @@ function makeDeck(elevation: number, parentId: string | null = LEVEL_ID): SlabNo }) } -function makeRailing(supportSlabId: string | undefined, parentId: string | null = LEVEL_ID) { +function makeRailing( + supportSlabId: string | undefined, + parentId: string | null = LEVEL_ID, + supportOffset?: number, +) { return FenceNode.parse({ parentId, start: [0, 0], end: [4, 0], supportSlabId, + supportOffset, }) } @@ -44,6 +49,17 @@ describe('resolveFenceLiftElevation', () => { expect(resolveFenceLiftElevation(railing, resolverFor())).toBe(0) }) + test('adds a manual support offset without changing the support source', () => { + const deck = makeDeck(1.25) + + expect(resolveFenceLiftElevation(makeRailing(deck.id, LEVEL_ID, 0.4), resolverFor(deck))).toBe( + 1.65, + ) + expect(resolveFenceLiftElevation(makeRailing(undefined, LEVEL_ID, -0.3), resolverFor())).toBe( + -0.3, + ) + }) + test('stale host (slab gone) falls back to the floor', () => { const deck = makeDeck(1.25) const railing = makeRailing(deck.id) diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index f75baeabb..e854a2cab 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -1,10 +1,17 @@ import { + type AnyNodeId, type FenceNode as FenceNodeType, getFenceControlHandle, type HandleDescriptor, isSplineFence, type NodeDefinition, + type SceneApi, } from '@pascal-app/core' +import { + clearStructuralElevationGuide, + publishStructuralElevationGuide, + resolveStructuralElevationSnap, +} from '@pascal-app/editor' import { buildFenceFloorplan } from './floorplan' import { fenceControlPointAffordance, @@ -14,6 +21,7 @@ import { } from './floorplan-affordances' import { fenceFloorplanMoveTarget } from './floorplan-move' import { buildFenceGeometry } from './geometry' +import { resolveFenceLiftElevation } from './lift' import { fencePaint } from './paint' import { fenceParametrics } from './parametrics' import { FenceNode } from './schema' @@ -26,6 +34,10 @@ const SIDE_HANDLE_MIN_HEIGHT = 0.4 const HEIGHT_HANDLE_OFFSET = 0.45 const MIN_FENCE_HEIGHT = 0.3 +function fenceBaseElevation(n: FenceNodeType, sceneApi?: SceneApi): number { + return resolveFenceLiftElevation(n, (id) => sceneApi?.get(id as AnyNodeId)) +} + function fenceMidpointFrame(n: FenceNodeType): { midX: number midZ: number @@ -43,6 +55,15 @@ function fenceMidpointFrame(n: FenceNodeType): { } } +function fenceElevationGuideSource(n: FenceNodeType) { + const { midX, midZ } = fenceMidpointFrame(n) + return { + nodeId: n.id, + levelId: n.parentId, + anchor: [midX, midZ] as const, + } +} + // Side-move arrows: click to hand the fence to its move tool. Same shape // as wall — front + back faces, positioned past the fence thickness near // the top so they don't compete with endpoint pickers in the floating @@ -53,14 +74,16 @@ function fenceSideMoveHandle(side: 'front' | 'back'): HandleDescriptor editor.engageMove(node), placement: { - position: (n) => { + position: (n, sceneApi) => { const { midX, midZ, normalX, normalZ } = fenceMidpointFrame(n) const offset = Math.max( (n.thickness ?? 0.1) / 2 + SIDE_HANDLE_OFFSET, SIDE_HANDLE_MIN_OFFSET, ) const h = n.height ?? 1.8 - const handleY = Math.max(h - SIDE_HANDLE_TOP_INSET, SIDE_HANDLE_MIN_HEIGHT) + const handleY = + fenceBaseElevation(n, sceneApi) + + Math.max(h - SIDE_HANDLE_TOP_INSET, SIDE_HANDLE_MIN_HEIGHT) return [midX + sign * normalX * offset, handleY, midZ + sign * normalZ * offset] }, rotationY: (n) => { @@ -92,9 +115,13 @@ function fenceHeightHandle(): HandleDescriptor { currentValue: (n) => n.height ?? 1.8, apply: (_n, newHeight) => ({ height: newHeight }), placement: { - position: (n) => { + position: (n, sceneApi) => { const { midX, midZ } = fenceMidpointFrame(n) - return [midX, (n.height ?? 1.8) + HEIGHT_HANDLE_OFFSET, midZ] + return [ + midX, + fenceBaseElevation(n, sceneApi) + (n.height ?? 1.8) + HEIGHT_HANDLE_OFFSET, + midZ, + ] }, rotationY: (n) => { const { normalX, normalZ } = fenceMidpointFrame(n) @@ -104,6 +131,42 @@ function fenceHeightHandle(): HandleDescriptor { } } +function fenceElevationHandle(currentBase: number): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + shape: 'tracker', + gridSnap: true, + currentValue: () => currentBase, + magneticSnap: (n, newValue, sceneApi) => + resolveStructuralElevationSnap(fenceElevationGuideSource(n), newValue, sceneApi.nodes()), + onDrag: (n, sceneApi) => + publishStructuralElevationGuide( + fenceElevationGuideSource(n), + fenceBaseElevation(n, sceneApi), + sceneApi.nodes(), + ), + onDragEnd: (n) => clearStructuralElevationGuide(n.id), + apply: (initial, newBase, sceneApi) => { + const supportBase = resolveFenceLiftElevation( + { ...initial, supportOffset: undefined }, + (id) => sceneApi.get(id as AnyNodeId), + ) + const nextOffset = newBase - supportBase + return { + supportOffset: Math.abs(nextOffset) < 1e-6 ? undefined : nextOffset, + } + }, + placement: { + position: (n, sceneApi) => { + const { midX, midZ } = fenceMidpointFrame(n) + return [midX, fenceBaseElevation(n, sceneApi), midZ] + }, + }, + } +} + // Corner picker — dashed vertical leader + billboarded hex disc at the // endpoint. Tap engages the endpoint-move flow (sister to the wall // pickers). nodeHeight controls the leader's vertical reach so the @@ -116,9 +179,9 @@ function fenceCornerPicker(endpoint: 'start' | 'end'): HandleDescriptor n.height ?? 1.8, onActivate: (node, _scene, editor) => editor.engageEndpointMove(node, endpoint), placement: { - position: (n) => { + position: (n, sceneApi) => { const corner = endpoint === 'start' ? n.start : n.end - return [corner[0], 0, corner[1]] + return [corner[0], fenceBaseElevation(n, sceneApi), corner[1]] }, }, } @@ -134,9 +197,9 @@ function fenceControlPointPicker(index: number): HandleDescriptor nodeHeight: (n) => n.height ?? 1.8, onActivate: (node, _scene, editor) => editor.engageControlPointMove(node, index), placement: { - position: (n) => { + position: (n, sceneApi) => { const point = n.path?.[index] ?? n.start - return [point[0], 0, point[1]] + return [point[0], fenceBaseElevation(n, sceneApi), point[1]] }, }, } @@ -152,13 +215,14 @@ function fenceTangentPicker(index: number, side: 'in' | 'out'): HandleDescriptor nodeHeight: (n) => (n.height ?? 1.8) * 0.6, onActivate: (node, _scene, editor) => editor.engageTangentMove(node, index, side), placement: { - position: (n) => { + position: (n, sceneApi) => { const point = n.path?.[index] ?? n.start - if (!n.path) return [point[0], 0, point[1]] + const baseElevation = fenceBaseElevation(n, sceneApi) + if (!n.path) return [point[0], baseElevation, point[1]] const handle = getFenceControlHandle(n.path, n.tangents, index) return [ point[0] + sign * handle.x * TANGENT_HANDLE_ARM_SCALE, - 0, + baseElevation, point[1] + sign * handle.y * TANGENT_HANDLE_ARM_SCALE, ] }, @@ -166,9 +230,14 @@ function fenceTangentPicker(index: number, side: 'in' | 'out'): HandleDescriptor } } -const fenceHandles = (node: FenceNodeType): HandleDescriptor[] => { +const fenceHandles = ( + node: FenceNodeType, + sceneApi?: SceneApi, +): HandleDescriptor[] => { + const elevationHandle = fenceElevationHandle(fenceBaseElevation(node, sceneApi)) if (isSplineFence(node) && node.path) { return [ + elevationHandle, fenceHeightHandle(), ...node.path.flatMap((_, index) => [ fenceControlPointPicker(index), @@ -181,6 +250,7 @@ const fenceHandles = (node: FenceNodeType): HandleDescriptor[] => return [ fenceSideMoveHandle('front'), fenceSideMoveHandle('back'), + elevationHandle, fenceHeightHandle(), fenceCornerPicker('start'), fenceCornerPicker('end'), @@ -203,7 +273,7 @@ const fenceHandles = (node: FenceNodeType): HandleDescriptor[] => export const fenceDefinition: NodeDefinition = { kind: 'fence', snapProfile: 'structural', - schemaVersion: 1, + schemaVersion: 2, schema: FenceNode, category: 'structure', surfaceRole: 'wall', diff --git a/packages/nodes/src/fence/lift.ts b/packages/nodes/src/fence/lift.ts index 01397d324..d36187ab7 100644 --- a/packages/nodes/src/fence/lift.ts +++ b/packages/nodes/src/fence/lift.ts @@ -13,13 +13,14 @@ import type { FenceNode } from './schema' * callable from the geometry builder with `ctx.resolve`. */ export function resolveFenceLiftElevation( - node: Pick, + node: Pick, resolve: (id: string) => AnyNode | undefined, ): number { - if (!node.supportSlabId) return 0 + const offset = node.supportOffset ?? 0 + if (!node.supportSlabId) return offset const host = resolve(node.supportSlabId) - if (host?.type !== 'slab') return 0 - if ((host.parentId ?? null) !== (node.parentId ?? null)) return 0 + if (host?.type !== 'slab') return offset + if ((host.parentId ?? null) !== (node.parentId ?? null)) return offset const elevation = (host as SlabNode).elevation - return Number.isFinite(elevation) ? elevation : 0 + return (Number.isFinite(elevation) ? elevation : 0) + offset } diff --git a/packages/nodes/src/shared/quick-measurement.test.ts b/packages/nodes/src/shared/quick-measurement.test.ts index 3ae030b46..6379f2ba6 100644 --- a/packages/nodes/src/shared/quick-measurement.test.ts +++ b/packages/nodes/src/shared/quick-measurement.test.ts @@ -46,10 +46,13 @@ describe('quick measurement reports', () => { ], ], elevation: 0.25, + thickness: 0.08, } as SlabNode) expect(report?.metrics.find((metric) => metric.key === 'area')?.value).toBeCloseTo(11) expect(report?.metrics.find((metric) => metric.key === 'perimeter')?.value).toBeCloseTo(14) + expect(report?.metrics.find((metric) => metric.key === 'thickness')?.value).toBeCloseTo(0.08) + expect(report?.anchor[1]).toBeCloseTo(0.29) }) test('keeps zone hover quantities explicitly footprint-only', () => { diff --git a/packages/nodes/src/shared/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts index e6465b965..23f58da7e 100644 --- a/packages/nodes/src/shared/wall-opening-ceiling.ts +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -1,9 +1,7 @@ import { type AnyNode, type AnyNodeId, - getWallPlaneTop, - resolveWallEffectiveHeight, - spatialGridManager, + getWallEffectiveHeightForNodes, type WallNode, } from '@pascal-app/core' @@ -33,19 +31,7 @@ export function resolveWallOpeningCeiling( wall: WallNode, nodes: Readonly>, ): number { - const levelId = wall.parentId ?? 'default' - const support = spatialGridManager.getSlabSupportForWall( - levelId, - wall.start, - wall.end, - wall.curveOffset ?? 0, - wall.thickness, - wall.supportSlabId, - ) - // Covering-clamped plane: openings cap under a flush/thick slab from the - // level above, matching the shortened wall body. - const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) - return resolveWallEffectiveHeight(wall, planeTop, support.elevation) + return getWallEffectiveHeightForNodes(wall, nodes as Record) } /** diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index e29bf72ea..7caa824f8 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -3,7 +3,10 @@ import { type AnyNodeId, type SiteNode, + type TerrainField, + terrainFieldOf, useLiveNodeOverrides, + useLiveTerrain, useRegistry, useScene, } from '@pascal-app/core' @@ -18,17 +21,18 @@ import { useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' -import { - BufferGeometry, - Float32BufferAttribute, - type Group, - Path, - Shape, - ShapeGeometry, -} from 'three' +import { BufferAttribute, BufferGeometry, type Group, Path, Shape, ShapeGeometry } from 'three' import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl' import { MeshLambertNodeMaterial } from 'three/webgpu' import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes' +import { + buildDrapedPolyline, + type DrapedPolyline, + terrainGridKey, + updateDrapedHeights, +} from './terrain-drape' +import { HORIZON_PLANE_Y, terrainFootprint } from './terrain-geometry' +import { TerrainRenderer } from './terrain-renderer' const Y_OFFSET = 0.01 @@ -37,30 +41,47 @@ const Y_OFFSET = 0.01 const noopRaycast = () => {} /** - * Creates simple line geometry for site boundary - * Single horizontal line at ground level + * The site boundary line, laid on the ground rather than across it. + * + * On flat ground this is the ring it always was: the polygon's own vertices at + * `Y_OFFSET`, closed. On sculpted ground `buildDrapedPolyline` subdivides each + * edge at the mesh's creases so the line lies *on* the rendered surface — the lot + * line is on screen in every frame, and a flat ring slicing through a hill is the + * most conspicuous way sculpted ground reads as broken. + * + * UVs keep their `(u, 0)` layout, now measured as normalized XZ arc length rather + * than vertex index, so a dashed or gradient material spaces evenly along the + * perimeter instead of bunching on short edges. */ -const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeometry => { +const createBoundaryLineGeometry = ( + points: Array<[number, number]>, + field: TerrainField | null, +): { geometry: BufferGeometry; ring: DrapedPolyline } => { const geometry = new BufferGeometry() + const ring = buildDrapedPolyline({ points, field, lift: Y_OFFSET, closed: true }) + const uvs = new Float32Array(ring.uvs.length * 2) + for (let index = 0; index < ring.uvs.length; index++) { + uvs[index * 2] = ring.uvs[index] ?? 0 + } + // `BufferAttribute`, not `Float32BufferAttribute`: the latter copies its input + // into a fresh array, which would leave `ring.positions` a detached CPU copy and + // silently break the in-place dab rewrite below. This shares the buffer. + geometry.setAttribute('position', new BufferAttribute(ring.positions, 3)) + geometry.setAttribute('uv', new BufferAttribute(uvs, 2)) + return { geometry, ring } +} - if (points.length < 2) return geometry - - const positions: number[] = [] - const uvs: number[] = [] - - // Create a simple line loop at ground level - points.forEach(([x, z], index) => { - positions.push(x ?? 0, Y_OFFSET, z ?? 0) - uvs.push(points.length > 1 ? index / (points.length - 1) : 0, 0) - }) - // Close the loop - positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0) - uvs.push(1, 0) - - geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) - geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) - - return geometry +/** + * Re-upload a draped ring's positions after an in-place height rewrite. + * + * No `addUpdateRange`: a dab can move any subset of the ring's vertices (the brush + * is a disc, the ring is a loop, and the two intersect in up to four arcs), so a + * range would either be the whole buffer or a list. The whole buffer is a few KB. + */ +function markPositionsDirty(geometry: BufferGeometry): void { + const attribute = geometry.getAttribute('position') as BufferAttribute + attribute.needsUpdate = true + geometry.computeBoundingSphere() } type S = ReturnType @@ -124,6 +145,23 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { ) const polygonPoints = livePolygon?.points ?? node.polygon?.points + // Persisted terrain only. A stroke is applied imperatively further down, for the + // same reason `TerrainRenderer` does it: a dab must not re-render this subtree. + const persistedField = useMemo( + () => terrainFieldOf({ id: node.id, terrain: node.terrain }), + [node.id, node.terrain], + ) + + // The terrain *grid* — mounting, resizing, or re-origining it. `hasTerrain` + // catches the first dab on a site that had no terrain at all, which is the one + // case where a stroke legitimately changes the grid and everything keyed on it + // (the horizon punch, the boundary subdivision) has to rebuild. + const hasTerrain = useLiveTerrain((state) => state.strokes.has(node.id)) + const terrainGrid = hasTerrain + ? (useLiveTerrain.getState().fieldOf(node.id) ?? persistedField) + : persistedField + const terrainKey = terrainGridKey(terrainGrid) + // Centroid + radius of the lot polygon, for the presentation fade below. const fadeBounds = useMemo(() => { if (!polygonPoints || polygonPoints.length < 3) return null @@ -228,6 +266,10 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { return shape }, [polygonPoints, slabPolygons]) + // The terrain footprint is punched out alongside the recessed slabs, and for the + // same reason: the disc must not cap ground that is modelled below it. + // + // biome-ignore lint/correctness/useExhaustiveDependencies: `terrainKey` is the grid signature the footprint is a function of; depending on the field itself would rebuild an 800 m disc every dab. const horizonGeometry = useMemo(() => { if (!fadeBounds) return null const radius = Math.max(fadeBounds.radius * 8, 400) @@ -239,17 +281,64 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { shape.lineTo(Math.cos(angle) * radius, Math.sin(angle) * radius) } shape.closePath() - addSlabHoles(shape, slabPolygons, fadeBounds.cx, fadeBounds.cz) + const holes = terrainGrid ? [...slabPolygons, terrainFootprint(terrainGrid)] : slabPolygons + addSlabHoles(shape, holes, fadeBounds.cx, fadeBounds.cz) return new ShapeGeometry(shape) - }, [fadeBounds, slabPolygons]) + }, [fadeBounds, slabPolygons, terrainKey]) useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry]) - // Create boundary line geometry - const lineGeometry = useMemo(() => { + // Boundary line geometry, subdivided against the terrain grid when there is one. + // + // Keyed on `terrainKey`, not on the field: the crease set depends on the grid + // (origin/spacing/extent) and not on the heights, so a sculpt stroke — which + // produces a new field object every dab — must not rebuild this. Heights are + // rewritten in place by the subscription below. + // + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the grid, not the field — see above. + const boundary = useMemo(() => { if (!polygonPoints || polygonPoints.length < 2) return null - return createBoundaryLineGeometry(polygonPoints) - }, [polygonPoints]) - useEffect(() => () => lineGeometry?.dispose(), [lineGeometry]) + // The live field, not the persisted one: mounting mid-stroke (the very first + // dab on a terrain-free site remounts this subtree) must drape against the + // ground actually on screen. + const field = useLiveTerrain.getState().fieldOf(node.id) ?? persistedField + return createBoundaryLineGeometry(polygonPoints, field) + }, [polygonPoints, terrainKey, node.id]) + useEffect(() => () => boundary?.geometry.dispose(), [boundary]) + const lineGeometry = boundary?.geometry ?? null + + // Per-dab height rewrite, imperative for the same reason `TerrainRenderer`'s + // upload is: a stroke pushes dozens of patches a second, and routing each through + // a React render would rebuild the ring's buffers at pointer rate. The XZ of every + // vertex is a function of the grid alone, so a dab only ever moves Y. + // + // A ref, not the memo value in a dependency, so the subscription outlives a + // polygon edit without resubscribing. + const boundaryRef = useRef<{ geometry: BufferGeometry; ring: DrapedPolyline } | null>(null) + boundaryRef.current = boundary + useEffect(() => { + let lastPatch = useLiveTerrain.getState().strokeOf(node.id)?.lastPatch ?? null + return useLiveTerrain.subscribe((state) => { + const current = boundaryRef.current + if (!current) return + const stroke = state.strokes.get(node.id) + if (!stroke) { + // The stroke ended. Its heights are now the node's, and the commit + // re-rendered this subtree with them — but a *cancelled* stroke (Escape) + // commits nothing, so the ring has to be put back explicitly. + if (lastPatch) { + lastPatch = null + const field = terrainFieldOf({ id: node.id, terrain: node.terrain }) + updateDrapedHeights(current.ring, field, Y_OFFSET) + markPositionsDirty(current.geometry) + } + return + } + if (!stroke.lastPatch || stroke.lastPatch === lastPatch) return + lastPatch = stroke.lastPatch + updateDrapedHeights(current.ring, stroke.field, Y_OFFSET) + markPositionsDirty(current.geometry) + }) + }, [node.id, node.terrain]) const groundGeometry = useMemo(() => { if (!groundShape) return null @@ -259,6 +348,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { const handlers = useNodeEvents(node, 'site') + // Terrain replaces the flat ground fill rather than sitting on top of it: two + // coplanar ground surfaces z-fight, and the flat one would poke through any + // excavation. `hasTerrain` (above) is subscribed at the site level rather than + // inside TerrainRenderer so a stroke that starts on a site with no persisted + // terrain still mounts the mesh. + const showTerrain = terrainGrid !== null + if (!(node && lineGeometry)) { return null } @@ -270,8 +366,11 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { ))} + {/* Sculpted ground, when the site has terrain */} + {showTerrain && } + {/* Ground fill: site polygon with slab holes, occludes below-grade geometry */} - {groundGeometry && ( + {groundGeometry && !showTerrain && ( { number, + origin: readonly [number, number] = [0, 0], +): TerrainField { + const base = createTerrainField({ cols, rows, spacing, step: 0.01, origin }) + 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(height(c, r) / base.step) + } + } + return { ...base, heights } +} + +/** + * A maximally warped cell: opposite corners high, the other two at the datum. + */ +function saddleField(): TerrainField { + return fieldOf(3, 3, 2, (c, r) => ((c + r) % 2 === 0 ? 4 : 0)) +} + +function vertexAt(positions: Float32Array, index: number): [number, number, number] { + return [positions[index * 3] ?? 0, positions[index * 3 + 1] ?? 0, positions[index * 3 + 2] ?? 0] +} + +describe('creaseCrossings', () => { + test('a segment crossing one grid line inside a cell splits there', () => { + const field = createTerrainField({ cols: 3, rows: 3, spacing: 1 }) + // From (0.5, 0.5) to (1.5, 0.5): crosses u = 1 halfway. v never changes, and + // u + v goes 1 → 2, crossing the diagonal u+v = 2 at the very end (t = 1), + // which is excluded as an endpoint. + expect(creaseCrossings(field, 0.5, 0.5, 1.5, 0.5)).toEqual([0.5]) + }) + + test('the diagonal family is u + v, matching the mesh triangulation', () => { + const field = createTerrainField({ cols: 3, rows: 3, spacing: 1 }) + // Inside cell [0,0], from (0.1, 0.1) to (0.9, 0.9): no grid line is crossed, + // but u + v goes 0.2 → 1.8 and so passes the cell's diagonal at u + v = 1. + const crossings = creaseCrossings(field, 0.1, 0.1, 0.9, 0.9) + expect(crossings).toHaveLength(1) + expect(crossings[0]).toBeCloseTo(0.5, 12) + }) + + test('a segment lying along a grid line adds no crossings', () => { + const field = createTerrainField({ cols: 5, rows: 5, spacing: 1 }) + // v is pinned at the integer 2 the whole way: it is *on* a crease, never + // crossing one, and the surface is planar on both sides of it. Kept inside one + // cell in u so the other two families contribute nothing either. + expect(creaseCrossings(field, 0.2, 2, 0.8, 2)).toEqual([]) + }) + + test('crossings are sorted, deduplicated, and exclude the endpoints', () => { + const field = createTerrainField({ cols: 5, rows: 5, spacing: 1 }) + // A 45° segment corner-to-corner hits u, v, and u+v creases at coincident + // parameters; each shared parameter must appear once. + const crossings = creaseCrossings(field, 0, 0, 4, 4) + expect(crossings).toEqual([...crossings].sort((a, b) => a - b)) + expect(new Set(crossings).size).toBe(crossings.length) + expect(crossings.every((t) => t > 0 && t < 1)).toBe(true) + }) + + test('crossings stop at the field edge, so an edge far outside stays cheap', () => { + const field = createTerrainField({ cols: 3, rows: 3, spacing: 1 }) + // From x = -50 to x = 50 along z = 1: only u ∈ [0,2] creases, plus the + // diagonals u + v ∈ [1, 3] that the same span reaches — a handful, not 100. + expect(creaseCrossings(field, -50, 1, 50, 1).length).toBeLessThan(8) + }) +}) + +describe('buildDrapedPolyline', () => { + test('with no field it emits the input points at the lift, closing the ring', () => { + const ring = buildDrapedPolyline({ + points: [ + [0, 0], + [4, 0], + [4, 4], + ], + field: null, + lift: 0.01, + closed: true, + }) + expect(ring.uvs).toHaveLength(4) + // The ring closes back on its first vertex, so a LineStrip needs no extra + // segment to join the last edge. + expect(vertexAt(ring.positions, 3)[0]).toBe(0) + expect(vertexAt(ring.positions, 3)[2]).toBe(0) + for (let index = 0; index < 4; index++) { + expect(vertexAt(ring.positions, index)[1]).toBeCloseTo(0.01, 6) + } + }) + + test('an open polyline ends on its last point, not back at the first', () => { + const ring = buildDrapedPolyline({ + points: [ + [0, 0], + [3, 0], + ], + field: null, + lift: 0, + closed: false, + }) + expect(ring.uvs).toHaveLength(2) + expect(vertexAt(ring.positions, 1)).toEqual([3, 0, 0]) + }) + + test('every vertex sits exactly on the shared rendered-surface authority', () => { + const field = saddleField() + const ring = buildDrapedPolyline({ + points: [ + [0.5, 0.5], + [3.5, 3.5], + ], + field, + lift: 0.01, + closed: false, + }) + for (let index = 0; index < ring.uvs.length; index++) { + const [x, y, z] = vertexAt(ring.positions, index) + expect(y).toBeCloseTo(surfaceHeightAt(field, x, z) + 0.01, 5) + } + }) + + test('midpoints of every sub-segment stay on the surface too', () => { + // The real guarantee: not just that the *vertices* are on the surface (any + // sampling gets that) but that the straight line between consecutive vertices + // is, which is only true if each sub-segment lies inside one triangle. + const field = saddleField() + const ring = buildDrapedPolyline({ + points: [ + [0.3, 0.7], + [3.7, 2.9], + ], + field, + lift: 0, + closed: false, + }) + for (let index = 1; index < ring.uvs.length; index++) { + const [x0, y0, z0] = vertexAt(ring.positions, index - 1) + const [x1, y1, z1] = vertexAt(ring.positions, index) + const mx = (x0 + x1) / 2 + const mz = (z0 + z1) / 2 + expect((y0 + y1) / 2).toBeCloseTo(surfaceHeightAt(field, mx, mz), 5) + } + }) + + test('the explicit rendered-surface name agrees with heightAt on a warped cell', () => { + const field = saddleField() + expect(surfaceHeightAt(field, 1, 1)).toBe(heightAt(field, 1, 1)) + }) + + test('a flat field drapes flat, at the lift', () => { + const field = createTerrainField({ cols: 5, rows: 5, spacing: 1 }) + const ring = buildDrapedPolyline({ + points: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + field, + lift: 0.02, + closed: true, + }) + for (let index = 0; index < ring.uvs.length; index++) { + expect(vertexAt(ring.positions, index)[1]).toBeCloseTo(0.02, 6) + } + }) + + test('arc length runs 0 → 1 monotonically in XZ', () => { + const field = fieldOf(5, 5, 1, (c) => c * 2) + const ring = buildDrapedPolyline({ + points: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + field, + lift: 0, + closed: true, + }) + expect(ring.uvs[0]).toBe(0) + expect(ring.uvs.at(-1)).toBeCloseTo(1, 6) + for (let index = 1; index < ring.uvs.length; index++) { + expect(ring.uvs[index]!).toBeGreaterThanOrEqual(ring.uvs[index - 1]!) + } + }) + + test('an off-grid origin is honoured', () => { + const field = fieldOf(3, 3, 2, (c, r) => c + r, [-3, 5]) + const ring = buildDrapedPolyline({ + points: [ + [-3, 5], + [1, 9], + ], + field, + lift: 0, + closed: false, + }) + for (let index = 0; index < ring.uvs.length; index++) { + const [x, y, z] = vertexAt(ring.positions, index) + expect(y).toBeCloseTo(surfaceHeightAt(field, x, z), 5) + } + }) + + test('a degenerate polygon yields an empty ring rather than throwing', () => { + const ring = buildDrapedPolyline({ points: [], field: null, lift: 0, closed: true }) + expect(ring.uvs).toHaveLength(0) + expect(ring.positions).toHaveLength(0) + }) +}) + +describe('updateDrapedHeights', () => { + test('rewrites Y in place and leaves XZ untouched', () => { + const flat = createTerrainField({ cols: 5, rows: 5, spacing: 1 }) + const ring = buildDrapedPolyline({ + points: [ + [0.5, 0.5], + [3.5, 2.5], + ], + field: flat, + lift: 0.01, + closed: false, + }) + const xzBefore = [...ring.positions].filter((_, index) => index % 3 !== 1) + const positionsIdentity = ring.positions + + // Same grid, different heights — exactly what a sculpt dab produces. + const hill = fieldOf(5, 5, 1, (c, r) => (c === 2 && r === 1 ? 3 : 0)) + updateDrapedHeights(ring, hill, 0.01) + + expect(ring.positions).toBe(positionsIdentity) + expect([...ring.positions].filter((_, index) => index % 3 !== 1)).toEqual(xzBefore) + for (let index = 0; index < ring.uvs.length; index++) { + const [x, y, z] = vertexAt(ring.positions, index) + expect(y).toBeCloseTo(surfaceHeightAt(hill, x, z) + 0.01, 5) + } + // And the hill actually moved something, so the assertion above has teeth. + expect(Math.max(...[...ring.positions].filter((_, i) => i % 3 === 1))).toBeGreaterThan(0.5) + }) + + test('cancelling a stroke restores the sculpted baseline, not flat ground', () => { + // The Escape path. `SiteRenderer`'s stroke subscription re-drapes against the + // *persisted* field when a stroke disappears without committing, and the case + // that matters is a site already sculpted before the abandoned stroke: restoring + // to flat would silently erase earlier work the user never touched. Asserting it + // on the two fields is what makes "Escape leaves the scene untouched" checkable + // without a canvas. + const committed = fieldOf(5, 5, 1, (c, r) => (r === 2 ? 2 : 0)) + const points: Array<[number, number]> = [ + [0, 2], + [4, 2], + ] + const ring = buildDrapedPolyline({ points, field: committed, lift: 0.01, closed: false }) + const baseline = [...ring.positions] + + // A dab lands, then the stroke is abandoned and the renderer re-drapes against + // the persisted field — the same call, with the same grid, so no reallocation. + updateDrapedHeights( + ring, + fieldOf(5, 5, 1, () => 9), + 0.01, + ) + expect([...ring.positions]).not.toEqual(baseline) + updateDrapedHeights(ring, committed, 0.01) + + expect([...ring.positions]).toEqual(baseline) + // And the baseline is genuinely sculpted, so this is not a flat-vs-flat pass. + expect(Math.max(...baseline.filter((_, i) => i % 3 === 1))).toBeGreaterThan(1) + }) + + test('a cleared field drops the ring back to the flat lift', () => { + const hill = fieldOf(5, 5, 1, (c) => c) + const ring = buildDrapedPolyline({ + points: [ + [0, 0], + [4, 0], + ], + field: hill, + lift: 0.01, + closed: false, + }) + updateDrapedHeights(ring, null, 0.01) + for (let index = 0; index < ring.uvs.length; index++) { + expect(vertexAt(ring.positions, index)[1]).toBeCloseTo(0.01, 6) + } + }) +}) + +describe('terrainGridKey', () => { + test('ignores heights, so a sculpt dab reuses the same subdivision', () => { + const flat = createTerrainField({ cols: 9, rows: 9, spacing: 0.5 }) + const hill = fieldOf(9, 9, 0.5, (c, r) => c + r) + expect(terrainGridKey(hill)).toBe(terrainGridKey(flat)) + }) + + test('changes when the grid is resized or re-originned', () => { + const base = createTerrainField({ cols: 9, rows: 9, spacing: 0.5 }) + expect(terrainGridKey({ ...base, cols: 17 })).not.toBe(terrainGridKey(base)) + expect(terrainGridKey({ ...base, spacing: 1 })).not.toBe(terrainGridKey(base)) + expect(terrainGridKey({ ...base, origin: [1, 0] })).not.toBe(terrainGridKey(base)) + }) + + test('no terrain is its own key', () => { + expect(terrainGridKey(null)).toBeNull() + }) +}) diff --git a/packages/nodes/src/site/terrain-drape.ts b/packages/nodes/src/site/terrain-drape.ts new file mode 100644 index 000000000..c0d00566b --- /dev/null +++ b/packages/nodes/src/site/terrain-drape.ts @@ -0,0 +1,244 @@ +/** + * Laying a polyline *on* the terrain surface, exactly. + * + * The site boundary is the first consumer: a ring of four flat vertices at + * `y = 0.01` slices straight through any hill it crosses and floats over any + * excavation, which is the single most obvious way sculpted ground looks broken — + * the lot line is the one element always on screen. + * + * The approach is exactness rather than density. The rendered surface is + * piecewise-**planar**: `buildTerrainMesh` emits two triangles per cell, so the + * only places it creases are the grid lines and each cell's diagonal. Subdivide a + * segment at exactly those crossings and every resulting sub-segment lies inside + * one triangle, where the surface is a plane and a straight line on it is on it + * everywhere. Uniform oversampling can only approach that, and needs an arbitrary + * density to argue about. + * + * In field index space (`u = (x - originX) / spacing`, `v = (z - originZ) / + * spacing`) the three crease families are just `u ∈ ℤ`, `v ∈ ℤ`, and `u + v ∈ ℤ` — + * the last being the diagonals, since a cell's diagonal runs from `(c+1, r)` to + * `(c, r+1)`. All three are affine in the segment parameter, so each crossing is + * one division. + * + * Heights come from `surfaceHeightAt`, the explicit rendered-surface alias for + * the shared `heightAt` triangle-plane authority. + * + * Nothing here touches Three.js: it is array math over a field, so the guarantee + * that a sub-segment lies inside one triangle is asserted in unit tests rather + * than eyeballed on a canvas. + */ + +import { surfaceHeightAt, type TerrainField } from '@pascal-app/core' + +/** A draped polyline: xyz per vertex, plus normalized arc length per vertex. */ +export type DrapedPolyline = { + /** xyz per vertex, length `count * 3`. */ + positions: Float32Array + /** Normalized distance along the line per vertex, length `count`. */ + uvs: Float32Array +} + +/** + * Crossings per segment, as a backstop only. + * + * A real segment produces at most ~3 × the field's sample count along it — a few + * hundred at 257². The cap exists so a caller that passes a segment spanning a + * degenerate field (tiny `spacing`, huge extent) cannot allocate unboundedly. + */ +const MAX_CROSSINGS_PER_SEGMENT = 4096 + +/** Two crossings closer than this (in segment parameter) are the same vertex. */ +const PARAM_EPSILON = 1e-9 + +/** + * Parameters in `(0, 1)` where the segment crosses a crease of the rendered + * surface, sorted and deduplicated. + * + * Exported for tests: the crease convention is the load-bearing part, and + * asserting it on the parameter list is far sharper than inferring it from + * vertex positions. + */ +export function creaseCrossings( + field: TerrainField, + ax: number, + az: number, + bx: number, + bz: number, +): number[] { + const u0 = (ax - field.origin[0]) / field.spacing + const v0 = (az - field.origin[1]) / field.spacing + const u1 = (bx - field.origin[0]) / field.spacing + const v1 = (bz - field.origin[1]) / field.spacing + + const crossings: number[] = [] + // Families are clamped to the index range the surface actually creases over. + // Outside the field the surface is the border cell's plane extruded outward, so + // crossings out there would add vertices to a stretch that is already flat — + // harmless geometrically, but an edge far from a fine grid would emit thousands. + addFamily(crossings, u0, u1, 0, field.cols - 1) + addFamily(crossings, v0, v1, 0, field.rows - 1) + addFamily(crossings, u0 + v0, u1 + v1, 0, field.cols - 1 + (field.rows - 1)) + + crossings.sort((a, b) => a - b) + const unique: number[] = [] + for (const t of crossings) { + const previous = unique.at(-1) + if (previous !== undefined && t - previous <= PARAM_EPSILON) continue + unique.push(t) + } + return unique +} + +/** + * Push the parameters where an affine coordinate `from → to` passes an integer in + * `[loBound, hiBound]`. + */ +function addFamily( + out: number[], + from: number, + to: number, + loBound: number, + hiBound: number, +): void { + const delta = to - from + // Parallel to this family: no crossing, whether or not it lies *on* a crease. + // A segment running along a grid line is already inside the planes on both + // sides, so it needs no extra vertex. + if (Math.abs(delta) < 1e-12) return + + const lo = Math.max(loBound, Math.ceil(Math.min(from, to))) + const hi = Math.min(hiBound, Math.floor(Math.max(from, to))) + let emitted = 0 + for (let k = lo; k <= hi; k++) { + const t = (k - from) / delta + if (t <= PARAM_EPSILON || t >= 1 - PARAM_EPSILON) continue + out.push(t) + emitted += 1 + if (emitted >= MAX_CROSSINGS_PER_SEGMENT) return + } +} + +/** + * Build a draped polyline through `points`. + * + * `field` of `null` means flat ground at the datum: the result is the input + * points at `y = lift`, no subdivision. Keeping that case *here* rather than at + * every call site is what lets a consumer hold one geometry and one code path + * whether or not the site has terrain — and it means a scene that never touched + * terrain builds byte-identical buffers to the ones it built before draping + * existed. + * + * `closed` repeats the first point at the end, so a `LineStrip` closes the ring. + */ +export function buildDrapedPolyline({ + points, + field, + lift, + closed, +}: { + points: ReadonlyArray + field: TerrainField | null + lift: number + closed: boolean +}): DrapedPolyline { + const xz: Array<[number, number]> = [] + const segments = closed ? points.length : points.length - 1 + + for (let index = 0; index < segments; index++) { + const from = points[index] + const to = points[(index + 1) % points.length] + if (!(from && to)) continue + const [ax, az] = from + const [bx, bz] = to + xz.push([ax, az]) + if (!field) continue + for (const t of creaseCrossings(field, ax, az, bx, bz)) { + xz.push([ax + (bx - ax) * t, az + (bz - az) * t]) + } + } + // The final vertex: the ring's start again when closed, otherwise the last + // point, which the segment loop above never emits. + const last = closed ? points[0] : points.at(-1) + if (last) xz.push([last[0], last[1]]) + + const positions = new Float32Array(xz.length * 3) + for (let index = 0; index < xz.length; index++) { + const [x, z] = xz[index]! + positions[index * 3] = x + positions[index * 3 + 2] = z + } + const ring: DrapedPolyline = { positions, uvs: new Float32Array(xz.length) } + writeDrapedHeights(ring, field, lift) + writeArcLengths(ring) + return ring +} + +/** + * Rewrite only the heights of an existing draped polyline, in place. + * + * This is the sculpt-stroke path. The crease set is a function of the field's + * *grid* — origin, spacing, sample counts — and not of its heights, so every dab + * of a stroke leaves the XZ of every vertex exactly where it was. Rebuilding the + * ring per dab would reallocate two buffers and a `BufferGeometry` at pointer + * rate for a result that differs only in Y. + * + * Reads each vertex's own XZ back out of the buffer rather than taking the source + * polygon again: there is then no second copy of the subdivision to keep in step. + * + * Arc lengths are deliberately *not* refreshed — see `writeArcLengths`. + */ +export function updateDrapedHeights( + ring: DrapedPolyline, + field: TerrainField | null, + lift: number, +): void { + writeDrapedHeights(ring, field, lift) +} + +function writeDrapedHeights(ring: DrapedPolyline, field: TerrainField | null, lift: number): void { + const count = ring.uvs.length + for (let index = 0; index < count; index++) { + const x = ring.positions[index * 3] ?? 0 + const z = ring.positions[index * 3 + 2] ?? 0 + ring.positions[index * 3 + 1] = field ? surfaceHeightAt(field, x, z) + lift : lift + } +} + +/** + * Normalized cumulative distance along the line, measured in **XZ**. + * + * Horizontal, not 3D: the value has to be stable while the ground moves under a + * stroke, and a 3D arc length is not — it would stretch as a hill rises, sliding + * any dash pattern along the ring dab by dab. XZ length is what a plan-view + * reading of the lot line uses anyway, so the 2D and 3D views agree on it. + */ +function writeArcLengths(ring: DrapedPolyline): void { + const count = ring.uvs.length + if (count === 0) return + let total = 0 + for (let index = 1; index < count; index++) { + const dx = (ring.positions[index * 3] ?? 0) - (ring.positions[(index - 1) * 3] ?? 0) + const dz = (ring.positions[index * 3 + 2] ?? 0) - (ring.positions[(index - 1) * 3 + 2] ?? 0) + total += Math.hypot(dx, dz) + ring.uvs[index] = total + } + if (total <= 0) return + for (let index = 1; index < count; index++) { + ring.uvs[index] = (ring.uvs[index] ?? 0) / total + } +} + +/** + * Everything about a field that changes the crease set, and nothing that doesn't. + * + * A React consumer needs to rebuild a draped ring when the *grid* moves and only + * rewrite heights when the *heights* move. Keying a memo on the field object would + * rebuild every dab (each `applyHeightPatch` returns a new field); keying it on + * this rebuilds only when a resize or re-origin actually invalidates the + * subdivision, which in practice means import. `null` for no terrain, so the + * flat-ground ring is one more value in the same key space. + */ +export function terrainGridKey(field: TerrainField | null): string | null { + if (!field) return null + return `${field.origin[0]}:${field.origin[1]}:${field.spacing}:${field.cols}:${field.rows}` +} diff --git a/packages/nodes/src/site/terrain-geometry.test.ts b/packages/nodes/src/site/terrain-geometry.test.ts new file mode 100644 index 000000000..81ecef056 --- /dev/null +++ b/packages/nodes/src/site/terrain-geometry.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, test } from 'bun:test' +import { + applyHeightPatch, + createTerrainField, + flattenPatch, + type TerrainField, +} from '@pascal-app/core' +import { + buildTerrainMesh, + buildTerrainSkirt, + HORIZON_PLANE_Y, + patchUpdateRange, + terrainFootprint, + updateTerrainMesh, + updateTerrainSkirt, +} from './terrain-geometry' + +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('buildTerrainMesh', () => { + test('emits one vertex per sample and two triangles per cell', () => { + const field = createTerrainField({ cols: 5, rows: 4, spacing: 1 }) + const mesh = buildTerrainMesh(field) + expect(mesh.positions).toHaveLength(5 * 4 * 3) + expect(mesh.normals).toHaveLength(5 * 4 * 3) + expect(mesh.uvs).toHaveLength(5 * 4 * 2) + expect(mesh.indices).toHaveLength(4 * 3 * 6) + }) + + test('vertex positions match the field origin and spacing', () => { + const field: TerrainField = { + ...createTerrainField({ cols: 3, rows: 3, spacing: 2 }), + origin: [10, 20], + } + const mesh = buildTerrainMesh(field) + // Sample [2,1] -> world (10 + 2*2, 20 + 1*2) = (14, 22). + const i = (1 * 3 + 2) * 3 + expect(mesh.positions[i]).toBeCloseTo(14, 6) + expect(mesh.positions[i + 2]).toBeCloseTo(22, 6) + }) + + test('vertex Y carries the quantized height', () => { + const field = rampField(1) + const mesh = buildTerrainMesh(field) + // Column 3 of row 0 is 3 m up on a 1:1 ramp. + expect(mesh.positions[3 * 3 + 1]).toBeCloseTo(3, 5) + }) + + test('flat ground yields straight-up normals', () => { + const mesh = buildTerrainMesh(createTerrainField({ cols: 4, rows: 4, spacing: 1 })) + for (let i = 0; i < 16; i++) { + expect(mesh.normals[i * 3 + 1]).toBeCloseTo(1, 6) + } + }) + + test('every normal is unit length on a slope', () => { + const mesh = buildTerrainMesh(rampField(0.5, 5, 5)) + for (let i = 0; i < 25; i++) { + const len = Math.hypot( + mesh.normals[i * 3] ?? 0, + mesh.normals[i * 3 + 1] ?? 0, + mesh.normals[i * 3 + 2] ?? 0, + ) + expect(len).toBeCloseTo(1, 6) + } + }) + + test('UVs span 0..1 across the field', () => { + const mesh = buildTerrainMesh(createTerrainField({ cols: 3, rows: 3, spacing: 1 })) + expect(mesh.uvs[0]).toBeCloseTo(0, 9) + expect(mesh.uvs[1]).toBeCloseTo(0, 9) + // Last sample [2,2]. + const last = (2 * 3 + 2) * 2 + expect(mesh.uvs[last]).toBeCloseTo(1, 9) + expect(mesh.uvs[last + 1]).toBeCloseTo(1, 9) + }) + + test('indices stay in range and wind counter-clockwise seen from +Y', () => { + const field = createTerrainField({ cols: 3, rows: 3, spacing: 1 }) + const mesh = buildTerrainMesh(field) + const vertexCount = field.cols * field.rows + for (const idx of mesh.indices) { + expect(idx).toBeLessThan(vertexCount) + } + // Every triangle's face normal must point up (+Y), and therefore agree with + // the analytic vertex normals. Checking the 3D cross product rather than a + // 2D signed area, because the 2D shortcut has the opposite sign convention + // in a Y-up right-handed frame and would assert the reverse. + for (let t = 0; t < mesh.indices.length; t += 3) { + const a = mesh.indices[t] ?? 0 + const b = mesh.indices[t + 1] ?? 0 + const c = mesh.indices[t + 2] ?? 0 + const abx = (mesh.positions[b * 3] ?? 0) - (mesh.positions[a * 3] ?? 0) + const abz = (mesh.positions[b * 3 + 2] ?? 0) - (mesh.positions[a * 3 + 2] ?? 0) + const acx = (mesh.positions[c * 3] ?? 0) - (mesh.positions[a * 3] ?? 0) + const acz = (mesh.positions[c * 3 + 2] ?? 0) - (mesh.positions[a * 3 + 2] ?? 0) + // Y component of (ab x ac). + const normalY = abz * acx - abx * acz + expect(normalY).toBeGreaterThan(0) + } + }) + + test('a degenerate 1x1 field produces no triangles rather than throwing', () => { + const mesh = buildTerrainMesh(createTerrainField({ cols: 1, rows: 1, spacing: 1 })) + expect(mesh.indices).toHaveLength(0) + expect(mesh.positions).toHaveLength(3) + }) +}) + +describe('patchUpdateRange', () => { + test('covers the patch rows widened by one on each side for normals', () => { + const field = createTerrainField({ cols: 10, rows: 10, spacing: 1 }) + const range = patchUpdateRange( + field, + { + col0: 3, + row0: 4, + cols: 2, + rows: 2, + heights: new Int16Array(4), + }, + 3, + ) + expect(range).not.toBeNull() + // Rows 3..6 inclusive -> vertices 30..69 -> elements 90..210. + expect(range?.start).toBe(3 * 10 * 3) + expect(range?.count).toBe(4 * 10 * 3) + }) + + test('clamps at the field edges', () => { + const field = createTerrainField({ cols: 8, rows: 8, spacing: 1 }) + const range = patchUpdateRange( + field, + { + col0: 0, + row0: 0, + cols: 1, + rows: 1, + heights: new Int16Array(1), + }, + 3, + ) + // Starts at row 0, not -1. + expect(range?.start).toBe(0) + }) + + test('scales with itemSize so uv (2) and position (3) ranges differ', () => { + const field = createTerrainField({ cols: 8, rows: 8, spacing: 1 }) + const patch = { col0: 0, row0: 2, cols: 1, rows: 1, heights: new Int16Array(1) } + const pos = patchUpdateRange(field, patch, 3) + const uv = patchUpdateRange(field, patch, 2) + expect(pos?.start).toBe(1 * 8 * 3) + expect(uv?.start).toBe(1 * 8 * 2) + }) +}) + +describe('updateTerrainMesh — the dirty-rect path', () => { + test('a patched rebuild matches a full rebuild exactly', () => { + const before = createTerrainField({ cols: 17, rows: 17, spacing: 0.5 }) + const patch = flattenPatch(before, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 1.5) + expect(patch).not.toBeNull() + const after = applyHeightPatch(before, patch as never) + + const incremental = buildTerrainMesh(before) + updateTerrainMesh(after, incremental, patch as never) + const full = buildTerrainMesh(after) + + // Positions must agree everywhere — the incremental path is only allowed to + // be cheaper, never different. + expect(Array.from(incremental.positions)).toEqual(Array.from(full.positions)) + }) + + test('normals agree too — the widened range catches neighbour shading', () => { + const before = rampField(0.3, 17, 17, 0.5) + const patch = flattenPatch(before, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 1) + const after = applyHeightPatch(before, patch as never) + + const incremental = buildTerrainMesh(before) + updateTerrainMesh(after, incremental, patch as never) + const full = buildTerrainMesh(after) + + for (let i = 0; i < full.normals.length; i++) { + expect(incremental.normals[i]).toBeCloseTo(full.normals[i] ?? 0, 6) + } + }) + + test('does not reallocate the buffers', () => { + const field = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const mesh = buildTerrainMesh(field) + const positions = mesh.positions + updateTerrainMesh(field, mesh, { + col0: 0, + row0: 3, + cols: 2, + rows: 2, + heights: new Int16Array(4), + }) + expect(mesh.positions).toBe(positions) + }) +}) + +describe('buildTerrainSkirt — the horizon seam', () => { + /** Top/bottom Y of the ring position at index `i`. */ + function span(positions: Float32Array, i: number): { top: number; bottom: number } { + return { top: positions[i * 2 * 3 + 1] ?? 0, bottom: positions[(i * 2 + 1) * 3 + 1] ?? 0 } + } + + test('one quad per boundary sample, closing the ring', () => { + const field = createTerrainField({ cols: 5, rows: 4, spacing: 1 }) + const skirt = buildTerrainSkirt(field) + const perimeter = 2 * 4 + 2 * 3 + // The first ring position is repeated at the end, so the strip closes without + // a special-cased final quad. + expect(skirt.positions).toHaveLength((perimeter + 1) * 2 * 3) + expect(skirt.indices).toHaveLength(perimeter * 6) + }) + + test('the top edge follows the terrain wherever the terrain is above the disc', () => { + const field = rampField(1, 5, 5) + const skirt = buildTerrainSkirt(field) + // Ring position 0 is sample [0,0] (height 0), and the ramp's far edge is 4 m. + // Position 4 is sample [4,0] — the top of the ramp. + expect(span(skirt.positions, 4).top).toBeCloseTo(4, 5) + expect(skirt.positions[4 * 2 * 3]).toBeCloseTo(4, 5) + }) + + test('the span brackets both the terrain edge and the horizon plane', () => { + // The correctness argument: whichever of the two is higher, a sightline through + // the rim crosses the curtain, because the curtain covers the rim's whole Y + // range at the rim's own XZ. Asserted on an excavated edge and a raised one. + const base = createTerrainField({ cols: 5, rows: 5, spacing: 1 }) + const dug = applyHeightPatch( + base, + flattenPatch(base, { minX: -1, minZ: -1, maxX: 1, maxZ: 5 }, -3) as never, + ) + const skirt = buildTerrainSkirt(dug) + const perimeter = 2 * 4 + 2 * 4 + for (let i = 0; i < perimeter; i++) { + const { top, bottom } = span(skirt.positions, i) + // Float32 storage rounds the plane itself, so the bracket is asserted to + // within a rounding step rather than exactly. + expect(top).toBeGreaterThan(HORIZON_PLANE_Y - 1e-6) + expect(bottom).toBeLessThan(HORIZON_PLANE_Y) + expect(bottom).toBeLessThan(top) + } + }) + + test('flat ground at the datum still spans the disc, so the rim is never open', () => { + // The punch in the horizon disc exists whenever there is a field at all, so + // even an untouched field has a rim to close. + const skirt = buildTerrainSkirt(createTerrainField({ cols: 5, rows: 5, spacing: 1 })) + const { top, bottom } = span(skirt.positions, 0) + expect(top).toBeCloseTo(0, 6) + expect(bottom).toBeLessThan(HORIZON_PLANE_Y) + }) + + test('every ring position is on the field boundary', () => { + const field = createTerrainField({ cols: 5, rows: 5, spacing: 2 }) + const skirt = buildTerrainSkirt(field) + const perimeter = 2 * 4 + 2 * 4 + const maxX = 8 + const maxZ = 8 + for (let i = 0; i <= perimeter; i++) { + const x = skirt.positions[i * 2 * 3] ?? 0 + const z = skirt.positions[i * 2 * 3 + 2] ?? 0 + const onEdge = x === 0 || x === maxX || z === 0 || z === maxZ + expect(onEdge).toBe(true) + // And the pair shares its XZ — the quad is vertical. + expect(skirt.positions[(i * 2 + 1) * 3]).toBe(x) + expect(skirt.positions[(i * 2 + 1) * 3 + 2]).toBe(z) + } + }) + + test('normals point outward and lie in the horizontal plane', () => { + const field = createTerrainField({ cols: 5, rows: 5, spacing: 1 }) + const skirt = buildTerrainSkirt(field) + const cx = 2 + const cz = 2 + const perimeter = 2 * 4 + 2 * 4 + for (let i = 0; i < perimeter; i++) { + const base = i * 2 * 3 + const x = skirt.positions[base] ?? 0 + const z = skirt.positions[base + 2] ?? 0 + const nx = skirt.normals[base] ?? 0 + const ny = skirt.normals[base + 1] ?? 0 + const nz = skirt.normals[base + 2] ?? 0 + expect(ny).toBe(0) + expect(Math.hypot(nx, nz)).toBeCloseTo(1, 6) + // Outward: the normal agrees with the direction away from the field centre. + expect(nx * (x - cx) + nz * (z - cz)).toBeGreaterThan(0) + } + }) + + test('updateTerrainSkirt rewrites in place and matches a full rebuild', () => { + const before = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const patch = flattenPatch(before, { minX: -1, minZ: -1, maxX: 2, maxZ: 2 }, 2.5) + const after = applyHeightPatch(before, patch as never) + + const skirt = buildTerrainSkirt(before) + const positions = skirt.positions + updateTerrainSkirt(after, skirt) + expect(skirt.positions).toBe(positions) + + const full = buildTerrainSkirt(after) + expect(Array.from(skirt.positions)).toEqual(Array.from(full.positions)) + }) + + test('a degenerate field yields no skirt rather than throwing', () => { + const skirt = buildTerrainSkirt(createTerrainField({ cols: 1, rows: 1, spacing: 1 })) + expect(skirt.positions).toHaveLength(0) + expect(skirt.indices).toHaveLength(0) + }) +}) + +/** + * The punch is what makes a pit visible: the horizon disc is a solid plate at + * `HORIZON_PLANE_Y`, so without a hole in it every excavation is roofed over and + * digging reads as no change at all. These assert the two properties the punch has + * to hold — it covers the whole field, and it stays inside the rim the skirt closes. + */ +describe('terrainFootprint — the horizon punch', () => { + test('the hole covers every interior sample of the field', () => { + const field = createTerrainField({ cols: 9, rows: 9, spacing: 2 }) + const [[minX, minZ], , [maxX, maxZ]] = terrainFootprint(field) as [ + [number, number], + [number, number], + [number, number], + [number, number], + ] + // A sample left outside the hole is a column of disc standing over the terrain + // there — the exact "excavation capped by the horizon" failure. + for (let row = 1; row < field.rows - 1; row++) { + for (let col = 1; col < field.cols - 1; col++) { + const x = field.origin[0] + col * field.spacing + const z = field.origin[1] + row * field.spacing + expect(x).toBeGreaterThan(minX) + expect(x).toBeLessThan(maxX) + expect(z).toBeGreaterThan(minZ) + expect(z).toBeLessThan(maxZ) + } + } + }) + + test('the hole stays inside the rim, by half a cell on every side', () => { + const field = createTerrainField({ cols: 5, rows: 7, spacing: 4 }) + const footprint = terrainFootprint(field) + const half = field.spacing / 2 + const edgeMinX = field.origin[0] + const edgeMinZ = field.origin[1] + const edgeMaxX = field.origin[0] + (field.cols - 1) * field.spacing + const edgeMaxZ = field.origin[1] + (field.rows - 1) * field.spacing + // Inset, never outset. Punching *wider* than the terrain edge would open a gap + // the skirt does not span — the skirt's curtain sits at the rim's exact XZ, so + // it can only close a hole that stops at or inside that rim. + expect(footprint).toEqual([ + [edgeMinX + half, edgeMinZ + half], + [edgeMaxX - half, edgeMinZ + half], + [edgeMaxX - half, edgeMaxZ - half], + [edgeMinX + half, edgeMaxZ - half], + ]) + }) + + test('every skirt ring position lies outside the punched hole', () => { + // The overlap that stops the z-fight, stated as the invariant that matters: + // the curtain is always on the disc side of the hole's edge, so there is a band + // of doubled coverage rather than two coplanar edges fighting along the rim. + const field = rampField(0.4, 9, 9, 1) + const skirt = buildTerrainSkirt(field) + const [[minX, minZ], , [maxX, maxZ]] = terrainFootprint(field) as [ + [number, number], + [number, number], + [number, number], + [number, number], + ] + const count = skirt.positions.length / 3 + expect(count).toBeGreaterThan(0) + for (let index = 0; index < count; index++) { + const x = skirt.positions[index * 3] as number + const z = skirt.positions[index * 3 + 2] as number + const outside = x <= minX || x >= maxX || z <= minZ || z >= maxZ + expect(outside).toBe(true) + } + }) + + test('an off-grid origin carries through, so the punch is not centred on assumption', () => { + const base = createTerrainField({ cols: 5, rows: 5, spacing: 1 }) + const field: TerrainField = { ...base, origin: [17.5, -3.25] } + const footprint = terrainFootprint(field) + expect(footprint[0]).toEqual([18, -2.75]) + expect(footprint[2]).toEqual([21, 0.25]) + }) + + test('a degenerate field yields an inverted footprint rather than throwing', () => { + // 1×1 has no cells, so min crosses max. The renderer only punches when there is + // a grid to punch, and `ShapeGeometry` tolerates it — worth pinning that this + // returns rather than throws, since it is reachable from a malformed import. + const footprint = terrainFootprint(createTerrainField({ cols: 1, rows: 1, spacing: 1 })) + expect(footprint).toHaveLength(4) + expect(footprint[0]![0]).toBeGreaterThan(footprint[1]![0]) + }) +}) diff --git a/packages/nodes/src/site/terrain-geometry.ts b/packages/nodes/src/site/terrain-geometry.ts new file mode 100644 index 000000000..ef1048c4e --- /dev/null +++ b/packages/nodes/src/site/terrain-geometry.ts @@ -0,0 +1,339 @@ +import { type HeightPatch, heightAtSample, normalAt, type TerrainField } from '@pascal-app/core' + +/** + * Builds the terrain mesh buffers from a `TerrainField`. + * + * Split out of the renderer as pure array math so the vertex layout is + * unit-testable without a canvas, and so the partial-upload path has one + * definition. The renderer owns the `BufferGeometry`; this owns what goes in it. + * + * The triangulation this file emits is the convention `surfaceHeightAt` + * (`core/lib/terrain-field.ts`) reads back: two triangles per cell split along the + * `(c+1,r)`–`(c,r+1)` diagonal. Anything drawn *on* the ground samples that + * function, so changing the winding below without changing it there would make + * every draped line sink into half of every cell. + * + * Two rules that are easy to get wrong and expensive to debug: + * + * - **Normals are analytic, never `computeVertexNormals()`.** Beyond the cost + * (~1.5 ms at 257²), `computeVertexNormals()` rewrites the *whole* normal + * attribute, which silently defeats the dirty-rect partial upload that makes + * sculpting cheap. `normalAt` central-differences the field instead, so a + * patch touches only the rows it changed. + * - **Vertices are one-per-sample and shared between triangles**, so a partial + * upload is a contiguous row range. Duplicating verts per-triangle for flat + * shading would triple memory and scatter the dirty range. + */ + +/** + * Y of the site's presentation horizon disc (`site/renderer.tsx`). + * + * It lives here, next to the skirt, because the skirt is the thing that has to + * bracket it: its top edge never sits below this plane and its base always clears + * it, so no sightline can pass between the sculpted ground and the surrounding + * one. Two readers, one number. + */ +export const HORIZON_PLANE_Y = -0.07 + +/** + * How far the skirt's base hangs below the lowest thing it must close over. + * + * Only the top edge is visually load-bearing; the base just has to be out of sight + * under the horizon disc. A metre reads as earth thickness from a grazing camera + * without being tall enough to poke out of a neighbouring excavation. + */ +const SKIRT_DROP = 1 + +/** One vertex per sample, row-major — index `r * cols + c` matches the field. */ +export type TerrainMeshBuffers = { + /** xyz per sample, length `cols * rows * 3`. */ + positions: Float32Array + /** xyz per sample, length `cols * rows * 3`. */ + normals: Float32Array + /** uv per sample, length `cols * rows * 2`. */ + uvs: Float32Array + /** Triangle indices, two per grid cell. */ + indices: Uint32Array +} + +/** Vertex index of sample [c,r]. */ +function vertexIndex(field: TerrainField, col: number, row: number): number { + return row * field.cols + col +} + +export function buildTerrainMesh(field: TerrainField): TerrainMeshBuffers { + const count = field.cols * field.rows + const positions = new Float32Array(count * 3) + const normals = new Float32Array(count * 3) + const uvs = new Float32Array(count * 2) + + for (let r = 0; r < field.rows; r++) { + for (let c = 0; c < field.cols; c++) { + const i = r * field.cols + c + writeVertex(field, positions, normals, uvs, c, r, i) + } + } + + // Two triangles per cell. Winding is counter-clockwise seen from +Y so the + // surface faces up under the default FrontSide material. + const cells = (field.cols - 1) * (field.rows - 1) + const indices = new Uint32Array(Math.max(0, cells) * 6) + let n = 0 + for (let r = 0; r < field.rows - 1; r++) { + for (let c = 0; c < field.cols - 1; c++) { + const a = vertexIndex(field, c, r) + const b = vertexIndex(field, c + 1, r) + const d = vertexIndex(field, c, r + 1) + const e = vertexIndex(field, c + 1, r + 1) + indices[n++] = a + indices[n++] = d + indices[n++] = b + indices[n++] = b + indices[n++] = d + indices[n++] = e + } + } + + return { positions, normals, uvs, indices } +} + +function writeVertex( + field: TerrainField, + positions: Float32Array, + normals: Float32Array, + uvs: Float32Array, + col: number, + row: number, + index: number, +): void { + const x = field.origin[0] + col * field.spacing + const z = field.origin[1] + row * field.spacing + const y = heightAtSample(field, col, row) + + positions[index * 3] = x + positions[index * 3 + 1] = y + positions[index * 3 + 2] = z + + const [nx, ny, nz] = normalAt(field, x, z) + normals[index * 3] = nx + normals[index * 3 + 1] = ny + normals[index * 3 + 2] = nz + + // UVs span the field 0..1 so a tiled ground material scales with the site + // rather than with sample count. + uvs[index * 2] = field.cols > 1 ? col / (field.cols - 1) : 0 + uvs[index * 2 + 1] = field.rows > 1 ? row / (field.rows - 1) : 0 +} + +/** + * A vertical curtain around the field boundary, closing the terrain against the + * site's horizon disc. + * + * The disc is a flat plate at `HORIZON_PLANE_Y` standing in for ground beyond the + * field, and the field's extent is punched out of it so an excavation is not simply + * hidden under it. That punch leaves an open rim, and sculpted ground turns it into + * a visible fault: a raised edge shows daylight under the terrain shell, a lowered + * one shows the disc's cut edge hanging in the air over the pit. + * + * The curtain spans `[min(h, H) - SKIRT_DROP, max(h, H)]` at every boundary + * sample, so it *brackets* both the terrain edge and the horizon plane no matter + * which is higher. That is the whole correctness argument: a sightline through the + * rim has to cross the curtain, because the curtain covers the rim's entire Y + * range at the rim's exact XZ. + * + * One vertex pair (top, bottom) per boundary sample, in a ring whose first sample + * is repeated at the end so the strip closes. + */ +export type TerrainSkirtBuffers = { + /** xyz, `(perimeter + 1) * 2` vertices — top then bottom per ring position. */ + positions: Float32Array + normals: Float32Array + indices: Uint32Array +} + +/** + * The field's own footprint, as a polygon to punch out of the horizon disc. + * + * The punch is the only reason an excavation is visible at all: without it the disc + * — a flat plate at `HORIZON_PLANE_Y` standing in for ground beyond the field — + * roofs over every pit, so digging a basement reads as no change whatsoever. On + * raised or flat ground it costs nothing visually, because the terrain shell and its + * skirt cover the hole from both sides. + * + * Inset by half a cell, so the disc and the terrain edge *overlap* rather than meet: + * two coplanar edges at exactly the same XZ z-fight along the whole perimeter. The + * inset is what the skirt's bracketing span is covering, which is why this lives + * beside `buildTerrainSkirt` — the two have to agree about where the rim is, and a + * footprint that punched wider than the skirt spans would open a gap the skirt never + * closes. + * + * Kept here rather than in the renderer so it is reachable without Three.js: it + * decides whether a pit is visible, and that is a property to assert, not to eyeball. + */ +export function terrainFootprint(field: TerrainField): Array<[number, number]> { + const inset = field.spacing / 2 + const minX = field.origin[0] + inset + const minZ = field.origin[1] + inset + const maxX = field.origin[0] + (field.cols - 1) * field.spacing - inset + const maxZ = field.origin[1] + (field.rows - 1) * field.spacing - inset + return [ + [minX, minZ], + [maxX, minZ], + [maxX, maxZ], + [minX, maxZ], + ] +} + +/** Boundary sample count — a function of the grid shape alone. */ +function perimeterCount(field: TerrainField): number { + if (field.cols < 2 || field.rows < 2) return 0 + return 2 * (field.cols - 1) + 2 * (field.rows - 1) +} + +/** + * The `i`th boundary sample, walking top → right → bottom → left. + * + * The direction matters: traversed this way the outward horizontal is the tangent + * rotated as `(tx, tz) → (tz, -tx)` on every edge, which is what makes one winding + * rule and one normal formula cover all four sides instead of four special cases. + */ +function boundarySample(field: TerrainField, i: number): { col: number; row: number } { + const lastCol = field.cols - 1 + const lastRow = field.rows - 1 + if (i < lastCol) return { col: i, row: 0 } + if (i < lastCol + lastRow) return { col: lastCol, row: i - lastCol } + if (i < 2 * lastCol + lastRow) return { col: 2 * lastCol + lastRow - i, row: lastRow } + return { col: 0, row: 2 * (lastCol + lastRow) - i } +} + +export function buildTerrainSkirt(field: TerrainField): TerrainSkirtBuffers { + const count = perimeterCount(field) + const vertices = count === 0 ? 0 : (count + 1) * 2 + const buffers: TerrainSkirtBuffers = { + positions: new Float32Array(vertices * 3), + normals: new Float32Array(vertices * 3), + indices: new Uint32Array(count * 6), + } + for (let i = 0; i < count; i++) { + const base = i * 6 + const top = i * 2 + const nextTop = (i + 1) * 2 + // Outward faces front: see the winding note on `buildTerrainMesh`. + buffers.indices[base] = top + buffers.indices[base + 1] = nextTop + buffers.indices[base + 2] = top + 1 + buffers.indices[base + 3] = top + 1 + buffers.indices[base + 4] = nextTop + buffers.indices[base + 5] = nextTop + 1 + } + updateTerrainSkirt(field, buffers) + return buffers +} + +/** + * Rewrite the whole skirt in place. + * + * Whole, not dirty-ranged, unlike the surface: the perimeter is `2(cols + rows)` + * vertices — 1 028 at 257², under 3% of the surface's vertex count — so the + * bookkeeping to find which boundary samples a rectangular patch touched costs + * more than re-uploading all of them. The surface's dirty range earns its + * complexity because it saves 400 KB a dab; here there is nothing to save. + */ +export function updateTerrainSkirt(field: TerrainField, buffers: TerrainSkirtBuffers): void { + const count = perimeterCount(field) + if (count === 0) return + + for (let i = 0; i <= count; i++) { + const { col, row } = boundarySample(field, i % count) + const x = field.origin[0] + col * field.spacing + const z = field.origin[1] + row * field.spacing + const h = heightAtSample(field, col, row) + + const previous = boundarySample(field, (i - 1 + count) % count) + const next = boundarySample(field, (i + 1) % count) + // Tangent across the neighbours, so a corner gets the bevelled average of its + // two edges rather than one of them arbitrarily. + const tx = next.col - previous.col + const tz = next.row - previous.row + const tLength = Math.hypot(tx, tz) || 1 + const nx = tz / tLength + const nz = -tx / tLength + + const topIndex = i * 2 + const bottomIndex = topIndex + 1 + writeSkirtVertex(buffers, topIndex, x, Math.max(h, HORIZON_PLANE_Y), z, nx, nz) + writeSkirtVertex(buffers, bottomIndex, x, Math.min(h, HORIZON_PLANE_Y) - SKIRT_DROP, z, nx, nz) + } +} + +function writeSkirtVertex( + buffers: TerrainSkirtBuffers, + index: number, + x: number, + y: number, + z: number, + nx: number, + nz: number, +): void { + buffers.positions[index * 3] = x + buffers.positions[index * 3 + 1] = y + buffers.positions[index * 3 + 2] = z + buffers.normals[index * 3] = nx + buffers.normals[index * 3 + 1] = 0 + buffers.normals[index * 3 + 2] = nz +} + +/** + * The contiguous vertex range a patch dirties, in **array elements** — the unit + * `BufferAttribute.addUpdateRange(start, count)` expects. + * + * A patch is rectangular, so the touched vertices are not contiguous unless the + * patch spans full rows. Rather than issue one range per row, this returns the + * single span from the patch's first vertex to its last: over-reporting a few + * clean vertices is far cheaper than many small GPU uploads, and it keeps the + * caller to one `addUpdateRange` call. + * + * The range is widened by one row on each side because a height change moves the + * *normals* of its neighbours too (central differences reach one sample out). + * Getting this wrong produces the classic symptom: correct silhouette, stale + * shading along the edit boundary. + */ +export function patchUpdateRange( + field: TerrainField, + patch: HeightPatch, + itemSize: number, +): { start: number; count: number } | null { + const firstRow = Math.max(0, patch.row0 - 1) + const lastRow = Math.min(field.rows - 1, patch.row0 + patch.rows) + if (lastRow < firstRow) return null + + const startVertex = firstRow * field.cols + const endVertex = lastRow * field.cols + (field.cols - 1) + return { + start: startVertex * itemSize, + count: (endVertex - startVertex + 1) * itemSize, + } +} + +/** + * Rewrites only the vertices a patch touched, in place. + * + * Takes the *already-patched* field (the one `applyHeightPatch` returned) and + * the patch that produced it, so the caller cannot accidentally rebuild from + * stale heights. Mutates the buffers rather than reallocating — that is the + * whole point of the dirty-rect path. + */ +export function updateTerrainMesh( + field: TerrainField, + buffers: TerrainMeshBuffers, + patch: HeightPatch, +): void { + const firstRow = Math.max(0, patch.row0 - 1) + const lastRow = Math.min(field.rows - 1, patch.row0 + patch.rows) + for (let r = firstRow; r <= lastRow; r++) { + for (let c = 0; c < field.cols; c++) { + const i = r * field.cols + c + writeVertex(field, buffers.positions, buffers.normals, buffers.uvs, c, r, i) + } + } +} diff --git a/packages/nodes/src/site/terrain-mesh.test.ts b/packages/nodes/src/site/terrain-mesh.test.ts new file mode 100644 index 000000000..963b50627 --- /dev/null +++ b/packages/nodes/src/site/terrain-mesh.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'bun:test' +import { + applyHeightPatch, + createTerrainField, + flattenPatch, + type TerrainField, +} from '@pascal-app/core' +import type { BufferAttribute } from 'three' +import { buildTerrainMesh } from './terrain-geometry' +import { + applyTerrainPatch, + createTerrainGeometry, + disposeTerrainGeometry, + needsRebuild, +} from './terrain-mesh' + +function attr(geometry: { getAttribute: (n: string) => unknown }, name: string): BufferAttribute { + return geometry.getAttribute(name) as BufferAttribute +} + +describe('createTerrainGeometry', () => { + test('wires all four attributes with the right item sizes', () => { + const field = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const target = createTerrainGeometry(field) + expect(attr(target.geometry, 'position').itemSize).toBe(3) + expect(attr(target.geometry, 'normal').itemSize).toBe(3) + expect(attr(target.geometry, 'uv').itemSize).toBe(2) + expect(target.geometry.getIndex()).not.toBeNull() + expect(target.geometry.getIndex()?.count).toBe(8 * 8 * 6) + disposeTerrainGeometry(target) + }) + + test('the attributes are backed by the same arrays as the CPU buffers', () => { + // The dirty-rect path mutates `buffers` in place and expects the GPU-side + // attribute to see it. If these ever diverge, patches would silently no-op. + const target = createTerrainGeometry(createTerrainField({ cols: 5, rows: 5, spacing: 1 })) + expect(attr(target.geometry, 'position').array).toBe(target.buffers.positions) + expect(attr(target.geometry, 'normal').array).toBe(target.buffers.normals) + disposeTerrainGeometry(target) + }) + + test('bounds cover the field extent without calling computeBoundingSphere', () => { + const field: TerrainField = { + ...createTerrainField({ cols: 5, rows: 5, spacing: 2 }), + origin: [10, 20], + } + const target = createTerrainGeometry(field) + // Field spans x 10..18, z 20..28 -> centre (14, 0, 24). + expect(target.geometry.boundingSphere?.center.x).toBeCloseTo(14, 6) + expect(target.geometry.boundingSphere?.center.z).toBeCloseTo(24, 6) + expect(target.geometry.boundingSphere?.radius).toBeCloseTo(Math.hypot(4, 0, 4), 6) + disposeTerrainGeometry(target) + }) + + test('bounds include the height range of a sculpted field', () => { + const base = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const raised = applyHeightPatch( + base, + flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 6) as never, + ) + const target = createTerrainGeometry(raised) + const sphere = target.geometry.boundingSphere + expect(sphere?.center.y).toBeCloseTo(3, 5) + // Radius must reach the top of the raised pad. + expect((sphere?.center.y ?? 0) + (sphere?.radius ?? 0)).toBeGreaterThanOrEqual(6) + disposeTerrainGeometry(target) + }) +}) + +describe('applyTerrainPatch', () => { + test('marks position and normal for upload, and nothing else', () => { + const before = createTerrainField({ cols: 17, rows: 17, spacing: 0.5 }) + const patch = flattenPatch(before, { minX: 2, minZ: 2, maxX: 4, maxZ: 4 }, 1) + const after = applyHeightPatch(before, patch as never) + + const target = createTerrainGeometry(before) + // `needsUpdate` is a setter-only property in three — reading it yields + // undefined. `version` is the observable it increments, so that is what a + // test can assert on. + const uvVersion = attr(target.geometry, 'uv').version + applyTerrainPatch(target, after, patch as never) + + expect(attr(target.geometry, 'position').version).toBeGreaterThan(0) + expect(attr(target.geometry, 'normal').version).toBeGreaterThan(0) + // UVs are a function of grid indices, never heights. + expect(attr(target.geometry, 'uv').version).toBe(uvVersion) + disposeTerrainGeometry(target) + }) + + test('uploads a partial range, not the whole buffer', () => { + const before = createTerrainField({ cols: 33, rows: 33, spacing: 0.5 }) + const patch = flattenPatch(before, { minX: 4, minZ: 4, maxX: 5, maxZ: 5 }, 2) + const after = applyHeightPatch(before, patch as never) + + const target = createTerrainGeometry(before) + applyTerrainPatch(target, after, patch as never) + + const position = attr(target.geometry, 'position') + const ranges = position.updateRanges + expect(ranges).toHaveLength(1) + // The whole buffer is 33*33*3 elements; a 2x2 patch must be far smaller. + expect(ranges[0]?.count).toBeLessThan(33 * 33 * 3) + expect(ranges[0]?.count).toBeGreaterThan(0) + disposeTerrainGeometry(target) + }) + + test('does not accumulate ranges across a stroke of many dabs', () => { + // The failure this guards: 60 dabs/second each appending a range would make + // every subsequent frame re-upload all of them. + let field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5 }) + const target = createTerrainGeometry(field) + for (let i = 0; i < 12; i++) { + const patch = flattenPatch(field, { minX: i, minZ: 2, maxX: i + 1, maxZ: 3 }, i * 0.1) + if (!patch) continue + field = applyHeightPatch(field, patch) + applyTerrainPatch(target, field, patch) + } + expect(attr(target.geometry, 'position').updateRanges).toHaveLength(1) + disposeTerrainGeometry(target) + }) + + test('the patched buffers match a full rebuild', () => { + const before = createTerrainField({ cols: 17, rows: 17, spacing: 0.5 }) + const patch = flattenPatch(before, { minX: 2, minZ: 2, maxX: 6, maxZ: 6 }, 1.25) + const after = applyHeightPatch(before, patch as never) + + const target = createTerrainGeometry(before) + applyTerrainPatch(target, after, patch as never) + const full = buildTerrainMesh(after) + + expect(Array.from(target.buffers.positions)).toEqual(Array.from(full.positions)) + disposeTerrainGeometry(target) + }) + + test('refreshes the bounding sphere so a raised hill is not culled', () => { + const before = createTerrainField({ cols: 17, rows: 17, spacing: 0.5 }) + const target = createTerrainGeometry(before) + const radiusBefore = target.geometry.boundingSphere?.radius ?? 0 + + const patch = flattenPatch(before, { minX: 2, minZ: 2, maxX: 4, maxZ: 4 }, 20) + const after = applyHeightPatch(before, patch as never) + applyTerrainPatch(target, after, patch as never) + + expect(target.geometry.boundingSphere?.radius ?? 0).toBeGreaterThan(radiusBefore) + disposeTerrainGeometry(target) + }) + + test('a patch entirely outside the field is a no-op, not a crash', () => { + const field = createTerrainField({ cols: 9, rows: 9, spacing: 1 }) + const target = createTerrainGeometry(field) + const version = attr(target.geometry, 'position').version + applyTerrainPatch(target, field, { + col0: 0, + row0: 500, + cols: 2, + rows: 2, + heights: new Int16Array(4), + }) + expect(attr(target.geometry, 'position').version).toBe(version) + expect(attr(target.geometry, 'position').updateRanges).toHaveLength(0) + disposeTerrainGeometry(target) + }) +}) + +describe('needsRebuild', () => { + test('false for the field it was built from', () => { + const field = createTerrainField({ cols: 17, rows: 17, spacing: 0.5 }) + const target = createTerrainGeometry(field) + expect(needsRebuild(target, field)).toBe(false) + // A height change never needs a rebuild — that is the whole point. + const patch = flattenPatch(field, { minX: 1, minZ: 1, maxX: 3, maxZ: 3 }, 2) + expect(needsRebuild(target, applyHeightPatch(field, patch as never))).toBe(false) + disposeTerrainGeometry(target) + }) + + test('true when the field was resized', () => { + const target = createTerrainGeometry(createTerrainField({ cols: 17, rows: 17 })) + expect(needsRebuild(target, createTerrainField({ cols: 33, rows: 33 }))).toBe(true) + disposeTerrainGeometry(target) + }) +}) diff --git a/packages/nodes/src/site/terrain-mesh.ts b/packages/nodes/src/site/terrain-mesh.ts new file mode 100644 index 000000000..11666b93d --- /dev/null +++ b/packages/nodes/src/site/terrain-mesh.ts @@ -0,0 +1,175 @@ +import type { HeightPatch, TerrainField } from '@pascal-app/core' +import { BufferAttribute, BufferGeometry, Sphere, Vector3 } from 'three' +import { + buildTerrainMesh, + buildTerrainSkirt, + HORIZON_PLANE_Y, + patchUpdateRange, + type TerrainMeshBuffers, + type TerrainSkirtBuffers, + updateTerrainMesh, + updateTerrainSkirt, +} from './terrain-geometry' + +/** + * The Three.js side of the terrain heightfield: owns the `BufferGeometry` and the + * dirty-rect upload. + * + * Kept beside the pure builder rather than in `viewer` because the dependency + * runs `nodes -> viewer`, not the other way, and this is a per-kind concern + * anyway. `terrain-geometry.ts` computes *what* goes in the buffers (testable + * without a canvas); this owns the GPU resource. The only thing that cannot live + * on the pure side is `addUpdateRange`/`needsUpdate`, which is why this file is + * thin. + * + * Why patch instead of rebuild: a 129² field is 16 641 vertices, so a full + * re-upload is ~400 KB per dab. There is no render-on-demand to hide behind — + * `FrameLimiter` runs a perpetual rAF at 50 FPS — so a brush dragged across a + * slope would re-upload the whole field dozens of times a second. + */ + +/** A terrain geometry plus the CPU buffers backing it. */ +export type TerrainGeometry = { + readonly geometry: BufferGeometry + readonly buffers: TerrainMeshBuffers + /** The edge curtain that closes the field against the horizon disc. */ + readonly skirt: { readonly geometry: BufferGeometry; readonly buffers: TerrainSkirtBuffers } +} + +export function createTerrainGeometry(field: TerrainField): TerrainGeometry { + const buffers = buildTerrainMesh(field) + const geometry = new BufferGeometry() + geometry.setAttribute('position', new BufferAttribute(buffers.positions, 3)) + geometry.setAttribute('normal', new BufferAttribute(buffers.normals, 3)) + geometry.setAttribute('uv', new BufferAttribute(buffers.uvs, 2)) + geometry.setIndex(new BufferAttribute(buffers.indices, 1)) + setTerrainBounds(geometry, field) + + // A separate geometry, not extra vertices on the surface: the surface's dirty + // range is a row span over a `cols * rows` layout, and appending a perimeter ring + // to it would break that indexing for a saving of one draw call. + const skirtBuffers = buildTerrainSkirt(field) + const skirtGeometry = new BufferGeometry() + skirtGeometry.setAttribute('position', new BufferAttribute(skirtBuffers.positions, 3)) + skirtGeometry.setAttribute('normal', new BufferAttribute(skirtBuffers.normals, 3)) + skirtGeometry.setIndex(new BufferAttribute(skirtBuffers.indices, 1)) + setSkirtBounds(skirtGeometry, field) + + return { geometry, buffers, skirt: { geometry: skirtGeometry, buffers: skirtBuffers } } +} + +/** + * Push one patch to the GPU, touching only the affected rows. + * + * Both `position` and `normal` are updated: a height change moves its + * neighbours' normals too, and `patchUpdateRange` already widens the span by one + * row on each side to cover that. UVs are never touched — they are a function of + * grid indices, not heights. + */ +export function applyTerrainPatch( + target: TerrainGeometry, + field: TerrainField, + patch: HeightPatch, +): void { + updateTerrainMesh(field, target.buffers, patch) + + // The skirt goes up unconditionally, before the range bail: a patch that leaves + // the surface's dirty span empty can still have moved a boundary sample, and a + // stale skirt is a hole at the property line. + updateTerrainSkirt(field, target.skirt.buffers) + for (const name of ['position', 'normal'] as const) { + const attribute = target.skirt.geometry.getAttribute(name) as BufferAttribute + attribute.needsUpdate = true + } + setSkirtBounds(target.skirt.geometry, field) + + const range = patchUpdateRange(field, patch, 3) + if (!range) return + + for (const name of ['position', 'normal'] as const) { + const attribute = target.geometry.getAttribute(name) as BufferAttribute + // `clearUpdateRanges` first: ranges accumulate, so a stroke dabbing 60 times + // a second would otherwise grow an unbounded list that all gets re-uploaded. + attribute.clearUpdateRanges() + attribute.addUpdateRange(range.start, range.count) + attribute.needsUpdate = true + } + + setTerrainBounds(target.geometry, field) +} + +/** + * Set bounds analytically instead of calling `computeBoundingSphere()`. + * + * The computed version walks every vertex twice and allocates; the field's + * horizontal extent is known in O(1) from `origin`/`spacing`/`cols`/`rows`, and + * only the height range needs a scan. Skipping bounds entirely is not an option — + * a stale bounding sphere gets a raised hill frustum-culled while it is still on + * screen, which reads as terrain flickering out at certain camera angles. + */ +function setTerrainBounds(geometry: BufferGeometry, field: TerrainField): void { + const { minY, maxY } = heightSpan(field) + const minX = field.origin[0] + const minZ = field.origin[1] + const maxX = minX + (field.cols - 1) * field.spacing + const maxZ = minZ + (field.rows - 1) * field.spacing + + const center = new Vector3((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) + const radius = Math.hypot((maxX - minX) / 2, (maxY - minY) / 2, (maxZ - minZ) / 2) + + geometry.boundingSphere = new Sphere(center, radius) + if (geometry.boundingBox) { + geometry.boundingBox.set(new Vector3(minX, minY, minZ), new Vector3(maxX, maxY, maxZ)) + } +} + +/** + * Bounds for the skirt, which hangs below the field's own low point. + * + * Reusing `setTerrainBounds` would cull the curtain the moment its top edge left + * the surface's sphere — the exact symptom the surface's analytic bounds exist to + * avoid, one geometry over. The vertical span brackets the horizon plane for the + * same reason the geometry does. + */ +function setSkirtBounds(geometry: BufferGeometry, field: TerrainField): void { + const { minY, maxY } = heightSpan(field) + const low = Math.min(minY, HORIZON_PLANE_Y) - 1 + const high = Math.max(maxY, HORIZON_PLANE_Y) + + const minX = field.origin[0] + const minZ = field.origin[1] + const maxX = minX + (field.cols - 1) * field.spacing + const maxZ = minZ + (field.rows - 1) * field.spacing + + const center = new Vector3((minX + maxX) / 2, (low + high) / 2, (minZ + maxZ) / 2) + const radius = Math.hypot((maxX - minX) / 2, (high - low) / 2, (maxZ - minZ) / 2) + geometry.boundingSphere = new Sphere(center, radius) +} + +/** Height span in metres, always including the datum so flat ground has a box. */ +function heightSpan(field: TerrainField): { minY: number; maxY: number } { + let minH = 0 + let maxH = 0 + for (let i = 0; i < field.heights.length; i++) { + const h = field.heights[i] ?? 0 + if (h < minH) minH = h + if (h > maxH) maxH = h + } + return { minY: minH * field.step, maxY: maxH * field.step } +} + +/** + * True when a geometry was built for a differently-shaped field. + * + * A resized field cannot be patched — the vertex count changed — so the caller + * must rebuild. Checking here keeps that decision in one place instead of every + * caller comparing `cols`/`rows` by hand. + */ +export function needsRebuild(target: TerrainGeometry, field: TerrainField): boolean { + return target.buffers.positions.length !== field.cols * field.rows * 3 +} + +export function disposeTerrainGeometry(target: TerrainGeometry): void { + target.geometry.dispose() + target.skirt.geometry.dispose() +} diff --git a/packages/nodes/src/site/terrain-renderer.tsx b/packages/nodes/src/site/terrain-renderer.tsx new file mode 100644 index 000000000..73acc7ed6 --- /dev/null +++ b/packages/nodes/src/site/terrain-renderer.tsx @@ -0,0 +1,103 @@ +'use client' + +import { type SiteNode, terrainFieldOf, useLiveTerrain } from '@pascal-app/core' +import { useEffect, useMemo, useRef } from 'react' +import type { Material } from 'three' +import { + applyTerrainPatch, + createTerrainGeometry, + disposeTerrainGeometry, + needsRebuild, + type TerrainGeometry, +} from './terrain-mesh' + +/** + * Renders the site's sculpted ground. + * + * Mounted by `SiteRenderer` only when the site has terrain (or a stroke is in + * flight), so a scene that never touched terrain mounts nothing at all and pays + * nothing. + * + * Two things here are load-bearing and easy to get wrong: + * + * - **The geometry is a ref, not React state.** A sculpt stroke pushes dozens of + * patches a second; routing each through a re-render would rebuild the + * `BufferGeometry` object identity every dab and throw away the partial-upload + * win entirely. The component subscribes to the *patch*, mutates the buffers in + * place, and never re-renders during a stroke. + * - **It stays on the default `SCENE_LAYER`.** This is real, visible, + * shadow-receiving geometry — not an overlay. Overlay/hit-area meshes must use + * the editor layer, but moving actual scene geometry there would exclude it from + * the MRT depth/normal pass and break the screen-space ink against it. + */ +export const TerrainRenderer = ({ + material, + site, +}: { + /** Owned by `SiteRenderer` so the ground material stays defined in one place. */ + material: Material + site: SiteNode +}) => { + const targetRef = useRef(null) + + // Persisted terrain only — the live stroke is applied imperatively below, so a + // dab never triggers a React render. + const persistedField = useMemo( + () => terrainFieldOf({ id: site.id, terrain: site.terrain }), + [site.id, site.terrain], + ) + + const target = useMemo(() => { + const field = useLiveTerrain.getState().fieldOf(site.id) ?? persistedField + if (!field) return null + return createTerrainGeometry(field) + }, [persistedField, site.id]) + + targetRef.current = target + useEffect( + () => () => { + if (target) disposeTerrainGeometry(target) + }, + [target], + ) + + // Imperative subscription rather than a selector hook: the point is to react to + // a dab *without* re-rendering. Zustand's subscribe gives the previous state + // too, so a patch is only uploaded once even if an unrelated store update + // notifies. + useEffect(() => { + let lastPatch = useLiveTerrain.getState().strokeOf(site.id)?.lastPatch ?? null + return useLiveTerrain.subscribe((state) => { + const stroke = state.strokes.get(site.id) + const current = targetRef.current + if (!(stroke && current)) { + lastPatch = null + return + } + if (!stroke.lastPatch || stroke.lastPatch === lastPatch) return + lastPatch = stroke.lastPatch + // A resized field cannot be patched — the vertex count changed. That only + // happens on import, never mid-stroke, so bail and let the persisted-field + // rebuild handle it rather than silently corrupting the buffers. + if (needsRebuild(current, stroke.field)) return + applyTerrainPatch(current, stroke.field, stroke.lastPatch) + }) + }, [site.id]) + + if (!target) return null + + return ( + <> + + {/* + The edge curtain. Not `castShadow`: it hangs a metre below the ground it + closes, so it would cast a rim of shade onto the horizon disc all round the + lot. `receiveShadow` for the same reason the surface has it — a raised edge + is lit ground that a building next to it should darken. + */} + + + ) +} + +export default TerrainRenderer diff --git a/packages/nodes/src/slab/__tests__/definition.test.ts b/packages/nodes/src/slab/__tests__/definition.test.ts index b625c187e..c9d3f0ae4 100644 --- a/packages/nodes/src/slab/__tests__/definition.test.ts +++ b/packages/nodes/src/slab/__tests__/definition.test.ts @@ -1,27 +1,43 @@ import { describe, expect, test } from 'bun:test' -import { pointInPolygon2D, SlabNode } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, pointInPolygon2D, SlabNode } from '@pascal-app/core' import { slabDefinition } from '../definition' -function getHeightHandle(slab: SlabNode) { +function getThicknessHandle(slab: SlabNode) { const handles = typeof slabDefinition.handles === 'function' ? slabDefinition.handles(slab) : (slabDefinition.handles ?? []) - const heightHandle = handles.find( - (handle) => handle.kind === 'linear-resize' && handle.axis === 'y', + const thicknessHandle = handles.find( + (handle) => + handle.kind === 'linear-resize' && handle.axis === 'y' && handle.shape !== 'tracker', ) - if (!(heightHandle && heightHandle.kind === 'linear-resize')) { - throw new Error('Missing slab height handle') + if (!(thicknessHandle && thicknessHandle.kind === 'linear-resize')) { + throw new Error('Missing slab thickness handle') } - return heightHandle + return thicknessHandle } -function getHeightHandlePosition(slab: SlabNode) { - return getHeightHandle(slab).placement.position(slab, {} as never) +function getThicknessHandlePosition(slab: SlabNode) { + return getThicknessHandle(slab).placement.position(slab, {} as never) +} + +function getBaseElevationHandle(slab: SlabNode) { + const handles = + typeof slabDefinition.handles === 'function' + ? slabDefinition.handles(slab) + : (slabDefinition.handles ?? []) + const baseHandle = handles.find( + (handle) => + handle.kind === 'linear-resize' && handle.axis === 'y' && handle.shape === 'tracker', + ) + if (!(baseHandle && baseHandle.kind === 'linear-resize')) { + throw new Error('Missing slab base elevation handle') + } + return baseHandle } describe('slabDefinition handles', () => { - test('keeps the height handle over solid slab area when the center is a hole', () => { + test('keeps the thickness handle over solid slab area when the center is a hole', () => { const slab = SlabNode.parse({ polygon: [ [0, 0], @@ -39,15 +55,16 @@ describe('slabDefinition handles', () => { ], }) - const [x, , z] = getHeightHandlePosition(slab) + const [x, , z] = getThicknessHandlePosition(slab) expect(pointInPolygon2D([x, z], slab.polygon, { includeBoundary: false })).toBe(true) expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false) }) - test('routes the elevation arrow through adaptive slab top changes', () => { + test('resizes thickness upward without moving the underside anchor', () => { const slab = SlabNode.parse({ - elevation: 0.05, + elevation: 0.7, + thickness: 0.2, polygon: [ [0, 0], [2, 0], @@ -55,27 +72,121 @@ describe('slabDefinition handles', () => { [0, 2], ], }) - const heightHandle = getHeightHandle(slab) + const thicknessHandle = getThicknessHandle(slab) - expect(heightHandle.min).toBe(-1) - // Crossing zero flips the recessed intent in the same patch; coming back - // above the plane clears it. - expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ - elevation: -0.15, - recessed: true, - }) - expect(heightHandle.apply(slab, 0.1, {} as never)).toEqual({ - elevation: 0.1, - thickness: 0.1, + expect(thicknessHandle.currentValue(slab)).toBe(0.2) + const update = thicknessHandle.apply(slab, 0.35, {} as never) + expect(update).toEqual({ + elevation: expect.any(Number), + thickness: 0.35, recessed: false, }) - // The arrow is the drag surface: past SLAB_UNSTICK_THRESHOLD a - // grounded slab pops to the default deck thickness instead of - // stretching further. - expect(heightHandle.apply(slab, 0.6, {} as never)).toEqual({ - elevation: 0.6, - thickness: 0.05, - recessed: false, + expect(update.elevation).toBeCloseTo(0.85) + }) + + test('moves the underside anchor without changing slab thickness', () => { + const slab = SlabNode.parse({ + elevation: 0.7, + thickness: 0.2, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + const baseHandle = getBaseElevationHandle(slab) + + expect(baseHandle.currentValue(slab)).toBeCloseTo(0.5) + expect(baseHandle.placement.position(slab, {} as never)[1]).toBeCloseTo(0.5) + expect(baseHandle.apply(slab, 0.9, {} as never)).toEqual({ + elevation: 1.1, + }) + expect(slab.thickness).toBe(0.2) + }) + + test('previews attached stair rise while resizing a raised slab', () => { + const slab = SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_a', + elevation: 0.7, + thickness: 0.2, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + const level = { + id: 'level_a', + type: 'level', + children: [slab.id, 'stair_a'], + height: 2.5, + } as unknown as AnyNode + const stair = { + id: 'stair_a', + type: 'stair', + parentId: 'level_a', + children: ['segment_a'], + position: [3, 0, 3], + rotation: 0, + stairType: 'straight', + deckSlabId: slab.id, + supportSlabId: 'ground', + } as unknown as AnyNode + const segment = { + id: 'segment_a', + type: 'stair-segment', + parentId: 'stair_a', + segmentType: 'stair', + height: 0.7, + width: 1, + length: 3, + stepCount: 10, + fillToFloor: false, + thickness: 0.2, + } as unknown as AnyNode + const nodes = { + [level.id]: level, + [slab.id]: slab, + [stair.id]: stair, + [segment.id]: segment, + } as Record + const handle = getThicknessHandle(slab) + const preview = new Map( + handle.previewOverrides?.(slab, 0.4, { nodes: () => nodes } as never) ?? [], + ) + + expect(preview.has(stair.id as AnyNodeId)).toBe(true) + expect(preview.get(segment.id as AnyNodeId)?.height).toBeCloseTo(0.9) + }) + + test('keeps recessed slabs on their depth control only', () => { + const slab = SlabNode.parse({ + elevation: -0.2, + recessed: true, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + const handles = + typeof slabDefinition.handles === 'function' + ? slabDefinition.handles(slab) + : (slabDefinition.handles ?? []) + const depthHandle = handles[0] + + expect(handles).toHaveLength(1) + expect(handles.some((handle) => 'shape' in handle && handle.shape === 'tracker')).toBe(false) + expect(depthHandle?.kind).toBe('linear-resize') + if (depthHandle?.kind !== 'linear-resize') throw new Error('Missing slab depth handle') + expect(depthHandle.currentValue(slab)).toBe(-0.2) + expect(depthHandle.apply(slab, -0.35, {} as never)).toEqual({ + elevation: -0.35, + recessed: true, }) }) }) diff --git a/packages/nodes/src/slab/__tests__/elevation-limit.test.ts b/packages/nodes/src/slab/__tests__/elevation-limit.test.ts index 7a829488d..6570d7cb0 100644 --- a/packages/nodes/src/slab/__tests__/elevation-limit.test.ts +++ b/packages/nodes/src/slab/__tests__/elevation-limit.test.ts @@ -1,160 +1,162 @@ import { describe, expect, test } from 'bun:test' -import { SlabNode } from '@pascal-app/core' +import { MIN_SLAB_THICKNESS, SlabNode } from '@pascal-app/core' import { + applySlabAnchorElevationChange, + applySlabBaseElevationChange, applySlabElevationPreset, + applySlabRecessDepthChange, + applySlabThicknessChange, applySlabTopChange, - SLAB_UNSTICK_THRESHOLD, + getSlabAnchorElevation, + getSlabBaseElevation, + getSlabRecessDepth, } from '../elevation-limit' function slab(overrides: Partial = {}): SlabNode { return SlabNode.parse({ polygon: [], ...overrides }) } -const drag = (node: SlabNode, newTop: number) => applySlabTopChange(node, newTop, { mode: 'drag' }) -const panel = (node: SlabNode, newTop: number) => - applySlabTopChange(node, newTop, { mode: 'panel' }) - -describe('applySlabTopChange — drag (viewport arrow)', () => { - test('stretches a grounded slab up to the unstick threshold', () => { +describe('solid slab vertical interval', () => { + test('changes thickness upward from a grounded underside', () => { const grounded = slab({ elevation: 0.1, thickness: 0.1 }) - expect(drag(grounded, 0.25)).toEqual({ + expect(applySlabThicknessChange(grounded, 0.25)).toEqual({ elevation: 0.25, thickness: 0.25, recessed: false, }) - expect(drag(grounded, 0.04)).toEqual({ - elevation: 0.04, - thickness: 0.04, - recessed: false, - }) - // The threshold itself still stretches — unstick starts strictly past it. - expect(drag(grounded, SLAB_UNSTICK_THRESHOLD)).toEqual({ - elevation: SLAB_UNSTICK_THRESHOLD, - thickness: SLAB_UNSTICK_THRESHOLD, - recessed: false, - }) + expect(getSlabBaseElevation(grounded)).toBe(0) }) - test('unsticks past the threshold: pops to the default deck thickness', () => { - const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + test('changes thickness upward from a floating underside', () => { + const floating = slab({ elevation: 0.5, thickness: 0.2 }) - expect(drag(grounded, 0.55)).toEqual({ - elevation: 0.55, - thickness: 0.05, + expect(applySlabThicknessChange(floating, 0.4)).toEqual({ + elevation: 0.7, + thickness: 0.4, recessed: false, }) }) - test('crosses a grounded slab into a pool and back out', () => { - const grounded = slab({ elevation: 0.1, thickness: 0.1 }) - const intoPool = drag(grounded, -0.15) - - expect(intoPool).toEqual({ elevation: -0.15, recessed: true }) + test('clamps thickness without moving the authored underside', () => { + const floating = slab({ elevation: 0.5, thickness: 0.2 }) - const pool = { ...grounded, ...intoPool } - expect(drag(pool, 0.08)).toEqual({ - elevation: 0.08, + expect(applySlabThicknessChange(floating, 0)).toEqual({ + elevation: 0.3 + MIN_SLAB_THICKNESS, + thickness: MIN_SLAB_THICKNESS, recessed: false, }) }) - test('moves a floating deck and clamps its underside to ground', () => { + test('moves the underside anchor without changing thickness', () => { const floating = slab({ elevation: 0.5, thickness: 0.2 }) - expect(drag(floating, 0.4)).toEqual({ - elevation: 0.4, - recessed: false, + expect(applySlabBaseElevationChange(floating, 0.8)).toEqual({ + elevation: 1, }) + }) +}) - const landedChange = drag(floating, 0.1) - expect(landedChange).toEqual({ elevation: 0.2, recessed: false }) +describe('applySlabTopChange', () => { + test('moves a solid slab while preserving thickness', () => { + const floating = slab({ elevation: 0.5, thickness: 0.2 }) - // Landed (underside 0) → grounded again: below the threshold the way - // back up stretches, past it the slab unsticks to the default deck. - const landed = { ...floating, ...landedChange } - expect(drag(landed, 0.3)).toEqual({ - elevation: 0.3, - thickness: 0.3, + expect(applySlabTopChange(floating, 0.7)).toEqual({ + elevation: 0.7, recessed: false, }) - expect(drag(landed, 0.5)).toEqual({ - elevation: 0.5, - thickness: 0.05, + expect(applySlabTopChange(floating, 0.1)).toEqual({ + elevation: 0.1, recessed: false, }) + expect(getSlabBaseElevation({ ...floating, ...applySlabTopChange(floating, 0.1) })).toBeCloseTo( + -0.1, + ) }) - test('keeps a recessed pool thickness unchanged', () => { - const pool = slab({ elevation: -0.15, thickness: 0.08, recessed: true }) + test('keeps the grounded pool cross-zero gesture', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + const intoPool = applySlabTopChange(grounded, -0.15) - expect(drag(pool, -0.3)).toEqual({ + expect(intoPool).toEqual({ elevation: -0.15, recessed: true }) + + const pool = { ...grounded, ...intoPool } + expect(applySlabTopChange(pool, -0.3)).toEqual({ elevation: -0.3, recessed: true, }) - }) - - test('allows a grounded stretch below the edit-time minimum thickness', () => { - const grounded = slab({ elevation: 0.01, thickness: 0.01 }) - - expect(drag(grounded, 0.015)).toEqual({ - elevation: 0.015, - thickness: 0.015, + expect(applySlabTopChange(pool, 0.08)).toEqual({ + elevation: 0.08, + thickness: 0.08, recessed: false, + recessedRimElevation: undefined, }) }) }) -describe('applySlabTopChange — panel (pure placement)', () => { - test('moves a grounded slab without coupling thickness', () => { - // The panel never stretches: raising a grounded slab lifts the body - // (thickness preserved by omission) instead of thickening it. - const grounded = slab({ elevation: 0.1, thickness: 0.1 }) - - expect(panel(grounded, 0.3)).toEqual({ elevation: 0.3, recessed: false }) - expect(panel(grounded, 0.55)).toEqual({ elevation: 0.55, recessed: false }) - }) +describe('anchor-relative slab presets', () => { + test('keeps a raised solid slab on its underside anchor', () => { + const raised = slab({ elevation: 0.8, thickness: 0.2 }) - test('clamps a grounded slab at underside 0 instead of shrinking it', () => { - const grounded = slab({ elevation: 0.2, thickness: 0.2 }) + expect(getSlabAnchorElevation(raised)).toBeCloseTo(0.6) + const standard = applySlabElevationPreset(raised, 0.05) + expect(standard).toEqual({ + elevation: expect.any(Number), + thickness: 0.05, + recessed: false, + recessedRimElevation: undefined, + }) + expect(standard.elevation).toBeCloseTo(0.65) - expect(panel(grounded, 0.1)).toEqual({ elevation: 0.2, recessed: false }) + const thick = applySlabElevationPreset(raised, 0.15) + expect(thick).toEqual({ + elevation: expect.any(Number), + thickness: 0.15, + recessed: false, + recessedRimElevation: undefined, + }) + expect(thick.elevation).toBeCloseTo(0.75) }) - test('moves a floating deck preserving thickness and clamps its underside', () => { - const floating = slab({ elevation: 0.5, thickness: 0.2 }) + test('sinks below the current anchor and returns to a solid without losing it', () => { + const raised = slab({ elevation: 0.8, thickness: 0.2, fillToTerrain: true }) + const recessedPatch = applySlabElevationPreset(raised, -0.15) - expect(panel(floating, 0.4)).toEqual({ elevation: 0.4, recessed: false }) - expect(panel(floating, 0.1)).toEqual({ elevation: 0.2, recessed: false }) + expect(recessedPatch).toEqual({ + elevation: expect.any(Number), + recessed: true, + recessedRimElevation: expect.any(Number), + fillToTerrain: undefined, + }) + expect(recessedPatch.elevation).toBeCloseTo(0.45) + expect(recessedPatch.recessedRimElevation).toBeCloseTo(0.6) + + const recessed = { ...raised, ...recessedPatch } as SlabNode + expect(getSlabAnchorElevation(recessed)).toBeCloseTo(0.6) + expect(getSlabRecessDepth(recessed)).toBeCloseTo(0.15) + const restored = applySlabElevationPreset(recessed, 0.05) + expect(restored).toEqual({ + elevation: expect.any(Number), + thickness: 0.05, + recessed: false, + recessedRimElevation: undefined, + }) + expect(restored.elevation).toBeCloseTo(0.65) }) - test('keeps the pool cross-zero gesture', () => { - const grounded = slab({ elevation: 0.1, thickness: 0.1 }) - const intoPool = panel(grounded, -0.15) - - expect(intoPool).toEqual({ elevation: -0.15, recessed: true }) - - const pool = { ...grounded, ...intoPool } - expect(panel(pool, -0.3)).toEqual({ elevation: -0.3, recessed: true }) - expect(panel(pool, 0.08)).toEqual({ elevation: 0.08, recessed: false }) - }) -}) + test('moves a recessed rim and resizes its depth independently', () => { + const recessed = slab({ + elevation: 0.45, + recessed: true, + recessedRimElevation: 0.6, + }) -test('slab elevation presets keep their explicit writes', () => { - expect(applySlabElevationPreset(-0.15)).toEqual({ elevation: -0.15, recessed: true }) - expect(applySlabElevationPreset(0)).toEqual({ - elevation: 0, - thickness: 0, - recessed: false, - }) - expect(applySlabElevationPreset(0.05)).toEqual({ - elevation: 0.05, - thickness: 0.05, - recessed: false, - }) - expect(applySlabElevationPreset(0.15)).toEqual({ - elevation: 0.15, - thickness: 0.15, - recessed: false, + const moved = applySlabAnchorElevationChange(recessed, 0.8) + expect(moved).toEqual({ + elevation: expect.any(Number), + recessedRimElevation: 0.8, + }) + expect(moved.elevation).toBeCloseTo(0.65) + expect(applySlabRecessDepthChange(recessed, 0.25)).toEqual({ elevation: 0.35 }) }) }) diff --git a/packages/nodes/src/slab/__tests__/geometry.test.ts b/packages/nodes/src/slab/__tests__/geometry.test.ts index 82ca0814c..2143e5202 100644 --- a/packages/nodes/src/slab/__tests__/geometry.test.ts +++ b/packages/nodes/src/slab/__tests__/geometry.test.ts @@ -1,8 +1,38 @@ import { describe, expect, test } from 'bun:test' -import { SlabNode } from '@pascal-app/core' +import { + BuildingNode, + createTerrainField, + encodeTerrainField, + type GeometryContext, + LevelNode, + SiteNode, + SlabNode, +} from '@pascal-app/core' import { Mesh } from 'three' import { buildSlabGeometry } from '../geometry' +function geometryContext(site: ReturnType): GeometryContext { + const building = BuildingNode.parse({ + id: 'building_test', + parentId: site.id, + children: ['level_test'], + }) + const level = LevelNode.parse({ + id: 'level_test', + parentId: building.id, + level: 0, + height: 2.5, + children: [], + }) + const nodes = { [site.id]: site, [building.id]: building, [level.id]: level } + return { + resolve: (id) => nodes[id as keyof typeof nodes], + children: [], + siblings: [], + parent: level, + } +} + describe('buildSlabGeometry', () => { test('copies the primary UVs into uv2 for every slab mesh', () => { const slab = SlabNode.parse({ @@ -45,14 +75,75 @@ describe('buildSlabGeometry', () => { expect(mesh.position.y).toBe(0) } - const recessed = SlabNode.parse({ elevation: -0.2, recessed: true, polygon }) + const recessed = SlabNode.parse({ + elevation: 0.45, + recessed: true, + recessedRimElevation: 0.6, + polygon, + }) const recessedGroup = buildSlabGeometry(recessed, undefined, 'solid', false) const recessedMeshes = recessedGroup.children.filter( (child): child is Mesh => child instanceof Mesh, ) expect(recessedMeshes.length).toBeGreaterThan(0) + let localTop = Number.NEGATIVE_INFINITY for (const mesh of recessedMeshes) { - expect(mesh.position.y).toBeCloseTo(-0.2) + expect(mesh.position.y).toBeCloseTo(0.45) + mesh.geometry.computeBoundingBox() + localTop = Math.max(localTop, mesh.geometry.boundingBox?.max.y ?? Number.NEGATIVE_INFINITY) } + expect(localTop).toBeCloseTo(0.15) + }) + + test('adds a terrain-following perimeter below the fixed slab underside', () => { + const site = SiteNode.parse({ + id: 'site_test', + children: ['building_test'], + terrain: encodeTerrainField( + createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [-1, -1] }), + ), + }) + const slab = SlabNode.parse({ + elevation: 0.8, + thickness: 0.2, + fillToTerrain: true, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + + const group = buildSlabGeometry(slab, geometryContext(site), 'solid', false) + const meshes = group.children.filter((child): child is Mesh => child instanceof Mesh) + expect(meshes).toHaveLength(3) + + const fill = meshes[2]! + fill.geometry.computeBoundingBox() + expect(fill.userData.slotId).toBe('side') + expect(fill.geometry.boundingBox?.min.y).toBeCloseTo(0) + expect(fill.geometry.boundingBox?.max.y).toBeCloseTo(0.6) + }) + + test('follows the flat datum before the site has a persisted terrain field', () => { + const site = SiteNode.parse({ id: 'site_test', children: ['building_test'] }) + const slab = SlabNode.parse({ + elevation: 0.4, + thickness: 0.1, + fillToTerrain: true, + polygon: [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ], + }) + + const group = buildSlabGeometry(slab, geometryContext(site), 'solid', false) + const fill = group.children.filter((child): child is Mesh => child instanceof Mesh)[2]! + fill.geometry.computeBoundingBox() + expect(fill.geometry.boundingBox?.min.y).toBeCloseTo(0) + expect(fill.geometry.boundingBox?.max.y).toBeCloseTo(0.3) }) }) diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index d44d2b07b..9d58ca766 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -1,11 +1,28 @@ import { + type AnyNode, + type AnyNodeId, type HandleDescriptor, + MIN_SLAB_THICKNESS, + markSlabChangeDependents, type NodeDefinition, pointInPolygon2D, + type SceneApi, type SlabNode as SlabNodeType, + syncStairRises, } from '@pascal-app/core' +import { + clearStructuralElevationGuide, + publishStructuralElevationGuide, + resolveStructuralElevationSnap, +} from '@pascal-app/editor' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' -import { applySlabTopChange, slabElevationUpperBound } from './elevation-limit' +import { + applySlabBaseElevationChange, + applySlabThicknessChange, + applySlabTopChange, + getSlabBaseElevation, + slabElevationUpperBound, +} from './elevation-limit' import { buildSlabFloorplan } from './floorplan' import { slabAddVertexAffordance, @@ -92,15 +109,40 @@ function slabHandleAnchor(slab: SlabNodeType): [number, number] { return best ?? fallback } -// Slab elevation arrow — vertical chevron on solid slab surface near the -// polygon center. The shared top-change policy stretches grounded slabs up -// to SLAB_UNSTICK_THRESHOLD (past it the slab pops to a thin floating -// deck), moves floating slabs, and preserves the drag-through-zero pool -// gesture. Same registry-handle pipeline as the column height arrow, so -// live override + commit-on-release come for free. `max` clamps the drag -// under the storey plane while plane-bound walls elect this slab as their -// base. -function slabHeightHandle(): HandleDescriptor { +function slabElevationGuideSource(slab: SlabNodeType) { + return { + nodeId: slab.id, + levelId: slab.parentId, + anchor: slabHandleAnchor(slab), + } +} + +function slabChangePreviewOverrides( + slab: SlabNodeType, + patch: Partial, + sceneApi: SceneApi, +): ReadonlyArray]> { + const next = { ...slab, ...patch } as SlabNodeType + const nodes = { ...sceneApi.nodes(), [slab.id]: next } as Record + const previews = new Map>() + + markSlabChangeDependents(slab, next, nodes, (id) => previews.set(id, {})) + for (const update of syncStairRises(nodes)) { + const segment = nodes[update.id] + if ( + segment?.type !== 'stair-segment' || + !segment.parentId || + !previews.has(segment.parentId as AnyNodeId) + ) { + continue + } + previews.set(update.id, update.data) + } + + return [...previews] +} + +function slabRecessedDepthHandle(): HandleDescriptor { return { kind: 'linear-resize', axis: 'y', @@ -108,7 +150,18 @@ function slabHeightHandle(): HandleDescriptor { min: MIN_SLAB_ELEVATION, max: (n, sceneApi) => slabElevationUpperBound(sceneApi.nodes(), n), currentValue: (n) => n.elevation ?? 0.05, - apply: (n, newValue) => applySlabTopChange(n, newValue, { mode: 'drag' }), + magneticSnap: (n, newValue, sceneApi) => + resolveStructuralElevationSnap(slabElevationGuideSource(n), newValue, sceneApi.nodes()), + onDrag: (n, sceneApi) => + publishStructuralElevationGuide( + slabElevationGuideSource(n), + n.elevation ?? 0.05, + sceneApi.nodes(), + ), + onDragEnd: (n) => clearStructuralElevationGuide(n.id), + apply: (n, newValue) => applySlabTopChange(n, newValue), + previewOverrides: (n, newValue, sceneApi) => + slabChangePreviewOverrides(n, applySlabTopChange(n, newValue), sceneApi), placement: { position: (n) => { const [cx, cz] = slabHandleAnchor(n) @@ -119,8 +172,81 @@ function slabHeightHandle(): HandleDescriptor { } } -function slabHandles(_node: SlabNodeType): HandleDescriptor[] { - return [slabHeightHandle()] +function slabThicknessHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_SLAB_THICKNESS, + max: (n, sceneApi) => + Math.max( + MIN_SLAB_THICKNESS, + slabElevationUpperBound(sceneApi.nodes(), n) - getSlabBaseElevation(n), + ), + currentValue: (n) => n.thickness ?? 0.05, + magneticSnap: (n, newThickness, sceneApi) => { + const base = getSlabBaseElevation(n) + const snappedTop = resolveStructuralElevationSnap( + slabElevationGuideSource(n), + base + newThickness, + sceneApi.nodes(), + ) + return Math.max(MIN_SLAB_THICKNESS, snappedTop - base) + }, + onDrag: (n, sceneApi) => + publishStructuralElevationGuide( + slabElevationGuideSource(n), + n.elevation ?? 0.05, + sceneApi.nodes(), + ), + onDragEnd: (n) => clearStructuralElevationGuide(n.id), + apply: (n, newThickness) => applySlabThicknessChange(n, newThickness), + previewOverrides: (n, newThickness, sceneApi) => + slabChangePreviewOverrides(n, applySlabThicknessChange(n, newThickness), sceneApi), + placement: { + position: (n) => { + const [cx, cz] = slabHandleAnchor(n) + return [cx, (n.elevation ?? 0.05) + HEIGHT_HANDLE_OFFSET, cz] + }, + }, + } +} + +function slabBaseElevationHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + shape: 'tracker', + gridSnap: true, + min: (n) => MIN_SLAB_ELEVATION - (n.thickness ?? 0.05), + max: (n, sceneApi) => slabElevationUpperBound(sceneApi.nodes(), n) - (n.thickness ?? 0.05), + currentValue: (n) => getSlabBaseElevation(n), + magneticSnap: (n, newValue, sceneApi) => + resolveStructuralElevationSnap(slabElevationGuideSource(n), newValue, sceneApi.nodes()), + onDrag: (n, sceneApi) => + publishStructuralElevationGuide( + slabElevationGuideSource(n), + getSlabBaseElevation(n), + sceneApi.nodes(), + ), + onDragEnd: (n) => clearStructuralElevationGuide(n.id), + apply: (n, newBase) => applySlabBaseElevationChange(n, newBase), + previewOverrides: (n, newBase, sceneApi) => + slabChangePreviewOverrides(n, applySlabBaseElevationChange(n, newBase), sceneApi), + placement: { + position: (n) => { + const [cx, cz] = slabHandleAnchor(n) + return [cx, getSlabBaseElevation(n), cz] + }, + }, + } +} + +function slabHandles(node: SlabNodeType): HandleDescriptor[] { + return node.recessed + ? [slabRecessedDepthHandle()] + : [slabThicknessHandle(), slabBaseElevationHandle()] } /** diff --git a/packages/nodes/src/slab/elevation-limit.ts b/packages/nodes/src/slab/elevation-limit.ts index bc335b68a..fc90ac385 100644 --- a/packages/nodes/src/slab/elevation-limit.ts +++ b/packages/nodes/src/slab/elevation-limit.ts @@ -5,6 +5,7 @@ import { getSlabElevationUpperBound, getStoredLevelHeight, type LevelNode, + MIN_SLAB_THICKNESS, type SlabElevationClamp, type SlabNode, type WallNode, @@ -17,58 +18,120 @@ type SlabLevelContext = { } const GROUNDED_SLAB_EPSILON = 1e-3 -/** Deck thickness an unsticking slab pops to — the schema default. */ -const UNSTUCK_DECK_THICKNESS = 0.05 + +/** Level-local Y of a solid slab's underside (its placement anchor). */ +export function getSlabBaseElevation(slab: Pick): number { + return (slab.elevation ?? 0.05) - (slab.thickness ?? 0.05) +} + +/** Stable vertical reference used by relative slab presets. */ +export function getSlabAnchorElevation( + slab: Pick, +): number { + return slab.recessed ? (slab.recessedRimElevation ?? 0) : getSlabBaseElevation(slab) +} + /** - * Grounded-stretch ceiling for the 3D elevation arrow (m) — above any - * plausible step/platform height. While grounded, dragging the top up to - * here stretches the body; dragging past it unsticks the slab into a - * thin floating deck and the drag continues as pure placement. + * Translate a solid slab to a new underside elevation without changing its + * authored thickness. Recessed slabs use a different rim/depth contract and + * keep the existing top/depth control instead. */ -export const SLAB_UNSTICK_THRESHOLD = 0.4 +export function applySlabBaseElevationChange( + slab: Pick, + newBase: number, +): Pick { + return { elevation: newBase + (slab.thickness ?? 0.05) } +} + +/** Translate a solid underside or recessed rim without resizing the body. */ +export function applySlabAnchorElevationChange( + slab: Pick, + newAnchor: number, +): Partial { + if (!slab.recessed) return applySlabBaseElevationChange(slab, newAnchor) + const rim = getSlabAnchorElevation(slab) + return { + elevation: slab.elevation + (newAnchor - rim), + recessedRimElevation: newAnchor, + } +} + +export function getSlabRecessDepth( + slab: Pick, +): number { + return Math.max(MIN_SLAB_THICKNESS, (slab.recessedRimElevation ?? 0) - slab.elevation) +} + +/** Resize a recess downward while keeping its rim anchor fixed. */ +export function applySlabRecessDepthChange( + slab: Pick, + newDepth: number, +): Pick { + const depth = Math.max(MIN_SLAB_THICKNESS, newDepth) + return { elevation: (slab.recessedRimElevation ?? 0) - depth } +} -export type SlabTopChangeMode = 'drag' | 'panel' +/** + * Resize a solid slab upward from its underside. The occupied interval changes + * from [base, old top] to [base, base + new thickness], so anything hosted on + * the walking surface observes the new elevation. + */ +export function applySlabThicknessChange( + slab: Pick, + newThickness: number, +): Pick { + const thickness = Math.max(MIN_SLAB_THICKNESS, newThickness) + return { + elevation: getSlabBaseElevation(slab) + thickness, + thickness, + recessed: false, + } +} /** - * The one owner of the slab vertical-editing rules. Both edit surfaces - * route through it: the viewport arrow as `mode: 'drag'`, the panel - * elevation input as `mode: 'panel'`. - * - * Hysteresis-free state machine (pure in current state + newTop): - * - recessed → move the pool floor; rising to ≥ 0 un-recesses. - * - grounded, newTop ≤ 0 → pool gesture (both modes). - * - grounded drag, newTop ≤ {@link SLAB_UNSTICK_THRESHOLD} → stretch - * (elevation and thickness move together, underside stays at 0). - * - grounded drag past the threshold → unstick: pop to the default deck - * thickness and continue as placement. - * - otherwise (floating, or any panel edit) → placement: move the body - * preserving thickness, clamping the underside to the level plane — - * landing re-grounds the slab, so the way back up stretches again - * below the threshold. + * Move a slab's authored top while preserving thickness. Recessed slabs keep + * their pool-depth gesture; a grounded solid crossing below datum becomes + * recessed. Solid slab thickness is edited separately. */ -export function applySlabTopChange( - slab: SlabNode, - newTop: number, - options: { mode: SlabTopChangeMode }, -): Partial { - if (slab.recessed) return { elevation: newTop, recessed: newTop < 0 } +export function applySlabTopChange(slab: SlabNode, newTop: number): Partial { + if (slab.recessed) { + const rim = getSlabAnchorElevation(slab) + if (newTop < rim) return { elevation: newTop, recessed: true } + return { + elevation: Math.max(newTop, rim + MIN_SLAB_THICKNESS), + thickness: Math.max(MIN_SLAB_THICKNESS, newTop - rim), + recessed: false, + recessedRimElevation: undefined, + } + } const grounded = Math.abs(slab.elevation - slab.thickness) < GROUNDED_SLAB_EPSILON if (grounded && newTop <= 0) return { elevation: newTop, recessed: true } - if (grounded && options.mode === 'drag') { - return newTop <= SLAB_UNSTICK_THRESHOLD - ? { elevation: newTop, thickness: newTop, recessed: false } - : { elevation: newTop, thickness: UNSTUCK_DECK_THICKNESS, recessed: false } - } - - return { elevation: Math.max(newTop, slab.thickness), recessed: false } + return { elevation: newTop, recessed: false } } -export function applySlabElevationPreset(newTop: number): Partial { - return newTop < 0 - ? { elevation: newTop, recessed: true } - : { elevation: newTop, thickness: Math.max(newTop, 0), recessed: false } +/** + * Apply a signed surface preset around the current anchor. Negative values make + * a recess below the anchor; positive values make a solid upward from it. + */ +export function applySlabElevationPreset(slab: SlabNode, signedDepth: number): Partial { + const anchor = getSlabAnchorElevation(slab) + if (signedDepth < 0) { + return { + elevation: anchor + signedDepth, + recessed: true, + recessedRimElevation: anchor, + fillToTerrain: undefined, + } + } + const thickness = Math.max(MIN_SLAB_THICKNESS, signedDepth) + return { + elevation: anchor + thickness, + thickness, + recessed: false, + recessedRimElevation: undefined, + } } function resolveSlabLevelContext( diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 591957889..81046a368 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -1,11 +1,21 @@ import { + type AnyNode, + type AnyNodeId, + type BuildingNode, type GeometryContext, + getLevelElevations, getMaterialPresetByRef, + getRenderableSlabPolygon, + type LevelNode, + type SiteNode, type SlabNode, slabPolygonContextFromGeometry, + surfaceHeightAt, + terrainFieldOf, } from '@pascal-app/core' import { applyMaterialPresetToMaterials, + buildTerrainPerimeterFillGeometry, type ColorPreset, createDefaultMaterial, createMaterial, @@ -25,6 +35,7 @@ import { type Texture, Vector3, } from 'three' +import { creaseCrossings } from '../site/terrain-drape' import { SLAB_SIDE_SLOT_DEFAULT, SLAB_TOP_SLOT_DEFAULT, type SlabSlotId } from './slots' /** @@ -46,7 +57,6 @@ type SlabMaterial = Material & { } const slabMaterialCache = new Map() - function getSlabSlotMaterial( node: SlabNode, slotId: SlabSlotId, @@ -176,6 +186,85 @@ function getLegacySlabMaterial(node: SlabNode, shading: RenderShading): Material return material } +function terrainFillContext(ctx: GeometryContext | undefined) { + if (!ctx) return null + const level = ctx.parent + if (level?.type !== 'level' || !level.parentId) return null + const building = ctx.resolve(level.parentId as AnyNodeId) + if (building?.type !== 'building' || !building.parentId) return null + const site = ctx.resolve(building.parentId as AnyNodeId) + if (site?.type !== 'site') return null + const field = terrainFieldOf(site) + + const nodes: Record = { + [site.id]: site, + [building.id]: building, + [level.id]: level as LevelNode, + } + for (const childId of building.children) { + const child = ctx.resolve(childId as AnyNodeId) + if (child) nodes[child.id] = child + } + const elevation = getLevelElevations(nodes).get(level.id) + if (!elevation) return null + const baseWorldY = (building.position?.[1] ?? 0) + elevation.baseY + if (Math.abs(baseWorldY) >= 1e-4) return null + + return { + baseWorldY, + building, + field, + } +} + +function buildSlabTerrainFillGeometry( + node: SlabNode, + polygon: Array<[number, number]>, + ctx: GeometryContext | undefined, +): BufferGeometry | null { + const terrain = terrainFillContext(ctx) + if (!terrain || polygon.length < 3) return null + + let area2 = 0 + for (let index = 0; index < polygon.length; index += 1) { + const [ax, az] = polygon[index]! + const [bx, bz] = polygon[(index + 1) % polygon.length]! + area2 += ax * bz - bx * az + } + const contour = area2 < 0 ? [...polygon].reverse() : polygon + const angle = terrain.building.rotation?.[1] ?? 0 + const cos = Math.cos(angle) + const sin = Math.sin(angle) + const [offsetX, , offsetZ] = terrain.building.position ?? [0, 0, 0] + const toSite = (x: number, z: number): [number, number] => [ + cos * x + sin * z + offsetX, + -sin * x + cos * z + offsetZ, + ] + + const points: Array<[number, number]> = [] + for (let index = 0; index < contour.length; index += 1) { + const [ax, az] = contour[index]! + const [bx, bz] = contour[(index + 1) % contour.length]! + const [siteAx, siteAz] = toSite(ax, az) + const [siteBx, siteBz] = toSite(bx, bz) + points.push([ax, az]) + if (terrain.field) { + for (const t of creaseCrossings(terrain.field, siteAx, siteAz, siteBx, siteBz)) { + points.push([ax + (bx - ax) * t, az + (bz - az) * t]) + } + } + } + + const top = node.elevation - node.thickness + const localPoints = points.map(([x, z]) => ({ x, z })) + const bottomY = points.map(([x, z]) => { + const [siteX, siteZ] = toSite(x, z) + const ground = terrain.field ? surfaceHeightAt(terrain.field, siteX, siteZ) : 0 + return Math.min(top, ground - terrain.baseWorldY) + }) + return buildTerrainPerimeterFillGeometry(localPoints, bottomY, top, 1e-4) +} + export function buildSlabGeometry( node: SlabNode, ctx?: GeometryContext, @@ -185,7 +274,8 @@ export function buildSlabGeometry( sceneTheme?: string, ): Group { const group = new Group() - const merged = generateSlabGeometry(node, slabPolygonContextFromGeometry(ctx)) + const polygonContext = slabPolygonContextFromGeometry(ctx) + const merged = generateSlabGeometry(node, polygonContext) const { top, side } = splitSlabFacesByFacing(merged) merged.dispose() @@ -210,10 +300,36 @@ export function buildSlabGeometry( mesh.receiveShadow = true mesh.userData.slotId = slotId // Solid slabs bake [elevation − thickness, elevation] into the geometry; - // recessed shells are authored at local Y=0 and sink so the recess floor - // sits at `elevation` (< 0) with the shell rim on the level plane. + // recessed shells are authored from floor to rim and translated so the + // floor sits at `elevation`. if (node.recessed) mesh.position.y = elevation group.add(mesh) } + + if (node.fillToTerrain && !node.recessed) { + const terrainFill = buildSlabTerrainFillGeometry( + node, + getRenderableSlabPolygon(node, polygonContext), + ctx, + ) + if (terrainFill) { + const mesh = new Mesh( + terrainFill, + getSlabSlotMaterial( + node, + 'side', + shading, + textures, + colorPreset, + sceneTheme, + ctx?.materials, + ), + ) + mesh.castShadow = true + mesh.receiveShadow = true + mesh.userData.slotId = 'side' + group.add(mesh) + } + } return group } diff --git a/packages/nodes/src/slab/panel.tsx b/packages/nodes/src/slab/panel.tsx index cb73a8be6..07e88ade2 100644 --- a/packages/nodes/src/slab/panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -7,6 +7,7 @@ import { holeEditScope, PanelSection, PanelWrapper, + SegmentedControl, SliderControl, triggerSFX, useEditingHole, @@ -16,7 +17,17 @@ import { import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect, useRef } from 'react' -import { applySlabElevationPreset, applySlabTopChange, clampSlabElevation } from './elevation-limit' +import { + applySlabAnchorElevationChange, + applySlabElevationPreset, + applySlabRecessDepthChange, + applySlabThicknessChange, + applySlabTopChange, + clampSlabElevation, + getSlabAnchorElevation, + getSlabBaseElevation, + getSlabRecessDepth, +} from './elevation-limit' /** * Phase 5 Stage E — slab inspector (kind-owned). @@ -59,24 +70,76 @@ export function SlabPanel() { const current = nodeRef.current if (!current) return const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) - handleUpdate(applySlabTopChange(current, elevation, { mode: 'panel' })) + handleUpdate(applySlabTopChange(current, elevation)) }, [handleUpdate], ) const handleThicknessChange = useCallback( (proposed: number) => { - handleUpdate({ thickness: Math.max(MIN_SLAB_THICKNESS, proposed) }) + const current = nodeRef.current + if (!current) return + const base = getSlabBaseElevation(current) + const requested = applySlabThicknessChange(current, proposed) + const clamped = clampSlabElevation(useScene.getState().nodes, current, requested.elevation) + handleUpdate( + applySlabThicknessChange(current, Math.max(MIN_SLAB_THICKNESS, clamped.elevation - base)), + ) }, [handleUpdate], ) - const handleElevationPreset = useCallback( + const handleAnchorChange = useCallback( (proposed: number) => { const current = nodeRef.current if (!current) return - const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) - handleUpdate(applySlabElevationPreset(elevation)) + const patch = applySlabAnchorElevationChange(current, proposed) + const requestedTop = patch.elevation ?? current.elevation + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, requestedTop) + if (current.recessed) { + const delta = elevation - requestedTop + handleUpdate({ + ...patch, + elevation, + recessedRimElevation: (patch.recessedRimElevation ?? proposed) + delta, + }) + return + } + handleUpdate(applySlabAnchorElevationChange(current, elevation - current.thickness)) + }, + [handleUpdate], + ) + + const handleRecessDepthChange = useCallback( + (proposed: number) => { + const current = nodeRef.current + if (!current?.recessed) return + handleUpdate(applySlabRecessDepthChange(current, proposed)) + }, + [handleUpdate], + ) + + const handleElevationPreset = useCallback( + (signedDepth: number) => { + const current = nodeRef.current + if (!current) return + const anchor = getSlabAnchorElevation(current) + const requested = applySlabElevationPreset(current, signedDepth) + if (requested.recessed) { + handleUpdate(requested) + return + } + const requestedTop = requested.elevation ?? current.elevation + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, requestedTop) + const thickness = Math.max(MIN_SLAB_THICKNESS, elevation - anchor) + handleUpdate({ ...requested, elevation: anchor + thickness, thickness }) + }, + [handleUpdate], + ) + + const handleTerrainModeChange = useCallback( + (mode: 'fixed' | 'terrain') => { + handleUpdate({ fillToTerrain: mode === 'terrain' ? true : undefined }) }, [handleUpdate], ) @@ -196,16 +259,16 @@ export function SlabPanel() { const elevationPresets = unit === 'imperial' ? [ - { label: 'Sunken (-6")', elevation: -0.1524 }, - { label: 'Ground (0")', elevation: 0 }, - { label: 'Raised (+2")', elevation: 0.0508 }, - { label: 'Step (+6")', elevation: 0.1524 }, + { label: 'Sunken (6")', elevation: -0.1524 }, + { label: 'Thin (1")', elevation: 0.0254 }, + { label: 'Standard (2")', elevation: 0.0508 }, + { label: 'Thick (6")', elevation: 0.1524 }, ] : [ - { label: 'Sunken (-15cm)', elevation: -0.15 }, - { label: 'Ground (0m)', elevation: 0 }, - { label: 'Raised (+5cm)', elevation: 0.05 }, - { label: 'Step (+15cm)', elevation: 0.15 }, + { label: 'Sunken (15cm)', elevation: -0.15 }, + { label: 'Thin (2cm)', elevation: 0.02 }, + { label: 'Standard (5cm)', elevation: 0.05 }, + { label: 'Thick (15cm)', elevation: 0.15 }, ] return ( @@ -217,9 +280,9 @@ export function SlabPanel() { > - {!node.recessed && ( + + + {node.recessed ? ( + + ) : ( )} + {!node.recessed && ( + <> +
+ Foundation +
+ + {node.fillToTerrain && ( +
+ Extends the perimeter down to terrain. The flat surface, base, and thickness stay + unchanged. +
+ )} + + )} +
{elevationPresets.map((preset) => ( ): Map { @@ -49,6 +49,15 @@ function levelSlabContextSignatures(nodes: Record): Map() for (const [levelId, parts] of partsByLevel.entries()) { signatures.set(levelId, parts.sort().join('||')) diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index ba54c3c1a..80a432618 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -5,23 +5,33 @@ import { emitter, type GridEvent, type LevelNode, + resolveSlabPlacementElevation, snapPointAlongAngleRay, snapPointToGrid, useScene, } from '@pascal-app/core' import { CursorSphere, + clearPlacementSurface, clearSlabSnapFeedback, EDITOR_LAYER, + type HorizontalConstructionPlane, isAngleSnapActive, isGridSnapActive, markToolCancelConsumed, + publishHorizontalConstructionPlane, + publishPlacementSurface, + resampleTerrainConstructionPlane, + resolveEventConstructionPlane, + resolveLevelConstructionPlane, + resolvePointerSupportSurface, resolveSlabPlanPointSnap, triggerSFX, useEditor, useFloorplanDraftPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { type SlabCompletionTrigger, shouldRegistryCommitSlab } from './placement-ownership' @@ -40,15 +50,25 @@ import { SlabNode } from './schema' */ const Y_OFFSET = 0.02 +const SURFACE_UP = new Vector3(0, 1, 0) +const surfacePointScratch = new Vector3() -function commitSlabDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string { +function commitSlabDrawing( + levelId: LevelNode['id'], + points: Array<[number, number]>, + baseElevation: number | null, +): string { const { createNode, nodes } = useScene.getState() const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length const name = `Slab ${slabCount + 1}` // A placed slab preset seeds `toolDefaults.slab` (thickness, material, …) // before the tool activates; the drawn polygon always wins. const defaults = useEditor.getState().toolDefaults.slab ?? {} - const slab = SlabNode.parse({ ...defaults, name, polygon: points }) + const authored = SlabNode.parse({ ...defaults, name, polygon: points }) + const slab = { + ...authored, + elevation: resolveSlabPlacementElevation(authored, baseElevation), + } createNode(slab, levelId) triggerSFX('sfx:structure-build') return slab.id @@ -60,18 +80,30 @@ export const SlabTool: React.FC = () => { const closingLineRef = useRef(null!) const currentLevelId = useViewer((s) => s.selection.levelId) const setSelection = useViewer((s) => s.setSelection) + const slabDefaults = useEditor((s) => s.toolDefaults.slab) + const isRecessed = slabDefaults?.recessed === true + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera const [points, setPoints] = useState>([]) const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) const [levelY, setLevelY] = useState(0) const previousSnappedPointRef = useRef<[number, number] | null>(null) + const constructionPlaneRef = useRef(null) // Clear preset-seeded defaults on deactivation so a later manual slab draw // isn't built with a stale preset's parameters. Unmount-only. useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), []) - useEffect(() => () => clearSlabSnapFeedback(), []) + useEffect( + () => () => { + clearSlabSnapFeedback() + clearPlacementSurface() + }, + [], + ) // Publish the live vertex count so the HUD shows "Finish" only at ≥ 3 points. useEffect(() => { @@ -96,15 +128,40 @@ export const SlabTool: React.FC = () => { useEffect(() => { if (!currentLevelId) return + const pointedSurfaceFor = (event: GridEvent) => + event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(cameraRef.current, event.position) + : null + + const resetDraft = () => { + setPoints([]) + constructionPlaneRef.current = null + previousSnappedPointRef.current = null + clearSlabSnapFeedback() + clearPlacementSurface() + } + const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return - const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] + const pointed = points.length === 0 && !isRecessed ? pointedSurfaceFor(event) : null + const plane = + constructionPlaneRef.current ?? (isRecessed ? resolveLevelConstructionPlane() : null) + if (plane) { + publishHorizontalConstructionPlane(event, plane) + } else if (pointed) { + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + } + + const localPosition = pointed?.localPoint ?? event.localPosition + const rawPoint: [number, number] = [localPosition[0], localPosition[2]] // Slab drafting is the 'polygon' snap context (grid / lines / off — no // angle, no Shift bypass; Shift cycles the mode, Off is the bypass). const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 const gridPosition: [number, number] = [...snapPointToGrid(rawPoint, gridStep)] setCursorPosition(gridPosition) - setLevelY(event.localPosition[1]) const lastPoint = points[points.length - 1] // Angle lock only when the mode asks for it (polygon never does today, but // honour the flag so the behaviour follows the HUD). @@ -117,6 +174,13 @@ export const SlabTool: React.FC = () => { fallbackPoint: orthoPoint, levelId: currentLevelId, }).point + const hoverPlane = + plane ?? + resampleTerrainConstructionPlane( + resolveEventConstructionPlane(event, pointed), + displayPoint, + ) + setLevelY(hoverPlane.localY) useFloorplanDraftPreview.getState().setCursorPoint(displayPoint) setSnappedCursorPosition(displayPoint) if ( @@ -128,10 +192,10 @@ export const SlabTool: React.FC = () => { triggerSFX('sfx:grid-snap') } previousSnappedPointRef.current = displayPoint - cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1]) + cursorRef.current.position.set(displayPoint[0], hoverPlane.localY, displayPoint[1]) } - const onGridClick = (_event: GridEvent) => { + const onGridClick = (event: GridEvent) => { if (!currentLevelId) return const clickPoint = previousSnappedPointRef.current ?? cursorPosition const firstPoint = points[0] @@ -142,12 +206,26 @@ export const SlabTool: React.FC = () => { Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { if (shouldRegistryCommitSlab(useEditor.getState().viewMode, 'grid')) { - const slabId = commitSlabDrawing(currentLevelId, points) + const slabId = commitSlabDrawing( + currentLevelId, + points, + constructionPlaneRef.current?.elevation ?? null, + ) setSelection({ selectedIds: [slabId] }) } - setPoints([]) - clearSlabSnapFeedback() + resetDraft() } else { + if (points.length === 0) { + const plane = isRecessed + ? (resolveLevelConstructionPlane() ?? resolveEventConstructionPlane(event, null)) + : resampleTerrainConstructionPlane( + resolveEventConstructionPlane(event, pointedSurfaceFor(event)), + clickPoint, + ) + constructionPlaneRef.current = plane + setLevelY(plane.localY) + publishHorizontalConstructionPlane(event, plane) + } // Every non-closing vertex is a "start" tick; the closing click above // fires the structure-build (end) cue. triggerSFX('sfx:structure-build-start') @@ -160,11 +238,14 @@ export const SlabTool: React.FC = () => { const finishDrawing = (trigger: SlabCompletionTrigger) => { if (points.length < 3) return if (shouldRegistryCommitSlab(useEditor.getState().viewMode, trigger)) { - const slabId = commitSlabDrawing(currentLevelId, points) + const slabId = commitSlabDrawing( + currentLevelId, + points, + constructionPlaneRef.current?.elevation ?? null, + ) setSelection({ selectedIds: [slabId] }) } - setPoints([]) - clearSlabSnapFeedback() + resetDraft() } const onGridDoubleClick = (_event: GridEvent) => { @@ -173,8 +254,7 @@ export const SlabTool: React.FC = () => { const onCancel = () => { if (points.length > 0) markToolCancelConsumed() - setPoints([]) - clearSlabSnapFeedback() + resetDraft() } const onKeyDown = (e: KeyboardEvent) => { @@ -197,7 +277,7 @@ export const SlabTool: React.FC = () => { emitter.off('grid:double-click', onGridDoubleClick) emitter.off('tool:cancel', onCancel) } - }, [currentLevelId, points, cursorPosition, setSelection]) + }, [currentLevelId, points, cursorPosition, isRecessed, setSelection]) useEffect(() => { if (!(mainLineRef.current && closingLineRef.current)) return diff --git a/packages/nodes/src/stair/floor-stack.test.ts b/packages/nodes/src/stair/floor-stack.test.ts index 4a822512b..b6477693e 100644 --- a/packages/nodes/src/stair/floor-stack.test.ts +++ b/packages/nodes/src/stair/floor-stack.test.ts @@ -3,15 +3,17 @@ import { type AnyNode, type AnyNodeDefinition, getFloorPlacedElevation, + getFloorStackedPosition, nodeRegistry, registerNode, + resolveSupportSlabPatch, type SlabNode, StairNode, StairSegmentNode, spatialGridManager, } from '@pascal-app/core' import { stairDefinition } from './definition' -import { getStairSegmentFloorPlacedFootprints } from './floor-stack' +import { getStairFloorPlacedFootprints, getStairSegmentFloorPlacedFootprints } from './floor-stack' const LEVEL_ID = 'level_test' @@ -112,6 +114,82 @@ describe('stair floor-stack footprints', () => { expect(footprints[1]?.rotation[1]).toBeCloseTo(Math.PI / 2) }) + test('derives spiral footprints from the parent instead of retained straight segments', () => { + const retainedSegment = StairSegmentNode.parse({ + id: 'sseg_retained_after_spiral_conversion', + width: 1, + length: 4, + height: 2.8, + }) + const spiral = StairNode.parse({ + id: 'stair_spiral_preset', + parentId: LEVEL_ID, + stairType: 'spiral', + position: [3, 0, 4], + innerRadius: 0.3, + width: 1, + stepCount: 12, + sweepAngle: Math.PI * 2, + children: [retainedSegment.id], + }) + const footprints = getStairFloorPlacedFootprints(spiral, { + [spiral.id]: spiral, + [retainedSegment.id]: retainedSegment, + }) + + expect(footprints.length).toBeGreaterThanOrEqual(24) + expect( + footprints.some((footprint) => (footprint.position?.[0] ?? 0) < spiral.position[0]), + ).toBe(true) + expect( + footprints.some((footprint) => (footprint.position?.[2] ?? 0) < spiral.position[2]), + ).toBe(true) + }) + + test('keeps a columnless spiral center hole out of the support footprint', () => { + registerNode(stairDefinition as unknown as AnyNodeDefinition) + addSlab( + [ + [-0.08, -0.08], + [0.08, -0.08], + [0.08, 0.08], + [-0.08, 0.08], + ], + 0.8, + 'slab_inside_spiral_hole', + ) + + const level = makeLevel() + const spiral = StairNode.parse({ + id: 'stair_spiral_hole', + parentId: LEVEL_ID, + stairType: 'spiral', + position: [0, 0, 0], + innerRadius: 0.5, + width: 0.8, + stepCount: 16, + showCenterColumn: false, + }) + const footprints = getStairFloorPlacedFootprints(spiral, { [spiral.id]: spiral }) + + expect( + footprints.some((footprint) => { + const position = footprint.position ?? spiral.position + const radialDistance = Math.hypot(position[0], position[2]) + return radialDistance < spiral.innerRadius + }), + ).toBe(false) + expect( + getFloorPlacedElevation({ + node: spiral, + nodes: { [level.id]: level, [spiral.id]: spiral }, + position: spiral.position, + rotation: spiral.rotation, + levelId: LEVEL_ID, + }), + ).toBeCloseTo(0) + }) + test('uses the max slab elevation across stair segment footprints', () => { registerNode(stairDefinition as unknown as AnyNodeDefinition) @@ -174,4 +252,108 @@ describe('stair floor-stack footprints', () => { }), ).toBeCloseTo(0.75) }) + + test('persists the pointer-elected base across stacked slabs', () => { + registerNode(stairDefinition as unknown as AnyNodeDefinition) + + const polygon: Array<[number, number]> = [ + [-2, -1], + [2, -1], + [2, 5], + [-2, 5], + ] + addSlab(polygon, 0, 'slab_floor') + addSlab(polygon, 0.8, 'slab_deck') + + const level = makeLevel() + const segment = StairSegmentNode.parse({ + id: 'sseg_pointer_support', + width: 1, + length: 3, + height: 2.8, + }) + const stair = StairNode.parse({ + id: 'stair_pointer_support', + parentId: LEVEL_ID, + position: [0, 0, 0], + rotation: 0, + children: [segment.id], + }) + const nodes = { + [level.id]: level, + [stair.id]: stair, + [segment.id]: { ...segment, parentId: stair.id }, + } + + const underDeckPatch = resolveSupportSlabPatch(stair, nodes, { maxElevation: 0 }) + const onDeckPatch = resolveSupportSlabPatch(stair, nodes, { maxElevation: 0.8 }) + + expect(underDeckPatch.supportSlabId).toBe('slab_floor') + expect(onDeckPatch.supportSlabId).toBe('slab_deck') + expect( + getFloorStackedPosition({ + node: { ...stair, ...underDeckPatch }, + nodes, + position: stair.position, + rotation: stair.rotation, + })[1], + ).toBeCloseTo(0) + expect( + getFloorStackedPosition({ + node: { ...stair, ...onDeckPatch }, + nodes, + position: stair.position, + rotation: stair.rotation, + })[1], + ).toBeCloseTo(0.8) + }) + + test('elects a raised slab under a spiral preset footprint', () => { + registerNode(stairDefinition as unknown as AnyNodeDefinition) + + addSlab( + [ + [0.65, -0.35], + [1.35, -0.35], + [1.35, 0.35], + [0.65, 0.35], + ], + 0.8, + 'slab_under_spiral', + ) + + const level = makeLevel() + const retainedSegment = StairSegmentNode.parse({ + id: 'sseg_spiral_support_retained', + width: 0.4, + length: 0.4, + height: 2.8, + }) + const spiral = StairNode.parse({ + id: 'stair_spiral_support', + parentId: LEVEL_ID, + stairType: 'spiral', + position: [0, 0, 0], + innerRadius: 0.3, + width: 1, + stepCount: 12, + sweepAngle: Math.PI * 2, + children: [retainedSegment.id], + }) + const nodes = { + [level.id]: level, + [spiral.id]: spiral, + [retainedSegment.id]: { ...retainedSegment, parentId: spiral.id }, + } + + expect( + getFloorPlacedElevation({ + node: spiral, + nodes, + position: spiral.position, + rotation: spiral.rotation, + levelId: LEVEL_ID, + }), + ).toBeCloseTo(0.8) + }) }) diff --git a/packages/nodes/src/stair/floor-stack.ts b/packages/nodes/src/stair/floor-stack.ts index c21d4c85d..85c5a335f 100644 --- a/packages/nodes/src/stair/floor-stack.ts +++ b/packages/nodes/src/stair/floor-stack.ts @@ -15,6 +15,10 @@ export function getStairFloorPlacedFootprints( stair: StairNode, nodes: Readonly>, ): FloorPlacedFootprint[] { + if ((stair.stairType ?? 'straight') !== 'straight') { + return getArcStairFloorPlacedFootprints(stair) + } + const segments = (stair.children ?? []) .map((childId) => nodes[childId as AnyNodeId]) .filter((node): node is StairSegmentNode => node?.type === 'stair-segment') @@ -22,6 +26,51 @@ export function getStairFloorPlacedFootprints( return getStairSegmentFloorPlacedFootprints(stair, segments) } +const MAX_ARC_FOOTPRINT_SWEEP = Math.PI / 12 + +function getArcStairFloorPlacedFootprints(stair: StairNode): FloorPlacedFootprint[] { + const isSpiral = stair.stairType === 'spiral' + const innerRadius = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? (isSpiral ? 0.2 : 0.9)) + const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4) + const sweepAngle = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2) + const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) + const sliceCount = Math.max(stepCount, Math.ceil(Math.abs(sweepAngle) / MAX_ARC_FOOTPRINT_SWEEP)) + const footprints: FloorPlacedFootprint[] = [] + + for (let index = 0; index < sliceCount; index += 1) { + const startAngle = -sweepAngle / 2 + (sweepAngle * index) / sliceCount + const endAngle = -sweepAngle / 2 + (sweepAngle * (index + 1)) / sliceCount + const midAngle = (startAngle + endAngle) / 2 + const halfSweep = Math.abs(endAngle - startAngle) / 2 + const projectedInnerRadius = innerRadius * Math.cos(halfSweep) + const radialSize = outerRadius - projectedInnerRadius + const tangentialSize = 2 * outerRadius * Math.sin(halfSweep) + const centerRadius = (outerRadius + projectedInnerRadius) / 2 + const [offsetX, offsetZ] = rotateXZ( + Math.cos(midAngle) * centerRadius, + Math.sin(midAngle) * centerRadius, + stair.rotation, + ) + + footprints.push({ + position: [stair.position[0] + offsetX, stair.position[1], stair.position[2] + offsetZ], + dimensions: [Math.max(radialSize, 0.01), 0.01, Math.max(tangentialSize, 0.01)], + rotation: [0, midAngle - stair.rotation, 0], + }) + } + + if (isSpiral && (stair.showCenterColumn ?? true)) { + const columnRadius = Math.max(0.05, Math.min(innerRadius * 0.72, innerRadius - 0.03)) + footprints.push({ + position: stair.position, + dimensions: [columnRadius * 2, 0.01, columnRadius * 2], + rotation: [0, 0, 0], + }) + } + + return footprints +} + export function getStairSegmentFloorPlacedFootprints( stair: StairNode, segments: readonly StairSegmentNode[], diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index d81d8d8d9..199df4fdb 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -5,11 +5,12 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + getWallBaseElevationForNodes, getWallCurveLength, getWallThickness, pauseSceneHistory, resolveAlignment, - resolveWallSupportSlabPatch, + resolveMovedWallSupportSlabPatch, resumeSceneHistory, runAsSingleSceneHistoryStep, useLiveNodeOverrides, @@ -39,6 +40,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useCallback, useEffect, useRef, useState } from 'react' +import { LevelOffsetGroup } from '../shared/level-offset-group' import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** @@ -209,6 +211,12 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ }) const [altPressed, setAltPressed] = useState(false) const unit = useViewer((s) => s.unit) + const nodes = useScene((state) => state.nodes) + const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(target.wall.id)) + const effectiveWall = liveOverride + ? ({ ...target.wall, ...liveOverride } as WallNode) + : target.wall + const wallBaseElevation = getWallBaseElevationForNodes(effectiveWall, nodes) // Alt-detach only affects walls sharing the moving endpoint; walls linked // solely to the fixed endpoint never move, so the hint would be noise. @@ -564,7 +572,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ affectedIds.flatMap((id) => { const wall = committedNodes[id] return wall?.type === 'wall' - ? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }] + ? [{ id, data: resolveMovedWallSupportSlabPatch(wall, committedNodes) }] : [] }), ) @@ -661,48 +669,52 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ end: previewEnd, curveOffset: target.wall.curveOffset, }) - const wallHeight = resolveWallOpeningCeiling(target.wall, useScene.getState().nodes) + const wallHeight = resolveWallOpeningCeiling(effectiveWall, nodes) const dimMidX = (previewStart[0] + previewEnd[0]) / 2 const dimMidZ = (previewStart[1] + previewEnd[1]) / 2 return ( - - - - - - {canDetachCorner && ( + + + -
-
- {altPressed ? 'Detaching corner' : 'Alt to detach'} -
-
+ - )} - {angleLabel && } -
+ {canDetachCorner && ( + +
+
+ {altPressed ? 'Detaching corner' : 'Alt to detach'} +
+
+ + )} + {angleLabel && ( + + )} +
+ ) } diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 95744fa7f..293b40c4a 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -17,7 +17,7 @@ import { planAutoCeilingsForLevel, planAutoSlabsForLevel, planWallMoveJunctions, - resolveWallSupportSlabPatch, + resolveMovedWallSupportSlabPatch, resumeSceneHistory, type SlabNode, useLiveNodeOverrides, @@ -608,7 +608,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { affectedWallIds.flatMap((id) => { const wall = committedNodes[id] return wall?.type === 'wall' - ? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }] + ? [{ id, data: resolveMovedWallSupportSlabPatch(wall, committedNodes) }] : [] }), ) diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 0144af7d5..0841857d8 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -22,7 +22,6 @@ import { ActionButton, ActionGroup, curveReshapeScope, - formatLinearMeasurement, getLinearUnitLabel, linearControlValueToMeters, metersToLinearUnit, @@ -106,12 +105,12 @@ export default function WallPanel() { }) }) - // Effective height while the wall is plane-bound (`height` absent): the - // storey plane minus the elected slab base — what the wall currently - // renders at. `undefined` for walls with an explicit custom height. - const planeBoundHeightMeters = useScene((s) => { + // Existing plane-bound walls have no stored height. Resolve their current + // body height for display and materialize it if the user edits height or + // enables terrain infill. + const resolvedHeightMeters = useScene((s) => { const wall = selectedId ? (s.nodes[selectedId as AnyNodeId] as WallNode | undefined) : undefined - if (wall?.type !== 'wall' || wall.height != null) return undefined + if (wall?.type !== 'wall') return undefined return resolveWallOpeningCeiling(wall, s.nodes) }) @@ -156,20 +155,15 @@ export default function WallPanel() { [handleUpdate], ) - const handleTopModeChange = useCallback( - (mode: 'storey' | 'custom') => { + const handleBaseModeChange = useCallback( + (mode: 'terrain' | 'fixed') => { const n = nodeRef.current if (!n) return - const isCustom = n.height != null - if (mode === 'custom' && !isCustom) { - // Seed from the current effective height so the geometry doesn't - // jump at the moment of detaching from the storey plane. - const seeded = resolveWallOpeningCeiling(n, useScene.getState().nodes) - handleUpdate({ height: Math.max(0.1, seeded) }) - } else if (mode === 'storey' && isCustom) { - // Absent `height` = plane-bound; the store strips undefined keys. - handleUpdate({ height: undefined }) - } + const height = n.height ?? resolveWallOpeningCeiling(n, useScene.getState().nodes) + handleUpdate({ + height: Math.max(0.1, height), + fillToTerrain: mode === 'terrain' ? true : undefined, + }) }, [handleUpdate], ) @@ -189,8 +183,8 @@ export default function WallPanel() { const length = getWallCurveLength(node) - const isPlaneBound = node.height == null - const height = node.height ?? planeBoundHeightMeters ?? 2.5 + const followsTerrain = node.fillToTerrain === true + const height = node.height ?? resolvedHeightMeters ?? 2.5 const thickness = node.thickness ?? 0.1 const curveOffset = getClampedWallCurveOffset(node) const maxCurveOffset = getMaxWallCurveOffset(node) @@ -229,36 +223,35 @@ export default function WallPanel() { unit={unitLabel} value={displayLength} /> + + handleUpdate({ + height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), + }) + } + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayHeight * 100) / 100} + />
- Top + Base
- {isPlaneBound ? ( + {followsTerrain && (
- Currently {formatLinearMeasurement(height, unit)} + Extends downward to meet the terrain. Height and top stay unchanged.
- ) : ( - - handleUpdate({ - height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), - }) - } - precision={2} - step={0.1} - unit={unitLabel} - value={Math.round(displayHeight * 100) / 100} - /> )} { // the "segment tees into an existing wall" chain-termination test, so // snapping onto the chain's own segments never reads as a join. const chainWallIds = useRef([]) + const constructionPlane = useRef(null) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) @@ -537,11 +546,67 @@ export const WallTool: React.FC = () => { // grid plane alone. const pointedSurfaceFor = (event: GridEvent) => event.nativeEvent?.target instanceof HTMLCanvasElement - ? resolvePointerSupportSurface(cameraRef.current, event.position) + ? resolvePointerSupportSurface(cameraRef.current, event.position, { + includeNodeTopSurfaces: true, + }) : null + const snappedWallConstructionPlane = ( + targetWallIds: string[], + walls: WallNode[], + ): HorizontalConstructionPlane | null => { + const activeWallIds = new Set(walls.map((wall) => wall.id)) + const targetIds = targetWallIds.filter((id) => activeWallIds.has(id as WallNode['id'])) + if (targetIds.length === 0) return null + + const buildingId = useViewer.getState().selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined + const currentLevelId = useViewer.getState().selection.levelId + const levelMesh = currentLevelId + ? sceneRegistry.nodes.get(currentLevelId as AnyNodeId) + : undefined + let resolved: HorizontalConstructionPlane | null = null + + for (const id of targetIds) { + const targetWall = walls.find((wall) => wall.id === id) + if (!targetWall) continue + const wallMesh = sceneRegistry.nodes.get(id as AnyNodeId) + if (!wallMesh) continue + wallMesh.getWorldPosition(wallSurfaceWorldScratch) + const worldY = wallSurfaceWorldScratch.y + + wallSurfaceLocalScratch.copy(wallSurfaceWorldScratch) + if (buildingMesh) buildingMesh.worldToLocal(wallSurfaceLocalScratch) + const localY = wallSurfaceLocalScratch.y + + wallSurfaceLocalScratch.copy(wallSurfaceWorldScratch) + if (levelMesh) levelMesh.worldToLocal(wallSurfaceLocalScratch) + const elevation = wallSurfaceLocalScratch.y + + if ( + resolved && + (Math.abs(resolved.worldY - worldY) > 1e-4 || + Math.abs((resolved.elevation ?? elevation) - elevation) > 1e-4 || + resolved.supportSlabId !== (targetWall.supportSlabId ?? null)) + ) { + // An ambiguous junction at different elevations transfers no plane. + return null + } + resolved = { + localY, + worldY, + elevation, + supportSlabId: targetWall.supportSlabId ?? null, + sourceNodeId: targetWall.id as AnyNodeId, + } + } + + return resolved + } + const stopDrafting = () => { buildingState.current = 0 + constructionPlane.current = null chainFirstVertex.current = null chainWallIds.current = [] const draftPreview = useFloorplanDraftPreview.getState() @@ -555,6 +620,7 @@ export const WallTool: React.FC = () => { useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() useSegmentDraftChain.getState().clear('wall') + clearPlacementSurface() } const onGridMove = (event: GridEvent) => { @@ -565,8 +631,10 @@ export const WallTool: React.FC = () => { // lands where the cursor points and the preview/cursor Y // (`event.localPosition[1]`) sits at the base the committed wall // will elect. Aiming past the deck edge drops it back to the floor. - const pointed = pointedSurfaceFor(event) - if (pointed) { + const pointed = buildingState.current === 0 ? pointedSurfaceFor(event) : null + if (constructionPlane.current) { + publishHorizontalConstructionPlane(event, constructionPlane.current) + } else if (pointed) { publishPlacementSurface( surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), SURFACE_UP, @@ -578,7 +646,9 @@ export const WallTool: React.FC = () => { // can align with the level beneath it. Kept separate from `walls` so the // measurement HUD only reports against the active level. const snapWalls = [...walls, ...getBelowLevelWalls()] - const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] + const localPoint: WallPlanPoint = pointed?.localPoint + ? [pointed.localPoint[0], pointed.localPoint[2]] + : [event.localPosition[0], event.localPosition[2]] // Snapping is governed entirely by the snapping mode (grid / lines / // angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass. const angleLocked = buildingState.current === 1 && isAngleSnapActive() @@ -602,7 +672,8 @@ export const WallTool: React.FC = () => { if (buildingState.current === 1) { const snappedLocal = gridPosition - endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) + const draftY = constructionPlane.current?.localY ?? event.localPosition[1] + endingPoint.current.set(snappedLocal[0], draftY, snappedLocal[1]) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setWallDraftStart([startingPoint.current.x, startingPoint.current.z]) draftPreview.setWallDraftEnd(snappedLocal) @@ -645,7 +716,11 @@ export const WallTool: React.FC = () => { ), ) } else { - cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1]) + const hoverPlane = resampleTerrainConstructionPlane( + resolveEventConstructionPlane(event, pointed), + gridPosition, + ) + cursorRef.current.position.set(gridPosition[0], hoverPlane.localY, gridPosition[1]) setDraftMeasurement(null) setAxisGuide(null) } @@ -661,18 +736,29 @@ export const WallTool: React.FC = () => { const walls = getCurrentLevelWalls() const snapWalls = [...walls, ...getBelowLevelWalls()] - const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] + const pointed = buildingState.current === 0 ? pointedSurfaceFor(event) : null + const localClick: WallPlanPoint = pointed?.localPoint + ? [pointed.localPoint[0], pointed.localPoint[2]] + : [event.localPosition[0], event.localPosition[2]] if (buildingState.current === 0) { - const snappedStart = alignPoint( - snapWallDraftPointDetailed({ - point: localClick, - walls: snapWalls, - magnetic: isMagneticSnapActive(), - }).point, - ) + const snapResult = snapWallDraftPointDetailed({ + point: localClick, + walls: snapWalls, + magnetic: isMagneticSnapActive(), + }) + const snappedStart = alignPoint(snapResult.point) + const resolvedPlane = + (pointed?.sourceNodeId + ? resolveEventConstructionPlane(event, pointed) + : pointMatches(snappedStart, snapResult.point) + ? snappedWallConstructionPlane(snapResult.targetWallIds, walls) + : null) ?? resolveEventConstructionPlane(event, pointed) + const plane = resampleTerrainConstructionPlane(resolvedPlane, snappedStart) + constructionPlane.current = plane + publishHorizontalConstructionPlane(event, plane) gridPosition = snappedStart - startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) + startingPoint.current.set(snappedStart[0], plane.localY, snappedStart[1]) chainFirstVertex.current = startingPoint.current.clone() endingPoint.current.copy(startingPoint.current) buildingState.current = 1 @@ -682,7 +768,7 @@ export const WallTool: React.FC = () => { setAxisGuide({ origin: snappedStart, endOrigin: null, - y: event.localPosition[1], + y: plane.localY, angleLabel: null, }) triggerSFX('sfx:structure-build-start') @@ -709,12 +795,16 @@ export const WallTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return - const pointed = pointedSurfaceFor(event) // Both start and end are building-local ✓ const createdWall = createWallOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, - { supportCap: pointed ? pointed.elevation : null }, + { + supportCap: constructionPlane.current?.elevation ?? null, + preferredSupportSlabId: constructionPlane.current?.supportSlabId ?? null, + constructionElevation: constructionPlane.current?.elevation ?? null, + constructionHeight: previewHeightRef.current, + }, ) if (!createdWall) return chainWallIds.current.push(createdWall.id) @@ -757,7 +847,8 @@ export const WallTool: React.FC = () => { // chains its next segment from the same point (its own snap // pipeline can resolve a slightly different endpoint). useSegmentDraftChain.getState().setChainStart('wall', [nextStart[0], nextStart[1]]) - startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) + const draftY = constructionPlane.current?.localY ?? event.localPosition[1] + startingPoint.current.set(nextStart[0], draftY, nextStart[1]) endingPoint.current.copy(startingPoint.current) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setWallDraftEnd(null) @@ -768,7 +859,7 @@ export const WallTool: React.FC = () => { setAxisGuide({ origin: nextStart, endOrigin: null, - y: event.localPosition[1], + y: draftY, angleLabel: null, }) // Hide the preview until the next `onGridMove` writes the diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 316d3a46c..fad3a529c 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -137,6 +137,10 @@ export { THUMBNAIL_HEIGHT, THUMBNAIL_WIDTH, } from './lib/snapshot-pipeline' +export { + buildTerrainPerimeterFillGeometry, + type TerrainPerimeterPoint, +} from './lib/terrain-perimeter-fill' export { getPascalTextureRef, type PascalTextureColorSpace, diff --git a/packages/viewer/src/lib/terrain-perimeter-fill.ts b/packages/viewer/src/lib/terrain-perimeter-fill.ts new file mode 100644 index 000000000..2f1d03890 --- /dev/null +++ b/packages/viewer/src/lib/terrain-perimeter-fill.ts @@ -0,0 +1,86 @@ +import { BufferGeometry, Float32BufferAttribute, ShapeUtils, Vector2 } from 'three' +import { ensureRenderableGeometryAttributes } from './csg-utils' + +export type TerrainPerimeterPoint = { x: number; z: number } + +export function buildTerrainPerimeterFillGeometry( + points: readonly TerrainPerimeterPoint[], + bottomY: readonly number[], + topY: number, + epsilon = 1e-6, +): BufferGeometry | null { + if (points.length < 3 || bottomY.length !== points.length) return null + if (bottomY.every((y) => y >= topY - epsilon)) return null + + let signedArea = 0 + for (let index = 0; index < points.length; index += 1) { + const a = points[index]! + const b = points[(index + 1) % points.length]! + signedArea += a.x * b.z - b.x * a.z + } + const counterClockwise = signedArea > 0 + const positions: number[] = [] + const push = (point: TerrainPerimeterPoint, y: number) => { + positions.push(point.x, y, point.z) + } + + for (let index = 0; index < points.length; index += 1) { + const next = (index + 1) % points.length + const a = points[index]! + const b = points[next]! + const ay = bottomY[index]! + const by = bottomY[next]! + if (ay >= topY - epsilon && by >= topY - epsilon) continue + + if (counterClockwise) { + push(a, topY) + push(b, by) + push(a, ay) + push(a, topY) + push(b, topY) + push(b, by) + } else { + push(a, topY) + push(a, ay) + push(b, by) + push(a, topY) + push(b, by) + push(b, topY) + } + } + + const faces = ShapeUtils.triangulateShape( + points.map((point) => new Vector2(point.x, point.z)), + [], + ) + for (const face of faces) { + const [ia, ib, ic] = face + if (ia == null || ib == null || ic == null) continue + if ( + bottomY[ia]! >= topY - epsilon && + bottomY[ib]! >= topY - epsilon && + bottomY[ic]! >= topY - epsilon + ) { + continue + } + const a = points[ia]! + const b = points[ib]! + const c = points[ic]! + const cross = (b.x - a.x) * (c.z - a.z) - (b.z - a.z) * (c.x - a.x) + push(a, bottomY[ia]!) + if (cross >= 0) { + push(b, bottomY[ib]!) + push(c, bottomY[ic]!) + } else { + push(c, bottomY[ic]!) + push(b, bottomY[ib]!) + } + } + + if (positions.length === 0) return null + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.computeVertexNormals() + ensureRenderableGeometryAttributes(geometry) + return geometry +} diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 00ba8c88e..b265b2296 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -186,8 +186,8 @@ function generateSolidSlabGeometry( } /** - * Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|. - * No top cap — the opening at ground level is handled by the ground occluder hole. + * Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to the rim. + * No top cap — the opening at the rim is handled by the ground occluder hole. * mesh.position.y must be set to elevation so the floor sits at the correct world Y. * * Geometry is built directly in 3D (Y-up) to avoid rotation confusion: @@ -199,7 +199,8 @@ function generatePoolGeometry( context: SlabPolygonContext, ): THREE.BufferGeometry { const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context)) - const depth = Math.abs(slabNode.elevation ?? 0.05) + const floor = slabNode.elevation ?? 0.05 + const depth = Math.max(0, (slabNode.recessedRimElevation ?? 0) - floor) const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) if (polygon.length < 3) return new THREE.BufferGeometry() diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index 7581b2c05..e63b75fe1 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -118,7 +118,7 @@ export const StairSystem = () => { if (group) { const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined if (mergedMesh?.visible !== false) { - updateMergedStairGeometry(node as StairNode, group, nodes) + updateMergedStairGeometry(getEffectiveNode(node as StairNode), group, nodes) stairsProcessed++ } } diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 754b02efb..06eb8c5a7 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -22,6 +22,7 @@ import { resolveWallTop, sceneRegistry, spatialGridManager, + terrainSupportLift, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -34,9 +35,11 @@ import { } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import * as THREE from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' +import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill' import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, @@ -714,8 +717,13 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { node.curveOffset ?? 0, node.thickness, node.supportSlabId, + undefined, + node.supportOffset, ) const slabElevation = slabSupport.elevation + const terrainBottomAt = node.fillToTerrain + ? (x: number, z: number) => terrainSupportLift(nodes, levelId, x, z) + : undefined const childrenIds = node.children || [] // Merge live overrides into door / window children so cutouts track an @@ -745,6 +753,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabSupport.baseElevation, slabSupport.baseSegments, planeTop, + terrainBottomAt, ) const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) // World transform the render mesh will apply (position + Y-rotation below). @@ -770,6 +779,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabSupport.baseElevation, slabSupport.baseSegments, planeTop, + terrainBottomAt, ) collisionMesh.geometry.dispose() collisionMesh.geometry = collisionGeo @@ -851,6 +861,72 @@ function applyWorldPlanarWallUVs( * Key insight from demo: polygon is built in WORLD coordinates first, * then we transform to wall-local for the 3D mesh. */ +const WALL_TERRAIN_SAMPLE_STEP = 0.25 + +type WallTerrainBottomSampler = (x: number, z: number) => number | null + +function densifyClosedWallPerimeter(points: Point2D[]): Point2D[] { + const dense: Point2D[] = [] + for (let index = 0; index < points.length; index += 1) { + const start = points[index]! + const end = points[(index + 1) % points.length]! + const length = Math.hypot(end.x - start.x, end.y - start.y) + const segments = Math.max(1, Math.ceil(length / WALL_TERRAIN_SAMPLE_STEP)) + for (let segment = 0; segment < segments; segment += 1) { + const t = segment / segments + dense.push({ + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + }) + } + } + return dense +} + +function buildWallTerrainFillGeometry( + perimeter: Point2D[], + worldToLocal: (point: Point2D) => { x: number; z: number }, + wallBaseElevation: number, + terrainBottomAt: WallTerrainBottomSampler, +): THREE.BufferGeometry | null { + const worldPoints = densifyClosedWallPerimeter(perimeter) + if (worldPoints.length < 3) return null + + const localPoints = worldPoints.map(worldToLocal) + const bottomY = worldPoints.map((point) => { + const terrainElevation = terrainBottomAt(point.x, point.y) + return terrainElevation == null ? 0 : Math.min(0, terrainElevation - wallBaseElevation) + }) + return buildTerrainPerimeterFillGeometry(localPoints, bottomY, 0) +} + +function mergeWallTerrainFill( + body: THREE.BufferGeometry, + fill: THREE.BufferGeometry | null, + wall: WallNode, + boundaryEdges: TaggedWallBoundaryEdge[], + effectiveWallHeight: number, +): THREE.BufferGeometry { + if (!fill) return body + + const bodyGeometry = body.index ? body.toNonIndexed() : body + if (bodyGeometry !== body) body.dispose() + ensureRenderableGeometryAttributes(bodyGeometry) + ensureRenderableGeometryAttributes(fill) + const merged = mergeGeometries([bodyGeometry, fill], false) + if (!merged) { + fill.dispose() + return bodyGeometry + } + + bodyGeometry.dispose() + fill.dispose() + merged.computeVertexNormals() + assignWallMaterialGroups(merged, wall, boundaryEdges, effectiveWallHeight) + ensureRenderableGeometryAttributes(merged) + return merged +} + export function generateExtrudedWall( wallNode: WallNode, childrenNodes: AnyNode[], @@ -861,6 +937,7 @@ export function generateExtrudedWall( { start: 0, end: 1, elevation: baseElevation }, ], storeyHeight = DEFAULT_LEVEL_HEIGHT, + terrainBottomAt?: WallTerrainBottomSampler, ): THREE.BufferGeometry { const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } @@ -913,6 +990,9 @@ export function generateExtrudedWall( // Convert polygon to local coordinates const localPoints = polyPoints.map(worldToLocal) const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData) + const terrainFill = terrainBottomAt + ? buildWallTerrainFillGeometry(polyPoints, worldToLocal, slabElevation, terrainBottomAt) + : null // Build THREE.js shape // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z @@ -1041,7 +1121,13 @@ export function generateExtrudedWall( splitGeometry.computeVertexNormals() assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitGeometry) - return splitGeometry + return mergeWallTerrainFill( + splitGeometry, + terrainFill, + wallNode, + boundaryEdges, + effectiveWallHeight, + ) } // Create wall brush from geometry @@ -1079,7 +1165,13 @@ export function generateExtrudedWall( assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitResultGeometry) - return splitResultGeometry + return mergeWallTerrainFill( + splitResultGeometry, + terrainFill, + wallNode, + boundaryEdges, + effectiveWallHeight, + ) } /** diff --git a/packages/viewer/src/systems/wall/wall-terrain-bottom.test.ts b/packages/viewer/src/systems/wall/wall-terrain-bottom.test.ts new file mode 100644 index 000000000..ee510e52d --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-terrain-bottom.test.ts @@ -0,0 +1,44 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { calculateLevelMiters, WallNode } from '@pascal-app/core' +import { generateExtrudedWall } from './wall-system' + +describe('wall terrain bottom', () => { + test('samples both faces independently without changing the authored body height', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + height: 2.5, + thickness: 1, + supportSlabId: 'ground', + fillToTerrain: true, + }) + const geometry = generateExtrudedWall( + wall, + [], + calculateLevelMiters([wall]), + 1.5, + 1.5, + undefined, + 4, + (_x, z) => (z >= 0 ? 0.2 : 0.8), + ) + geometry.computeBoundingBox() + + const position = geometry.getAttribute('position') + let positiveFaceMinY = Number.POSITIVE_INFINITY + let negativeFaceMinY = Number.POSITIVE_INFINITY + for (let index = 0; index < position.count; index += 1) { + const z = position.getZ(index) + const y = position.getY(index) + if (z > 0.49) positiveFaceMinY = Math.min(positiveFaceMinY, y) + if (z < -0.49) negativeFaceMinY = Math.min(negativeFaceMinY, y) + } + + expect(positiveFaceMinY).toBeCloseTo(-1.3) + expect(negativeFaceMinY).toBeCloseTo(-0.7) + expect(geometry.boundingBox?.max.y).toBeCloseTo(2.5) + geometry.dispose() + }) +}) diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 883c5b467..5269d612c 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -100,6 +100,12 @@ export function MyTool() { a wall segment's 45° lock while in `angles` mode — the tool may still publish passive dashed alignment/proximity guides as long as it does not apply the guide snap delta. Use this for chained wall segments: users keep the fast constrained draft but still see proximity feedback for later points. +- **Vertical structural datums use their own ephemeral guide channel.** Slab, ceiling, wall-base, + and fence-base elevation handles resolve same-level structural Y targets through scalar snap + callbacks, then publish a short horizontal datum + elevation readout only while exactly aligned. + The payload is owner-scoped and cleared through the handle descriptor's `onDragEnd`; it is editor + feedback, never a scene node. Do not encode Y datums into the floor-plane XZ alignment store or + the wall-opening guide store — their coordinate and lifecycle contracts differ. - **Help mirrors the model.** The shortcut dialog and the contextual HUD are part of the interaction contract: they describe the always-visible mode chip + `Alt` = force, **not** a hidden Shift bypass. The HUD is driven by the active interaction scope, so it shows only the current context's controls. diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 757aa7760..80c588215 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -6,9 +6,10 @@ Applies to: anything that reads or writes vertical geometry — levels, walls, s The invariant, in one sentence: -> Wall tops are pinned to the level plane; floors and platforms move what stands on -> them, never the walls or the storey above; anything that doesn't fit is clamped, -> never asked. +> Ordinary plane-bound wall tops are pinned to the level plane. A wall drafted on +> terrain or another raised support preserves the ghost's body height by materializing +> that height and translating the wall from its elected base. Optional terrain infill +> extends only the bottom; it never changes the authored wall height or top. **Sources**: `packages/core/src/services/storey.ts`, `packages/core/src/systems/wall/wall-top.ts`, `packages/core/src/systems/slab/slab-support.ts`, `packages/core/src/systems/stair/stair-rise.ts`, `packages/core/src/store/use-scene.ts` (migration Pass 3) @@ -17,19 +18,24 @@ The invariant, in one sentence: | Field | Meaning | Absent means | |---|---|---| | `level.height` | Storey height in meters, floor-to-floor. Level world Y = per-building prefix sum of stored heights, ordered by the `level` ordinal (`getLevelElevations`). | Unmigrated legacy data (never seen post-load; the migration writes it). Consumers fall back to `DEFAULT_LEVEL_HEIGHT` (2.5). | -| `wall.height` | Explicit custom height (half wall, parapet). Top = elected base + height. | **Plane-bound** (the default): the top follows `getWallPlaneTop` — `min(level height, lowest covering-slab underside over the span)`. Slabs lift only the base. | +| `wall.height` | Explicit body height (half wall, parapet, or a raised-support draft whose ghost height must remain invariant). Ground-hosted walls always resolve top = elected base + height, including below datum; other legacy sunken supports retain their absolute-top constraint. | **Plane-bound** (the default for ordinary datum placement): the top follows `getWallPlaneTop` — `min(level height, lowest covering-slab underside over the span)`. | | `ceiling.height` | Explicit custom height, write-clamped to the bound. | **Follows the level**: resolves live to `getCeilingClampBound` = `min(level height, covering underside) − 0.01`. | | `slab.elevation` | The walking surface (top), level-local. | Default 0.05. | | `slab.thickness` | Grows **downward**: the solid occupies `[elevation − thickness, elevation]`. | Default 0.05. | -| `slab.recessed` | Pool intent: open shell, floor at (negative) `elevation`, inner walls up to the plane. Excluded from "covering" queries and wall-face adoption. | Solid slab. | +| `slab.recessed` | Recess intent: open shell whose floor is `elevation` and whose rim is `recessedRimElevation`. Excluded from "covering" queries and wall-face adoption. | Solid slab. | +| `slab.recessedRimElevation` | Optional rim anchor for a raised/lowered recess. Relative presets preserve this anchor while changing depth. | Level plane (`0`), preserving legacy pools. | +| `slab.fillToTerrain` | Adds a terrain-following perimeter foundation below a solid slab's fixed underside. The walking surface and authored structural thickness stay flat. | No terrain foundation. | | `supportSlabId` | Persisted support host on walls and all floor-placed kinds. Written at commit **only when overlapping supports disagree on elevation**; `'ground'` sentinel pins bare ground under a deck. | Support is elected per query (coverage election for walls, footprint max for items). | +| `wall.supportOffset` | Optional level-local delta from the elected support. Terrain wall chains use it to keep every segment on the first point's construction plane while storing only one number, never terrain samples. | Zero offset: the wall sits directly on its elected slab or sculpted ground source. | +| `fence.supportOffset` | Optional level-local delta from the fence's slab host or level plane. It translates the complete fence while preserving height. | Zero offset: the fence sits directly on its host or level plane. | +| `wall.fillToTerrain` | Extends the wall downward from its authored base to the terrain with independently sampled left/right faces. The wall body height and top stay unchanged. | Fixed base with no terrain infill. | | `stair.deckSlabId` | Destination deck: rise follows `deck.elevation − the stair's own elected base` live; cutout sync disabled while attached. | Destination is a level. | | `stair.totalRise` | Explicit custom rise (wins over everything). | Follows: derived from the deck or the containing level; `syncStairRises` converges straight-stair segments to the resolved rise. | Two schema rules protect these semantics: - **No Zod defaults on meaning-bearing fields.** `level.height`, `wall.height`, `ceiling.height`, `stair.totalRise` are `.optional()` with no `.default()` — absence is data. Creation sites write values explicitly; `migrateNodes` output is cast, not parsed, so a schema default would never materialize on legacy load anyway. -- **The store deletes explicit-`undefined` keys.** `updateNode(id, { height: undefined })` removes the key (see `mergeNodeUpdate` in `node-actions.ts`); that is how "Follows level/deck" mode switches work. UI mode controls derive state from field presence — no persisted mode enums. +- **The store deletes explicit-`undefined` keys.** `updateNode(id, { height: undefined })` removes the key (see `mergeNodeUpdate` in `node-actions.ts`). Legacy plane-bound walls may still omit `height`; the wall panel resolves and materializes their current body height before enabling terrain infill. Ceiling and stair follow modes continue to derive from field presence — no persisted mode enums. ## Resolution helpers (use these, never `?? 2.5`) @@ -38,24 +44,44 @@ Two schema rules protect these semantics: | `getStoredLevelHeight`, `getLevelElevations`, `getLevelAbove/Below` | `services/storey.ts` | Level heights, per-building stacking, neighbors | | `getWallPlaneTop` | `services/storey.ts` | A plane-bound wall's top: level height clamped to covering-slab undersides, span-sampled with boundary-inclusive band overlap | | `resolveWallTop`, `resolveWallEffectiveHeight`, `MIN_WALL_HEIGHT` | `systems/wall/wall-top.ts` | A wall's top / effective height given plane + elected base | -| `getWallEffectiveHeightForNodes` | spatial-grid manager | The above with the real slab election, for UI overlays | +| `getWallBaseElevationForNodes`, `getWallEffectiveHeightForNodes` | spatial-grid manager | The elected base and body height with terrain/support offsets, for UI overlays | | `getCeilingClampBound`, `getCoveringSlabUndersideAt` | `services/storey.ts` | Ceiling bound; the cross-level covering query (level above, non-recessed slabs) | | `resolveCeilingHeight` | `services/level-height.ts` | A ceiling's effective height (explicit or follows) | | `resolveStairTotalRise`, `syncStairRises` | `systems/stair/stair-rise.ts` | Stair rise precedence + straight-flight convergence | | `computeWallSlabSupport`, `getSlabSupportForItem`, `getSupportCandidatesForFootprint` | `systems/slab/slab-support.ts` + spatial-grid manager | Support election (rendered polygons, host-preferring, optional `maxElevation` cap) | -| `clampSlabElevationForWalls`, `applySlabTopChange`, `SLAB_UNSTICK_THRESHOLD` | slab-support + `nodes/slab/elevation-limit.ts` | Slab edit clamps and the adaptive drag/panel rules | +| `resolveSlabPlacementElevation` | `systems/slab/slab-placement.ts` | Translates a solid slab's authored top/thickness interval onto a captured base plane; recessed slabs stay level-relative | +| `getSlabBaseElevation`, `applySlabBaseElevationChange`, `applySlabThicknessChange` | `nodes/slab/elevation-limit.ts` | Separates whole-body underside placement from fixed-base thickness editing | +| `resolveFenceLiftElevation` | `nodes/fence/lift.ts` | Fence slab-host elevation plus its optional manual support offset | +| `clampSlabElevationForWalls` | slab-support + `nodes/slab/elevation-limit.ts` | Slab top clamp under plane-bound walls | ## Clamp rules (clamp, never ask) - A slab under plane-bound walls clamps its elevation to `level height − MIN_WALL_HEIGHT` (0.5). - Ceilings clamp (at write time, and reactively downward via space-detection) to `min(level top, covering-slab underside) − 0.01`. - Plane-bound wall tops clamp to covering-slab undersides — a thick or flush upper-level slab shortens the walls below it (Revit's attach-to-floor-bottom, automatic). Explicit-height walls are exempt. -- Slab vertical editing is adaptive: the panel moves placement (thickness untouched); the viewport drag stretches a grounded slab (elevation and thickness together) up to `SLAB_UNSTICK_THRESHOLD` (0.4), then unsticks it into a 0.05-thick deck; floating decks move with thickness preserved and re-ground at underside 0; pools keep the drag-through-zero gesture. +- Solid slab controls have non-overlapping contracts: the cube translates the occupied interval while preserving `thickness`; the chevron and panel thickness control hold the underside fixed and move the walking surface by writing `thickness` and `elevation` together. Panel elevation translates the body while preserving thickness. Recesses store their rim separately so floor/rim/depth controls and presets remain relative to the same anchor. +- Structural elevation trackers move the reference base, not the body dimensions. A slab tracker moves its underside and keeps `thickness`; a wall tracker writes `supportOffset` and materializes its current body `height`; a fence tracker writes `supportOffset` and keeps `height`. Recessed slabs retain their separate rim/depth control. - Wall-face adoption in `getRenderableSlabPolygon` applies only to grounded slabs (`elevation − thickness ≤ 0.01`, not recessed) — floating decks keep their drawn polygon and are skipped as seam candidates. ## Pointer-decided placement -Grid events intersect a plane that rides the ghost's elevation, so any stacked-surface decision must come from the true camera ray, not the plane hit: `getPointedSupportSurface` returns the nearest slab plane the ray crosses inside its rendered polygon plus the crossing point, and both the support-election cap (`maxElevation`) and the cursor XZ derive from that single computation. Pointing under a deck elects the floor; pointing at the deck top elects the deck; commits persist the capped winner (or `'ground'`). 2D floorplan placement has no camera ray and keeps max-election. +Grid events intersect a plane that rides the ghost's elevation, so any stacked-surface decision must come from the true camera ray, not the plane hit: `getPointedSupportSurface` returns the nearest eligible surface plus the crossing point, and both the support-election cap (`maxElevation`) and the cursor XZ derive from that single computation. Pointing under a deck elects the floor; pointing at the deck top elects the deck. Wall drafting may additionally include upward-facing wall, stackable-item, and column meshes. Those node-top hits freeze a scalar construction plane for the throw; they are not a persistent hosting edge and do not follow later host edits. Commits persist the elected slab/ground source plus `wall.supportOffset`. 2D floorplan placement has no camera ray and keeps max-election. + +Wall and slab drafting share the horizontal construction-plane resolver. A slab freezes the +first snapped vertex's plane, keeps later vertices on that flat plane, and translates its authored +vertical interval onto the captured base at commit. It never drapes thickness or individual +vertices over terrain. Optional terrain following is a separate perimeter foundation from the +fixed underside; it never changes the slab interval. Recessed slabs keep an explicit rim anchor. A locked +construction plane is tagged `fixed-plane` so a plane at world Y=0 cannot be mistaken for the +terrain query plane on later pointer moves. + +Auto-room surfaces derive their vertical placement from the enclosing walls when every boundary +wall agrees on one base and top plane. The floor keeps its established 0.05 m walking-surface +offset above that base; the ceiling sits 0.01 m below the common wall top. Existing +`autoFromWalls` surfaces reconcile with later wall support-offset changes. Manual surfaces are +never rewritten, and a mixed-elevation enclosure keeps its existing/fallback placement rather +than choosing an arbitrary wall. Auto slabs are excluded from this wall-base election so a +derived floor cannot recursively lift its own walls and then itself. ## Load migration (lives in `migrateNodes` Pass 3, indefinitely) @@ -72,7 +98,7 @@ Because community autosave only persists after the first post-load edit, the mig - **Ordinals are semantic.** `level < 0` renders "Basement N"; `level === 0` is the ground-floor lookup. Never renumber without the zero anchor. - **Boundary geometry.** Auto slabs derive polygons from wall centerlines, so wall/ceiling clamp samples sit exactly on polygon edges — always use the boundary-inclusive band-overlap helpers (`wallOverlapsSlabFootprint`, `slabCoversPoint`), never raw ray-cast point-in-polygon on those paths. - **Straight stairs build from stored segment heights**, not the resolved rise — any rise change must go through `syncStairRises` (applied by `StairOpeningSystem`, history-paused, one microtask after store updates so the spatial grid has settled). -- **Reactivity is explicit.** A `level.height` change dirties that level's walls/stairs/ceilings/fences; a slab change dirties the level below's walls/ceilings and deck-attached stairs (`spatial-grid-sync.ts`). If a new consumer reads these bounds, wire its dirty rule there. +- **Reactivity is explicit.** A `level.height` change dirties that level's walls/stairs/ceilings/fences; a slab change dirties overlapping same-level supports, the level below's walls/ceilings, and deck-attached stairs. Every live terrain dab dirties ground-hosted structures and `fillToTerrain` walls/slabs through the transient `useLiveTerrain` subscription in `spatial-grid-sync.ts`; the scene graph and undo history are still written only once when the stroke commits. Ending or canceling a stroke runs the same sweep so dependents settle back onto the persisted field. Slab handles reuse the slab-change dependency helper for live previews. If a new consumer reads these bounds, wire its dirty rule there. - **Host lifecycle.** Deleting a slab strips `supportSlabId`/`deckSlabId` from survivors in the same undo commit; a host merely reshaped away falls back silently and resumes if the slab returns. - **Clone paths differ.** `clone-scene-graph.ts` remaps `supportSlabId`/`deckSlabId`; the editor clipboard (`scene-clipboard.ts`) intentionally does not (it re-elects); room placement remaps them (fixed in the private repo's `room-placement.ts`). When adding a new clone/instantiation path, remap both fields.