From 8075d1e6179fc6ed2f2e2c539ad0c8910dcaaad1 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 13:11:18 +0200 Subject: [PATCH 1/3] feat: add terrain sculpting and terrain-aware walls --- apps/editor/components/build-tab.tsx | 20 +- .../floor-placed-elevation.test.ts | 243 +++++++++- .../spatial-grid/floor-placed-elevation.ts | 62 ++- .../spatial-grid/spatial-grid-manager.ts | 65 ++- .../spatial-grid/spatial-grid-sync.test.ts | 216 +++++++++ .../hooks/spatial-grid/spatial-grid-sync.ts | 79 ++- .../src/hooks/spatial-grid/support-host-id.ts | 2 + .../hooks/spatial-grid/support-host-patch.ts | 34 +- .../hooks/spatial-grid/support-host.test.ts | 118 ++++- packages/core/src/index.ts | 51 ++ packages/core/src/lib/terrain-brush.test.ts | 457 ++++++++++++++++++ packages/core/src/lib/terrain-brush.ts | 436 +++++++++++++++++ packages/core/src/lib/terrain-codec.test.ts | 182 +++++++ packages/core/src/lib/terrain-codec.ts | 164 +++++++ packages/core/src/lib/terrain-field.test.ts | 404 ++++++++++++++++ packages/core/src/lib/terrain-field.ts | 321 ++++++++++++ packages/core/src/lib/terrain-raycast.test.ts | 183 +++++++ packages/core/src/lib/terrain-raycast.ts | 216 +++++++++ packages/core/src/lib/terrain-source.test.ts | 105 ++++ packages/core/src/lib/terrain-source.ts | 100 ++++ packages/core/src/lib/terrain-support.test.ts | 131 +++++ packages/core/src/lib/terrain-support.ts | 174 +++++++ packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/site.test.ts | 65 +++ packages/core/src/schema/nodes/site.ts | 15 +- packages/core/src/schema/nodes/wall.test.ts | 19 + packages/core/src/schema/nodes/wall.ts | 7 + packages/core/src/schema/terrain.ts | 34 ++ .../core/src/store/use-live-terrain.test.ts | 150 ++++++ packages/core/src/store/use-live-terrain.ts | 104 ++++ .../core/src/systems/wall/wall-top.test.ts | 7 + packages/core/src/systems/wall/wall-top.ts | 11 +- .../editor/custom-camera-controls.tsx | 24 +- .../first-person/build-collider-world.test.ts | 36 +- .../first-person/build-collider-world.ts | 17 + .../first-person/terrain-collider.test.ts | 132 +++++ .../editor/first-person/terrain-collider.ts | 212 ++++++++ .../src/components/editor/floorplan-panel.tsx | 40 +- .../src/components/editor/group-actions.ts | 10 + .../components/editor/site-edge-labels.tsx | 27 +- .../editor/wall-move-side-handles.tsx | 19 +- .../tools/shared/pointer-support-cap.test.ts | 98 ++++ .../tools/shared/pointer-support-cap.ts | 184 ++++++- .../site/stroke-pointer-ownership.test.ts | 91 ++++ .../tools/site/stroke-pointer-ownership.ts | 57 +++ .../tools/site/terrain-brush-context-menu.tsx | 192 ++++++++ .../tools/site/terrain-brush-cursor.tsx | 236 +++++++++ .../tools/site/terrain-sculpt-grid.tsx | 147 ++++++ .../tools/site/terrain-sculpt-tool.tsx | 343 +++++++++++++ .../src/components/tools/tool-manager.tsx | 13 +- .../tools/wall/wall-drafting.test.ts | 87 ++++ .../components/tools/wall/wall-drafting.ts | 145 +++++- .../tools/wall/wall-snap-geometry.test.ts | 8 + .../tools/wall/wall-snap-geometry.ts | 73 ++- .../ui/action-menu/control-modes.tsx | 5 +- .../ui/command-palette/editor-commands.tsx | 12 + .../ui/controls/terrain-sculpt-panel.tsx | 193 ++++++++ .../components/ui/helpers/helper-manager.tsx | 41 ++ packages/editor/src/hooks/use-grid-events.ts | 20 + packages/editor/src/hooks/use-keyboard.ts | 35 ++ packages/editor/src/index.tsx | 8 + packages/editor/src/lib/ground-surface.ts | 95 ++++ .../lib/interaction/overlay-policy.test.ts | 1 + packages/editor/src/lib/interaction/scope.ts | 7 + packages/editor/src/lib/site-boundary.test.ts | 53 ++ packages/editor/src/lib/site-boundary.ts | 27 ++ .../editor/src/lib/terrain-sculpt.test.ts | 393 +++++++++++++++ packages/editor/src/lib/terrain-sculpt.ts | 255 ++++++++++ .../editor/src/lib/terrain-verb-color.test.ts | 51 ++ packages/editor/src/lib/terrain-verb-color.ts | 58 +++ .../src/lib/touch-gesture-priority.test.ts | 45 ++ .../editor/src/lib/touch-gesture-priority.ts | 30 ++ .../src/store/terrain-sculpt-mode.test.ts | 223 +++++++++ packages/editor/src/store/use-editor.tsx | 173 ++++++- .../src/store/use-interaction-scope.test.ts | 1 + .../nodes/src/shared/wall-opening-ceiling.ts | 18 +- packages/nodes/src/site/renderer.tsx | 175 +++++-- packages/nodes/src/site/terrain-drape.test.ts | 320 ++++++++++++ packages/nodes/src/site/terrain-drape.ts | 244 ++++++++++ .../nodes/src/site/terrain-geometry.test.ts | 410 ++++++++++++++++ packages/nodes/src/site/terrain-geometry.ts | 339 +++++++++++++ packages/nodes/src/site/terrain-mesh.test.ts | 181 +++++++ packages/nodes/src/site/terrain-mesh.ts | 175 +++++++ packages/nodes/src/site/terrain-renderer.tsx | 103 ++++ packages/nodes/src/wall/definition.ts | 2 +- .../nodes/src/wall/move-endpoint-tool.tsx | 82 ++-- packages/nodes/src/wall/move-tool.tsx | 4 +- packages/nodes/src/wall/panel.tsx | 77 ++- packages/nodes/src/wall/tool.tsx | 217 ++++++++- .../viewer/src/systems/wall/wall-system.tsx | 170 ++++++- .../systems/wall/wall-terrain-bottom.test.ts | 44 ++ wiki/architecture/vertical-model.md | 19 +- 92 files changed, 10398 insertions(+), 275 deletions(-) create mode 100644 packages/core/src/hooks/spatial-grid/support-host-id.ts create mode 100644 packages/core/src/lib/terrain-brush.test.ts create mode 100644 packages/core/src/lib/terrain-brush.ts create mode 100644 packages/core/src/lib/terrain-codec.test.ts create mode 100644 packages/core/src/lib/terrain-codec.ts create mode 100644 packages/core/src/lib/terrain-field.test.ts create mode 100644 packages/core/src/lib/terrain-field.ts create mode 100644 packages/core/src/lib/terrain-raycast.test.ts create mode 100644 packages/core/src/lib/terrain-raycast.ts create mode 100644 packages/core/src/lib/terrain-source.test.ts create mode 100644 packages/core/src/lib/terrain-source.ts create mode 100644 packages/core/src/lib/terrain-support.test.ts create mode 100644 packages/core/src/lib/terrain-support.ts create mode 100644 packages/core/src/schema/nodes/site.test.ts create mode 100644 packages/core/src/schema/terrain.ts create mode 100644 packages/core/src/store/use-live-terrain.test.ts create mode 100644 packages/core/src/store/use-live-terrain.ts create mode 100644 packages/editor/src/components/editor/first-person/terrain-collider.test.ts create mode 100644 packages/editor/src/components/editor/first-person/terrain-collider.ts create mode 100644 packages/editor/src/components/tools/shared/pointer-support-cap.test.ts create mode 100644 packages/editor/src/components/tools/site/stroke-pointer-ownership.test.ts create mode 100644 packages/editor/src/components/tools/site/stroke-pointer-ownership.ts create mode 100644 packages/editor/src/components/tools/site/terrain-brush-context-menu.tsx create mode 100644 packages/editor/src/components/tools/site/terrain-brush-cursor.tsx create mode 100644 packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx create mode 100644 packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx create mode 100644 packages/editor/src/components/ui/controls/terrain-sculpt-panel.tsx create mode 100644 packages/editor/src/lib/ground-surface.ts create mode 100644 packages/editor/src/lib/site-boundary.test.ts create mode 100644 packages/editor/src/lib/terrain-sculpt.test.ts create mode 100644 packages/editor/src/lib/terrain-sculpt.ts create mode 100644 packages/editor/src/lib/terrain-verb-color.test.ts create mode 100644 packages/editor/src/lib/terrain-verb-color.ts create mode 100644 packages/editor/src/lib/touch-gesture-priority.test.ts create mode 100644 packages/editor/src/lib/touch-gesture-priority.ts create mode 100644 packages/editor/src/store/terrain-sculpt-mode.test.ts create mode 100644 packages/nodes/src/site/terrain-drape.test.ts create mode 100644 packages/nodes/src/site/terrain-drape.ts create mode 100644 packages/nodes/src/site/terrain-geometry.test.ts create mode 100644 packages/nodes/src/site/terrain-geometry.ts create mode 100644 packages/nodes/src/site/terrain-mesh.test.ts create mode 100644 packages/nodes/src/site/terrain-mesh.ts create mode 100644 packages/nodes/src/site/terrain-renderer.tsx create mode 100644 packages/viewer/src/systems/wall/wall-terrain-bottom.test.ts diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 1ec86d042b..763656c04b 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -4,6 +4,7 @@ import { nodeRegistry } from '@pascal-app/core' import { getFloorplanNodeExtension, MaterialPaintPanel, + TerrainSculptPanel, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -43,7 +44,7 @@ type BuildType = { kind?: string paletteOrder?: number /** Non-placement special mode. */ - mode?: 'material-paint' + mode?: 'material-paint' | 'terrain-sculpt' } type MepItem = { @@ -71,6 +72,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/site.webp', mode: 'terrain-sculpt' }, ] function collectBuildTypes(): BuildType[] { @@ -144,6 +146,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' @@ -238,7 +248,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) @@ -248,6 +258,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. @@ -319,6 +331,10 @@ export function BuildTab() {
+ ) : mode === 'terrain-sculpt' ? ( +
+ +
) : mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive) && roofFeatures.length > 0 ? ( 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 ebdb24c3e2..21c9eb5a64 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 ca66b3cc34..d2b6746252 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 f73fce4c1d..f08e3b1ab0 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 280db599b6..c74b5e9f0b 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,4 +1,9 @@ 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 useScene, { clearSceneHistory } from '../../store/use-scene' import { spatialGridManager } from './spatial-grid-manager' @@ -6,7 +11,9 @@ import { initSpatialGridSync, markCoveringDependentsBelow, markLevelHeightDependents, + markTerrainSupportDependents, } from './spatial-grid-sync' +import { GROUND_SUPPORT_ID } from './support-host-id' const SQUARE: Array<[number, number]> = [ [0, 0], @@ -287,3 +294,212 @@ describe('sync dirty helpers (pure)', () => { expect(marked).toEqual([]) }) }) + +// 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') + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + 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('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 walls', () => { + const level = makeLevel('level_0', 0, 2.5, [ + 'wall_ground', + 'wall_fill', + 'wall_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'), + makeFloorNode('column_a', 'level_0', [3, 0, 3]), + ) + + const marked: string[] = [] + markTerrainSupportDependents(nodes, (id) => marked.push(id)) + + expect(marked).toEqual(['wall_ground', 'wall_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 886d597012..742fbcb04c 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,6 +1,7 @@ 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 useScene from '../../store/use-scene' import { getFloorPlacedFootprints } from './floor-placed-elevation' @@ -9,6 +10,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 @@ -119,6 +121,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 +142,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) + } } } @@ -188,6 +203,14 @@ export function initSpatialGridSync(): () => void { 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 || @@ -253,6 +276,60 @@ export function markDeckAttachedStairs( } } +/** + * 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. Cheaper than it looks: this fires once per + * committed stroke, not per dab. + * + * 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 === '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 0000000000..44cfc104c8 --- /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 588ca7ee65..7a4f432590 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 88ed945322..0138ed7150 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 a477a88f4c..cb229a55d5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,6 +55,7 @@ export { getFloorStackedPosition, } from './hooks/spatial-grid/floor-placed-elevation' export { + getWallBaseElevationForNodes, getWallEffectiveHeightForNodes, type PointedSupportSurface, pointInPolygon, @@ -71,6 +72,7 @@ export { export { type FenceSupportInput, resolveFenceSupportSlabPatch, + resolveMovedWallSupportSlabPatch, resolveSupportSlabPatch, resolveWallSupportSlabPatch, type SupportSlabPatch, @@ -147,6 +149,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, @@ -226,6 +273,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, 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 0000000000..22f284cbc1 --- /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 0000000000..5c2e4c6e09 --- /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 0000000000..89f6f357a8 --- /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 0000000000..c266af5176 --- /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 0000000000..aedc2946a0 --- /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 0000000000..edd8dc3c05 --- /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 0000000000..3e683ee051 --- /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 0000000000..bd3eb6a104 --- /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 0000000000..6a1579532b --- /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 0000000000..759d9b2e2c --- /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 0000000000..07fd0b46b5 --- /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 0000000000..fecfed1085 --- /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/schema/index.ts b/packages/core/src/schema/index.ts index fd0e8f7f8f..0b0d1246f4 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -299,6 +299,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/site.test.ts b/packages/core/src/schema/nodes/site.test.ts new file mode 100644 index 0000000000..776c706e3c --- /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 6cb33d6810..7f09fa5915 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/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 164a5c37de..82dcc498d4 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -24,6 +24,25 @@ import { 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 82a8142918..8d9bfae3e2 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -196,6 +196,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(), @@ -212,6 +218,7 @@ export const WallNode = BaseNode.extend({ - thickness: thickness in meters - assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility - 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 0000000000..57c0a8e883 --- /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 0000000000..a282204bdc --- /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 0000000000..54ad09e070 --- /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/wall/wall-top.test.ts b/packages/core/src/systems/wall/wall-top.test.ts index e452dbfe5d..a743dcad21 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 208ed4bd8d..2bbf475da7 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 fcbb802ff9..aa0d694e8f 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/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index 610618cda0..ed398e69c2 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 14267ecc96..eb8474d87d 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 0000000000..ab699aa2e3 --- /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 0000000000..2d29fe909c --- /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-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 07739f78bb..48bb16aac8 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -87,11 +87,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' @@ -181,6 +182,7 @@ import { chainEndJoinsExistingWall, createWallOnCurrentLevel, isSegmentLongEnough, + resolveTerrainWallConstructionOptions, snapWallDraftPoint, snapWallDraftPointDetailed, snapPointToGrid as snapWallPointToGrid, @@ -5512,6 +5514,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([]) @@ -6503,7 +6507,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) && @@ -8426,6 +8430,7 @@ export function FloorplanPanel({ const clearWallPlacementDraft = useCallback(() => { setDraftStart(null) setWallChainFirstVertex(null) + wallConstructionOptionsRef.current = undefined wallChainWallIdsRef.current = [] setDraftEnd(null) useSegmentDraftChain.getState().clear('wall') @@ -9554,10 +9559,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], @@ -10305,6 +10320,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) @@ -10331,7 +10354,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 9572367db4..2a413d54fa 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/site-edge-labels.tsx b/packages/editor/src/components/editor/site-edge-labels.tsx index 2f7d8f0b8a..7842eea245 100644 --- a/packages/editor/src/components/editor/site-edge-labels.tsx +++ b/packages/editor/src/components/editor/site-edge-labels.tsx @@ -7,6 +7,7 @@ import { Html } from '@react-three/drei' import { createPortal, useFrame, useThree } from '@react-three/fiber' import { useCallback, useMemo, useRef, useState } from 'react' import { type Camera, type Object3D, Vector3 } from 'three' +import { groundHeightAt } from '../../lib/ground-surface' import { formatLinearMeasurement } from '../../lib/measurements' import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' import { useActiveHandleDrag } from '../../store/use-interaction-scope' @@ -16,6 +17,16 @@ type ViewportSize = { height: number } +/** + * How far the label floats above the ground under it. + * + * Was a bare `0.5` in the position literal, which on flat ground is the same thing. + * Named now because it is no longer the label's *height* but its clearance: on a + * sculpted lot the number underneath moves, and half a metre is what keeps the text + * clear of a rim the boundary line is draped over. + */ +const LABEL_LIFT = 0.5 + const htmlPosition = new Vector3() function calculateHtmlPosition(el: Object3D, camera: Camera, size: ViewportSize) { @@ -78,14 +89,26 @@ export function SiteEdgeLabels() { if (obj) setSiteObj(obj) }) + // No live-terrain subscription here, deliberately: these labels only render while + // a site-boundary handle drag is active, and sculpt mode hides those handles + // (`siteBoundaryHandlesEnabled`), so the ground cannot move while they are on + // screen. The drag itself rewrites `polygon` every frame, which is what re-runs + // this — so the heights are always read against the field as of this frame. const edges = useMemo(() => { 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 +122,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 3ae9b8a109..e3f0061a36 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, @@ -154,6 +156,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,12 +200,13 @@ 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) => ( ))} @@ -215,6 +219,8 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { } 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 +233,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 } @@ -760,7 +769,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 +785,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/pointer-support-cap.test.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts new file mode 100644 index 0000000000..97fc1f5314 --- /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 b0bc8398e1..b01803bd89 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/stroke-pointer-ownership.test.ts b/packages/editor/src/components/tools/site/stroke-pointer-ownership.test.ts new file mode 100644 index 0000000000..f176a69e62 --- /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 0000000000..d44a2f4b7b --- /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-context-menu.tsx b/packages/editor/src/components/tools/site/terrain-brush-context-menu.tsx new file mode 100644 index 0000000000..25a67cf0c7 --- /dev/null +++ b/packages/editor/src/components/tools/site/terrain-brush-context-menu.tsx @@ -0,0 +1,192 @@ +'use client' + +import type { SiteNode } from '@pascal-app/core' +import { Html } from '@react-three/drei' +import { AnimatePresence, motion } from 'motion/react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { brushRadiusRange } from '../../../lib/terrain-sculpt' +import useEditor from '../../../store/use-editor' +import { SegmentedControl } from '../../ui/controls/segmented-control' +import { SliderControl } from '../../ui/controls/slider-control' + +const MENU_WIDTH = 288 +const MENU_HEIGHT = 292 +const MENU_MARGIN = 8 +const CONTEXT_CLICK_SLOP = 5 + +type MenuPosition = { x: number; y: number } + +export function TerrainBrushContextMenu({ + canvas, + onAnchorChange, + site, +}: { + canvas: HTMLCanvasElement + onAnchorChange: (anchor: { clientX: number; clientY: number } | null) => void + site: SiteNode +}) { + const brush = useEditor((state) => state.terrainBrush) + const setTerrainBrush = useEditor((state) => state.setTerrainBrush) + const [minRadius, maxRadius] = brushRadiusRange(site) + const [open, setOpen] = useState(false) + const [position, setPosition] = useState({ x: MENU_MARGIN, y: MENU_MARGIN }) + const menuRef = useRef(null) + const rightPointerRef = useRef<{ + pointerId: number + x: number + y: number + moved: boolean + } | null>(null) + + const closeMenu = useCallback(() => { + setOpen(false) + onAnchorChange(null) + }, [onAnchorChange]) + + useEffect(() => { + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 2) return + rightPointerRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + moved: false, + } + } + const onPointerMove = (event: PointerEvent) => { + const start = rightPointerRef.current + if (!start || start.pointerId !== event.pointerId) return + if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CONTEXT_CLICK_SLOP) { + start.moved = true + } + } + const onPointerCancel = (event: PointerEvent) => { + if (rightPointerRef.current?.pointerId === event.pointerId) { + rightPointerRef.current = null + } + } + const onContextMenu = (event: MouseEvent) => { + event.preventDefault() + const gesture = rightPointerRef.current + rightPointerRef.current = null + if (gesture?.moved || (event.buttons & 1) !== 0) return + + const rect = canvas.getBoundingClientRect() + const maxX = Math.max(MENU_MARGIN, rect.width - MENU_WIDTH - MENU_MARGIN) + const maxY = Math.max(MENU_MARGIN, rect.height - MENU_HEIGHT - MENU_MARGIN) + setPosition({ + x: Math.min(Math.max(event.clientX - rect.left, MENU_MARGIN), maxX), + y: Math.min(Math.max(event.clientY - rect.top, MENU_MARGIN), maxY), + }) + setOpen(true) + onAnchorChange({ clientX: event.clientX, clientY: event.clientY }) + } + + canvas.addEventListener('pointerdown', onPointerDown) + canvas.addEventListener('pointermove', onPointerMove) + canvas.addEventListener('pointercancel', onPointerCancel) + canvas.addEventListener('contextmenu', onContextMenu, true) + return () => { + canvas.removeEventListener('pointerdown', onPointerDown) + canvas.removeEventListener('pointermove', onPointerMove) + canvas.removeEventListener('pointercancel', onPointerCancel) + canvas.removeEventListener('contextmenu', onContextMenu, true) + } + }, [canvas, onAnchorChange]) + + useEffect(() => { + if (!open) return + const closeOutside = (event: PointerEvent) => { + if (menuRef.current?.contains(event.target as Node)) return + closeMenu() + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeMenu() + } + + window.addEventListener('pointerdown', closeOutside, true) + window.addEventListener('keydown', closeOnEscape) + window.addEventListener('blur', closeMenu) + window.addEventListener('resize', closeMenu) + return () => { + window.removeEventListener('pointerdown', closeOutside, true) + window.removeEventListener('keydown', closeOnEscape) + window.removeEventListener('blur', closeMenu) + window.removeEventListener('resize', closeMenu) + } + }, [closeMenu, open]) + + const calculatePosition = useCallback( + () => [position.x, position.y] as [number, number], + [position.x, position.y], + ) + + return ( + + + {open && ( + event.preventDefault()} + onPointerDown={(event) => event.stopPropagation()} + ref={menuRef} + style={{ pointerEvents: 'auto', transformOrigin: 'top left' }} + transition={{ type: 'spring', stiffness: 460, damping: 34, mass: 0.7 }} + > +
+

