Skip to content
Merged
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
60 changes: 59 additions & 1 deletion packages/editor/src/lib/lot/drop-in.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { type AnyNodeId, type SiteNode, useScene } from '@pascal-app/core'
import { type AnyNodeId, generateId, SiteNode, useScene } from '@pascal-app/core'
import { dropInLot } from './drop-in'
import type { ParcelResolveData } from './lot-patch'
import type { ParcelProvider } from './parcel-provider'
Expand Down Expand Up @@ -245,6 +245,64 @@ describe('dropInLot onto a site that already has a lot', () => {
useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
})

test('a terrain response applies the parcel to the hydrated site and preserves its current settings', async () => {
let finishTerrain!: () => void
let startedTerrain!: () => void
const waiting = new Promise<void>((resolve) => {
finishTerrain = resolve
})
const started = new Promise<void>((resolve) => {
startedTerrain = resolve
})
const read = fakeProvider(null, 'sloped')
const provider: ParcelProvider = async (endpoint, request) => {
if (endpoint === 'elevation') {
startedTerrain()
await waiting
}
return read(endpoint, request)
}
const dropping = dropInLot(
{ address: GAINESVILLE },
{
dossier: false,
roads: false,
provider,
},
)
await started
expect(useScene.getState().rootNodeIds).toEqual([])
const hydrated = SiteNode.parse({
id: generateId('site'),
type: 'site',
name: 'My site',
polygon: { type: 'polygon', points: rect(30, 30) },
setbacks: { front: 8, side: 3, rear: 6 },
zone: 'Owner verified zone',
metadata: { ownerNote: 'Keep the driveway' },
})
useScene.setState({ nodes: { [hydrated.id]: hydrated }, rootNodeIds: [hydrated.id] } as never)
finishTerrain()

const result = await dropping
expect(result.ok).toBe(true)
expect(result.siteId).toBe(hydrated.id)
expect(useScene.getState().rootNodeIds).toEqual([hydrated.id])
const current = site(hydrated.id)
const points = current.polygon!.points
expect(Math.max(...points.map((p) => p[0])) - Math.min(...points.map((p) => p[0]))).toBe(25)
expect(Math.max(...points.map((p) => p[1])) - Math.min(...points.map((p) => p[1]))).toBe(32)
expect(current.parcel?.apn).toBe('06075-030-062')
expect(current.address?.city).toBe('Gainesville')
expect(current.terrain).toBeDefined()
expect(current.setbacks).toEqual(hydrated.setbacks)
expect(current.zone).toBe(hydrated.zone)
expect(current.metadata).toMatchObject({
ownerNote: 'Keep the driveway',
terrainSample: result.terrain,
})
})

