Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/editor/src/components/editor/custom-camera-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -1377,7 +1387,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
<CameraControls
azimuthRotateSpeed={ORBIT_ROTATE_SPEED}
makeDefault
maxDistance={100}
maxDistance={maxDistance}
impl={FloorAwareCameraControls}
maxPolarAngle={maxPolarAngle}
minDistance={minDistance}
Expand Down
32 changes: 32 additions & 0 deletions packages/editor/src/lib/floorplan/site-plan/geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,38 @@ describe('setbackEnvelope', () => {
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', () => {
Expand Down
37 changes: 36 additions & 1 deletion packages/editor/src/lib/floorplan/site-plan/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, EdgeRole>()
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3D setbacks miss straight frontage

Medium Severity

The new nearly-straight frontage inheritance lives only in the editor setbackEnvelope. The nodes copy, which the 3D dashed overlay uses and which is documented as the same rules, still treats the leftover half as a side yard, so a split frontage still draws a curved envelope in the scene.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 27811f6. Configure here.

const d = setbackForRole(
setbacks,
role === 'left' || role === 'right' ? (roles[i] as EdgeRole) : role,
Expand Down
4 changes: 4 additions & 0 deletions packages/editor/src/lib/lot/drop-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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'
Expand Down
40 changes: 40 additions & 0 deletions packages/nodes/src/site/lot-mask.ts
Original file line number Diff line number Diff line change
@@ -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<Point>) {
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<Point>): Material {
const masked = material.clone() as unknown as NodeMaterial
masked.maskNode = insideLotNode(polygon)
return masked as unknown as Material
}
29 changes: 19 additions & 10 deletions packages/nodes/src/site/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Horizon disc caps live excavation

Medium Severity

The horizon disc no longer punches terrainFootprint and instead sits at a React horizonY from terrainBlockBase. A sculpt stroke rewrites the mesh and skirt in place but does not move that disc, so a pit that drops below the last committed base is roofed over until the stroke commits.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 27811f6. Configure here.

useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry])
useEffect(() => () => horizonMaterial?.dispose(), [horizonMaterial])

Expand Down Expand Up @@ -558,7 +562,12 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {

{/* Sculpted ground, when the site has terrain */}
{showSiteSurfaces && showTerrain && (
<TerrainRenderer holes={slabPolygons} material={groundMaterial} site={node} />
<TerrainRenderer
holes={slabPolygons}
material={groundMaterial}
polygon={polygonPoints}
site={node}
/>
)}

{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
Expand All @@ -577,7 +586,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
<mesh
geometry={horizonGeometry}
material={horizonMaterial}
position={[fadeBounds.cx, HORIZON_PLANE_Y, fadeBounds.cz]}
position={[fadeBounds.cx, horizonY, fadeBounds.cz]}
raycast={noopRaycast}
receiveShadow
rotation={[-Math.PI / 2, 0, 0]}
Expand Down
85 changes: 85 additions & 0 deletions packages/nodes/src/site/terrain-geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import {
createTerrainField,
flattenPatch,
type HeightPatch,
heightAt,
type TerrainField,
} from '@pascal-app/core'
import {
buildTerrainMesh,
buildTerrainSkirt,
HORIZON_PLANE_Y,
patchUpdateRange,
SKIRT_DROP,
skirtRing,
terrainBlockBase,
terrainFootprint,
updateTerrainMesh,
updateTerrainSkirt,
Expand Down Expand Up @@ -531,3 +535,84 @@ describe('terrainFootprint — the horizon punch', () => {
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))
})
})
Loading
Loading