diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index cf55be26c0..8196ce05b0 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -10,7 +10,13 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { GRID_LAYER, getLevelPresentationY, useViewer, ZONE_LAYER } from '@pascal-app/viewer' +import { + GRID_LAYER, + getLevelPresentationY, + useSceneGroundReplacement, + useViewer, + ZONE_LAYER, +} from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' @@ -1368,6 +1374,10 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = // navigation guardrails. const isPresetCapture = captureMode.mode === 'preset' const minDistance = isPresetCapture ? 0.5 : 2 + // Over modeled or mapped surroundings (a neighbourhood instead of the + // horizon disc) the camera may pull back far enough to take it all in. + const groundReplaced = useSceneGroundReplacement() + const maxDistance = groundReplaced ? 1500 : 100 if (isFirstPersonMode) { return null @@ -1377,7 +1387,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = { expect(setbackEnvelope(LOT, { front: 0, side: 0, rear: 0 }, 0)).toEqual([]) expect(setbackEnvelope([[0, 0]], { front: 1, side: 1, rear: 1 }, 0)).toEqual([]) }) + it('a frontage the county drew as two nearly straight lines takes one front yard, not an arc', () => { + // 1054 Rivera Dr, Sacramento as the parcel fabric draws it: the front (north, y ≈ −15.4) split + // at a kink of under 2° into edges 3 and 4; the street lookup failed, so only edge 4 came back + // as frontage + const rivera: Pt[] = [ + [12.39, -15.35], + [12.77, 14.38], + [-14.32, 15.47], + [-14.34, -15.26], + [-1.53, -15.56], + ] + const env = setbackEnvelope(rivera, { front: 6.096, side: 1.524, rear: 4.572 }, 4, { + streetEdges: [4], + }) + // straight across the whole frontage (the kink's own corner, no curve round it): five corners at most + expect(env.length).toBeLessThanOrEqual(5) + const segDist = (p: readonly number[], a: readonly number[], b: readonly number[]) => { + const abx = b[0]! - a[0]! + const aby = b[1]! - a[1]! + const t = Math.max( + 0, + Math.min(1, ((p[0]! - a[0]!) * abx + (p[1]! - a[1]!) * aby) / (abx * abx + aby * aby)), + ) + return Math.hypot(p[0]! - (a[0]! + abx * t), p[1]! - (a[1]! + aby * t)) + } + // both halves of the frontage keep the front yard + for (const v of env) + for (const i of [3, 4]) + expect(segDist(v, rivera[i]!, rivera[(i + 1) % rivera.length]!)).toBeGreaterThan( + 6.096 - 0.06, + ) + }) }) describe('rayToPolygon + castYardDimensions', () => { diff --git a/packages/editor/src/lib/floorplan/site-plan/geometry.ts b/packages/editor/src/lib/floorplan/site-plan/geometry.ts index 3cc80c2aa4..8e9af39ae8 100644 --- a/packages/editor/src/lib/floorplan/site-plan/geometry.ts +++ b/packages/editor/src/lib/floorplan/site-plan/geometry.ts @@ -336,9 +336,44 @@ export function setbackEnvelope( else if (runs[(i + 1) % n] !== g && streetLike(after)) runRole.set(g, after) } } + // One lot line drawn as two: the county fabric splits a frontage at a kink of + // a degree or two, and only one half may come back from the street lookup + // (or the north-facing fallback). The half left out took a side yard, and + // the front yard swung round their shared corner in a circle as deep as + // itself. An edge running on nearly straight from a front or street edge + // is that frontage too. + const roleOf = (i: number) => + runRole.get(runs[(i + n) % n] as number) ?? (roles[(i + n) % n] as EdgeRole) + const direction = (i: number) => { + const p = points[(i + n) % n] as Pt + const q = points[(i + 1 + n) % n] as Pt + const l = Math.hypot(q[0] - p[0], q[1] - p[1]) || 1 + return [(q[0] - p[0]) / l, (q[1] - p[1]) / l] as const + } + const straightOn = Math.cos((8 * Math.PI) / 180) + const continued = new Map() + for (let pass = 0; pass < n; pass++) { + let grew = false + for (let i = 0; i < n; i++) { + const own = continued.get(i) ?? roleOf(i) + if (streetLike(own)) continue + const d = direction(i) + for (const j of [i - 1, i + 1]) { + const k = (j + n) % n + const theirs = continued.get(k) ?? roleOf(k) + const e = direction(k) + if (streetLike(theirs) && d[0] * e[0] + d[1] * e[1] > straightOn) { + continued.set(i, theirs) + grew = true + break + } + } + } + if (!grew) break + } const distances: number[] = [] for (let i = 0; i < n; i++) { - const role = runRole.get(runs[i] as number) ?? (roles[i] as EdgeRole) + const role = continued.get(i) ?? runRole.get(runs[i] as number) ?? (roles[i] as EdgeRole) const d = setbackForRole( setbacks, role === 'left' || role === 'right' ? (roles[i] as EdgeRole) : role, diff --git a/packages/editor/src/lib/lot/drop-in.ts b/packages/editor/src/lib/lot/drop-in.ts index e7fbad41d4..fc1acd9fb7 100644 --- a/packages/editor/src/lib/lot/drop-in.ts +++ b/packages/editor/src/lib/lot/drop-in.ts @@ -250,6 +250,7 @@ export async function dropInLot( let roadsFailure = '' if (options.roads !== false && data.originLngLat) { for (let attempt = 0; attempt < 2 && !roads; attempt++) { + const asked = Date.now() try { const [lng, lat] = data.originLngLat const r = (await provider('roads', { @@ -265,6 +266,9 @@ export async function dropInLot( } catch (error) { roadsFailure = error instanceof Error ? error.message : 'road lookup failed' } + // a quick miss is worth one more ask; a slow one (every mirror timed out) would only be + // slow again, and the lot is waiting — the front edge falls back instead + if (!roads && Date.now() - asked > 4000) break } } else if (options.roads === false) { roadsFailure = 'skipped' diff --git a/packages/nodes/src/site/lot-mask.ts b/packages/nodes/src/site/lot-mask.ts new file mode 100644 index 0000000000..fcfa63d81c --- /dev/null +++ b/packages/nodes/src/site/lot-mask.ts @@ -0,0 +1,40 @@ +import type { Material } from 'three' +import { float, mod, positionLocal, select } from 'three/tsl' +import type { Node, NodeMaterial } from 'three/webgpu' + +type Point = readonly [number, number] + +/** + * True for a fragment inside the lot polygon (even-odd crossing count on its + * local x, z). Unrolled edge by edge into the graph: a parcel has tens of + * corners, so a loop and a uniform array would buy nothing. + */ +export function insideLotNode(polygon: ReadonlyArray) { + const at = positionLocal.xz + let crossings: Node<'float'> = float(0) + for (let i = 0; i < polygon.length; i++) { + const [ax, az] = polygon[i] as Point + const [bx, bz] = polygon[(i + 1) % polygon.length] as Point + if (az === bz) continue + const slope = (bx - ax) / (bz - az) + const crossesAt = at.y.sub(az).mul(slope).add(ax) + const crosses = at.y + .greaterThanEqual(Math.min(az, bz)) + .and(at.y.lessThan(Math.max(az, bz))) + .and(at.x.lessThan(crossesAt)) + crossings = crossings.add(select(crosses, float(1), float(0))) + } + return mod(crossings, float(2)).greaterThan(0.5) +} + +/** + * The ground material cut to the lot: the terrain field is the padded, + * north-up box around the parcel, and only the parcel itself is ground the + * project owns. Per fragment, so the edge is the property line exactly and + * follows a sculpt stroke with no rebuild; the shadow pass honours the mask too. + */ +export function lotMaskedMaterial(material: Material, polygon: ReadonlyArray): Material { + const masked = material.clone() as unknown as NodeMaterial + masked.maskNode = insideLotNode(polygon) + return masked as unknown as Material +} diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 83f521d690..bff8f17bb2 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -60,7 +60,7 @@ import { terrainGridKey, updateDrapedHeights, } from './terrain-drape' -import { HORIZON_PLANE_Y, terrainFootprint } from './terrain-geometry' +import { HORIZON_PLANE_Y, terrainBlockBase } from './terrain-geometry' import { TerrainRenderer } from './terrain-renderer' const Y_OFFSET = 0.01 @@ -324,10 +324,15 @@ 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. + // With terrain the lot is a block — its ground cut to the property line, its + // skirt dropping to one flat base — and the disc lies just under that base, so + // nothing of it caps the lot and nothing of the block stands proud of it from + // the far side. The recessed slabs are still punched: a basement can go deeper. + const horizonY = useMemo( + () => (terrainGrid ? terrainBlockBase(terrainGrid) - 0.02 : HORIZON_PLANE_Y), + [terrainGrid], + ) + const horizonGeometry = useMemo(() => { if (!fadeBounds || groundReplaced) return null const radius = Math.max(fadeBounds.radius * 8, 400) @@ -339,10 +344,9 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { shape.lineTo(Math.cos(angle) * radius, Math.sin(angle) * radius) } shape.closePath() - const holes = terrainGrid ? [...slabPolygons, terrainFootprint(terrainGrid)] : slabPolygons - addSlabHoles(shape, holes, fadeBounds.cx, fadeBounds.cz) + addSlabHoles(shape, slabPolygons, fadeBounds.cx, fadeBounds.cz) return new ShapeGeometry(shape) - }, [fadeBounds, slabPolygons, terrainKey, groundReplaced]) + }, [fadeBounds, slabPolygons, groundReplaced]) useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry]) useEffect(() => () => horizonMaterial?.dispose(), [horizonMaterial]) @@ -558,7 +562,12 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { {/* Sculpted ground, when the site has terrain */} {showSiteSurfaces && showTerrain && ( - + )} {/* Ground fill: site polygon with slab holes, occludes below-grade geometry */} @@ -577,7 +586,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { { expect(footprint[0]![0]).toBeGreaterThan(footprint[1]![0]) }) }) + +describe('the lot block — a skirt along the property line', () => { + // A dented lot inside a 21 x 21 m field on a 10% grade, wound clockwise on + // purpose: the ring must come out in the field perimeter's own direction. + const lot: [number, number][] = [ + [2, 2], + [2, 18], + [10, 14], + [18, 18], + [18, 2], + ] + const field = rampField(0.1, 21, 21, 1) + + test('the ring walks the lot the perimeter’s way round, a point at least every half cell', () => { + const ring = skirtRing(field, lot) + let area = 0 + const n = ring.length / 2 + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + area += + (ring[i * 2] ?? 0) * (ring[j * 2 + 1] ?? 0) - (ring[j * 2] ?? 0) * (ring[i * 2 + 1] ?? 0) + const step = Math.hypot( + (ring[j * 2] ?? 0) - (ring[i * 2] ?? 0), + (ring[j * 2 + 1] ?? 0) - (ring[i * 2 + 1] ?? 0), + ) + expect(step).toBeLessThanOrEqual(field.spacing / 2 + 1e-6) + } + expect(area).toBeGreaterThan(0) + expect( + skirtRing(field, [ + [0, 0], + [1, 1], + ]), + ).toHaveLength(0) + }) + + test('its top lies on the ground, its bottom on one flat base under the lowest point', () => { + const skirt = buildTerrainSkirt(field, skirtRing(field, lot)) + const base = terrainBlockBase(field) + expect(base).toBeLessThanOrEqual(HORIZON_PLANE_Y - SKIRT_DROP + 1e-6) + const count = skirt.positions.length / 6 + for (let i = 0; i < count; i++) { + const top = i * 6 + const x = skirt.positions[top] ?? 0 + const z = skirt.positions[top + 2] ?? 0 + expect(skirt.positions[top + 1] ?? 0).toBeCloseTo(heightAt(field, x, z), 4) + expect(skirt.positions[top + 4] ?? 0).toBeCloseTo(base, 5) + expect(skirt.positions[top + 4] ?? 0).toBeLessThan(skirt.positions[top + 1] ?? 0) + } + // thicker uphill: the edge's depth follows the grade + const depthAt = (i: number) => + (skirt.positions[i * 6 + 1] ?? 0) - (skirt.positions[i * 6 + 4] ?? 0) + const xs = Array.from({ length: count }, (_, i) => skirt.positions[i * 6] ?? 0) + const low = xs.indexOf(Math.min(...xs)) + const high = xs.indexOf(Math.max(...xs)) + expect(depthAt(high) - depthAt(low)).toBeCloseTo(1.6, 1) + }) + + test('faces point out of the lot, and a sculpt rewrite matches a rebuild', () => { + const ring = skirtRing(field, lot) + const skirt = buildTerrainSkirt(field, ring) + const n = ring.length / 2 + let cx = 0 + let cz = 0 + for (let i = 0; i < n; i++) { + cx += (ring[i * 2] ?? 0) / n + cz += (ring[i * 2 + 1] ?? 0) / n + } + // on the straight west edge (x = 2) the normal is -x + const west = Array.from({ length: n }, (_, i) => i).find( + (i) => ring[i * 2] === 2 && (ring[i * 2 + 1] ?? 0) > 4 && (ring[i * 2 + 1] ?? 0) < 16, + ) as number + expect(skirt.normals[west * 6] ?? 0).toBeCloseTo(-1, 5) + expect(cx).toBeGreaterThan(2) + + const patch = flattenPatch(field, { minX: 6, minZ: 6, maxX: 14, maxZ: 14 }, -2) as HeightPatch + const dug = applyHeightPatch(field, patch) + updateTerrainSkirt(dug, skirt) + expect(Array.from(skirt.positions)).toEqual(Array.from(buildTerrainSkirt(dug, ring).positions)) + }) +}) diff --git a/packages/nodes/src/site/terrain-geometry.ts b/packages/nodes/src/site/terrain-geometry.ts index 75d782d076..db66f4b2d8 100644 --- a/packages/nodes/src/site/terrain-geometry.ts +++ b/packages/nodes/src/site/terrain-geometry.ts @@ -1,4 +1,10 @@ -import { type HeightPatch, heightAtSample, normalAt, type TerrainField } from '@pascal-app/core' +import { + type HeightPatch, + heightAt, + heightAtSample, + normalAt, + type TerrainField, +} from '@pascal-app/core' /** * Builds the terrain mesh buffers from a `TerrainField`. @@ -42,7 +48,7 @@ export const HORIZON_PLANE_Y = -0.07 * 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 +export const SKIRT_DROP = 1 /** One vertex per sample, row-major — index `r * cols + c` matches the field. */ export type TerrainMeshBuffers = { @@ -149,6 +155,11 @@ export type TerrainSkirtBuffers = { positions: Float32Array normals: Float32Array indices: Uint32Array + /** + * When the skirt follows the lot's property line (`skirtRing`): its x, z pairs, + * counter-clockwise. Absent, the skirt runs round the field's own rectangle. + */ + ring?: Float32Array } /** @@ -206,13 +217,63 @@ function boundarySample(field: TerrainField, i: number): { col: number; row: num return { col: 0, row: 2 * (lastCol + lastRow) - i } } -export function buildTerrainSkirt(field: TerrainField): TerrainSkirtBuffers { - const count = perimeterCount(field) +/** + * The base of the lot as a block: one flat level under the field's lowest + * point (and under the horizon plane), so the skirt along the property line + * reads as earth whose thickness follows the slope, and the horizon disc can + * sit just beneath it without cutting into the lot anywhere. + */ +export function terrainBlockBase(field: TerrainField): number { + let minH = 0 + for (let i = 0; i < field.heights.length; i++) { + const h = field.heights[i] ?? 0 + if (h < minH) minH = h + } + return Math.min(minH * field.step, HORIZON_PLANE_Y) - SKIRT_DROP +} + +/** + * The lot's property line as the skirt walks it: counter-clockwise in x, z (the + * field perimeter's own direction, so one winding and one normal formula serve + * both), a point at least every half grid cell so the top edge follows the + * ground between samples. Empty when the polygon is not a ring. + */ +export function skirtRing( + field: TerrainField, + polygon: ReadonlyArray, +): Float32Array { + const points = polygon.filter(([x, z]) => Number.isFinite(x) && Number.isFinite(z)) + if (points.length < 3 || field.cols < 2 || field.rows < 2) return new Float32Array(0) + let area = 0 + for (let i = 0; i < points.length; i++) { + const [x0, z0] = points[i] as readonly [number, number] + const [x1, z1] = points[(i + 1) % points.length] as readonly [number, number] + area += x0 * z1 - x1 * z0 + } + const ordered = area > 0 ? points : [...points].reverse() + const step = field.spacing / 2 + const out: number[] = [] + for (let i = 0; i < ordered.length; i++) { + const [ax, az] = ordered[i] as readonly [number, number] + const [bx, bz] = ordered[(i + 1) % ordered.length] as readonly [number, number] + const n = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step)) + for (let k = 0; k < n; k++) out.push(ax + ((bx - ax) * k) / n, az + ((bz - az) * k) / n) + } + return Float32Array.from(out) +} + +export function buildTerrainSkirt( + field: TerrainField, + ring?: Float32Array | null, +): TerrainSkirtBuffers { + const onRing = !!ring && ring.length >= 6 + const count = onRing ? (ring as Float32Array).length / 2 : 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), + ...(onRing ? { ring: ring as Float32Array } : {}), } for (let i = 0; i < count; i++) { const base = i * 6 @@ -240,6 +301,10 @@ export function buildTerrainSkirt(field: TerrainField): TerrainSkirtBuffers { * complexity because it saves 400 KB a dab; here there is nothing to save. */ export function updateTerrainSkirt(field: TerrainField, buffers: TerrainSkirtBuffers): void { + if (buffers.ring) { + updateRingSkirt(field, buffers, buffers.ring) + return + } const count = perimeterCount(field) if (count === 0) return @@ -266,6 +331,35 @@ export function updateTerrainSkirt(field: TerrainField, buffers: TerrainSkirtBuf } } +/** + * The property-line skirt: its top on the rendered ground (`heightAt`, the + * surface's own triangles), its bottom on the block's flat base — never above + * the ground it closes, so nothing stands proud of the lot from any side. + */ +function updateRingSkirt( + field: TerrainField, + buffers: TerrainSkirtBuffers, + ring: Float32Array, +): void { + const count = ring.length / 2 + if (count < 3) return + const base = terrainBlockBase(field) + for (let i = 0; i <= count; i++) { + const at = i % count + const x = ring[at * 2] ?? 0 + const z = ring[at * 2 + 1] ?? 0 + const prev = (at - 1 + count) % count + const next = (at + 1) % count + const tx = (ring[next * 2] ?? 0) - (ring[prev * 2] ?? 0) + const tz = (ring[next * 2 + 1] ?? 0) - (ring[prev * 2 + 1] ?? 0) + const tLength = Math.hypot(tx, tz) || 1 + const nx = tz / tLength + const nz = -tx / tLength + writeSkirtVertex(buffers, i * 2, x, heightAt(field, x, z), z, nx, nz) + writeSkirtVertex(buffers, i * 2 + 1, x, base, z, nx, nz) + } +} + function writeSkirtVertex( buffers: TerrainSkirtBuffers, index: number, diff --git a/packages/nodes/src/site/terrain-mesh.ts b/packages/nodes/src/site/terrain-mesh.ts index 735cdc6e71..129a8ab587 100644 --- a/packages/nodes/src/site/terrain-mesh.ts +++ b/packages/nodes/src/site/terrain-mesh.ts @@ -5,6 +5,7 @@ import { buildTerrainSkirt, HORIZON_PLANE_Y, patchUpdateRange, + SKIRT_DROP, type TerrainMeshBuffers, type TerrainSkirtBuffers, updateTerrainMesh, @@ -45,7 +46,16 @@ export type TerrainGeometry = { readonly skirt: { readonly geometry: BufferGeometry; readonly buffers: TerrainSkirtBuffers } } -export function createTerrainGeometry(field: TerrainField, holes: Ring[] = []): TerrainGeometry { +/** + * `ring` (from `skirtRing`) runs the skirt along the lot's property line instead + * of the field's rectangle: the surface is cut to the lot by its material (see + * `lotMaskedMaterial`), and the skirt closes it as a block with a flat base. + */ +export function createTerrainGeometry( + field: TerrainField, + holes: Ring[] = [], + ring: Float32Array | null = null, +): TerrainGeometry { const buffers = buildTerrainMesh(field) const holeBoundary = cutTerrainHoles(buffers, holes) const geometry = new BufferGeometry() @@ -65,7 +75,7 @@ export function createTerrainGeometry(field: TerrainField, holes: Ring[] = []): // 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 skirtBuffers = buildTerrainSkirt(field, ring) const skirtGeometry = new BufferGeometry() skirtGeometry.setAttribute( 'position', @@ -76,6 +86,7 @@ export function createTerrainGeometry(field: TerrainField, holes: Ring[] = []): new BufferAttribute(skirtBuffers.normals, 3).setUsage(DynamicDrawUsage), ) skirtGeometry.setIndex(new BufferAttribute(skirtBuffers.indices, 1)) + if (skirtBuffers.ring) skirtGeometry.userData.ring = skirtBuffers.ring setSkirtBounds(skirtGeometry, field, span) return { @@ -105,6 +116,7 @@ export function applyTerrainPatch( updateTerrainMesh(field, target.buffers, patch) if (target.holeBoundary) updateTerrainHoleBoundary(target.holeBoundary, target.buffers) if ( + target.skirt.buffers.ring || patch.col0 <= 0 || patch.row0 <= 0 || patch.col0 + patch.cols >= field.cols || @@ -190,13 +202,26 @@ function setSkirtBounds( field: TerrainField, { minY, maxY }: { minY: number; maxY: number }, ): void { - const low = Math.min(minY, HORIZON_PLANE_Y) - 1 + const low = Math.min(minY, HORIZON_PLANE_Y) - SKIRT_DROP 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 + let minX = field.origin[0] + let minZ = field.origin[1] + let maxX = minX + (field.cols - 1) * field.spacing + let maxZ = minZ + (field.rows - 1) * field.spacing + const ring = (geometry.userData as { ring?: Float32Array }).ring + if (ring && ring.length >= 6) { + minX = maxX = ring[0] ?? 0 + minZ = maxZ = ring[1] ?? 0 + for (let i = 2; i < ring.length; i += 2) { + const x = ring[i] ?? 0 + const z = ring[i + 1] ?? 0 + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minZ = Math.min(minZ, z) + maxZ = Math.max(maxZ, z) + } + } geometry.boundingSphere ??= new Sphere() const sphere = geometry.boundingSphere diff --git a/packages/nodes/src/site/terrain-renderer.tsx b/packages/nodes/src/site/terrain-renderer.tsx index 86afdeb301..1ef42e8148 100644 --- a/packages/nodes/src/site/terrain-renderer.tsx +++ b/packages/nodes/src/site/terrain-renderer.tsx @@ -3,6 +3,8 @@ import { type Ring, type SiteNode, terrainFieldOf, useLiveTerrain } from '@pascal-app/core' import { useEffect, useMemo, useRef } from 'react' import type { Material } from 'three' +import { lotMaskedMaterial } from './lot-mask' +import { skirtRing } from './terrain-geometry' import { applyTerrainPatch, createTerrainGeometry, @@ -34,11 +36,18 @@ export const TerrainRenderer = ({ material, site, holes, + polygon, }: { /** Owned by `SiteRenderer` so the ground material stays defined in one place. */ material: Material site: SiteNode holes: Ring[] + /** + * The lot's property line (live while it is being edited). With one, the + * ground is the lot alone — cut to the line, closed by a skirt down to a flat + * base — rather than the field's padded box. + */ + polygon?: ReadonlyArray | null }) => { const targetRef = useRef(null) @@ -52,8 +61,22 @@ export const TerrainRenderer = ({ const target = useMemo(() => { const field = useLiveTerrain.getState().fieldOf(site.id) ?? persistedField if (!field) return null - return createTerrainGeometry(field, holes) - }, [persistedField, site.id, holes]) + const ring = polygon && polygon.length >= 3 ? skirtRing(field, polygon) : null + return createTerrainGeometry(field, holes, ring && ring.length >= 6 ? ring : null) + }, [persistedField, site.id, holes, polygon]) + + // The surface (and the cut edges round recessed slabs) stop at the property + // line; the skirt keeps the plain material — it *is* the line. + const surfaceMaterial = useMemo( + () => (polygon && polygon.length >= 3 ? lotMaskedMaterial(material, polygon) : material), + [material, polygon], + ) + useEffect( + () => () => { + if (surfaceMaterial !== material) surfaceMaterial.dispose() + }, + [surfaceMaterial, material], + ) targetRef.current = target useEffect( @@ -90,19 +113,20 @@ export const TerrainRenderer = ({ return ( <> - + {target.holeBoundary && ( )} {/* - 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 + The edge curtain — along the property line when there is one, from the + ground down to the block's flat base. Not `castShadow`: it hangs 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. */} diff --git a/packages/viewer/src/components/viewer/scene-ground-replacement.tsx b/packages/viewer/src/components/viewer/scene-ground-replacement.tsx index 04f480c0ac..ab1a10ebb0 100644 --- a/packages/viewer/src/components/viewer/scene-ground-replacement.tsx +++ b/packages/viewer/src/components/viewer/scene-ground-replacement.tsx @@ -25,6 +25,21 @@ export function useSceneGroundReplacement(): boolean { return useStore(store, (state) => state.owners > 0) } +/** + * The same claim from outside React (a map layer that hangs its own meshes on + * the scene): returns the release. The last release restores the fallback. + */ +export function claimSceneGround(scene: Scene): () => void { + const store = groundStore(scene) + store.setState((state) => ({ owners: state.owners + 1 })) + let released = false + return () => { + if (released) return + released = true + store.setState((state) => ({ owners: Math.max(0, state.owners - 1) })) + } +} + /** Mount alongside replacement ground. The last release restores the fallback. */ export function SceneGroundReplacement() { const scene = useThree((state) => state.scene) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 90fb094fdb..16fa08255f 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -64,6 +64,7 @@ export { } from './components/viewer/scene-atmosphere' export { SceneEnvironment } from './components/viewer/scene-environment' export { + claimSceneGround, SceneGroundReplacement, useSceneGroundReplacement, } from './components/viewer/scene-ground-replacement'