test('the second lot replaces the first lot’s address, APN and county', async () => {
const provider = fakeProvider(null)
const first = await dropInLot({ address: LAKELAND }, { ...offline, provider })
Expand Down
92 changes: 42 additions & 50 deletions packages/editor/src/lib/lot/drop-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
* 3. `sitePatchFromParcel` — the site node patch: ring, address,
* provenance, the street-facing front edge, planning-default setbacks
* when the site has none, north up.
* 4. The scene's site node is updated (created at the root when the scene
* has none) and a building that fell outside the new ring is
* re-centred on it — `building.position` only, never the walls.
* 5. `elevation` — USGS ground over the lot into the site's
* 4. `elevation` — USGS ground over the lot into the site's
* heightfield (`site.terrain`, terrain.ts; fail-soft, flat lots write
* nothing), so a foundation can read the hill.
* 5. The parcel and terrain are applied to the current site together
* (created at the root when the scene has none). A building outside
* the new ring is re-centred — `building.position` only, never the walls.
*
* Used by the Lot rail panel, the Generate panel (drop in, then generate)
* and the Site inspector's "Find parcel", so every path behaves the same.
Expand Down Expand Up @@ -271,71 +271,63 @@ export async function dropInLot(
roadsFailure = 'skipped'
}

const scene = useScene.getState()
let site = findSiteNode(options.siteId)
if (!site) {
site = SiteNode.parse({ id: generateId('site'), type: 'site', name: 'Site' })
scene.createNodes([{ node: site as AnyNode }])
}
const computed = sitePatchFromParcel(site, input, data, roads, undefined, extras)
if (!computed)
const geometry = sitePatchFromParcel(null, input, data, roads, undefined, extras)
if (!geometry)
return {
ok: false,
error: 'parcel geometry was unusable',
message: 'The parcel geometry was unusable.',
}
// the surveyed contour lines ride the site with the patch (the site plan
// draws them over the heightfield's own); absent without 3DEP lines
const contours = dossier && dossierOrigin ? contourLinesFromDossier(dossier, dossierOrigin) : null
useScene
.getState()
.updateNode(
site.id as AnyNodeId,
{ ...computed.patch, terrainContours: contours ?? undefined } as Partial<AnyNode>,
)

// The ground over the lot — fail-soft. A sloping lot writes the
// heightfield; a flat one (or a failed read) clears any terrain the
// previous lot left behind so the site never shows another parcel's hill.
let terrain: TerrainSampleSummary | null = null
let terrainFailure = ''
let terrainRead: TerrainSampleResult | null = null
if (options.terrain !== false) {
const read: TerrainSampleResult = data.originLngLat
? await sampleLotTerrain(computed.patch.polygon?.points ?? [], data.originLngLat, {
terrainRead = data.originLngLat
? await sampleLotTerrain(geometry.patch.polygon?.points ?? [], data.originLngLat, {
provider,
...(options.terrainDeadlineMs ? { deadlineMs: options.terrainDeadlineMs } : {}),
})
: { ok: false, reason: 'no parcel origin' }
const current = useScene.getState().nodes[site.id as AnyNodeId]
const { terrainSample: _previous, ...meta } =
current &&
typeof current.metadata === 'object' &&
current.metadata !== null &&
!Array.isArray(current.metadata)
? (current.metadata as Record<string, unknown>)
: {}
if (read.ok && read.summary) {
terrain = read.summary
useScene.getState().updateNode(
site.id as AnyNodeId,
{
terrain: read.terrain,
metadata: { ...meta, terrainSample: read.summary },
} as unknown as Partial<AnyNode>,
)
} else {
terrainFailure = read.reason ?? 'no terrain'
useScene
.getState()
.updateNode(
site.id as AnyNodeId,
{ terrain: undefined, metadata: meta } as unknown as Partial<AnyNode>,
)
}
if (terrainRead.ok && terrainRead.summary) terrain = terrainRead.summary
else terrainFailure = terrainRead.reason ?? 'no terrain'
} else {
terrainFailure = 'skipped'
}

// Hydration or a person's edit can replace the site while the providers run.
// Read it after every await, preserve its current settings, and commit the
// parcel with its terrain so a late read cannot leave the starter polygon.
const scene = useScene.getState()
let site = findSiteNode(options.siteId)
const computed = sitePatchFromParcel(site, input, data, roads, undefined, extras)
if (!computed)
return {
ok: false,
error: 'parcel geometry was unusable',
message: 'The parcel geometry was unusable.',
}
const contours = dossier && dossierOrigin ? contourLinesFromDossier(dossier, dossierOrigin) : null
const patch: Partial<SiteNode> = {
...computed.patch,
terrainContours: contours ?? undefined,
}
if (terrainRead) {
const { terrainSample: _previous, ...metadata } = computed.patch.metadata as Record<
string,
unknown
>
patch.terrain = terrainRead.ok ? terrainRead.terrain : undefined
patch.metadata = terrain ? { ...metadata, terrainSample: terrain } : metadata
}
if (site) scene.updateNode(site.id as AnyNodeId, patch as Partial<AnyNode>)
else {
site = SiteNode.parse({ id: generateId('site'), type: 'site', name: 'Site', ...patch })
scene.createNodes([{ node: site as AnyNode }])
}

// A house the new lot leaves past its lines stands centred on the buildable area again, facing
// as it did.
let recentred = false
Expand Down
Loading