Brush settings

+

Adjust without leaving the terrain.

+
+
+ 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} + /> +
+
+ )} +
+ + ) +} 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 0000000000..d1e4648b6e --- /dev/null +++ b/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx @@ -0,0 +1,236 @@ +'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 { useEffect, useMemo, useRef } from 'react' +import { + BufferGeometry, + Float32BufferAttribute, + type Group, + type Line, + Raycaster, + Vector2, +} from 'three' +import { EDITOR_LAYER } from '../../../lib/constants' +import { sculptFieldForSite } 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 + +/** + * 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<{ + lockedPointer: { clientX: number; clientY: number } | null + site: SiteNode +}> = ({ lockedPointer, 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. The mapping lives in `lib/terrain-verb-color` because the + // panel's verb picker paints the same swatches and the two must not drift. + 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 clear = () => { + pointerRef.current = null + } + canvas.addEventListener('pointermove', track) + canvas.addEventListener('pointerleave', clear) + return () => { + canvas.removeEventListener('pointermove', track) + canvas.removeEventListener('pointerleave', clear) + } + }, [gl]) + + useFrame(() => { + const line = lineRef.current + const center = centerRef.current + const pointer = pointerRef.current + if (!(line && center)) return + if (!(lockedPointer || pointer)) { + 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 + + if (lockedPointer) { + const rect = gl.domElement.getBoundingClientRect() + ndc.current.set( + ((lockedPointer.clientX - rect.left) / rect.width) * 2 - 1, + -((lockedPointer.clientY - rect.top) / rect.height) * 2 + 1, + ) + } else if (pointer) { + ndc.current.set(pointer.x, pointer.y) + } + raycaster.current.setFromCamera(ndc.current, camera) + const { origin, direction } = raycaster.current.ray + const hit = raycastTerrain( + aim, + [origin.x, origin.y, origin.z], + [direction.x, direction.y, direction.z], + ) + 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)) + 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 0000000000..2381fd84d2 --- /dev/null +++ b/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx @@ -0,0 +1,147 @@ +'use client' + +import { + type HeightPatch, + type SiteNode, + type TerrainField, + terrainFieldOf, + useLiveTerrain, + useScene, +} from '@pascal-app/core' +import { getSceneTheme, useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import { BufferAttribute, BufferGeometry, type LineSegments } from 'three' +import { EDITOR_LAYER } from '../../../lib/constants' +import { sculptFieldForSite } from '../../../lib/terrain-sculpt' + +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): BufferGeometry { + const geometry = new BufferGeometry() + const positions = new Float32Array(field.cols * field.rows * 3) + const segmentCount = + field.rows * Math.max(0, field.cols - 1) + field.cols * Math.max(0, field.rows - 1) + const indices = new Uint32Array(segmentCount * 2) + + 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 + 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 + indices[indexOffset++] = sampleIndex + indices[indexOffset++] = sampleIndex + field.cols + } + } + + const positionAttribute = new BufferAttribute(positions, 3) + geometry.setAttribute('position', positionAttribute) + geometry.setIndex(new BufferAttribute(indices, 1)) + 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. + */ +export function TerrainSculptGrid({ site }: { 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), [field]) + useEffect(() => () => geometry.dispose(), [geometry]) + + 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 0000000000..03c19bb2c0 --- /dev/null +++ b/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx @@ -0,0 +1,343 @@ +'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 { useCallback, useEffect, useRef, useState } from 'react' +import { Raycaster, Vector2 } from 'three' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { commitStroke, resolveFlattenTarget, sculptFieldForSite } from '../../../lib/terrain-sculpt' +import useEditor from '../../../store/use-editor' +import { strokePointerAction } from './stroke-pointer-ownership' +import { TerrainBrushContextMenu } from './terrain-brush-context-menu' +import { TerrainBrushCursor } 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 [brushMenuAnchor, setBrushMenuAnchor] = useState<{ + clientX: number + clientY: number + } | null>(null) + const handleBrushMenuAnchorChange = useCallback( + (anchor: { clientX: number; clientY: number } | null) => setBrushMenuAnchor(anchor), + [], + ) + 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], + ) + return hit ? [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 + // 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) + + // 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 patch = advanceStroke(active.stroke, point[0], point[1]) + if (!patch) return + 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) + + // 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) + } + 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() + } + }, [camera, gl]) + + if (!site) return null + + return ( + <> + + + + + ) +} + +export default TerrainSculptTool diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 80d0a8d7e8..5d07acebd5 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, @@ -32,6 +33,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' @@ -163,8 +165,11 @@ export const ToolManager: React.FC = () => { | 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' + // switches to full polygon editing only after a flag activates site mode. The rule + // itself lives in `lib/site-boundary` because the floorplan's SVG handles must + // reach the same answer, and they used to compute it separately. + const sculpting = mode === 'terrain-sculpt' + const showSiteBoundaryEditor = 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 +261,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' && ( )} 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 40703ca048..d71a613f29 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,84 @@ 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 terrain = applyHeightPatch( + field, + flattenPatch(field, { minX: -2, minZ: -2, 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(terrain), + } 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 +411,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 +433,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 e588ce5a8e..60d5c44de0 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 bf2526fdc1..b707fefcf4 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 5a6ae7cb60..7ef593bc43 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 746890eea3..597e81c29e 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 300cb4298f..a68c2b224a 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 0000000000..f1e920bd8b --- /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 type { LucideIcon } from 'lucide-react' +import { ArrowDownToLine, Mountain, MoveDown, MoveUp, Pipette, Waves } from 'lucide-react' +import { brushRadiusRange, flattenSite, resetSiteTerrain } from '../../../lib/terrain-sculpt' +import { TERRAIN_VERB_COLOR } from '../../../lib/terrain-verb-color' +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; Icon: LucideIcon; hint: string }> = [ + { value: 'raise', Icon: MoveUp, hint: 'Raise' }, + { value: 'lower', Icon: MoveDown, hint: 'Lower' }, + { value: 'flatten', Icon: ArrowDownToLine, hint: 'Flatten' }, + { value: 'smooth', Icon: Waves, 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 ( +
+
+ {/* + The icon is tinted with the same colour the brush ring will take, so the + mapping is learned here and read at the point of action. Without it the ring's + colour is a code with no key: the canvas can say "these two verbs differ" but + only the picker can say which one is armed. + */} + setTerrainVerb(next)} + options={VERB_OPTIONS.map(({ value, Icon, hint }) => ({ + value, + label: ( + + ), + }))} + 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 83b1c2e3b9..ff1f2af62f 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 9c1a22c85f..158db78450 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -9,6 +9,7 @@ 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 { resolveTerrainGroundHit } from '../lib/ground-surface' /** * Custom grid events hook that uses manual raycasting instead of mesh events. @@ -38,6 +39,25 @@ 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 hit = 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 1eaafd691a..990a1e0988 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 88d2890a36..8e40907c28 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -225,6 +225,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' @@ -473,6 +474,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/ground-surface.ts b/packages/editor/src/lib/ground-surface.ts new file mode 100644 index 0000000000..6137d77f35 --- /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 aa7860767f..08560c856d 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 a8a8cd5fbf..03e0db7b1a 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/site-boundary.test.ts b/packages/editor/src/lib/site-boundary.test.ts new file mode 100644 index 0000000000..82fd1dbf78 --- /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 2e68718dc1..bded45a5eb 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 0000000000..99ae81a4a0 --- /dev/null +++ b/packages/editor/src/lib/terrain-sculpt.test.ts @@ -0,0 +1,393 @@ +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, + fieldExtentForSite, + flattenSite, + resetSiteTerrain, + resolveFlattenTarget, + sculptFieldForSite, +} 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('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 0000000000..1671429409 --- /dev/null +++ b/packages/editor/src/lib/terrain-sculpt.ts @@ -0,0 +1,255 @@ +// 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, + isDatumField, + minBrushRadius, + 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)) +} + +/** + * 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 0000000000..f8368f789d --- /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 0000000000..014946f8ae --- /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 0000000000..8f59294e39 --- /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 0000000000..aa5b681be9 --- /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 0000000000..3d8b0722b1 --- /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 c9ec6e3f6b..651d01001b 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-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts index 594e77c571..c3b9a10353 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/shared/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts index e6465b9655..23f58da7eb 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 e29bf72ea2..7caa824f8f 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 0000000000..c0d00566b5 --- /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 0000000000..81ecef056a --- /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 0000000000..ef1048c4e5 --- /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 0000000000..963b50627e --- /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 0000000000..11666b93db --- /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 0000000000..73acc7ed6a --- /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/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 278b893c22..9b47d5fb1b 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -33,7 +33,7 @@ import { wallSlots } from './slots' export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', - schemaVersion: 6, + schemaVersion: 7, schema: WallNode, category: 'structure', surfaceRole: 'wall', diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index d81d8d8d91..199df4fdbe 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 95744fa7ff..293b40c4a3 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 a532eb72d9..8fb0d98488 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -25,7 +25,6 @@ import { ActionButton, ActionGroup, curveReshapeScope, - formatLinearMeasurement, getLinearUnitLabel, linearControlValueToMeters, metersToLinearUnit, @@ -109,12 +108,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) }) @@ -159,20 +158,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], ) @@ -192,8 +186,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) @@ -232,36 +226,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) @@ -538,11 +558,142 @@ 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 eventConstructionPlane = ( + event: GridEvent, + pointed: ReturnType, + ): DraftConstructionPlane => { + 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 currentLevelId = useViewer.getState().selection.levelId + const levelMesh = currentLevelId + ? sceneRegistry.nodes.get(currentLevelId as AnyNodeId) + : undefined + if (levelMesh) { + wallSurfaceLocalScratch.set(event.position[0], event.position[1], event.position[2]) + levelMesh.worldToLocal(wallSurfaceLocalScratch) + elevation = wallSurfaceLocalScratch.y + } + return { + localY: event.localPosition[1], + worldY: event.position[1], + elevation, + supportSlabId: null, + sourceNodeId: null, + } + } + + const groundConstructionPlaneAt = ( + plane: DraftConstructionPlane, + point: WallPlanPoint, + ): DraftConstructionPlane => { + if (plane.supportSlabId !== GROUND_SUPPORT_ID || plane.sourceNodeId) return plane + + const currentLevelId = useViewer.getState().selection.levelId + if (!currentLevelId) return plane + const elevation = terrainSupportLift( + useScene.getState().nodes, + currentLevelId, + point[0], + point[1], + ) + if (elevation == null) return plane + + const levelMesh = sceneRegistry.nodes.get(currentLevelId as AnyNodeId) + wallSurfaceWorldScratch.set(point[0], elevation, point[1]) + if (levelMesh) levelMesh.localToWorld(wallSurfaceWorldScratch) + const worldY = wallSurfaceWorldScratch.y + + const buildingId = useViewer.getState().selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined + wallSurfaceLocalScratch.copy(wallSurfaceWorldScratch) + if (buildingMesh) buildingMesh.worldToLocal(wallSurfaceLocalScratch) + + return { + localY: wallSurfaceLocalScratch.y, + worldY, + elevation, + supportSlabId: GROUND_SUPPORT_ID, + sourceNodeId: null, + } + } + + const snappedWallConstructionPlane = ( + targetWallIds: string[], + walls: WallNode[], + ): DraftConstructionPlane | 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: DraftConstructionPlane | 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 publishConstructionPlane = (event: GridEvent, plane: DraftConstructionPlane) => { + publishPlacementSurface( + surfacePointScratch.set(event.position[0], plane.worldY, event.position[2]), + SURFACE_UP, + ) + } + const stopDrafting = () => { buildingState.current = 0 + constructionPlane.current = null chainFirstVertex.current = null chainWallIds.current = [] const draftPreview = useFloorplanDraftPreview.getState() @@ -556,6 +707,7 @@ export const WallTool: React.FC = () => { useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() useSegmentDraftChain.getState().clear('wall') + clearPlacementSurface() } const onGridMove = (event: GridEvent) => { @@ -566,8 +718,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) { + publishConstructionPlane(event, constructionPlane.current) + } else if (pointed) { publishPlacementSurface( surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), SURFACE_UP, @@ -579,7 +733,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() @@ -603,7 +759,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) @@ -646,7 +803,11 @@ export const WallTool: React.FC = () => { ), ) } else { - cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1]) + const hoverPlane = groundConstructionPlaneAt( + eventConstructionPlane(event, pointed), + gridPosition, + ) + cursorRef.current.position.set(gridPosition[0], hoverPlane.localY, gridPosition[1]) setDraftMeasurement(null) setAxisGuide(null) } @@ -662,18 +823,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 + ? eventConstructionPlane(event, pointed) + : pointMatches(snappedStart, snapResult.point) + ? snappedWallConstructionPlane(snapResult.targetWallIds, walls) + : null) ?? eventConstructionPlane(event, pointed) + const plane = groundConstructionPlaneAt(resolvedPlane, snappedStart) + constructionPlane.current = plane + publishConstructionPlane(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 @@ -683,7 +855,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') @@ -710,12 +882,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) @@ -758,7 +934,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) @@ -769,7 +946,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/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 754b02efbb..d3fc2324cf 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,6 +35,7 @@ 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' @@ -714,8 +716,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 +752,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 +778,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabSupport.baseElevation, slabSupport.baseSegments, planeTop, + terrainBottomAt, ) collisionMesh.geometry.dispose() collisionMesh.geometry = collisionGeo @@ -851,6 +860,147 @@ 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 +const WALL_TERRAIN_FILL_EPSILON = 1e-6 + +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) + }) + if (bottomY.every((y) => y >= -WALL_TERRAIN_FILL_EPSILON)) return null + + let signedArea = 0 + for (let index = 0; index < localPoints.length; index += 1) { + const a = localPoints[index]! + const b = localPoints[(index + 1) % localPoints.length]! + signedArea += a.x * b.z - b.x * a.z + } + const counterClockwise = signedArea > 0 + const positions: number[] = [] + const push = (point: { x: number; z: number }, y: number) => { + positions.push(point.x, y, point.z) + } + + for (let index = 0; index < localPoints.length; index += 1) { + const next = (index + 1) % localPoints.length + const a = localPoints[index]! + const b = localPoints[next]! + const ay = bottomY[index]! + const by = bottomY[next]! + if (ay >= -WALL_TERRAIN_FILL_EPSILON && by >= -WALL_TERRAIN_FILL_EPSILON) continue + + if (counterClockwise) { + push(a, 0) + push(b, by) + push(a, ay) + push(a, 0) + push(b, 0) + push(b, by) + } else { + push(a, 0) + push(a, ay) + push(b, by) + push(a, 0) + push(b, by) + push(b, 0) + } + } + + const faces = THREE.ShapeUtils.triangulateShape( + localPoints.map((point) => new THREE.Vector2(point.x, point.z)), + [], + ) + for (const face of faces) { + const ia = face[0] + const ib = face[1] + const ic = face[2] + if (ia == null || ib == null || ic == null) continue + const a = localPoints[ia]! + const b = localPoints[ib]! + const c = localPoints[ic]! + if ( + bottomY[ia]! >= -WALL_TERRAIN_FILL_EPSILON && + bottomY[ib]! >= -WALL_TERRAIN_FILL_EPSILON && + bottomY[ic]! >= -WALL_TERRAIN_FILL_EPSILON + ) { + continue + } + 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 THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geometry.computeVertexNormals() + ensureRenderableGeometryAttributes(geometry) + return geometry +} + +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 +1011,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 +1064,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 +1195,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 +1239,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 0000000000..ee510e52df --- /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/vertical-model.md b/wiki/architecture/vertical-model.md index 757aa7760c..ee9df490f4 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,21 @@ 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. | | `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. | +| `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,7 +41,7 @@ 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 | @@ -55,7 +58,7 @@ Two schema rules protect these semantics: ## 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. ## Load migration (lives in `migrateNodes` Pass 3, indefinitely) @@ -72,7 +75,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 the level below's walls/ceilings and deck-attached stairs; terrain changes dirty ground-hosted and `fillToTerrain` walls (`spatial-grid-sync.ts`). 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. From 4d9b2b70fac578f7432843d6bf1e57a2111e8cdb Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 30 Jul 2026 12:27:47 +0200 Subject: [PATCH 2/3] Complete terrain-aware vertical editing --- apps/editor/components/build-tab.tsx | 2 +- .../public/audios/sfx/terrain_flatten.mp3 | Bin 0 -> 24494 bytes .../public/audios/sfx/terrain_lower.mp3 | Bin 0 -> 24494 bytes .../public/audios/sfx/terrain_raise.mp3 | Bin 0 -> 24494 bytes .../public/audios/sfx/terrain_smooth.mp3 | Bin 0 -> 24494 bytes apps/editor/public/icons/terrain-flatten.webp | Bin 0 -> 20268 bytes apps/editor/public/icons/terrain-lower.webp | Bin 0 -> 22074 bytes apps/editor/public/icons/terrain-raise.webp | Bin 0 -> 20418 bytes apps/editor/public/icons/terrain-smooth.webp | Bin 0 -> 17682 bytes .../spatial-grid/spatial-grid-sync.test.ts | 66 ++++- .../hooks/spatial-grid/spatial-grid-sync.ts | 76 ++++-- packages/core/src/index.ts | 5 +- packages/core/src/lib/space-detection.test.ts | 115 +++++++++ packages/core/src/lib/space-detection.ts | 169 +++++++++++-- packages/core/src/registry/handles.ts | 6 + packages/core/src/schema/nodes/fence.ts | 4 + packages/core/src/schema/nodes/slab.ts | 6 +- .../src/systems/slab/slab-placement.test.ts | 34 +++ .../core/src/systems/slab/slab-placement.ts | 19 ++ .../editor/elevation-3d-guide-layer.tsx | 120 +++++++++ .../editor/floorplan-camera-sync.test.ts | 28 +++ .../editor/floorplan-camera-sync.ts | 12 +- .../src/components/editor/floorplan-panel.tsx | 35 ++- .../editor/handles/use-handle-drag.ts | 2 +- .../components/editor/node-arrow-handles.tsx | 8 +- .../components/editor/site-edge-labels.tsx | 9 +- .../editor/wall-move-side-handles.tsx | 182 +++++++++++++- .../shared/horizontal-construction-plane.ts | 123 +++++++++ .../tools/site/site-boundary-editor.tsx | 19 +- .../tools/site/terrain-brush-context-menu.tsx | 192 -------------- .../tools/site/terrain-brush-cursor.tsx | 59 +++-- .../tools/site/terrain-sculpt-grid.tsx | 99 ++++++-- .../tools/site/terrain-sculpt-tool.tsx | 59 +++-- .../src/components/tools/stair/stair-tool.tsx | 120 +++++---- .../src/components/tools/tool-manager.tsx | 16 +- .../tools/wall/wall-drafting.test.ts | 9 +- .../ui/controls/terrain-sculpt-panel.tsx | 40 +-- packages/editor/src/hooks/use-grid-events.ts | 14 +- packages/editor/src/index.tsx | 18 ++ .../src/lib/active-placement-surface.test.ts | 24 ++ .../src/lib/active-placement-surface.ts | 9 +- .../editor/src/lib/elevation-guides.test.ts | 107 ++++++++ packages/editor/src/lib/elevation-guides.ts | 236 ++++++++++++++++++ packages/editor/src/lib/sfx-bus.ts | 35 ++- packages/editor/src/lib/sfx-player.test.ts | 114 ++++++++- packages/editor/src/lib/sfx-player.ts | 134 +++++++++- .../editor/src/lib/terrain-sculpt.test.ts | 36 +++ packages/editor/src/lib/terrain-sculpt.ts | 46 ++++ .../editor/src/store/use-elevation-guides.ts | 40 +++ packages/nodes/src/ceiling/definition.ts | 22 ++ .../nodes/src/fence/__tests__/lift.test.ts | 18 +- packages/nodes/src/fence/definition.ts | 96 ++++++- packages/nodes/src/fence/lift.ts | 11 +- .../src/shared/quick-measurement.test.ts | 3 + .../src/slab/__tests__/definition.test.ts | 173 ++++++++++--- .../slab/__tests__/elevation-limit.test.ts | 206 +++++++-------- .../nodes/src/slab/__tests__/geometry.test.ts | 97 ++++++- packages/nodes/src/slab/definition.ts | 152 ++++++++++- packages/nodes/src/slab/elevation-limit.ts | 143 ++++++++--- packages/nodes/src/slab/geometry.ts | 124 ++++++++- packages/nodes/src/slab/panel.tsx | 143 +++++++++-- packages/nodes/src/slab/quick-measurement.ts | 5 +- packages/nodes/src/slab/system.tsx | 17 +- packages/nodes/src/slab/tool.tsx | 112 +++++++-- packages/nodes/src/stair/floor-stack.test.ts | 184 +++++++++++++- packages/nodes/src/stair/floor-stack.ts | 49 ++++ packages/nodes/src/wall/tool.tsx | 114 ++------- packages/viewer/src/index.ts | 4 + .../viewer/src/lib/terrain-perimeter-fill.ts | 86 +++++++ .../viewer/src/systems/slab/slab-system.tsx | 7 +- .../viewer/src/systems/stair/stair-system.tsx | 2 +- .../viewer/src/systems/wall/wall-system.tsx | 78 +----- wiki/architecture/tools.md | 6 + wiki/architecture/vertical-model.md | 31 ++- 74 files changed, 3447 insertions(+), 883 deletions(-) create mode 100644 apps/editor/public/audios/sfx/terrain_flatten.mp3 create mode 100644 apps/editor/public/audios/sfx/terrain_lower.mp3 create mode 100644 apps/editor/public/audios/sfx/terrain_raise.mp3 create mode 100644 apps/editor/public/audios/sfx/terrain_smooth.mp3 create mode 100644 apps/editor/public/icons/terrain-flatten.webp create mode 100644 apps/editor/public/icons/terrain-lower.webp create mode 100644 apps/editor/public/icons/terrain-raise.webp create mode 100644 apps/editor/public/icons/terrain-smooth.webp create mode 100644 packages/core/src/systems/slab/slab-placement.test.ts create mode 100644 packages/core/src/systems/slab/slab-placement.ts create mode 100644 packages/editor/src/components/editor/elevation-3d-guide-layer.tsx create mode 100644 packages/editor/src/components/tools/shared/horizontal-construction-plane.ts delete mode 100644 packages/editor/src/components/tools/site/terrain-brush-context-menu.tsx create mode 100644 packages/editor/src/lib/active-placement-surface.test.ts create mode 100644 packages/editor/src/lib/elevation-guides.test.ts create mode 100644 packages/editor/src/lib/elevation-guides.ts create mode 100644 packages/editor/src/store/use-elevation-guides.ts create mode 100644 packages/viewer/src/lib/terrain-perimeter-fill.ts diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 763656c04b..6c76ac53eb 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -72,7 +72,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/site.webp', mode: 'terrain-sculpt' }, + { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, ] function collectBuildTypes(): BuildType[] { 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 0000000000000000000000000000000000000000..c472bd9ac1e1e9c714a3e384379419e5e9dbfd91 GIT binary patch literal 24494 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d8bfaa495ba94133038cd60e829f8e7526d22fb6 GIT binary patch literal 24494 zcmZ^~Wl&pf+lCt)LU4Br?ox_taCa~6#a)U+aCdiyB8B2!+@WYG(Bj$xrAUDtp7+}` z`_IlWWCey{9dpTj$+;9Dyoi9;7oE12mJIA23jjb+HutvS=i%UneR1*p_uKz{dwEnJ z`2T$T-=%`HjSK7w_IChJ0Kha9fQ$;nz``LQCMBn&re|bkW#{7K7Z4T|mzI@RR902j z(K9f5XJ%z%@95;>;qB`m7!)1}jfqc8PS4EAD<~?jtgfy9*xL5Fv!`!pWNcz;W^Q?9 zZR6Ya&;7%bvx}=gfA1fkUjH-fCM{t%$s_Rk;s1Ss;cxzTX&U;GDEi(1`{VzgcNotI z000E+s@&iJ;3BKp`w_VSfJ*5%5GEV|K+J7G0)TskUFcCT_agl7F!;ph<)s9b4FozA zyuyy4v!x;RC>TdZX4`og1Oz^7GNKIrlCTq_;s`9JL4*THa37(}y*x8^cX!8BqkQ(# zb!(5N2EZ|VhN?@@A|+xJBA0$#0x{7>c(c9ZThrKp7rOM0Y5ZU{M-%M0oZRO+9rx+k zX7MdS+WpXRL{N&9qR9>Cr~AXDhUTE4cfPZ3SQg+md?43%Ngx_D%iEe6mm2W$s5ycd zqGY9K|L-OQ$3F4wA2LWNcoG@)$z6+Fo-ax#-d?L&2CalURJOcae6E-9ZQlu*8q#b$ zSY1)MB$`JhDL~u$R=ISfUQdEpJ8lz`vQVSi*kmw+`@6zB$sdD%XxY-DNzm2_leDI{Gg{o>PiNbu`+#aAf0UbF=Wsd*U3c2FO09l@m& zB891;g@4J>nQsWro6Nwr%qTy6>;jk)GZ+5=pQq=%1*1G@J1&HU$yLYjpDGi4Xuf@a zlqp!#zR){CDHk5e-S=@kC3*kS+xZw&?a{G%YN`Hmd*_W-9ph)MKnBL`52&=1{|^X& z{uRyTFk3CfJ)Q0D7F7GBRg;)=#Q&yS?bgM%|l2 z-d^LE8`&+guE4nS<0YTz@#~7~gxGh(p$LGQa0e~iu`-O_zsJdNO^(dc$|BOOH15S% zgB^(LZ^0EfsMR4>Y$EJXI+HZnE(ACb8t+{Wg7YC^r@SG-huq)6%UNn7tCbuDTKwg5 zP8JK=xNB67a3}&|dVFEU-0dauHMjWuZ%XDu*roI=PJl7lP;UiN`LMV}~>0?NXBoX-Fo{RqAA6BT%29x`cy{NQS-ITQzP6^ilr~drxO!b31&tm#>DXuj#BHU6tnnI0tJ7hAX zX(SiOtr2h4qVK84r(eJ_%`JM!tT%&3){ww+v8QSUKROt)Lyc6uLAqQZg``05PL$Rc zh*-%1=HDn1onFz$K})3_GS|TZ7zSnjaIQtG$?>r@B9l+;K!m5Z`k#e>^*&xdn}!#( z6VH7Ia}9$zBIE#I3FoNc$2lG2PX_GNsnV0XZww^3H;hPRtnOWkhF`E0inE(;E{urh zwXgWIYjoKy=KPO*>z+T{;pjO3t(dd@_koied%8MiwwSj}--ae>üm4{Qk2_cJu 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 T0098}fdBydf9YQTlfV5xy<2dd literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3b541c8dceeb97b76f7a53679890ba82db60285d GIT binary patch literal 24494 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 gvv0ox>b=+3cGxb41;BKK1pNU3od4`2|GV%0A0s&r9{>OV literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/terrain-flatten.webp b/apps/editor/public/icons/terrain-flatten.webp new file mode 100644 index 0000000000000000000000000000000000000000..f62d3281e5114ccd418241965c2b873922d87de1 GIT binary patch literal 20268 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/terrain-smooth.webp b/apps/editor/public/icons/terrain-smooth.webp new file mode 100644 index 0000000000000000000000000000000000000000..f6162dc066b474bf312e5ac612cbbc08fa6657c4 GIT binary patch literal 17682 zcmV(-K-|AlNk&ErMF0R-MM6+kP&iEeL;wIU|G+;G4N!6$NsuH#W|raZ<6HlKaOU(> zRUh{f(fAn%2}I}e20z9RxT*> zY>RV}e1W}VpS`MJy9bz5aZa6+e9YdpC0z@%o2u)|OwXQkAo+}K*Dmnib+Nlku5--{ zz1O+YNj_nl7q=nNtve6X-E;EzKHFeRnt}AuRwkJNy4%u&^tKvoNuDa7jH;^O#iJL< z=p=cEZA;iL&Fn#(i~G(~6+l!;@=vz8*>&4pV41n>9TID*40Qn{$ zUO9ho-C?Q$lKjNxzPk%0 z{j*4d=`0dSSI=-t)sl7*b)P}6vFx8g5;0RTk|td>kV>_7?cJ@aQ#0-E^1necGm~b{ z4C$%jaUuSH{#OBYR9-fQ8ZUU|5>9zPhK(b50KIA58lx$02m*(ZazkwvF z@`FwSNFvFRE}v7^<&W^tiwBuRjKo%2ZA(@D5Rmli^(y_v)9g9Xp9g|K#U8V$*y1`dqK%w)n)I^hQrCac*IQ4y7q!C)ZD4wxvw zf&>2hX1&HW-46hPVHbt#G8jaLLV+CtB0vZO$+Nq@r(f$tPR^5)QVg>Q1p_FG9XkjB zfe8VBJPz()k`0+mb`-F3peTw0cBTLkLI5DZk4w>m3`8R3F_{XGdsgm&A9p&S2b|y< z*CKUo5+)N00nds241x<5EC>}UzyN@pLXQP`2>kjLU;!jd4uc)62q9jAO{H+4L0Azg zRK!2B4Y2VV2x|g-CM(!JfU=Mc{AoUP1pe9PP}{bRB<;U;9_OknBVq#hikbH{Kg9McY{ zye}YYhdC2CD;4g58QwuSsirVgA-w@ZmF`rbS!TOd*2)X;O6e?S-j&NRb5b#LGQnxX z%*@QpuYg_wM^)DHUE8*6Ns?ssxz4!)&CDSu&VVo>^f;zQn2=vboL~&(o;Ce!+onj8 zZClUxnVWe)GcyflX0pL7ddejYkVzdLgSD=oFqoRP!R0lWht$l>kQCwW2A?vi`x}Fbq5RC1#hI?HO$8 zsASFLXBa^R)0{Cj{F9-hN-FpnDzd$D!!#Z>jbJ8QuPPa4Y%?i?PwS+Oe=)KkD z+_r6O&ph|OpJQ8X()kCcPwUpUZQHr$OaMW!(EkhlztI1eQnO(4Wj#>zyaA#35G6jJ zXnh~rq(8S~=Err?CZCy!^$5jHm|yFdUOMX7z<1o)A78bat(r%%_lrKz{i5f5%J8X!85=g7GSR4Xm*Ts~6durl0WvDo zik=xNky-IHgH?nPp$GyYfS{p)VQ8kn;zZKG<51%PkMM}P8P?pSj%<_E?IC5YT36E8 zvU#nyC*~9O0f{U87Bu;6ul&Y<;}zSD*cHl4!j2ALzBR30<% zS7~52V{8f*T(Dpj%rT*w{*|Z`?NW4xh(Hx6P!a)?z`*lUjhhW2i~>Wji2s!yAOf!# z9~C(bLW9h~7}^#LaLInA9XZLmiR3G0A${jb-gUJ{VJAPK7?20hicpL&QNd>l#Y z4Shf>7%k|@P^}1J5K)5giolFP1qNUiQVTzV8?7=S2$SQiMq!*0U{n|e=?JT^fb%;P z;<_dcBNmx8%$l$Si!>kuk=^my*7=Rq|E}u_(+F#UrC@Ea_po{biZa2@`24{;d(ZP; zQASUaCt);a77I~eOhrV#Y$7NjD7f~7Sh#gCRLB4pp)@gRJEDRa_^6xTdYl&u?=o27 z=2ILP5hj<@w&EKB!GbR?4i}=5R13m&upSj^(k_rwf3ZJLbgFtdmTS^^L_7-@XW9m- zqUZITi?lPms!TX(n3Cz7DX54h7D-i+a=5hwt+(4s$_Jon}U3$A9kR-~0b3oFMR>aUa&bJ1G`T zFzJR?G3Pm*I`75@g9#IFWOSE}MOak)09_P-H|7$8kD+IFILUcYlU$qNV@dPF(c<_Z z?bGFlOS>j#=*8%Z#G*XO)0mnS%4THa>YCk6o|XMy6RLoF^w%4-=8@qS zddM&8EsyY{Z9dpB7({S->suE~LD1Cq)ypd6q9iBKUwGO8F}PBZs}Fq_b2EPkhj4Kz zPRC7e#}b2zTcTJHaS9^0RVg0UZG^8hV|rft*P*}pa_2Jk;d;57)%v3hq6ZzN}f66{KskN{^Mr2&_(P#k5Dc4B1foQdi8fvvT8(`@ZAa&ZVz1Ui|F;&@PPal8aue3odbPZRB=`rx36wLGpAsy+rer$siFnS0?i;0pL3bmbV>VkY4yB&4fyuo0=;R z_x$OTQ0;v0)B<(ubPQ*H`oQ4SxqemOaFN~y!<<`k4Dcf@;)2g~&*kpS9hR;CN5Ss3#8R{@LwU}Ew3gt0>U0tdXP z)Kb(`<~ifE)Sh*Z@N8RL?BjKEb&Hc^7K5YJOFgxle<3w;nUc_Ucy}QoD60M4+lQrk zI^0|c?>Xq(mOdP&`Es;+3C5x&RZZwA> zaPv8O_ZQBeUYNAM7n2;_y?(SBuw!gyOiEJ(%{FgTS=2Y~9t@!!Jd$-Ni$sG7|5g9x zD^0?Hm~(y=YwkS@6;xHT?$}qGZp5SL;gk0q5}#32LD?tj&!#emyMox=lYYlq#X`#kiA2!hzGp& zybAj6ii;SrzyT0|wC#&SF*FR#cJ!3P(k6^NiN-%+E>923Pw=;RN&o3i!`({dT$it5t+4UhqXJ^)A9_Ay|x9J z~%JDz_i z-gWS<%jHU$%e^n%G~;^i_!{#paokmvRS={XS+V zrwz@k zTWL&`dQpLHnx`KO9|#|yZ*o~Kmdm@OUSSilvEX5d!Iwn!j=l-{tA6LF+b{ed2OqFU zqeKu@?>&!pAXMz{Wy}Y&V@Mr-f$Q=!dwjkby8VjRun--e5$vDupC3_X`^1@r-Rm)< z8rEh6tW!T)9bXf3%i>fg%VvbepEU>cZ@oBOo{H0@Pd@TZtTfr>SUnp^3{5fp1QnPQ zd_i5;@cwBAFWp>zI9i37+$fu{M*YmS%ds`hxo}BsMUlGCfB5mLXX&=*NurkF;s~Uk zWc!Q9)4e@K@yPOIo0DiZ`T2b5Pm2;8os1!nV$oR6Pt3(~If0MEr~h4-(md?Zh4A$E zyFxQ_!Q}J7>J0MiwE#VVXhjgok?8i~@KU<*FMoaCK3z=v;R1}gP0H9ytKBozQgMC3 zDU0i_ZL@C=_rDq;{@x;x8^EgnE21tj>+tevQU$!5nJTR6r0XY*_0|k%5oTomw>U-; zpEhq^gcnZ-Q{xfNR>Ah(oMI=YV8k<_9RlD)5$j6x*S!dj{lZYeU*{qAa=e-gn^~Ib zKC5_};VQNk4X%}eu1-JU%R5g(_fNa%b&gsA!IVMT{&Oy2E^f4HLT*}J2wrYFD2Mdz z+vq5vFDaQIWXvDy@ApD@X*xWmGI?Oo+u>y7*`w0*NSBG6DIyUMOgUz)Zko?7Rhdq@ zefj;)HLn@kYM9I14C^eaB}Kps_#s})v=iFsDVO-~g%^O}79au8=%HOqig)9cC~X46 zP&UUbDyXCiGtwmx2#V^!VBI85)7@G9eJ{XAo}8Eg*67RXKqdvxn!Gm`vg1$)WTtI% zD#m(qwz0LkPhKtc{X^5QAKmD;wT}hY{X^QCP0k_$QHN7c_xR=^Sjz2^^9tme#=h3N zf>R+uI<|f`4flH~N@$!84R`~|v*i=6lo!%P`ca;2rp9%_6B19A9?MA@HEh%j_@on8 z2epG!*$DneHhZDXo`J~Ns4`y;i8S8Q6QCd%G|+GJ(csZTTZe&?(LVs0SEQ4rE2 zkD>^;|78Q^q%%S{RTwHss)4!(kA$dA<(SduvWww^aA`O^oT~r-$rBlcIUIsVcI?)5 zd~MozcA+@!CciW38d z%yLwT2L3*m31}Jb;}Qv0rHDq$Y5FGCUk_^MhhGXK3^icgaI^99=INjOzNb&GhH*v> zY8E9T4mVe$m@EM0H3u)7JWG#(SIdLnxzX42o5Per$XYa$SAlGy24=g?)rYLpi%%~# z2OGh#3%~OwK)|4jWfskLV8z_k4Z#oXCrf_UYlXsdX-*Bx-u=wamv`I)TER&^9(DsU zAJ0htp76%y4?f37pY_d)AN^`4Q$`}zqd?bBhYtj_$q`bLh-%kCbXQZ;2M-S984>VY zOpE5VbJg6p>#XVoo%&15C7N36rrg7_V$FBF{yqfX7vghP(?!8N*LHZ%hn@3%LElF; zJy%#hC+`Guz)21R1KVNs!qe=bd1;4CnbPHFJ87)n z0?*ZKoq4`6;Xdh`ERnOH*Cu9lAz*rZxEpVOpK~lF zMovPDGB{&_8K@Y^3{l?Tq%eE*_CxdZ>0Gwke!dKq6jMz(#dC(g@7?rS9G~yGAbC@g zWCH>Y#GnT-2Yo|9BB1C(3ZtGhtANxu-c%6Hu_y_Lcwtwq4rta6m)X{IBfEY*jf+{G}QSZv|hS7lP=mXK!^S|z@tG&}nm-aYImbXD2{Ah<~a$ zxI{!mfI!oZQa<>4D$I@9a((m0AYKw#gjd~Gav=;E$l@;5Zyo-Y!49l6rz_SDlqeN2%+ja+|D~y<>2lfVqi&I76#!#o2(bNYCq|kg zP#7dTZX~EoMjxiX;VW!4=`^;MVIXn!=SmfLk7L>^$uI+H#?uakbLbsT@~tnx1YQD? zBf7m#tGM6etTR88@9loyo?PB>(BhtkCrFe=M>d=AVEw}Itsnpb9kSWZ;l?y1JTX&(_582ElL4yF&$vrIRTL)bumTj1G+G58(`071|DXp^V@g2o(m_z25)DbQx-+(? zA(&?J8h)8-2s?oQ{!0w;l~7e$)?#-*9=UmUswbwtDxjoKuV zR7Rq9ys?jZj^1CZeu$6D3B`T5!S(^kZgJ@0Z3X-LpzEOKf#EPnGa|$LF61XZN#Fr( zsvwgTjUzzw((oV?YeFO)uZD4QAm(Jywa0wAk=?9ppm?~R!Oc_%?(U|M0cn;@FHf~Q zA6jBSMLl&ELwHw+*4=&bzAUg=Vw3 zn8t7)E;*~0KgCE(x|5R{Y%qIX%0m%$0_(9%o6X6yphU_C?BdiDAl5L++?)ue0@nBS z!o{eDIW)O6=AL(}|8!X#P)X*Fl}%^PFsL0jS4<_J2EXQ}U- z`sY#%%pf!b>%kvq*KYZVjfwlHZg|iIYiOR1O%OWu$e>y4iMkl&`*ZEU0qs2+KS19E zG3UWp5IFtJl{7?2Wf;ft^LYauZ?O5gZ)yW+otd7NCX;ejGl0g()->}aooqHnI!voE zE7`~@W-ypq1*^aItNb!oo(`8sH}7M_(oBYzlMdP|h9`sjzr`aXh6Yksg+W15qfI?4 zCxu}YjlrCDVv(Fy8LXXrS`YR^wtn(4tZ~A_=k8@+^KCIZ`>jNU-}x&04p@uVN+_ny zEpt(J48dN%ve+UekKxhNy48Nf0LQ^75_L#|i7xW(N@nD03f?H<+BMgI*KhUoHvR`4 zZa%Rwu2zXbh|)(icypY&K<_f&>a5swq5(pqzEl_@dU9(s;s6V?{>N-11!HZ@InO*E zT?YToEELx(%A4@B!S{2#>4*Maczr*C!8sI#OMlht@W7a@ZG0hcYt*q(LTp{<@EspE z?nM3St6pN0!LwTa*CCo|EQhf?eb&!xJE`M;$i^m z2Y&Fs{&)+PLhQpoB&)>qN+@E39-r8M*xg!vXMIOFg@SyQ2$1rI1)u}5Dhr_qiiktj zczt3R>`n7NdJ7SANyI!(gmLPH|McEFx<21?OZ6nHu8#MgeyeYF`{<+@%O@A0_sNdb zWR|Lj!DWm?P!|m}^kd4ee&P#H@25$w`zU$CYZ`i!@-$74r($vz#jf(Qx39kFY*J@k zoielxMYbaHV7cUF*Lim}0o9m|W{>A49Y~1jT!&mfyqse&%*>gBGD_)9v>Z}r#v>pW zq|Gorc?$xI03@cHUtvQ9ei)ifmON#`*p7;4Zg6*q_A54JS z*eT1VC{5|c!9MBn-P8AQg|KOXR!Fvp>r)jcxLt5gU%1Ci(^9u(F{=myzmRnoV@i*7 z2S|7ktZY1cN*W?y_U6B(+~6q0Lnvla3Wel!I#Y4;|+t;gI zn@YsSI2sOgTC?Vdi>4MdAH@=x>diyj!?{>xUg}~l*xl5!8=fNIr9@~Q0W;2bb zN6jZ+c>Mh?8F_8mOMyjB^KgAwRS!cy6fgaG7#%<%UwU}ItC`bt&=rlDT8AkX6A{#U zZgZz>rtIazy~t!SqQr<0?c&R|8?B;YbM4v`q$q0zLJw9J{i66{?rQdz{VzOjSdRPj z)j!{j5{B4E_%~)16)`s{>B{-%{o(?Qz4j=2qHY}!Hg>N2;w}vrYOOFSGp1$?5kLSe zZ@-CKA^}~QHhp+-8OqaaDKBH9S(|z$B(cnN?eJZHnm4QK*Uz@A)3a5>Kyqc!-n=H! z^xXg8Fc0|A|FkKM#o9H+-a+l5q|H#l%LYjya0Bmye`2PMATR&ZBz>4eou|7tVl7t( zXVIcL)4M(|-i!7Fv+46)ko_`C#R*377uk#mXEAI<6S`eE7o2?*dmS-Xlzq5fM$5xi zaUxe-(s!M8-Anu7{AHhxXN-r$D2#(S0+zJioKS(TNfb#nP_xYXiH!Aj`>(#Aet1uQ z{{QpvON%ZEb&p9cF^migdlGWw>P_EIb*fIQ{zQaidCE4zK#UKD)yio-s8veyv@;pRsuiIQN8W`{PeD*O=}8FQWCi(_#Y?SDW&xnCUdlTu}l) zLEi^OW3xCMwy)10ZqA4P=mq(qhYv1wC6m4}B#s+o7XTK1raK{dj=Gu4rUpH}eFCbx zNqiarFDZ`PKbkySQX+7&7}Xyb^zm(BMx{A-_Z(bBuje=@NIPLpXe#MuBxbt&mq#6S zYL`i3v($0(6Mnuu*Cg$Y(@hLjhxAph?QxTHJ5 zHf>Bju|TlJZ!m|YQ$!GtM>hEWOpUtc$2AH{f7a~DAM$1X`;rW0ogr=NZ@Bt6=lUdc{S@zoj<7$f zVn=Q}*1+oX(Xk4)VWA*&W9g3M^j{rIhIK7yJiD{&gNmgqUpSntC%qw!+B(2-Nd$$a$M zlrwJT&TOW4=9PF=5cm`H%b};QOz4NcAF5^O`)$80P8Z+xuJAJq>g|L23}5B|phUQN zFpWIa-}FLyJ%J#}VPptwbGvjwW?>FPQsQ~)V@}#UdPwY5y^_Afi;ujeyI&Ly)PG<2Q8B`+EWS7fNrU!$+a zK!H(gTqRdP>q-)O%@Mt~5d88m-u%njmt#Y8QUl8-2_XH)Q1zx>zBBoCwFuFqC_y3& zs@0+2{1(?;)2Og08Y0~}Pd+Y-g;JFmCm%NR(0c2rd)T?RlQ?4>HZQ_CWJ7Huh6K&} z&*w>DaMPw**lc}YS#K<`gx*N4}nqy?r z{GV;5%CoutlpDuMB|vl{>u74 zX6aO01oB$?U8)J_Fl(B5FlaSW93UvIH(}-o7)X(#g=zjkax}+A_hO&(R zp_)d}8}jzBPn{dJln)L8Tq5vIZkmuAk=V64C*8p?eQioZhzXTEA3O^}XMxUn44PBr z`W$Iz&8-VP!}AlpDBc&eJ~cMTwK zrm~|u_?ge;Q=m#o7S4x^VnWa<(qqCBW5EovPpChj)rGiLeeolErNtoX^dfkWOemHqr@lS;!Br7 zZ}six0*lLfjCmPbx1&$nkaEOqa#qg|Z!=pvH4{pscbsf&?wUy#1$=2biZ3&?rUxO+ zC{L%x`cNv#DJBOuT3fv|Tz)uQGUt>pjFb=g*2D5fb3vL%A&0Sha458TT#)@WKdn|ro7HR+!l_ByIkxTdXn5Q*Z~p5~<}VxV}658wTj z{b9%%=rfAtr<={G8GUM0bLYCo*DZ?YkMb{{P{;WYDUD`WmQ5u;@ujERI(Ai%1>G#P zR8VY4dG&gFV87Hl=XF$VHaE$l)nP_rDMOd9jOqXH@wNk+ z2;%~|02?z0L@7@4V{D;#xxRX|b2P=9dvVpAWEUOvXlr$wWyOD`|ce2~!vhFx;) zme-QBy3zjnVmiUqM1AU4YSTyI81B)4um*3nueN^a=-8@uWg}*LGB8>b zPDH|qgqCBwJR$detMDlhXWk^HYOtkht>TGW^_`T#=LX8u9|LC5J~q?J=VC3fiyB|6h*WW*b|VLKa;Xk zMEOnc$si;;Mz42a4+++Ig?d@3Q_{d{!jxd3_$D!=l=6~2TIF0A$r2!EeyUjt6orzI zO6*(pgdk1=P(3x}BL_b*BXot^8+K?8f~$eP79r5lPmPt0sYl|ylhRCw>%R8O5jR)@fl<9#I5y?-EqquOYK3#gI4=VCR-}T~7D6W}18U8q->5X5 zK~5Egk5PMuMFnj#qRSJ+Ui<+o%~8ixP(JyDIhbj1slWqF8L+Gdh|eZ9xcE)-dzp7-iIx-3|c(w(K(zcTF9R*bSgTq-i(zyB6m=VD}$5xXH4Pl*eCJ}9$YzO`g zB?6bWvF})crNkfvM)_O>6UCdcpE9;~k2s!Hy<4z@P_v@Nfk)<{iPdTC()zdVOme%DQ+}%7CLS%dYw-A$S-0U|Qa7&sY zoR7mIW>I~_WMyOh945Rm&$&y@f<{mm4OIwze>U8`IbZI};##@ueIGpkm7keK^eJi$ zQci%G5n6}VM9%5%Xfh9YGOKUbALm=;!k|h9?i_DCiD$<6@S*PNS(Z}ZO~+d1U7>q+mCbVK&*sAyeyw`whhbZVA=(LxW=%sU%}WL|omwVG93wB#EJ9{& z+L}jcvu2exgUIsE-t-f{AV11Mn2XIrpJi2=k~ZFC^)}~m%+rn8R&Pr)_l;~^i`ni8 zLSzwueAqDae0IIux+YQ(Yfb7HfdH2dUUG6#5y-i870sxse)Fj|q9W?}dJU~5K0$7S##L|Z zdmKTJ2(oO40c_M=I~Ap-i9?19=6I9J%5Y$VtfM7p{IdCHHyaksPC~Tm&^ds3@YJyE zx5Mg+dH7x4S66=NTYlst+q2L}ObvmIS-;x`CJ+a$ZPCYKUv0XR4G}>m!vmnLX3}h7 zR7GK##t~WtwdKF};Z6LR!7)|(x%9j4S$FK?H@Wpki4`BNS;j+R98kFh^NbYd)tt6K?%PN-;ygW}#QL#O;* z|KY|{pWCd`3aE6+Pb9df%?xDbyA4xe;6^nPqLLnD;cGFbw_5+hJ?FJJs#7$g*U($@ zr)e?VLWLJ1A|jYR+#BZRANd<()Av2w)#q&6<2#O$HdC>%F`*^6P8a;w)e zstPo;vg?s(rCZDKIO>|$uI-vC@ibkAKz5FcX)IxAd2pY4hj(|xXiGW{p~fmdvYc5X zG9;QbuH}`Yd1-Sve5u2iYj=?`lu32D79R}k0p8^tuQeK z&&xOjAR<53LE+2Xx-wj^$ged`&>#>Qlq{jmpg9v_ta&vw8Clw`;-uQRgGxYi$nqe^ zWSXK#jKIBCyX0}X!*o3~QX{n88-SFkV%6LB@VvLTO{dKG6J&K_(r#lQ)HNU5-$iq@ zQ*DD5FM>Gg#zj(Q9pk+={>$f4oPah80XG@=Hx080U<#;j;gHlT7bT7?@^Z7lu`@r)Jp3JE@XxHHRql7@ptxl z=KJ&W%UIptZnUJ{vl`F=@Ag{7X8nJ5lWC45p;nZ)dc_GqoqQd0z~x`xPEPUQ9-@Z{ zkhUMFIoteLhr4pW2e+8f*c8L*wFUGvCaFo<$38e7y^`om0kWEDhlP`Annoc;B#)}2 z;z?OLB=1`!o2rUWvj=HJ=%iHfDms*yJB4v#_(5{Rty*+;gMzeKOk5G-0Cmsel8b)08Sw|omua@OgB%x4XeDlm zAg2Vty6s?^gZ0_X)i$Y4>vibLlXhA*kIWJ=I}C`dJ^&x4Y3W^4jy`F{5B{pOXdE{* z8B!aRaVyrewiL{s(OY!nLILT9VY}zQ7Di1oq)EsYDxyI#u89)xgqI|9`Il!wg{*nv zF%WWf1jo03pORP9JYx9HK}1IS_p*+RZl&5mnGZofs;SEu8|# z^NB7e7*x$vyHMwxaz4)D=@^06MrHK0E6b$-wHKyUJ=XAXPYRz^@m)MV6Ht;HnRLgtIe3Yi%Uf?X_pbC*iEN@>tgDu~jz^s#m!Acmzubhp`j_xz_^-2U@27PfcCD z_w{VfC|8BX$dq#Q@mA3DaltTV{1uhGw9p})RQVjaq9IbRbVv2T@XIlFk|OZEa3)A>K!>b z>FeI#F94OqY&zwk2FElWHUc?IxFrw6%|zr&Ffce>3gyn zyY+kZkgJnAtK#Bccij6x^WrxPF6-&t+zmYTua&0k=s>$xg;7VsORyYKU^!fm0&N9Q zCATr(4`f-lG4mlU|D0cYsjAvQ@T6;1hq1du(KMKS9Cnva=I$49J6yOglaujI=wJkh(PyP8~1E&8Gnd@{Qx5u4Igd)PDvqZK>qyz+WJ=NkS z^kyCEMQWX8@ukO?VWpYw+~K${9>yv}DTZl>$}%F=CQkw$zy-R0K4;YyJP5Bi5or+J z^>Cc*^v+N=`O_!uO zgRlL;J=xy4uq@}{H@gBi9)wCx7^9NBGk3=qAq#4?z&NAwbZz@!L`b71{Jv&T0a=gh22n|^ zINc5Ln?L*W;c&-q^}&b3QlwF1J*fDnRZ@pKP-}8zRG1brZd&t{rGx_LEjp|ri4H>3 zyZ`t&x##`+o@ev3;w-)DTH`5`XJI-57l77Ombu}XDa)mZm}!he`gUz?=hCg3lsX}) zIvFf8HjYdxgUxpg4?a5`q_SC2Lf`kx9Q$HHse=7uV%1cI=0hvj^?m&}TK~P3gcz`j zU5FkezPrD?UwnJr`5TJ2`od*%7u~2Ld@g5zF+)}-H)qY@E&z>~0%JV-Lqwf7)VLYZ z>`d+fSG6`<*C6AJj=i7DxBP6w#p(X_>%A5S5>QgOJZoIFAWeYQ2oly1HTZU|HwgF`xaNecyNW z&7qBtP6xAR34MmOjC)DTXj#8RntDLmfXQN!5(p*)q2jnldA4*}M`=~1(Xlx)iVbu~ zXi20b9SAxX3$GmfBkT}-qS)JwYfa4YQ%M;9%E9)T@5B73k}7W zwmnEGmi))JwAEg~igp;m4(PgQTvhEP^l6ku!f{+i_@w2~j1N_KMJ2sDlD0hwnq`m- z|_qybTbB2tTgTI!*DpNXzI-M8elLY;7?#iiabP^=FkPWz&hM&rgJhhs%2c}4db)MBH$Edw z*|;JkIxOpRGrgekKCTdKbFGTng8RIwLe?oynlq`jQOKzRZ;GZHeOZZvBFv@)B69Eg zJbc_{L^!(sv8Nw>a}`y0z>-0eX1z5RFPoWBx;sddr(8wO+5W_)P`gD_E!zFSy2OB$ zZ6}Jgo=?>_=fySCHly*8TKv6=uxncYr)WkX>ndeQzN8Ae4bqonIymN9l}h|ezxmN0 z<)8^CTSw0`B#JJe6HjUbaGJB2yJ;#`l};<}-MjlZy+N`CGdvW8fVJI|+XEf$*&kji zuh3sjMkHpV+N74Vh+rTtt7WH|4m@aPN`;_;NWI*HEgHoPKJK7c6q5`hQa;^e zsh|Ty#4`dgT!81wSkFyi_RWS)T_yp`WpRm1T&7u)5llH4_Z??N!6|E^er@HQ@me0N zaZG1btZsIHtvl)>Il^O#TG&9WqXTN^bHE8G46agV6sPsQuGf?9@Xt3h(%CDZQiRb) zEW3}nxG?6RG=YgrJB)_h1~u+*J?hZ;@df)LSmvKZ4ZV%Hf^d(sQ2j)X=8?5mXT*V;>P0;eKbF?%@_qz5e&{zSKB#z zs6peqY8h6#!wy&YHZ?olGV~pAN5fBNJ>VHLI8#a1QYcSho0jnL;TlE=MeT;t#E%R%6 z%IBmSImqq}5%F^4MmK;0F$jZx^_EN@y-Pb$&%bC-%6Y6Oxks(zJUt$L%(gFca1xVa zN=gG^AC^MEdJ_|ZVYhP0s?IX?xnBS5doiE>3X^+Hw!u#Aj*a6O8QjvNO;CDA2-W3D zLpp^{dl?%Dsy0T(w1`v1Wn4)0S{n#OE3hTE+Ka=Adzfi0-uH2DhrhEt^ZNns1Q7eo zI=#?{If~HNraF#5Z+!gY99iSkv0g4i+<7fY8RpOn0w^frbAU1}QJ`^4Rp+jtkTlI86`laNs`7 zN8K7AD(h)%6*Dv3ReKmgf@c+fNL@=aRbnW(PGx+>SCbo1GVYx-XHMFps~Rvv3|RVd z%&!!LjZMdPvf_B3B=9ibETT+K{LWQC3lBEpnzFf@0_q9`cdRs;y9(wdJjj)~CX0hn zY=gNhXegEDxc{za+~2$_Jb#q&idM zsf(*>dvO0J6p-{v%E%kzHi~VU>zkBG=8VVXX<;KSh0uGZxK3RTq6W&&#HEKS2pQH9 z$BS;xdW6#K)22+@1xj!{&N{utkx!E7J2gsq)_JsG23b8rU|QSsAM9L#B*9E@2ASw# zOpxd-z{6cpb=cZo^PWDQu6me8IDyzlz(amnenjkAt!x?jQ)trh3m)e9U#W=do`ci5 zsgE-1au$RD*_Q`2-`C3$>!;q3XN4|QMP6$@7omM~X zvWS3y3Fg1z5Z!fBjZ6w+vx#AtZ3-uO8w*vaw<_D2*RDqg@Q}X}JZ>-$s#^H}lU+g1 z3c)B<4k+10fE$xa#-}Q4=7k#F-}Ja%MmPo%9r^8J@9OK7f|W>p^X|NkPZzSfo4*FQHYfk3`nekW}8>!RAmuKv5%+s{1@^G;%^2^0aw zS(-G!Y}Wp47ku>&p8Jfx`w$(U;2{rw>}|P5%HNIOA9?$|-g_I&-tDEEfl@41FizvM zQT_EGtjes=F|K8{tr+VhBt^-Mi5&B~6^3ajV$(7LW`g61n(xf0nZ+hK3yL>i-gaH8;EKRUG9pTpaGILU-Co*?xI4r{ z9{mIfL&6%re_3UOTX>f@GGkuxD_41+6-{apm73&o!cHuOKz{8lB?bt)>n1D{Nd>7( zrCRn2f*@$f^(f&;bf^Y{ei8ESB>WSg9%X*R)UvvsZK61-Vb)YlAXHhoTC7IwVj%x=t<;nPT`7 { 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 @@ -365,11 +398,13 @@ describe('spatial-grid sync dirty rules (terrain support)', () => { beforeEach(() => { nodeRegistry._reset() registerFloorPlaced('column') + useLiveTerrain.getState().endAll() }) afterEach(() => { stopSync() stopSync = () => {} + useLiveTerrain.getState().endAll() nodeRegistry._reset() }) @@ -389,6 +424,29 @@ describe('spatial-grid sync dirty rules (terrain support)', () => { 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]) @@ -476,11 +534,13 @@ describe('spatial-grid sync dirty rules (terrain support)', () => { expect(marked).toEqual(['column_host']) }) - test('markTerrainSupportDependents includes ground-hosted and terrain-fill walls', () => { + 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( @@ -494,12 +554,14 @@ describe('spatial-grid sync dirty rules (terrain support)', () => { 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', 'column_a']) + 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 742fbcb04c..4453fdc070 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -3,6 +3,7 @@ import { isLevelAtSiteDatum } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' 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 { @@ -109,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']]) { @@ -181,24 +182,8 @@ 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) @@ -227,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 { @@ -276,6 +273,37 @@ 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. * @@ -283,8 +311,9 @@ export function markDeckAttachedStairs( * 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. Cheaper than it looks: this fires once per - * committed stroke, not per dab. + * 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 @@ -310,6 +339,11 @@ export function markTerrainSupportDependents( } 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 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cb229a55d5..1058ef27e6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -66,6 +66,7 @@ export { export { findLevelAncestorId, initSpatialGridSync, + markSlabChangeDependents, resolveBuildingForLevel, resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' @@ -133,6 +134,7 @@ export { export { type AutoCeilingPlanningContext, type AutoCeilingSyncPlan, + type AutoSlabPlanningContext, type AutoSlabSyncPlan, type AutoZoneSyncPlan, detectSpacesForLevel, @@ -340,6 +342,7 @@ export { isSplineFence, sampleFenceSpline, } from './systems/fence/fence-spline' +export { resolveSlabPlacementElevation } from './systems/slab/slab-placement' export { clampSlabElevationForWalls, getSlabElevationUpperBound, @@ -349,7 +352,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 c7eb3949a6..97a6462419 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 41302a7422..e5b8f8d896 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/registry/handles.ts b/packages/core/src/registry/handles.ts index 09f55805e1..434adf4aa3 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/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index 26ebff7774..f0d3ec9fa1 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/slab.ts b/packages/core/src/schema/nodes/slab.ts index ec8e99b743..813ba5089b 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/systems/slab/slab-placement.test.ts b/packages/core/src/systems/slab/slab-placement.test.ts new file mode 100644 index 0000000000..48868209aa --- /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 0000000000..ea96748fad --- /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/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 0000000000..f465804614 --- /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/floorplan-camera-sync.test.ts b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts index 41381c8ed9..fd96c2fbb3 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 80ff429507..448428e678 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 48bb16aac8..6e5f22e863 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, @@ -43,6 +44,8 @@ import { sampleWallCenterline, sceneRegistry, snapPointAlongAngleRay, + spatialGridManager, + terrainSupportLift, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -5544,6 +5547,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. @@ -8448,6 +8452,7 @@ export function FloorplanPanel({ }, []) const clearSlabPlacementDraft = useCallback(() => { setSlabDraftPoints([]) + slabPlacementBaseRef.current = null }, []) const clearZonePlacementDraft = useCallback(() => { setZoneDraftPoints([]) @@ -8572,18 +8577,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] }) @@ -10179,16 +10188,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) => { @@ -10213,7 +10238,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() }, 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 f33e7f5096..b350351c09 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 6127663346..8c8b3bbbd6 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) => { 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 e3f0061a36..6e6165d33f 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -35,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, @@ -206,14 +214,21 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { if (!levelObject || handles.length === 0) return null return createPortal( - - {handles.map((handle) => ( - - ))} - - - - , + <> + + {handles.map((handle) => ( + + ))} + + + + + + , levelObject, ) } @@ -390,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(), []) 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 0000000000..f61856be9d --- /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/site/site-boundary-editor.tsx b/packages/editor/src/components/tools/site/site-boundary-editor.tsx index a3d942e897..230d3f5444 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/terrain-brush-context-menu.tsx b/packages/editor/src/components/tools/site/terrain-brush-context-menu.tsx deleted file mode 100644 index 25a67cf0c7..0000000000 --- a/packages/editor/src/components/tools/site/terrain-brush-context-menu.tsx +++ /dev/null @@ -1,192 +0,0 @@ -'use client' - -import type { SiteNode } from '@pascal-app/core' -import { Html } from '@react-three/drei' -import { AnimatePresence, motion } from 'motion/react' -import { useCallback, useEffect, useRef, useState } from 'react' -import { brushRadiusRange } from '../../../lib/terrain-sculpt' -import useEditor from '../../../store/use-editor' -import { SegmentedControl } from '../../ui/controls/segmented-control' -import { SliderControl } from '../../ui/controls/slider-control' - -const MENU_WIDTH = 288 -const MENU_HEIGHT = 292 -const MENU_MARGIN = 8 -const CONTEXT_CLICK_SLOP = 5 - -type MenuPosition = { x: number; y: number } - -export function TerrainBrushContextMenu({ - canvas, - onAnchorChange, - site, -}: { - canvas: HTMLCanvasElement - onAnchorChange: (anchor: { clientX: number; clientY: number } | null) => void - site: SiteNode -}) { - const brush = useEditor((state) => state.terrainBrush) - const setTerrainBrush = useEditor((state) => state.setTerrainBrush) - const [minRadius, maxRadius] = brushRadiusRange(site) - const [open, setOpen] = useState(false) - const [position, setPosition] = useState({ x: MENU_MARGIN, y: MENU_MARGIN }) - const menuRef = useRef(null) - const rightPointerRef = useRef<{ - pointerId: number - x: number - y: number - moved: boolean - } | null>(null) - - const closeMenu = useCallback(() => { - setOpen(false) - onAnchorChange(null) - }, [onAnchorChange]) - - useEffect(() => { - const onPointerDown = (event: PointerEvent) => { - if (event.button !== 2) return - rightPointerRef.current = { - pointerId: event.pointerId, - x: event.clientX, - y: event.clientY, - moved: false, - } - } - const onPointerMove = (event: PointerEvent) => { - const start = rightPointerRef.current - if (!start || start.pointerId !== event.pointerId) return - if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CONTEXT_CLICK_SLOP) { - start.moved = true - } - } - const onPointerCancel = (event: PointerEvent) => { - if (rightPointerRef.current?.pointerId === event.pointerId) { - rightPointerRef.current = null - } - } - const onContextMenu = (event: MouseEvent) => { - event.preventDefault() - const gesture = rightPointerRef.current - rightPointerRef.current = null - if (gesture?.moved || (event.buttons & 1) !== 0) return - - const rect = canvas.getBoundingClientRect() - const maxX = Math.max(MENU_MARGIN, rect.width - MENU_WIDTH - MENU_MARGIN) - const maxY = Math.max(MENU_MARGIN, rect.height - MENU_HEIGHT - MENU_MARGIN) - setPosition({ - x: Math.min(Math.max(event.clientX - rect.left, MENU_MARGIN), maxX), - y: Math.min(Math.max(event.clientY - rect.top, MENU_MARGIN), maxY), - }) - setOpen(true) - onAnchorChange({ clientX: event.clientX, clientY: event.clientY }) - } - - canvas.addEventListener('pointerdown', onPointerDown) - canvas.addEventListener('pointermove', onPointerMove) - canvas.addEventListener('pointercancel', onPointerCancel) - canvas.addEventListener('contextmenu', onContextMenu, true) - return () => { - canvas.removeEventListener('pointerdown', onPointerDown) - canvas.removeEventListener('pointermove', onPointerMove) - canvas.removeEventListener('pointercancel', onPointerCancel) - canvas.removeEventListener('contextmenu', onContextMenu, true) - } - }, [canvas, onAnchorChange]) - - useEffect(() => { - if (!open) return - const closeOutside = (event: PointerEvent) => { - if (menuRef.current?.contains(event.target as Node)) return - closeMenu() - } - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') closeMenu() - } - - window.addEventListener('pointerdown', closeOutside, true) - window.addEventListener('keydown', closeOnEscape) - window.addEventListener('blur', closeMenu) - window.addEventListener('resize', closeMenu) - return () => { - window.removeEventListener('pointerdown', closeOutside, true) - window.removeEventListener('keydown', closeOnEscape) - window.removeEventListener('blur', closeMenu) - window.removeEventListener('resize', closeMenu) - } - }, [closeMenu, open]) - - const calculatePosition = useCallback( - () => [position.x, position.y] as [number, number], - [position.x, position.y], - ) - - return ( - - - {open && ( - event.preventDefault()} - onPointerDown={(event) => event.stopPropagation()} - ref={menuRef} - style={{ pointerEvents: 'auto', transformOrigin: 'top left' }} - transition={{ type: 'spring', stiffness: 460, damping: 34, mass: 0.7 }} - > -
-

Brush settings

-

Adjust without leaving the terrain.

-
-
- 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} - /> -
-
- )} -
- - ) -} diff --git a/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx b/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx index d1e4648b6e..18c3e0f44e 100644 --- a/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx +++ b/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx @@ -12,7 +12,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' -import { useEffect, useMemo, useRef } from 'react' +import { type MutableRefObject, useEffect, useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute, @@ -22,7 +22,7 @@ import { Vector2, } from 'three' import { EDITOR_LAYER } from '../../../lib/constants' -import { sculptFieldForSite } from '../../../lib/terrain-sculpt' +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' @@ -44,6 +44,12 @@ 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. * @@ -60,16 +66,15 @@ const NO_RAYCAST = () => null * poisons the MRT scene pass and makes FrontSide geometry render see-through. */ export const TerrainBrushCursor: React.FC<{ - lockedPointer: { clientX: number; clientY: number } | null + focusRef: MutableRefObject site: SiteNode -}> = ({ lockedPointer, site }) => { +}> = ({ 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. The mapping lives in `lib/terrain-verb-color` because the - // panel's verb picker paints the same swatches and the two must not drift. + // 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) @@ -100,14 +105,14 @@ export const TerrainBrushCursor: React.FC<{ y: -((event.clientY - rect.top) / rect.height) * 2 + 1, } } - const clear = () => { + const freeze = () => { pointerRef.current = null } canvas.addEventListener('pointermove', track) - canvas.addEventListener('pointerleave', clear) + canvas.addEventListener('pointerleave', freeze) return () => { canvas.removeEventListener('pointermove', track) - canvas.removeEventListener('pointerleave', clear) + canvas.removeEventListener('pointerleave', freeze) } }, [gl]) @@ -116,7 +121,7 @@ export const TerrainBrushCursor: React.FC<{ const center = centerRef.current const pointer = pointerRef.current if (!(line && center)) return - if (!(lockedPointer || pointer)) { + if (!(pointer || focusRef.current)) { line.visible = false center.visible = false if (heightRef.current) heightRef.current.style.display = 'none' @@ -132,22 +137,27 @@ export const TerrainBrushCursor: React.FC<{ const surface: TerrainField = stroke?.field ?? terrainFieldOf(site) ?? sculptFieldForSite(site) const aim: TerrainField = stroke?.snapshot ?? surface - if (lockedPointer) { - const rect = gl.domElement.getBoundingClientRect() - ndc.current.set( - ((lockedPointer.clientX - rect.left) / rect.width) * 2 - 1, - -((lockedPointer.clientY - rect.top) / rect.height) * 2 + 1, - ) - } else if (pointer) { + 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 } - raycaster.current.setFromCamera(ndc.current, camera) - const { origin, direction } = raycaster.current.ray - const hit = raycastTerrain( - aim, - [origin.x, origin.y, origin.z], - [direction.x, direction.y, direction.z], - ) if (!hit) { line.visible = false center.visible = false @@ -164,6 +174,7 @@ export const TerrainBrushCursor: React.FC<{ // 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 diff --git a/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx b/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx index 2381fd84d2..7460493511 100644 --- a/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx +++ b/packages/editor/src/components/tools/site/terrain-sculpt-grid.tsx @@ -8,11 +8,14 @@ import { useLiveTerrain, useScene, } from '@pascal-app/core' -import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' -import { BufferAttribute, BufferGeometry, type LineSegments } from 'three' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sculptFieldForSite } from '../../../lib/terrain-sculpt' +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 @@ -43,12 +46,10 @@ function writeGridHeights( attribute.needsUpdate = true } -function createGridGeometry(field: TerrainField): BufferGeometry { +function createGridGeometry(field: TerrainField, site: Pick): BufferGeometry { const geometry = new BufferGeometry() const positions = new Float32Array(field.cols * field.rows * 3) - const segmentCount = - field.rows * Math.max(0, field.cols - 1) + field.cols * Math.max(0, field.rows - 1) - const indices = new Uint32Array(segmentCount * 2) + const indices: number[] = [] for (let row = 0; row < field.rows; row += 1) { for (let col = 0; col < field.cols; col += 1) { @@ -62,21 +63,35 @@ function createGridGeometry(field: TerrainField): BufferGeometry { 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 - indices[indexOffset++] = sampleIndex - indices[indexOffset++] = sampleIndex + 1 + 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 - indices[indexOffset++] = sampleIndex - indices[indexOffset++] = sampleIndex + field.cols + 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(new BufferAttribute(indices, 1)) + geometry.setIndex(indices) writeGridHeights(positionAttribute, field, null) geometry.computeBoundingSphere() return geometry @@ -88,16 +103,53 @@ function createGridGeometry(field: TerrainField): BufferGeometry { * 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. + * 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({ site }: { site: SiteNode }) { +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), [field]) + 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 @@ -130,18 +182,11 @@ export function TerrainSculptGrid({ site }: { site: SiteNode }) { - - + /> ) } diff --git a/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx b/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx index 03c19bb2c0..a2c8b434f7 100644 --- a/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx +++ b/packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx @@ -16,14 +16,21 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { Raycaster, Vector2 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { commitStroke, resolveFlattenTarget, sculptFieldForSite } from '../../../lib/terrain-sculpt' +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 { TerrainBrushContextMenu } from './terrain-brush-context-menu' -import { TerrainBrushCursor } from './terrain-brush-cursor' +import { TerrainBrushCursor, type TerrainBrushFocus } from './terrain-brush-cursor' import { TerrainSculptGrid } from './terrain-sculpt-grid' /** @@ -51,14 +58,7 @@ import { TerrainSculptGrid } from './terrain-sculpt-grid' */ export const TerrainSculptTool: React.FC = () => { const { camera, gl } = useThree() - const [brushMenuAnchor, setBrushMenuAnchor] = useState<{ - clientX: number - clientY: number - } | null>(null) - const handleBrushMenuAnchorChange = useCallback( - (anchor: { clientX: number; clientY: number } | null) => setBrushMenuAnchor(anchor), - [], - ) + const brushFocusRef = useRef(null) const nodes = useScene((state) => state.nodes) const rootNodeIds = useScene((state) => state.rootNodeIds) const verb = useEditor((state) => state.terrainVerb) @@ -111,7 +111,8 @@ export const TerrainSculptTool: React.FC = () => { [origin.x, origin.y, origin.z], [direction.x, direction.y, direction.z], ) - return hit ? [hit.x, hit.z] : null + 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`. */ @@ -119,6 +120,11 @@ export const TerrainSculptTool: React.FC = () => { 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 @@ -173,8 +179,14 @@ export const TerrainSculptTool: React.FC = () => { target: activeVerb === 'flatten' ? resolveFlattenTarget(field, explicit, ...point) : undefined, }) - strokeRef.current = { stroke, field, siteId: target.id, pointerId: event.pointerId } + 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 @@ -204,8 +216,11 @@ export const TerrainSculptTool: React.FC = () => { } const point = groundPoint(event, active.field) if (!point) return - const patch = advanceStroke(active.stroke, point[0], point[1]) - if (!patch) 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. @@ -242,6 +257,7 @@ export const TerrainSculptTool: React.FC = () => { } 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 @@ -266,6 +282,7 @@ export const TerrainSculptTool: React.FC = () => { if (canvas.hasPointerCapture(active.pointerId)) { canvas.releasePointerCapture(active.pointerId) } + sfxEmitter.emit('sfx:terrain-sculpt-stop') useLiveTerrain.getState().end(active.siteId) return true } @@ -322,6 +339,7 @@ export const TerrainSculptTool: React.FC = () => { // 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]) @@ -329,13 +347,8 @@ export const TerrainSculptTool: React.FC = () => { return ( <> - - - + + ) } diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 3b1b8068f0..fed4e66d9d 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 5d07acebd5..08259b6f4a 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -25,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' @@ -164,12 +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. The rule - // itself lives in `lib/site-boundary` because the floorplan's SVG handles must - // reach the same answer, and they used to compute it separately. + // 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' - const showSiteBoundaryEditor = siteBoundaryHandlesEnabled({ mode, phase }) + // 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 @@ -396,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 d71a613f29..f2e91c1a96 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -135,10 +135,9 @@ describe('createWallOnCurrentLevel', () => { 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 terrain = applyHeightPatch( - field, - flattenPatch(field, { minX: -2, minZ: -2, maxX: 2, maxZ: 2 }, 1.5), - ) + 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', @@ -148,7 +147,7 @@ describe('createWallOnCurrentLevel', () => { metadata: {}, children: ['building_test'], terrain: encodeTerrainField(terrain), - } as AnyNode + } as unknown as AnyNode const building = { id: 'building_test', type: 'building', diff --git a/packages/editor/src/components/ui/controls/terrain-sculpt-panel.tsx b/packages/editor/src/components/ui/controls/terrain-sculpt-panel.tsx index f1e920bd8b..ec39f5be3f 100644 --- a/packages/editor/src/components/ui/controls/terrain-sculpt-panel.tsx +++ b/packages/editor/src/components/ui/controls/terrain-sculpt-panel.tsx @@ -7,20 +7,18 @@ import { type TerrainVerb, useScene, } from '@pascal-app/core' -import type { LucideIcon } from 'lucide-react' -import { ArrowDownToLine, Mountain, MoveDown, MoveUp, Pipette, Waves } from 'lucide-react' +import { Mountain, Pipette } from 'lucide-react' import { brushRadiusRange, flattenSite, resetSiteTerrain } from '../../../lib/terrain-sculpt' -import { TERRAIN_VERB_COLOR } from '../../../lib/terrain-verb-color' 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; Icon: LucideIcon; hint: string }> = [ - { value: 'raise', Icon: MoveUp, hint: 'Raise' }, - { value: 'lower', Icon: MoveDown, hint: 'Lower' }, - { value: 'flatten', Icon: ArrowDownToLine, hint: 'Flatten' }, - { value: 'smooth', Icon: Waves, hint: 'Smooth' }, +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 = { @@ -62,22 +60,24 @@ export function TerrainSculptPanel() { return (
- {/* - The icon is tinted with the same colour the brush ring will take, so the - mapping is learned here and read at the point of action. Without it the ring's - colour is a code with no key: the canvas can say "these two verbs differ" but - only the picker can say which one is armed. - */} setTerrainVerb(next)} - options={VERB_OPTIONS.map(({ value, Icon, hint }) => ({ + options={VERB_OPTIONS.map(({ value, iconSrc, hint }) => ({ value, label: ( - + + + {hint} + ), }))} value={verb} diff --git a/packages/editor/src/hooks/use-grid-events.ts b/packages/editor/src/hooks/use-grid-events.ts index 158db78450..a93533d076 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -9,6 +9,7 @@ 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' /** @@ -51,11 +52,14 @@ export function useGridEvents(gridY: number) { // 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 hit = resolveTerrainGroundHit( - [origin.x, origin.y, origin.z], - [direction.x, direction.y, direction.z], - -groundPlane.current.constant, - ) + 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 diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 8e40907c28..7e95a402e3 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. @@ -311,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, 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 0000000000..d234ee7fe1 --- /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 c9cf9a3cec..207881eb9e 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 0000000000..c0192aaea0 --- /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 0000000000..021afbf4ea --- /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/sfx-bus.ts b/packages/editor/src/lib/sfx-bus.ts index 7186b86a38..236c0c9ae8 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 53125f734a..da50715cb6 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 1304578650..ca968c361a 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/terrain-sculpt.test.ts b/packages/editor/src/lib/terrain-sculpt.test.ts index 99ae81a4a0..3d6b39f94d 100644 --- a/packages/editor/src/lib/terrain-sculpt.test.ts +++ b/packages/editor/src/lib/terrain-sculpt.test.ts @@ -12,11 +12,13 @@ import { 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 @@ -148,6 +150,40 @@ describe('sculptFieldForSite', () => { }) }) +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], diff --git a/packages/editor/src/lib/terrain-sculpt.ts b/packages/editor/src/lib/terrain-sculpt.ts index 1671429409..65ddef6058 100644 --- a/packages/editor/src/lib/terrain-sculpt.ts +++ b/packages/editor/src/lib/terrain-sculpt.ts @@ -11,8 +11,10 @@ import { commitTerrainField, createTerrainField, DEFAULT_TERRAIN_SPACING, + type HeightPatch, isDatumField, minBrushRadius, + pointInPolygon2D, quantize, runAsSingleSceneHistoryStep, type SiteNode, @@ -98,6 +100,50 @@ 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. * 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 0000000000..98d1050325 --- /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/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index fc8b863595..f362f83166 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 53d6f935f0..de578936ba 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 f75baeabb0..e854a2cab0 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 01397d324c..d36187ab7e 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 3ae030b466..6379f2ba6b 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/slab/__tests__/definition.test.ts b/packages/nodes/src/slab/__tests__/definition.test.ts index b625c187ef..c9d3f0ae4c 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 7a829488d0..6570d7cb03 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 82ca0814cd..2143e5202f 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 d44d2b07bd..9d58ca7661 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 bc335b68ae..fc90ac3858 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 5919578892..81046a368e 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 cb73a8be67..07e88ade22 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 ba54c3c1a2..80a432618f 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 4a822512be..b6477693ef 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 c21d4c85d9..85c5a335f7 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/tool.tsx b/packages/nodes/src/wall/tool.tsx index 6e70a1efdb..9e3d3d5e0f 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -5,7 +5,6 @@ import { collectAlignmentAnchors, DEFAULT_LEVEL_HEIGHT, emitter, - GROUND_SUPPORT_ID, type GridEvent, getWallMiterBoundaryPoints, type LevelNode, @@ -13,7 +12,6 @@ import { resolveAlignment, resolveBuildingForLevel, sceneRegistry, - terrainSupportLift, useScene, type WallMiterData, type WallNode, @@ -30,11 +28,15 @@ import { getAngleArcToSegmentReference, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + type HorizontalConstructionPlane, isAlignmentGuideActive, isAngleSnapActive, isMagneticSnapActive, markToolCancelConsumed, + publishHorizontalConstructionPlane, publishPlacementSurface, + resampleTerrainConstructionPlane, + resolveEventConstructionPlane, resolvePointerSupportSurface, type SegmentAngleReference, snapWallDraftPointDetailed, @@ -92,19 +94,6 @@ const surfacePointScratch = new Vector3() const wallSurfaceWorldScratch = new Vector3() const wallSurfaceLocalScratch = new Vector3() -type DraftConstructionPlane = { - /** Building-local Y used by the cursor and wall preview. */ - localY: number - /** World Y published to the shared placement grid. */ - worldY: number - /** Level-local Y used as the support election cap on commit. */ - elevation: number | null - /** Surface source selected at the first click. */ - supportSlabId: string | null - /** Optional scene node from which this frozen plane was derived. */ - sourceNodeId: AnyNodeId | null -} - type DraftMeasurementState = { lengthLabel: string lengthPosition: [number, number, number] @@ -494,7 +483,7 @@ export const WallTool: React.FC = () => { // 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 constructionPlane = useRef(null) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) @@ -563,78 +552,10 @@ export const WallTool: React.FC = () => { }) : null - const eventConstructionPlane = ( - event: GridEvent, - pointed: ReturnType, - ): DraftConstructionPlane => { - 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 currentLevelId = useViewer.getState().selection.levelId - const levelMesh = currentLevelId - ? sceneRegistry.nodes.get(currentLevelId as AnyNodeId) - : undefined - if (levelMesh) { - wallSurfaceLocalScratch.set(event.position[0], event.position[1], event.position[2]) - levelMesh.worldToLocal(wallSurfaceLocalScratch) - elevation = wallSurfaceLocalScratch.y - } - return { - localY: event.localPosition[1], - worldY: event.position[1], - elevation, - supportSlabId: null, - sourceNodeId: null, - } - } - - const groundConstructionPlaneAt = ( - plane: DraftConstructionPlane, - point: WallPlanPoint, - ): DraftConstructionPlane => { - if (plane.supportSlabId !== GROUND_SUPPORT_ID || plane.sourceNodeId) return plane - - const currentLevelId = useViewer.getState().selection.levelId - if (!currentLevelId) return plane - const elevation = terrainSupportLift( - useScene.getState().nodes, - currentLevelId, - point[0], - point[1], - ) - if (elevation == null) return plane - - const levelMesh = sceneRegistry.nodes.get(currentLevelId as AnyNodeId) - wallSurfaceWorldScratch.set(point[0], elevation, point[1]) - if (levelMesh) levelMesh.localToWorld(wallSurfaceWorldScratch) - const worldY = wallSurfaceWorldScratch.y - - const buildingId = useViewer.getState().selection.buildingId - const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined - wallSurfaceLocalScratch.copy(wallSurfaceWorldScratch) - if (buildingMesh) buildingMesh.worldToLocal(wallSurfaceLocalScratch) - - return { - localY: wallSurfaceLocalScratch.y, - worldY, - elevation, - supportSlabId: GROUND_SUPPORT_ID, - sourceNodeId: null, - } - } - const snappedWallConstructionPlane = ( targetWallIds: string[], walls: WallNode[], - ): DraftConstructionPlane | null => { + ): 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 @@ -645,7 +566,7 @@ export const WallTool: React.FC = () => { const levelMesh = currentLevelId ? sceneRegistry.nodes.get(currentLevelId as AnyNodeId) : undefined - let resolved: DraftConstructionPlane | null = null + let resolved: HorizontalConstructionPlane | null = null for (const id of targetIds) { const targetWall = walls.find((wall) => wall.id === id) @@ -684,13 +605,6 @@ export const WallTool: React.FC = () => { return resolved } - const publishConstructionPlane = (event: GridEvent, plane: DraftConstructionPlane) => { - publishPlacementSurface( - surfacePointScratch.set(event.position[0], plane.worldY, event.position[2]), - SURFACE_UP, - ) - } - const stopDrafting = () => { buildingState.current = 0 constructionPlane.current = null @@ -720,7 +634,7 @@ export const WallTool: React.FC = () => { // will elect. Aiming past the deck edge drops it back to the floor. const pointed = buildingState.current === 0 ? pointedSurfaceFor(event) : null if (constructionPlane.current) { - publishConstructionPlane(event, constructionPlane.current) + publishHorizontalConstructionPlane(event, constructionPlane.current) } else if (pointed) { publishPlacementSurface( surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), @@ -803,8 +717,8 @@ export const WallTool: React.FC = () => { ), ) } else { - const hoverPlane = groundConstructionPlaneAt( - eventConstructionPlane(event, pointed), + const hoverPlane = resampleTerrainConstructionPlane( + resolveEventConstructionPlane(event, pointed), gridPosition, ) cursorRef.current.position.set(gridPosition[0], hoverPlane.localY, gridPosition[1]) @@ -837,13 +751,13 @@ export const WallTool: React.FC = () => { const snappedStart = alignPoint(snapResult.point) const resolvedPlane = (pointed?.sourceNodeId - ? eventConstructionPlane(event, pointed) + ? resolveEventConstructionPlane(event, pointed) : pointMatches(snappedStart, snapResult.point) ? snappedWallConstructionPlane(snapResult.targetWallIds, walls) - : null) ?? eventConstructionPlane(event, pointed) - const plane = groundConstructionPlaneAt(resolvedPlane, snappedStart) + : null) ?? resolveEventConstructionPlane(event, pointed) + const plane = resampleTerrainConstructionPlane(resolvedPlane, snappedStart) constructionPlane.current = plane - publishConstructionPlane(event, plane) + publishHorizontalConstructionPlane(event, plane) gridPosition = snappedStart startingPoint.current.set(snappedStart[0], plane.localY, snappedStart[1]) chainFirstVertex.current = startingPoint.current.clone() diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 316d3a46cd..fad3a529c4 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 0000000000..2f1d038907 --- /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 00ba8c88e5..b265b22969 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 7581b2c05d..e63b75fe13 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 d3fc2324cf..06eb8c5a72 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -39,6 +39,7 @@ 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, @@ -861,7 +862,6 @@ function applyWorldPlanarWallUVs( * then we transform to wall-local for the 3D mesh. */ const WALL_TERRAIN_SAMPLE_STEP = 0.25 -const WALL_TERRAIN_FILL_EPSILON = 1e-6 type WallTerrainBottomSampler = (x: number, z: number) => number | null @@ -897,81 +897,7 @@ function buildWallTerrainFillGeometry( const terrainElevation = terrainBottomAt(point.x, point.y) return terrainElevation == null ? 0 : Math.min(0, terrainElevation - wallBaseElevation) }) - if (bottomY.every((y) => y >= -WALL_TERRAIN_FILL_EPSILON)) return null - - let signedArea = 0 - for (let index = 0; index < localPoints.length; index += 1) { - const a = localPoints[index]! - const b = localPoints[(index + 1) % localPoints.length]! - signedArea += a.x * b.z - b.x * a.z - } - const counterClockwise = signedArea > 0 - const positions: number[] = [] - const push = (point: { x: number; z: number }, y: number) => { - positions.push(point.x, y, point.z) - } - - for (let index = 0; index < localPoints.length; index += 1) { - const next = (index + 1) % localPoints.length - const a = localPoints[index]! - const b = localPoints[next]! - const ay = bottomY[index]! - const by = bottomY[next]! - if (ay >= -WALL_TERRAIN_FILL_EPSILON && by >= -WALL_TERRAIN_FILL_EPSILON) continue - - if (counterClockwise) { - push(a, 0) - push(b, by) - push(a, ay) - push(a, 0) - push(b, 0) - push(b, by) - } else { - push(a, 0) - push(a, ay) - push(b, by) - push(a, 0) - push(b, by) - push(b, 0) - } - } - - const faces = THREE.ShapeUtils.triangulateShape( - localPoints.map((point) => new THREE.Vector2(point.x, point.z)), - [], - ) - for (const face of faces) { - const ia = face[0] - const ib = face[1] - const ic = face[2] - if (ia == null || ib == null || ic == null) continue - const a = localPoints[ia]! - const b = localPoints[ib]! - const c = localPoints[ic]! - if ( - bottomY[ia]! >= -WALL_TERRAIN_FILL_EPSILON && - bottomY[ib]! >= -WALL_TERRAIN_FILL_EPSILON && - bottomY[ic]! >= -WALL_TERRAIN_FILL_EPSILON - ) { - continue - } - 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 THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) - geometry.computeVertexNormals() - ensureRenderableGeometryAttributes(geometry) - return geometry + return buildTerrainPerimeterFillGeometry(localPoints, bottomY, 0) } function mergeWallTerrainFill( diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 883c5b4677..5269d612c4 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 ee9df490f4..bd73275b67 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -22,9 +22,12 @@ The invariant, in one sentence: | `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. | @@ -46,20 +49,40 @@ Two schema rules protect these semantics: | `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 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) Because community autosave only persists after the first post-load edit, the migration must remain in `migrateNodes`: @@ -75,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; terrain changes dirty ground-hosted and `fillToTerrain` walls (`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; terrain changes dirty ground-hosted structures and `fillToTerrain` walls/slabs (`spatial-grid-sync.ts`). Slab handles reuse the same 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. From 658ce257ed78a827942a8bb17048b8a15ff9829a Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 30 Jul 2026 12:30:40 +0200 Subject: [PATCH 3/3] Prepare the 1.0 beta release --- .github/workflows/release.yml | 46 +++++++++++++++------ CHANGELOG.md | 36 ++++++++++++++++ package.json | 1 + packages/core/src/schema/nodes/wall.test.ts | 1 + wiki/architecture/vertical-model.md | 2 +- 5 files changed, 72 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06adf35d21..458b8165f5 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 42b0a6fea0..bf9e890764 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/package.json b/package.json index 083e1e251f..7c318deabb 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/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 3cf7e807fc..a508b0f814 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -13,6 +13,7 @@ import { WALL_SKIRTING_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WallFaceBandConfig, + WallNode, type WallNode as WallNodeType, WallTrimConfig, } from './wall' diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index bd73275b67..80c5882154 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -98,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 overlapping same-level supports, the level below's walls/ceilings, and deck-attached stairs; terrain changes dirty ground-hosted structures and `fillToTerrain` walls/slabs (`spatial-grid-sync.ts`). Slab handles reuse the same slab-change dependency helper for live previews. 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.