From d2054f2904dedeff591e9507262714e426aa77b7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 28 Oct 2026 03:27:57 -0400 Subject: [PATCH] editor: preserve lot imports across scene hydration --- packages/editor/src/lib/lot/drop-in.test.ts | 60 +++++++++++++- packages/editor/src/lib/lot/drop-in.ts | 92 ++++++++++----------- 2 files changed, 101 insertions(+), 51 deletions(-) diff --git a/packages/editor/src/lib/lot/drop-in.test.ts b/packages/editor/src/lib/lot/drop-in.test.ts index bed3000479..59f141f840 100644 --- a/packages/editor/src/lib/lot/drop-in.test.ts +++ b/packages/editor/src/lib/lot/drop-in.test.ts @@ -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' @@ -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((resolve) => { + finishTerrain = resolve + }) + const started = new Promise((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 }) diff --git a/packages/editor/src/lib/lot/drop-in.ts b/packages/editor/src/lib/lot/drop-in.ts index 74e90f0bef..45ce58fe21 100644 --- a/packages/editor/src/lib/lot/drop-in.ts +++ b/packages/editor/src/lib/lot/drop-in.ts @@ -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. @@ -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, - ) - // 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) - : {} - 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, - ) - } else { - terrainFailure = read.reason ?? 'no terrain' - useScene - .getState() - .updateNode( - site.id as AnyNodeId, - { terrain: undefined, metadata: meta } as unknown as Partial, - ) - } + 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 = { + ...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) + 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