From 37a4d995448f69f5496e7a9260bf8f78601cfbff Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 19:42:21 -0400 Subject: [PATCH 1/5] feat(core): the site comes first: get_site, place_building, a house past its lot line reported A house is placed on its lot by its building node's pose, never by moving its walls, so the model's coordinates survive and every path (a layout program, a hand trace, a DXF import, the lot drop-in) places it the same way. - Core owns the setback envelope (the edge roles, setbackEnvelope with #1017's straight-front rule, the oriented yards) and the building footprint (levelFootprintLoops, footprintOutline, siteFrame, levelFrame). The editor's site plan, its lot code and the 3D site import them; the 3D copy in nodes/src/site/setbacks.ts is deleted, so the 2D and 3D envelopes no longer diverge. - lib/site-placement.ts: siteBrief reads the site in the building's level frame (the lot, the buildable area and its state: lot / setbacks / none / advisory, its centre, the street side and the yaw that squares the house to it, north, address, parcel, zoning, the new-project placeholder, and the footprint's fit, yards and overhang, judged by area so an edge across a notch counts). placeBuildingPose centres a footprint (an outline about to be drawn, or the walls) in the buildable area, the pole when the centroid will not hold it, or at the yards given; the yaw is kept unless asked, the lot never resized, and a replay changes nothing. - Two shared tools, both surfaces: get_site (read) and place_building (the building node only), with annotations, justifications, the README table and the MCP agent guide. - verify_scene reports footprint_outside_site, footprint_outside_setbacks and buildable_area_none; create_room and add_wall say when what they drew crosses the lot line. - The lot drop-in re-centres a house the new lot leaves outside through placeBuildingPose, keeping its facing (buildingRecentreOffset is gone). Co-Authored-By: Claude Opus 5.5 --- .../agent-operations/__fixtures__/cases.ts | 10 + .../__fixtures__/site-cases.ts | 613 ++++++++++++ .../core/src/agent-operations/add-level.ts | 2 +- .../core/src/agent-operations/add-wall.ts | 20 +- .../core/src/agent-operations/create-room.ts | 8 + packages/core/src/agent-operations/index.ts | 4 + packages/core/src/agent-operations/site.ts | 129 +++ .../core/src/agent-operations/verify-scene.ts | 28 + packages/core/src/agent-tools/index.ts | 4 + packages/core/src/agent-tools/site.ts | 52 + packages/core/src/agent-tools/verify-scene.ts | 2 +- .../core/src/contracts/reference-inventory.ts | 1 + packages/core/src/index.ts | 42 + packages/core/src/lib/building-footprint.ts | 108 +++ packages/core/src/lib/setback-envelope.ts | 386 ++++++++ packages/core/src/lib/site-placement.ts | 911 ++++++++++++++++++ packages/editor/src/index.tsx | 5 - .../site-plan/build-site-plan-drawing.ts | 54 +- .../src/lib/floorplan/site-plan/coverage.ts | 6 +- .../src/lib/floorplan/site-plan/front-edge.ts | 3 +- .../lib/floorplan/site-plan/geometry.test.ts | 24 +- .../src/lib/floorplan/site-plan/geometry.ts | 417 +------- .../src/lib/floorplan/site-plan/index.ts | 16 - .../floorplan/site-plan/site-annotations.ts | 5 +- .../src/lib/floorplan/site-plan/site-parts.ts | 99 +- .../src/lib/floorplan/site-plan/yards.test.ts | 3 +- .../editor/src/lib/lot/clean-ring.test.ts | 3 +- packages/editor/src/lib/lot/drop-in.ts | 33 +- packages/editor/src/lib/lot/lot-patch.test.ts | 2 +- packages/editor/src/lib/lot/lot-patch.ts | 4 +- packages/mcp/README.md | 4 +- packages/mcp/src/resources/agent-guide.ts | 1 + .../src/tools/read-tool-annotations.test.ts | 2 + packages/mcp/src/tools/shared-tools.ts | 20 + .../{setbacks.test.ts => line-ribbon.test.ts} | 36 - packages/nodes/src/site/renderer.tsx | 3 +- packages/nodes/src/site/setbacks.ts | 240 ----- .../tool-annotation-justifications.json | 26 + scripts/openai-tool-annotation-policy.ts | 2 + 39 files changed, 2427 insertions(+), 901 deletions(-) create mode 100644 packages/core/src/agent-operations/__fixtures__/site-cases.ts create mode 100644 packages/core/src/agent-operations/site.ts create mode 100644 packages/core/src/agent-tools/site.ts create mode 100644 packages/core/src/lib/building-footprint.ts create mode 100644 packages/core/src/lib/site-placement.ts rename packages/nodes/src/site/{setbacks.test.ts => line-ribbon.test.ts} (64%) delete mode 100644 packages/nodes/src/site/setbacks.ts diff --git a/packages/core/src/agent-operations/__fixtures__/cases.ts b/packages/core/src/agent-operations/__fixtures__/cases.ts index db930ece8e..3d85f3787d 100644 --- a/packages/core/src/agent-operations/__fixtures__/cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/cases.ts @@ -25,6 +25,12 @@ import { ROOM_CASES } from './room-cases' import { SEARCH_ASSETS_CASES } from './search-assets-cases' import { CORNER_WINDOW_CASES } from './corner-window-cases' import { MERGE_WINDOWS_CASES } from './merge-windows-cases' +import { + GET_SITE_CASES, + PLACE_BUILDING_CASES, + SITE_LINE_CASES, + SITE_VERIFY_CASES, +} from './site-cases' import { ADD_FENCE_CASES, ADD_LEVEL_CASES, @@ -908,4 +914,8 @@ export const AGENT_TOOL_CASES: readonly AgentToolCase[] = [ ...FIND_BY_TYPE_CASES, ...CORNER_WINDOW_CASES, ...MERGE_WINDOWS_CASES, + ...GET_SITE_CASES, + ...PLACE_BUILDING_CASES, + ...SITE_LINE_CASES, + ...SITE_VERIFY_CASES, ] diff --git a/packages/core/src/agent-operations/__fixtures__/site-cases.ts b/packages/core/src/agent-operations/__fixtures__/site-cases.ts new file mode 100644 index 0000000000..f032480491 --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/site-cases.ts @@ -0,0 +1,613 @@ +import { siteBrief } from '../../lib/site-placement' +import { + type AnyNode, + BuildingNode, + FenceNode, + ItemNode, + LevelNode, + SiteNode, + WallNode, +} from '../../schema' +import type { AgentToolCase, SceneGraph } from './cases' + +/** + * `get_site` and `place_building`, and the lot-line signal of `create_room`, `add_wall` and + * `verify_scene`. The expected coordinates are worked by hand from each lot, not read back from + * the code that places the house. + */ + +type Pt = [number, number] + +/** Links parents' children and returns the graph, sites as roots. */ +function scene(...nodes: AnyNode[]): SceneGraph { + const byId = Object.fromEntries(nodes.map((node) => [node.id, { ...node }])) as Record< + string, + AnyNode & { children?: string[] } + > + for (const node of Object.values(byId)) { + const parent = node.parentId ? byId[node.parentId] : undefined + if (parent && Array.isArray(parent.children) && !parent.children.includes(node.id)) + parent.children = [...parent.children, node.id] + } + return { + nodes: byId, + rootNodeIds: nodes.filter((node) => node.type === 'site').map((node) => node.id), + } +} + +const site = (extra: Record = {}) => + SiteNode.parse({ id: 'site_lot', children: [], ...extra }) +const building = (extra: Record = {}, id = 'building_main') => + BuildingNode.parse({ id, parentId: 'site_lot', ...extra }) +const level = (buildingId = 'building_main', id = 'level_ground') => + LevelNode.parse({ id, parentId: buildingId, level: 0, name: 'Ground', height: 2.8 }) + +/** Four walls round a box from `[x0, z0]`, `w` along x and `d` along z. */ +function box(levelId: string, tag: string, [x0, z0]: Pt, w: number, d: number): AnyNode[] { + const corners: Pt[] = [ + [x0, z0], + [x0 + w, z0], + [x0 + w, z0 + d], + [x0, z0 + d], + ] + return corners.map((start, i) => + WallNode.parse({ + id: `wall_${tag}_${i}`, + parentId: levelId, + start, + end: corners[(i + 1) % 4], + thickness: 0.2, + }), + ) +} + +/** The rectangle `w × d` centred on `centre`, turned `deg` (x toward z); edge 0 is the far −z side. */ +function turnedLot(centre: Pt, w: number, d: number, deg: number): Pt[] { + const t = (deg * Math.PI) / 180 + const corner = ([x, z]: Pt): Pt => [ + centre[0] + x * Math.cos(t) - z * Math.sin(t), + centre[1] + x * Math.sin(t) + z * Math.cos(t), + ] + return ( + [ + [-w / 2, -d / 2], + [w / 2, -d / 2], + [w / 2, d / 2], + [-w / 2, d / 2], + ] as Pt[] + ).map(corner) +} + +// An 18 × 32 m parcel turned 20°, its centre at site [8, −6], front (edge 0) to the north, yards +// 6 / 1.5 / 6 m; the building stands at [3, 0, 2]. The envelope is the parcel inset 6 m front and +// rear, 1.5 m each side: 15 × 20 m round the same centre, which is [5, −8] in the level frame. +const TILTED = { + polygon: { type: 'polygon', points: turnedLot([8, -6], 18, 32, 20) }, + setbacks: { front: 6, side: 1.5, rear: 6 }, + setbacksSource: 'zoning-code', + frontEdge: 0, + address: { street: '1458 Oak St', city: 'Palo Alto', state: 'CA' }, + parcel: { apn: '003-12-045' }, +} + +// A 20 × 40 m lot along +z, its street on the north edge (edge 0, z = 0). +const STRAIGHT = { + polygon: { + type: 'polygon', + points: [ + [0, 0], + [20, 0], + [20, 40], + [0, 40], + ], + }, + frontEdge: 0, +} + +// A U-shaped lot: two arms 10 m wide, the notch x 10..20 above z = 10 not part of it. +const U_LOT = { + polygon: { + type: 'polygon', + points: [ + [0, 0], + [30, 0], + [30, 30], + [20, 30], + [20, 10], + [10, 10], + [10, 30], + [0, 30], + ], + }, +} + +const near = (a: number, b: number, tolerance = 0.02) => Math.abs(a - b) <= tolerance + +/** The footprint, read back from the scene the call left, is centred where it should be. */ +const centredOn = + (target: Pt, tolerance = 0.02) => + (_result: Record, nodes: Readonly>) => { + const brief = siteBrief(nodes, { buildingId: 'building_main' }) + const footprint = brief?.footprint + if (!footprint) return ['no footprint after the call'] + const centre = brief.buildable.centre ?? [Number.NaN, Number.NaN] + const problems: string[] = [] + if (!(near(centre[0], target[0], tolerance) && near(centre[1], target[1], tolerance))) + problems.push(`buildable centre ${centre} is not ${target}`) + if (footprint.offCentreM > tolerance) + problems.push(`footprint ${footprint.offCentreM} m off the buildable centre`) + if (footprint.fit !== 'buildable') problems.push(`footprint fit ${footprint.fit}`) + return problems + } + +export const GET_SITE_CASES: AgentToolCase[] = [ + { + name: 'a new project reads as the placeholder, buildable whole, centred on the origin', + tool: 'get_site', + scene: () => scene(site(), building(), level()), + input: {}, + expect: { + result: { + siteId: 'site_lot', + buildingId: 'building_main', + placeholder: true, + buildable: { state: 'lot', centre: [0, 0] }, + street: null, + footprint: null, + }, + mentions: ['placeholder'], + }, + }, + { + name: 'a turned parcel off the origin, read in the moved building’s level frame', + tool: 'get_site', + scene: () => scene(site(TILTED), building({ position: [3, 0, 2] }), level()), + input: {}, + expect: { + result: { + placeholder: false, + address: '1458 Oak St, Palo Alto, CA', + parcel: { apn: '003-12-045' }, + buildable: { state: 'setbacks', centre: [5, -8], source: 'zoning-code' }, + street: { side: 'north', edge: 0, squareRotationDeg: -20 }, + pose: { position: [3, 0, 2], rotationDeg: 0 }, + }, + mentions: ['[5, -8]'], + }, + }, + { + name: 'setbacks that leave nothing are named, the lot is not used instead', + tool: 'get_site', + scene: () => + scene( + site({ + polygon: { + type: 'polygon', + points: [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ], + }, + setbacks: { front: 6, side: 6, rear: 6 }, + }), + building(), + level(), + ), + input: {}, + expect: { result: { buildable: { state: 'none', centre: null, ring: [] } } }, + }, + { + name: 'the yards of a house, seen from the street', + tool: 'get_site', + scene: () => scene(site(STRAIGHT), building(), level(), ...box('level_ground', 'h', [7, 7], 10, 12)), + input: {}, + expect: { + result: { + // the walls are 0.2 m thick, so their outer faces stand 0.1 m out of their lines + footprint: { + fit: 'buildable', + yards: { front: 6.9, rear: 20.9, left: 2.9, right: 6.9 }, + }, + }, + }, + }, + { + name: 'no site: refused', + tool: 'get_site', + scene: () => scene(BuildingNode.parse({ id: 'building_main' }), level()), + input: {}, + expect: { refusal: 'no_site' }, + }, +] + +export const PLACE_BUILDING_CASES: AgentToolCase[] = [ + { + name: 'an outline on an empty building: the house will rise centred, before any wall', + tool: 'place_building', + scene: () => scene(site(), building(), level()), + input: { + outline: [ + [0, 0], + [12, 0], + [12, 10], + [0, 10], + ], + }, + expect: { + result: { moved: true, position: [-6, 0, -5], rotationDeg: 0, fit: 'buildable' }, + after: { building_main: { position: [-6, 0, -5] } }, + }, + }, + { + name: 'walls traced from a corner (the house off the bottom-left of the lot) stand centred', + tool: 'place_building', + scene: () => + scene(site(), building(), level(), ...box('level_ground', 'h', [-20, 1], 20, 26)), + input: {}, + expect: { + result: { moved: true, position: [10, 0, -14], fit: 'buildable' }, + // the walls keep their own coordinates + after: { wall_h_0: { start: [-20, 1], end: [0, 1] } }, + check: centredOn([-10, 14]), + }, + }, + { + name: 'a replayed call moves nothing', + tool: 'place_building', + scene: () => + scene( + site(), + building({ position: [10, 0, -14] }), + level(), + ...box('level_ground', 'h', [-20, 1], 20, 26), + ), + input: {}, + expect: { result: { moved: false, position: [10, 0, -14] }, mentions: ['nothing moved'] }, + }, + { + name: 'on a turned parcel the house is centred in the setbacks, still facing as it did', + tool: 'place_building', + scene: () => + scene( + site(TILTED), + building({ position: [3, 0, 2] }), + level(), + ...box('level_ground', 'h', [0, 0], 12, 10), + ), + input: {}, + expect: { + // the walls' footprint (with their 0.1 m outer faces) is centred on [6, 5]: site [8, −6] + result: { moved: true, position: [2, 0, -11], rotationDeg: 0, fit: 'buildable' }, + check: centredOn([6, 5]), + }, + }, + { + name: 'turned on purpose (squared to the street), it is still centred', + tool: 'place_building', + scene: () => + scene( + site(TILTED), + building({ position: [3, 0, 2] }), + level(), + ...box('level_ground', 'h', [0, 0], 12, 10), + ), + input: { rotationDeg: -20 }, + expect: { + result: { moved: true, rotationDeg: -20, fit: 'buildable' }, + check: centredOn([6, 5]), + }, + }, + { + name: 'a building already turned 0.7 rad keeps its turn and lands centred', + tool: 'place_building', + scene: () => + scene( + site(), + building({ rotation: [0, 0.7, 0], position: [20, 0, 20] }), + level(), + ...box('level_ground', 'h', [3, 4], 8, 6), + ), + input: {}, + expect: { + result: { moved: true, rotationDeg: 40.1, fit: 'buildable' }, + after: { building_main: { rotation: [0, 0.7, 0] } }, + check: centredOn([7, 7], 0.05), + }, + }, + { + name: 'a lot whose centroid is too near its edge for the house: the most interior point wins', + tool: 'place_building', + // a 20 m square with a 4 m strip running 40 m east: the centroid [18.6, 7.7] is on the lot, + // but a 16 m house centred there crosses the square's east side; at the square's middle it fits + scene: () => + scene( + site({ + polygon: { + type: 'polygon', + points: [ + [0, 0], + [60, 0], + [60, 4], + [20, 4], + [20, 20], + [0, 20], + ], + }, + }), + building(), + level(), + ), + input: { + outline: [ + [0, 0], + [16, 0], + [16, 16], + [0, 16], + ], + }, + expect: { + result: { moved: true, fit: 'buildable' }, + check: (result) => { + const [x, , z] = result.position as number[] + return near(x as number, 2, 0.5) && near(z as number, 2, 0.5) ? [] : [`at ${x}, ${z}`] + }, + }, + }, + { + name: 'a house bigger than the lot is centred, never the lot resized, and said to be outside', + tool: 'place_building', + scene: () => scene(site(), building(), level()), + input: { + outline: [ + [0, 0], + [40, 0], + [40, 34], + [0, 34], + ], + }, + expect: { + // 5 m past the lot line east and west, 2 m north and south: its corners 5.39 m out + result: { + position: [-20, 0, -17], + fit: 'outside', + overhang: { lotM: 5.39 }, + }, + check: (result) => { + const sides = [...(result.overhang as { sides: string[] }).sides].sort() + return sides.join() === 'east,north,south,west' ? [] : [`sides ${sides}`] + }, + after: { + site_lot: { + polygon: { + type: 'polygon', + points: [ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ], + }, + }, + }, + mentions: ['placeholder'], + }, + }, + { + name: 'yards from a site plan: 7 m front, 3 m left (east, seen from the north street)', + tool: 'place_building', + scene: () => + scene(site(STRAIGHT), building(), level(), ...box('level_ground', 'h', [0, 0], 10, 12)), + input: { yards: { front: 7, left: 3 } }, + expect: { + // outer faces x −0.1..10.1, z −0.1..12.1: 7 m from z = 0 and 3 m from x = 20 + result: { moved: true, position: [6.9, 0, 7.1], yards: { front: 7, left: 3 } }, + }, + }, + { + name: 'front and rear yards together: refused', + tool: 'place_building', + scene: () => + scene(site(STRAIGHT), building(), level(), ...box('level_ground', 'h', [0, 0], 10, 12)), + input: { yards: { front: 7, rear: 7 } }, + expect: { refusal: 'yards_conflict' }, + }, + { + name: 'a front yard the lot is too shallow for: refused', + tool: 'place_building', + scene: () => + scene(site(STRAIGHT), building(), level(), ...box('level_ground', 'h', [0, 0], 10, 12)), + input: { yards: { front: 30 } }, + expect: { refusal: 'yards_infeasible' }, + }, + { + name: 'yards on a site with no street: refused', + tool: 'place_building', + scene: () => scene(site(), building(), level(), ...box('level_ground', 'h', [0, 0], 10, 12)), + input: { yards: { front: 5 } }, + expect: { refusal: 'no_street' }, + }, + { + name: 'nothing to place yet: refused, asking for the outline', + tool: 'place_building', + scene: () => scene(site(), building(), level()), + input: {}, + expect: { refusal: 'needs_footprint', mentions: ['outline'] }, + }, + { + name: 'centring next to another building: refused', + tool: 'place_building', + scene: () => + scene( + site(), + building(), + level(), + ...box('level_ground', 'h', [0, 0], 10, 12), + building({}, 'building_garage'), + level('building_garage', 'level_garage'), + ...box('level_garage', 'g', [8, 8], 6, 6), + ), + input: { building: 'building_main' }, + expect: { refusal: 'other_building_on_site', mentions: ['building_garage'] }, + }, + { + name: 'two buildings and none named: refused', + tool: 'place_building', + surfaces: ['core', 'mcp'], + scene: () => + scene( + site(), + building(), + level(), + building({}, 'building_garage'), + level('building_garage', 'level_garage'), + ), + input: {}, + expect: { refusal: 'building_required' }, + }, + { + name: 'a fenced house: the fence moves with it and is named for a re-trace', + tool: 'place_building', + scene: () => + scene( + site(), + building(), + level(), + ...box('level_ground', 'h', [-20, 1], 20, 26), + FenceNode.parse({ + id: 'fence_front', + parentId: 'level_ground', + start: [-20, 0], + end: [0, 0], + }), + ItemNode.parse({ + id: 'item_tree', + parentId: 'level_ground', + position: [-25, 0, 5], + asset: { + id: 'tree', + name: 'Tree', + category: 'outdoor', + thumbnail: '', + src: 'asset://tree', + dimensions: [1, 3, 1], + }, + }), + ), + input: {}, + expect: { + result: { moved: true }, + contains: { movedWithIt: [{ id: 'fence_front' }, { id: 'item_tree' }] }, + after: { fence_front: { start: [-20, 0], end: [0, 0] } }, + }, + }, +] + +export const SITE_LINE_CASES: AgentToolCase[] = [ + { + name: 'a room past the lot line says how far and where', + tool: 'create_room', + scene: () => scene(site(), building(), level()), + input: { + name: 'Garage', + polygon: [ + [10, 0], + [20, 0], + [20, 5], + [10, 5], + ], + }, + expect: { + result: { site: { fit: 'outside', sides: ['east'] } }, + check: (result) => + near(Number((result.site as { overhangM?: number }).overhangM), 5, 0.01) + ? [] + : [`overhang ${(result.site as { overhangM?: number }).overhangM}`], + }, + }, + { + name: 'a room on the lot carries no site flag', + tool: 'create_room', + scene: () => scene(site(), building(), level()), + input: { + name: 'Living', + polygon: [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + }, + expect: { result: { ok: true }, check: (result) => ('site' in result ? ['site flag'] : []) }, + }, + { + name: 'a wall past the lot line says so', + tool: 'add_wall', + scene: () => scene(site(), building(), level()), + input: { start: [0, 20], end: [8, 20] }, + expect: { result: { site: { fit: 'outside', sides: ['south'] } } }, + }, +] + +export const SITE_VERIFY_CASES: AgentToolCase[] = [ + { + name: 'a house across the notch of a U lot, every corner on the lot, is past its line', + tool: 'verify_scene', + scene: () => + scene(site(U_LOT), building(), level(), ...box('level_ground', 'h', [5, 20], 20, 5)), + input: {}, + expect: { result: {}, contains: { issues: [{ type: 'footprint_outside_site' }] } }, + }, + { + name: 'a house on the lot but into the yards', + tool: 'verify_scene', + scene: () => + scene( + site({ ...STRAIGHT, setbacks: { front: 6, side: 1.5, rear: 6 } }), + building(), + level(), + ...box('level_ground', 'h', [1, 2], 10, 12), + ), + input: {}, + expect: { + result: {}, + contains: { issues: [{ type: 'footprint_outside_setbacks' }] }, + lacks: { issues: [{ type: 'footprint_outside_site' }] }, + }, + }, + { + name: 'setbacks that leave nothing to build on', + tool: 'verify_scene', + scene: () => + scene( + site({ ...STRAIGHT, setbacks: { front: 30, side: 1.5, rear: 30 } }), + building(), + level(), + ), + input: {}, + expect: { result: {}, contains: { issues: [{ type: 'buildable_area_none' }] } }, + }, + { + name: 'inside the buildable area but off its centre is no problem', + tool: 'verify_scene', + scene: () => + scene( + site({ ...STRAIGHT, setbacks: { front: 6, side: 1.5, rear: 6 } }), + building(), + level(), + ...box('level_ground', 'h', [2, 7], 8, 8), + ), + input: {}, + expect: { + result: {}, + lacks: { + issues: [ + { type: 'footprint_outside_site' }, + { type: 'footprint_outside_setbacks' }, + { type: 'buildable_area_none' }, + ], + }, + }, + }, +] diff --git a/packages/core/src/agent-operations/add-level.ts b/packages/core/src/agent-operations/add-level.ts index 6ae79ef24a..3a795a1e28 100644 --- a/packages/core/src/agent-operations/add-level.ts +++ b/packages/core/src/agent-operations/add-level.ts @@ -35,7 +35,7 @@ export function targetBuilding( (node): node is BuildingNode => node.type === 'building', ) if (buildings.length === 1) return buildings[0]! - if (!buildings.length) refuse('no_building', 'The scene has no building to add a level to.') + if (!buildings.length) refuse('no_building', 'The scene has no building.') return refuse( 'building_required', `The scene has ${buildings.length} buildings; say which: ${buildings.map((building) => building.id).join(', ')}.`, diff --git a/packages/core/src/agent-operations/add-wall.ts b/packages/core/src/agent-operations/add-wall.ts index 4138da26e1..fd2d877670 100644 --- a/packages/core/src/agent-operations/add-wall.ts +++ b/packages/core/src/agent-operations/add-wall.ts @@ -1,7 +1,9 @@ import { refuse } from '../agent-tools/refusal' +import { lotOverhang } from '../lib/site-placement' import { WallNode } from '../schema' import { isRoofLevel } from '../services/roof-level' import { normalizeWallCurveOffset } from '../systems/wall/wall-curve' +import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' import { WALL_MIN_LENGTH } from '../systems/wall/wall-topology' import { type LevelTargetInput, targetLevel } from './level-target' import type { AgentOperation, SceneNodes } from './types' @@ -51,6 +53,17 @@ export const addWall: AgentOperation = (nodes, input, context) => ...(input.height === undefined ? {} : { height: input.height }), ...(curveOffset ? { curveOffset } : {}), }) + // the wall's band, square ends: what crosses the lot line, not only its centreline + const half = (wall.thickness ?? DEFAULT_WALL_THICKNESS) / 2 + const [nx, nz] = [(-(end[1] - start[1]) / length) * half, ((end[0] - start[0]) / length) * half] + const site = lotOverhang(nodes, level.id, [ + [ + [start[0] + nx, start[1] + nz], + [end[0] + nx, end[1] + nz], + [end[0] - nx, end[1] - nz], + [start[0] - nx, start[1] - nz], + ], + ]) return { result: { ok: true, @@ -59,7 +72,12 @@ export const addWall: AgentOperation = (nodes, input, context) => levelId: level.id, length: Math.round(length * 1000) / 1000, ...(curveOffset ? { curveOffset } : {}), - message: `Added wall "${wall.name}" on ${level.name ?? level.id}`, + ...(site ? { site } : {}), + message: `Added wall "${wall.name}" on ${level.name ?? level.id}${ + site + ? `; it reaches ${site.overhangM} m past the lot line (${site.sides.join(', ')}): the house belongs inside the buildable area (get_site, place_building)` + : '' + }`, }, changes: { create: [{ node: wall, parentId: level.id }] }, } diff --git a/packages/core/src/agent-operations/create-room.ts b/packages/core/src/agent-operations/create-room.ts index 19d7271bca..cd1fbc6803 100644 --- a/packages/core/src/agent-operations/create-room.ts +++ b/packages/core/src/agent-operations/create-room.ts @@ -3,6 +3,7 @@ import { doorFacing, planWallOpening } from '../building/wall-openings' import { createZone, walledRoomAt } from '../commands/structure/create-zone' import { structureChangeBatch } from '../commands/structure/shared' import { isAllocatedRoomName } from '../lib/room-name' +import { lotOverhang } from '../lib/site-placement' import { type AnyNode, type AnyNodeId, generateId, type WallNode, type ZoneNode } from '../schema' import { applySceneChanges } from './apply-changes' import { type LevelTargetInput, targetLevel } from './level-target' @@ -222,6 +223,7 @@ export const createRoom: AgentOperation = (nodes, input, contex (a, i) => wallsAlong(scene, level.id, a, polygon[(i + 1) % polygon.length]!)[0]?.id ?? null, ) const reusedWalls = wallIds.filter((id) => id && nodes[id]).length + const site = lotOverhang(nodes, level.id, [polygon]) const result = { ok: true, zoneId: plan.zoneId, @@ -233,6 +235,7 @@ export const createRoom: AgentOperation = (nodes, input, contex doorIds, windowIds, ...(skippedOpenings.length ? { skippedOpenings } : {}), + ...(site ? { site } : {}), message: [ plan.renamed ? `Named the ${input.outdoor ? 'outdoor room' : 'room'} the walls already enclose "${input.name}" (it was ${plan.renamed}) on ${level.name || level.id}` @@ -243,6 +246,11 @@ export const createRoom: AgentOperation = (nodes, input, contex ...(skippedOpenings.length ? [`skipped ${plural(skippedOpenings.length, 'opening')} (see skippedOpenings)`] : []), + ...(site + ? [ + `it reaches ${site.overhangM} m past the lot line (${site.sides.join(', ')}): the house belongs inside the buildable area (get_site, place_building)`, + ] + : []), ].join(', '), } return { diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index e7ae7d3be6..97d11722e6 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -23,6 +23,7 @@ import { ROOM_OPERATIONS } from './room-structure' import { searchAssets } from './search-assets' import { searchMaterials } from './search-materials' import { setCheckpoint } from './set-checkpoint' +import { getSite, placeBuilding } from './site' import { fitStair, measureStairOperation } from './stairs' import type { AgentOperation, AgentOperations } from './types' import { verifyScene } from './verify-scene' @@ -67,6 +68,7 @@ export * from './scene-queries' export * from './scene-view' export * from './search-assets' export * from './search-materials' +export * from './site' export * from './stairs' export * from './types' export * from './verify-scene' @@ -101,6 +103,8 @@ const SINGLE_OPERATIONS = { place_in_room: placeInRoom, search_assets: searchAssets, search_materials: searchMaterials, + get_site: getSite, + place_building: placeBuilding, ...ROOM_OPERATIONS, } as const diff --git a/packages/core/src/agent-operations/site.ts b/packages/core/src/agent-operations/site.ts new file mode 100644 index 0000000000..7728d3d21a --- /dev/null +++ b/packages/core/src/agent-operations/site.ts @@ -0,0 +1,129 @@ +import { refuse } from '../agent-tools/refusal' +import { + describeSiteBrief, + placeBuildingPose, + roundedSiteBrief, + type SiteFootprint, + siteBrief, + type YardKey, +} from '../lib/site-placement' +import type { AnyNode } from '../schema' +import { targetBuilding } from './add-level' +import { pointInPolygon, type Vec2 } from './plan-geometry' +import type { AgentOperation, SceneNodes } from './types' + +type SiteInput = { building?: string } +type PlaceBuildingInput = SiteInput & { + outline?: number[][] + yards?: Partial> + rotationDeg?: number +} + +const r2 = (n: number) => Math.round(n * 100) / 100 || 0 + +/** `get_site`: the site a building stands on, in its level coordinates. */ +export const getSite: AgentOperation = (nodes, input, context) => { + const building = targetBuilding(nodes, input.building, context) + const brief = siteBrief(nodes, { buildingId: building.id }) + if (!brief) refuse('no_site', 'The scene has no site with a lot: add_level starts one.') + return { result: { ...roundedSiteBrief(brief), summary: describeSiteBrief(brief) } } +} + +function footprintFacts(footprint: SiteFootprint) { + const b = footprint.bounds + return { + footprint: { + size: [r2(b.maxX - b.minX), r2(b.maxZ - b.minZ)], + bounds: { minX: r2(b.minX), minZ: r2(b.minZ), maxX: r2(b.maxX), maxZ: r2(b.maxZ) }, + centre: [r2(footprint.centre[0]), r2(footprint.centre[1])], + }, + yards: Object.fromEntries( + Object.entries(footprint.yards).map(([key, m]) => [key, m == null ? null : r2(m)]), + ), + fit: footprint.fit, + overhang: { + lotM: r2(footprint.overhang.lotM), + setbacksM: r2(footprint.overhang.setbacksM), + sides: footprint.overhang.sides, + }, + } +} + +/** + * What stands on the building's levels outside its footprint and moves with it: fences, paths and + * the floor items out of doors (a plant cannot be told from a patio chair, so all of them). + */ +function outdoorOnLevels(nodes: SceneNodes, buildingId: string, rings: readonly Vec2[][]) { + const building = nodes[buildingId] + const levels = new Set(building?.type === 'building' ? building.children : []) + const outside = (x: number, z: number) => + !rings.some((ring) => pointInPolygon([x, z], ring as Vec2[])) + const moved: { id: string; type: string; name?: string }[] = [] + for (const node of Object.values(nodes) as AnyNode[]) { + if (!(node.parentId && levels.has(node.parentId))) continue + const flatwork = + node.type === 'slab' && + typeof node.metadata === 'object' && + node.metadata !== null && + 'flatwork' in node.metadata + const outdoorItem = + node.type === 'item' && outside(node.position[0] ?? 0, node.position[2] ?? 0) + if (node.type === 'fence' || flatwork || outdoorItem) + moved.push({ id: node.id, type: node.type, ...(node.name ? { name: node.name } : {}) }) + } + return moved +} + +/** `place_building`: stand a building on its site by its pose, its walls untouched. */ +export const placeBuilding: AgentOperation = (nodes, input, context) => { + const building = targetBuilding(nodes, input.building, context) + const placed = placeBuildingPose(nodes, { + buildingId: building.id, + ...(input.outline + ? { outline: input.outline.map((p) => [p[0] ?? 0, p[1] ?? 0] as const) } + : {}), + ...(input.yards ? { yards: input.yards } : {}), + ...(input.rotationDeg === undefined ? {} : { rotationDeg: input.rotationDeg }), + }) + if (!placed.ok) refuse(placed.code, placed.message, { buildingId: building.id }) + const facts = footprintFacts(placed.footprint) + const rotationDeg = Math.round(((placed.rotation[1] * 180) / Math.PI) * 10) / 10 || 0 + const [x, , z] = placed.position + const movedWithIt = placed.moved + ? outdoorOnLevels(nodes, building.id, placed.footprint.rings) + : [] + const where = describeSiteBrief({ ...placed.brief, footprint: placed.footprint }).at(-1) + const message = [ + placed.moved + ? `${building.name ?? 'The building'} now stands at site [${r2(x)}, ${r2(z)}], ${rotationDeg}°. Its walls keep their level coordinates; draw new ones in the same frame.` + : `${building.name ?? 'The building'} already stands there: nothing moved.`, + where, + placed.brief.placeholder + ? 'The lot is the new-project placeholder: say so, and ask for the address or the site plan.' + : '', + movedWithIt.length + ? `${movedWithIt.length} outdoor node(s) on its levels moved with it (fences, paths, plants, outdoor furniture): re-trace them to the lot.` + : '', + ].filter(Boolean) + return { + result: { + ok: true, + buildingId: building.id, + position: placed.position.map(r2), + rotationDeg, + moved: placed.moved, + ...facts, + ...(movedWithIt.length ? { movedWithIt: movedWithIt.slice(0, 24) } : {}), + message: message.join(' '), + }, + ...(placed.moved + ? { + changes: { + update: [ + { id: building.id, data: { position: placed.position, rotation: placed.rotation } }, + ], + }, + } + : {}), + } +} diff --git a/packages/core/src/agent-operations/verify-scene.ts b/packages/core/src/agent-operations/verify-scene.ts index be6446c2cf..323f0815ed 100644 --- a/packages/core/src/agent-operations/verify-scene.ts +++ b/packages/core/src/agent-operations/verify-scene.ts @@ -2,6 +2,7 @@ import { levelBuildingId } from '../building/level-duplication' import { getLevelDisplayName } from '../lib/level-name' import { wallSupportForNodes } from '../lib/opening-floor-datum' import { detectOpenWallEnds, type OpenWallEnd } from '../lib/room-graph' +import { siteBrief } from '../lib/site-placement' import { nodeRegistry } from '../registry' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema' import { roofsOver } from '../services/roof-level' @@ -523,6 +524,33 @@ export const verifyScene: AgentOperation = ( } } + // Where each building stands on its lot: past the lot line or into the required yards is a + // problem, off-centre is not. + for (const building of Object.values(nodes)) { + if (building.type !== 'building') continue + const brief = siteBrief(nodes, { buildingId: building.id }) + if (!brief) continue + const name = building.name ?? 'The building' + if (brief.buildable.state === 'none') + report( + 'buildable_area_none', + `The setbacks leave nothing to build on this lot: check the site's setbacks before standing ${name} on it.`, + ) + const footprint = brief.footprint + if (!footprint) continue + const sides = footprint.overhang.sides.join(', ') + if (footprint.fit === 'outside') + report( + 'footprint_outside_site', + `${name} reaches ${footprint.overhang.lotM.toFixed(1)} m past the lot line (${sides}): place_building centres it in the buildable area; a plan bigger than the lot needs saying so.`, + ) + else if (footprint.fit === 'lot' && brief.buildable.state !== 'none') + report( + 'footprint_outside_setbacks', + `${name} stands ${footprint.overhang.setbacksM.toFixed(1)} m into the required yards (${sides}): place_building centres it in the buildable area.`, + ) + } + const errors = schemaErrors(nodes) for (const error of errors.slice(0, 5)) report('schema_invalid', `Schema: ${error.nodeId}.${error.path} ${error.message}`) diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 50bf354342..4a39735454 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -27,6 +27,7 @@ import { placeItemsTool } from './place-items' import { createRoofTool } from './roofs' import { ROOM_TOOL_CONTRACTS } from './room-structure' import { searchMaterialsTool } from './search-materials' +import { getSiteTool, placeBuildingTool } from './site' import { createStairTool, fitStairTool, measureStairTool } from './stairs' import { verifySceneTool } from './verify-scene' import { viewSceneTool } from './view-scene' @@ -57,6 +58,7 @@ export * from './refusal' export * from './roofs' export * from './room-structure' export * from './search-materials' +export * from './site' export * from './stairs' export * from './verify-scene' export * from './view-scene' @@ -105,6 +107,8 @@ export const AGENT_TOOL_CONTRACTS = [ placeInRoomTool, searchAssetsTool, searchMaterialsTool, + getSiteTool, + placeBuildingTool, ...ROOM_TOOL_CONTRACTS, runBatchTool, ] as const satisfies readonly AgentToolContract[] diff --git a/packages/core/src/agent-tools/site.ts b/packages/core/src/agent-tools/site.ts new file mode 100644 index 0000000000..b3004c7330 --- /dev/null +++ b/packages/core/src/agent-tools/site.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' +import { measurement } from './measurement' +import { NodeId } from './node-id' + +const building = NodeId.optional().describe( + "The building, by its id (list_levels' parentId). Default: the building of the floor the person is viewing, else the scene's only building.", +) + +const yard = (side: string) => + measurement('length', 'm', { + min: 0, + description: `The ${side} yard: from the lot line to the house, square to the house.`, + }).optional() + +export const getSiteTool = { + name: 'get_site', + title: 'Get site', + description: + 'Read the site a building stands on, in its level coordinates: the lot, the buildable area inside the setbacks and its centre, the street side, north, the address, parcel and zoning when known, whether the lot is only the new-project placeholder, and where the footprint sits now with its yards.', + input: { building }, +} + +export const placeBuildingTool = { + name: 'place_building', + title: 'Place building', + description: + "Stand a building on its site before or after its walls go up. It moves only the building node, so walls, levels and roofs keep their coordinates and move with it. The footprint (outline, the plan you are about to draw, in the coordinates you will draw it; else the walls standing) lands centred in the buildable area, or at the yards a site plan or the person gives. The building keeps its rotation unless rotationDeg is given (get_site's squareRotationDeg squares it to the street). Fences, paths and plants on its levels move with it.", + input: { + building, + outline: z + .array(z.array(z.number()).length(2)) + .min(3) + .optional() + .describe( + "The footprint's corners in order, as [x, z] in metres, in the level coordinates the walls are (or will be) drawn in. Default: the walls standing.", + ), + yards: z + .object({ + front: yard('front'), + rear: yard('rear'), + left: yard('left (seen from the street)'), + right: yard('right (seen from the street)'), + }) + .optional() + .describe( + 'Where the house stands instead of centred: at most one of front and rear, and one of left and right, from a site plan or the person. An axis not given stays centred in the buildable area. Needs the site to know its street (frontEdge).', + ), + rotationDeg: measurement('angle', 'deg', { + description: "The building's rotation about the vertical. Default: the rotation it has.", + }).optional(), + }, +} diff --git a/packages/core/src/agent-tools/verify-scene.ts b/packages/core/src/agent-tools/verify-scene.ts index 52f1b0bce2..31aa911201 100644 --- a/packages/core/src/agent-tools/verify-scene.ts +++ b/packages/core/src/agent-tools/verify-scene.ts @@ -16,7 +16,7 @@ export function extendVerifyScene(extension: { } const DESCRIPTION = - 'Optional whole-scene evidence: per-level content and roles (storey, roof-only, support), then every problem found, each with a type: empty levels, walls with no room or door, rooms with no floor or ceiling, storeys with no stair, roof levels misused, walls intersecting a covering slab or ending coplanar with its walking surface (wall_covering_slab_contact, with wallId and slabId), openings off their wall, stairs off their slab or blocked, furniture blocking a door or overlapping, nodes their schema rejects. Static authored ceiling objects report transformed bounds extending above their ceiling attachment and bounds overlapping solid covering slab bands, with node IDs and measured metres; stored and derived slab holes are subtracted. Wall-mounted objects report a front oriented toward their host; static authored wall bounds report wall-body overlap and extension through the opposite face. With settled editor meshes, catalog wall bounds and large competing coplanar faces against the host or another item on the same wall are also measured within 1 mm. Hidden back-to-wall contact is not competing face evidence. These are observations, not instructions to change the scene or proof of exact mesh intersection. Bounds are measured in the owning building frame, including stacked level elevations. Unknown catalog raw bounds without meshes, animated models, unsupported attachment frames, unresolved scripted wall cuts, roof collisions, recessed slab shells and foundation volumes are not assessed.' + 'Optional whole-scene evidence: per-level content and roles (storey, roof-only, support), then every problem found, each with a type: empty levels, walls with no room or door, rooms with no floor or ceiling, storeys with no stair, roof levels misused, walls intersecting a covering slab or ending coplanar with its walking surface (wall_covering_slab_contact, with wallId and slabId), openings off their wall, stairs off their slab or blocked, furniture blocking a door or overlapping, a house past its lot line or in its required yards, nodes their schema rejects. Static authored ceiling objects report transformed bounds extending above their ceiling attachment and bounds overlapping solid covering slab bands, with node IDs and measured metres; stored and derived slab holes are subtracted. Wall-mounted objects report a front oriented toward their host; static authored wall bounds report wall-body overlap and extension through the opposite face. With settled editor meshes, catalog wall bounds and large competing coplanar faces against the host or another item on the same wall are also measured within 1 mm. Hidden back-to-wall contact is not competing face evidence. These are observations, not instructions to change the scene or proof of exact mesh intersection. Bounds are measured in the owning building frame, including stacked level elevations. Unknown catalog raw bounds without meshes, animated models, unsupported attachment frames, unresolved scripted wall cuts, roof collisions, recessed slab shells and foundation volumes are not assessed.' export const verifySceneTool = { name: 'verify_scene', diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 73aa11d14b..0b8ca92393 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -1001,6 +1001,7 @@ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[ 'textSizePt', ]), ...described('Site-plan facts kept on the node: values, not ids.', [ + 'flatwork', 'flatworkDims', 'services', 'streetNames', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4fa3d3758..1a8c595ab3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,6 +130,14 @@ export { resolveAssemblyStack, } from './lib/assembly-stack' export { loadAssetUrl, saveAsset } from './lib/asset-storage' +export { + footprintOutline, + levelFootprintLoops, + levelFrame, + siteFrame, + turnPlanPoint, + yawOf, +} from './lib/building-footprint' export { CEILING_SURFACE_ROLE, type CeilingSurfaceCell, @@ -347,12 +355,46 @@ export { export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' export { arcRuns, + castYardDimensionsOriented, + classifyEdges, + type EdgeRole, + edgeHeadingDeg, + edgeLength, envelopeFrontEdge, insetPolygon, type KeepOut, + mostNorthFacingEdge, + outwardNormal, + rayToPolygon, + resolveFrontEdge, + type SetbackInputs, + setbackEnvelope, + setbackForRole, sightTriangle, streetCorners, + type YardDimension, + type YardSide, } from './lib/setback-envelope' +export { + type BuildableArea, + type BuildingPlacement, + buildingFootprintRings, + describeSiteBrief, + lotOverhang, + measureFootprint, + type PlaceBuildingInput, + type PlacementRefusal, + type PlanBounds, + type PlanSide, + placeBuildingPose, + resolveSiteBuilding, + roundedSiteBrief, + type SiteBrief, + type SiteFootprint, + siteBrief, + siteSetbackEnvelope, + type YardKey, +} from './lib/site-placement' export { getRenderableSlabPolygon, prepareSlabPolygonContext, diff --git a/packages/core/src/lib/building-footprint.ts b/packages/core/src/lib/building-footprint.ts new file mode 100644 index 0000000000..4cfe3d4c4e --- /dev/null +++ b/packages/core/src/lib/building-footprint.ts @@ -0,0 +1,108 @@ +/** + * Where a building stands on its site: the building node's pose turns its levels' plan + * coordinates into site metres, and its walls' bands, unioned, are the footprint the site plan + * draws and the setbacks are measured to. Pure: no store. + */ +import type { AnyNode, BuildingNode, LevelNode, WallNode } from '../schema' +import { getWallPlanFootprint } from '../systems/wall/wall-footprint' +import { calculateLevelMiters } from '../systems/wall/wall-mitering' +import { unionPolygons } from './polygon-union' + +type Pt = readonly [number, number] +type Nodes = Readonly> + +/** A three.js Y rotation of a plan point: local (x, z) turned by `yaw`. */ +export function turnPlanPoint(x: number, z: number, yaw: number): Pt { + const c = Math.cos(yaw) + const s = Math.sin(yaw) + return [c * x + s * z, -s * x + c * z] +} + +export function yawOf(rotation: unknown): number { + if (typeof rotation === 'number') return rotation + if (Array.isArray(rotation)) return Number(rotation[1] ?? 0) || 0 + return 0 +} + +/** + * Level-local plan (x, z) → site metres, through the building's placement: three.js Y rotation, + * the convention BuildingRenderer applies, `site = position + (c·x + s·z, −s·x + c·z)`. + */ +export function siteFrame( + building: Pick | null, +): (x: number, z: number) => Pt { + const yaw = building?.rotation?.[1] ?? 0 + const ox = building?.position?.[0] ?? 0 + const oz = building?.position?.[2] ?? 0 + return (x, z) => { + const [tx, tz] = turnPlanPoint(x, z, yaw) + return [ox + tx, oz + tz] + } +} + +/** Site metres → the building's level-local plan (x, z): `siteFrame` inverted. */ +export function levelFrame( + building: Pick | null, +): (x: number, z: number) => Pt { + const yaw = building?.rotation?.[1] ?? 0 + const ox = building?.position?.[0] ?? 0 + const oz = building?.position?.[2] ?? 0 + return (x, z) => turnPlanPoint(x - ox, z - oz, -yaw) +} + +/** + * Per-wall plan footprints for a level, mitred, then transformed by the building's site + * placement (`siteFrame`); with no building, in the level's own coordinates. + */ +export function levelFootprintLoops( + nodes: Nodes, + level: LevelNode | null, + building: BuildingNode | null, +): Pt[][] { + if (!level) return [] + const walls: WallNode[] = [] + for (const childId of level.children ?? []) { + const child = nodes[childId] + if (child?.type === 'wall') walls.push(child) + } + if (walls.length === 0) return [] + + const miters = calculateLevelMiters(walls) + const toSite = siteFrame(building) + const loops: Pt[][] = [] + for (const wall of walls) { + const poly = getWallPlanFootprint(wall, miters) + if (poly.length < 3) continue + loops.push(poly.map((p) => toSite(p.x, p.y))) + } + return loops +} + +/** + * The outer ring(s) of a set of wall bands: their union, minus any ring that + * lies inside another (a room enclosed by partitions is a hole in the union, + * not a second building). Falls back to the bands themselves when the union + * yields nothing. + */ +export function footprintOutline(loops: readonly (readonly Pt[])[]): Pt[][] { + const rings = unionPolygons(loops.map((loop) => loop.map((p) => [p[0], p[1]]))) as Pt[][] + if (rings.length === 0) return loops.map((loop) => [...loop]) + const inside = (p: Pt, ring: readonly Pt[]): boolean => { + let hit = false + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const a = ring[i] as Pt + const b = ring[j] as Pt + if ( + a[1] > p[1] !== b[1] > p[1] && + p[0] < ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0] + ) { + hit = !hit + } + } + return hit + } + return rings.filter( + (ring, i) => + !rings.some((other, j) => j !== i && ring[0] !== undefined && inside(ring[0], other)), + ) +} diff --git a/packages/core/src/lib/setback-envelope.ts b/packages/core/src/lib/setback-envelope.ts index b72c47a55e..49bc4ae254 100644 --- a/packages/core/src/lib/setback-envelope.ts +++ b/packages/core/src/lib/setback-envelope.ts @@ -516,3 +516,389 @@ export function streetCorners( } return out } + +/* ---------------------------------------------- lot edges and setbacks */ + +export interface SetbackInputs { + front: number + side: number + rear: number + left?: number + right?: number + /** A corner lot's second street side; absent = the front setback. */ + streetSide?: number +} + +/** Per-edge classification of a lot polygon relative to its front edge. */ +export type EdgeRole = 'front' | 'rear' | 'left' | 'right' | 'street' + +/** Unit normal of edge `i` pointing OUT of the polygon, winding-agnostic. */ +export function outwardNormal(points: readonly Pt[], i: number): Pt { + const n = points.length + const p = points[i % n] as Pt + const q = points[(i + 1) % n] as Pt + const dx = q[0] - p[0] + const dy = q[1] - p[1] + const len = Math.hypot(dx, dy) || 1 + // Left normal of the edge direction. For a CCW ring (positive shoelace in + // this frame) the left normal points inward, so flip. + const sign = signedArea(points) > 0 ? -1 : 1 + return [(sign * -dy) / len, (sign * dx) / len] +} + +/** + * Compass heading of edge `i`'s outward normal, in DEGREES clockwise from + * north, where `northRotation` (radians, clockwise) is true north's offset + * from plan up (−y). This is the direction the edge faces. + */ +export function edgeHeadingDeg(points: readonly Pt[], i: number, northRotation = 0): number { + const [nx, ny] = outwardNormal(points, i) + // Plan up (−y) is 0°, +x (east) is 90°. + const raw = Math.atan2(nx, -ny) - northRotation + const deg = (raw * 180) / Math.PI + return ((deg % 360) + 360) % 360 +} + +/** Length of edge `i`, metres. */ +export function edgeLength(points: readonly Pt[], i: number): number { + const n = points.length + const p = points[i % n] as Pt + const q = points[(i + 1) % n] as Pt + return Math.hypot(q[0] - p[0], q[1] - p[1]) +} + +/** + * Fallback front edge: the edge whose outward normal points closest to true + * north. Ties break toward the longer edge — the street frontage of a typical + * lot is one of its long sides. + */ +export function mostNorthFacingEdge(points: readonly Pt[], northRotation = 0): number { + let best = 0 + let bestScore = Number.POSITIVE_INFINITY + for (let i = 0; i < points.length; i++) { + const heading = edgeHeadingDeg(points, i, northRotation) + const off = Math.min(heading, 360 - heading) + // Sub-degree ties resolve on length so a chamfer corner never wins. + const score = off - Math.min(0.5, edgeLength(points, i) / 1000) + if (score < bestScore) { + bestScore = score + best = i + } + } + return best +} + +/** `frontEdge` if it indexes a real edge, else the most north-facing edge. */ +export function resolveFrontEdge( + points: readonly Pt[], + frontEdge: number | undefined, + northRotation = 0, +): number { + if ( + typeof frontEdge === 'number' && + Number.isInteger(frontEdge) && + frontEdge >= 0 && + frontEdge < points.length + ) { + return frontEdge + } + return mostNorthFacingEdge(points, northRotation) +} + +/** + * Classify every edge as front / rear / left / right. + * + * - front = `frontIndex`. + * - rear = the edge whose outward normal is most anti-parallel to the front's. + * - the rest are sides. LEFT / RIGHT are defined along the FRONT EDGE + * DIRECTION (`points[front] → points[front + 1]`): an edge whose midpoint + * projects behind the front-edge midpoint on that axis is `left`, ahead of + * it is `right`. Deterministic and independent of winding. + */ +export function classifyEdges( + points: readonly Pt[], + frontIndex: number, + streetEdges: readonly number[] = [], +): EdgeRole[] { + const n = points.length + const roles: EdgeRole[] = new Array(n).fill('left') + if (n === 0) return roles + + const front = ((frontIndex % n) + n) % n + const fn = outwardNormal(points, front) + // a corner lot's other street edges (the parcel fabric's frontage) are + // 'street' — they take the street-side setback, never a side yard, and + // the rear is never one of them + const street = new Set(streetEdges.map((i) => ((i % n) + n) % n)) + street.delete(front) + + let rear = -1 + let rearDot = Number.POSITIVE_INFINITY + for (let i = 0; i < n; i++) { + if (i === front || street.has(i)) continue + const ni = outwardNormal(points, i) + const dot = fn[0] * ni[0] + fn[1] * ni[1] + if (dot < rearDot) { + rearDot = dot + rear = i + } + } + + const fp = points[front] as Pt + const fq = points[(front + 1) % n] as Pt + const dx = fq[0] - fp[0] + const dy = fq[1] - fp[1] + const len = Math.hypot(dx, dy) || 1 + const ux = dx / len + const uy = dy / len + const fmx = (fp[0] + fq[0]) / 2 + const fmy = (fp[1] + fq[1]) / 2 + + for (let i = 0; i < n; i++) { + if (i === front) { + roles[i] = 'front' + continue + } + if (street.has(i)) { + roles[i] = 'street' + continue + } + if (i === rear) { + roles[i] = 'rear' + continue + } + const p = points[i] as Pt + const q = points[(i + 1) % n] as Pt + const mx = (p[0] + q[0]) / 2 - fmx + const my = (p[1] + q[1]) / 2 - fmy + roles[i] = mx * ux + my * uy < 0 ? 'left' : 'right' + } + return roles +} + +/** Required setback distance (metres) for each edge role. */ +export function setbackForRole(setbacks: SetbackInputs, role: EdgeRole): number { + switch (role) { + case 'front': + return setbacks.front + case 'street': + return setbacks.streetSide ?? setbacks.front + case 'rear': + return setbacks.rear + case 'left': + return setbacks.left ?? setbacks.side + case 'right': + return setbacks.right ?? setbacks.side + } +} + +/** + * Buildable envelope — every lot edge pushed INWARD by its own setback + * (`insetPolygon`): straight where the lot is straight, concentric round a + * radius, clipped where the offsets cross. On a concave lot a reflex corner + * can self-intersect, in which case the caller still gets a ring but it is + * advisory, not a legal envelope. + * + * Returns `[]` when the polygon has fewer than 3 points, when a setback is + * not a distance, or when nothing buildable remains (setbacks larger than + * the lot). + */ +export function setbackEnvelope( + points: readonly Pt[], + setbacks: SetbackInputs, + frontIndex: number, + options: { streetEdges?: readonly number[]; sightTriangleM?: number } = {}, +): Pt[] { + const n = points.length + if (n < 3) return [] + const streetEdges = options.streetEdges ?? [] + const roles = classifyEdges(points, frontIndex, streetEdges) + // A radius drawn as a run of short edges takes ONE role. A curb return + // is street frontage: a run carrying, or touching, a front / street edge + // is front / street all along (otherwise the last sliver of the return + // became a side yard, and the envelope jutted out toward the corner); a + // run carrying the rear is rear; else + // each sliver keeps its own side. + const runs = arcRuns(points) + const runRole = new Map() + const streetLike = (r: EdgeRole) => r === 'front' || r === 'street' + for (let i = 0; i < n; i++) { + const r = roles[i] as EdgeRole + const g = runs[i] as number + const have = runRole.get(g) + if (streetLike(r) || (r === 'rear' && !(have && streetLike(have)))) runRole.set(g, r) + else if (!have) runRole.set(g, r) + } + for (let i = 0; i < n; i++) { + const g = runs[i] as number + const have = runRole.get(g) as EdgeRole + if (streetLike(have)) continue + const before = roles[(i - 1 + n) % n] as EdgeRole + const after = roles[(i + 1) % n] as EdgeRole + const members = runs.filter((x) => x === g).length + if (members > 1) { + if (runs[(i - 1 + n) % n] !== g && streetLike(before)) runRole.set(g, before) + 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 = 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, + ) + if (!Number.isFinite(d) || d < 0) return [] + distances.push(d) + } + // the corner sight triangles keep the envelope out of the clear-vision + // zone at each street intersection (the hypotenuse is a half-plane) + const keepOut: KeepOut[] = [] + if (options.sightTriangleM && options.sightTriangleM > 0) { + for (const [a, b] of streetCorners(points, [frontIndex, ...streetEdges])) { + const tri = sightTriangle(points, a, b, options.sightTriangleM) + if (tri) keepOut.push({ a: tri.a, b: tri.b, inside: tri.corner }) + } + } + return insetPolygon(points, distances, { keepOut }) +} + +/* ------------------------------------------------------------- yards */ + +/** + * Distance from `origin` along unit direction `(dx, dy)` to the first crossing + * of the polygon boundary. `null` when the ray never hits (origin outside, or + * a degenerate ring). + */ +export function rayToPolygon( + points: readonly Pt[], + origin: Pt, + dx: number, + dy: number, +): { distance: number; point: Pt } | null { + const n = points.length + let best: { distance: number; point: Pt } | null = null + for (let i = 0; i < n; i++) { + const p = points[i] as Pt + const q = points[(i + 1) % n] as Pt + const ex = q[0] - p[0] + const ey = q[1] - p[1] + const denom = dx * ey - dy * ex + if (Math.abs(denom) < 1e-12) continue + // origin + t*d = p + u*e + const t = ((p[0] - origin[0]) * ey - (p[1] - origin[1]) * ex) / denom + const u = ((p[0] - origin[0]) * dy - (p[1] - origin[1]) * dx) / denom + if (t <= 1e-9 || u < -1e-9 || u > 1 + 1e-9) continue + if (!best || t < best.distance) { + best = { distance: t, point: [origin[0] + dx * t, origin[1] + dy * t] } + } + } + return best +} + +/** Yard sides in the order the site-plan dimensions are cast. */ +export type YardSide = 'north' | 'south' | 'east' | 'west' + +export interface YardDimension { + side: YardSide + /** Bounding-box edge midpoint the dimension starts at. */ + from: Pt + /** Where the cast ray meets the lot line. */ + to: Pt + /** Metres. */ + distance: number +} + +/** + * Yard dimensions for a building TURNED on its lot. The footprint's bounds + * are taken in the building's own frame (`yaw`, the building node's Y + * rotation), and the four edge midpoints cast square to the house's faces + * out to the lot line — the yards a plan checker measures. With a house + * square to a diagonal lot the axis-aligned bbox sticks out past the real + * corners and reads a front yard inches short of the setback it actually + * meets (Land Park, 2026-09-06). `side` is the compass direction nearest the + * cast, so labels keep reading N / S / E / W. Sides whose ray misses the lot + * line (footprint outside the lot) are omitted rather than faked. + */ +export function castYardDimensionsOriented( + lot: readonly Pt[], + footprintLoops: readonly (readonly Pt[])[], + yaw: number, +): YardDimension[] { + if (lot.length < 3) return [] + const c = Math.cos(yaw) + const s = Math.sin(yaw) + // world = (c·lx + s·lz, −s·lx + c·lz) ⇒ local = (c·wx − s·wz, s·wx + c·wz) + const toLocal = (p: Pt): Pt => [c * p[0] - s * p[1], s * p[0] + c * p[1]] + const toWorld = (p: Pt): Pt => [c * p[0] + s * p[1], -s * p[0] + c * p[1]] + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const loop of footprintLoops) { + for (const p of loop) { + const [x, z] = toLocal(p) + if (x < minX) minX = x + if (x > maxX) maxX = x + if (z < minZ) minZ = z + if (z > maxZ) maxZ = z + } + } + if (!Number.isFinite(minX)) return [] + const midX = (minX + maxX) / 2 + const midZ = (minZ + maxZ) / 2 + const casts: { from: Pt; dir: Pt }[] = [ + { from: [midX, minZ], dir: [0, -1] }, + { from: [midX, maxZ], dir: [0, 1] }, + { from: [minX, midZ], dir: [-1, 0] }, + { from: [maxX, midZ], dir: [1, 0] }, + ] + const sides: YardSide[] = ['north', 'east', 'south', 'west'] + const out: YardDimension[] = [] + for (const cast of casts) { + const from = toWorld(cast.from) + const [dx, dy] = toWorld(cast.dir) + const hit = rayToPolygon(lot, from, dx, dy) + if (!hit) continue + // heading of the cast: plan up (−y) is north, +x east + const heading = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360 + const side = sides[Math.round(heading / 90) % 4] as YardSide + out.push({ side, from, to: hit.point, distance: hit.distance }) + } + return out +} diff --git a/packages/core/src/lib/site-placement.ts b/packages/core/src/lib/site-placement.ts new file mode 100644 index 0000000000..c30148f34c --- /dev/null +++ b/packages/core/src/lib/site-placement.ts @@ -0,0 +1,911 @@ +/** + * The site a building stands on, read the way an agent draws with it, and where the building's + * footprint goes on it. + * + * Frames: the site polygon and the building's pose are stored in SITE metres. Everything here + * that an agent reads to draw with is in the building's LEVEL coordinates (the frame its walls + * are written in): `site = position + turn(level, yaw)`. A house is placed by moving the building + * node, never its walls, so the model's coordinates survive and a storey built over several + * programs stays in one frame. Plan sides (north, east, south, west) are the level frame's + * −z, +x, +z and −x; true north is given separately. + */ +import type { AnyNode, BuildingNode, LevelNode, SiteNode, SiteSetbacks } from '../schema' +import { migrateSiteMetadata } from '../schema/nodes/site' +import { + footprintOutline, + levelFootprintLoops, + levelFrame, + siteFrame, + turnPlanPoint, +} from './building-footprint' +import { area, containsPoint, difference, distanceToBoundary } from './polygon-boolean' +import { polygonInteriorPoint } from './polygon-label' +import { segmentsIntersect } from './polygon-relations' +import { + outwardNormal, + type Pt, + polygonAreaAbs, + rayToPolygon, + resolveFrontEdge, + setbackEnvelope, +} from './setback-envelope' + +type Nodes = Readonly> +type P = [number, number] + +export type PlanSide = 'north' | 'east' | 'south' | 'west' +export type YardKey = 'front' | 'rear' | 'left' | 'right' +export type PlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number } + +export type BuildableArea = { + /** + * `lot`: no setbacks are recorded, so the whole lot is buildable. `setbacks`: the lot inside + * its setbacks. `none`: the setbacks leave nothing to build on. `advisory`: the inset of a + * concave lot crosses itself, a ring to aim at but not a legal envelope. + */ + state: 'lot' | 'setbacks' | 'none' | 'advisory' + ring: P[] + /** Its centroid when that lies inside, else its most interior point; null when there is none. */ + centre: P | null + setbacks?: SiteSetbacks + source?: string +} + +export type SiteFootprint = { + /** The union of every storey's walls (a storey's rooms where it has no walls). */ + rings: P[][] + bounds: PlanBounds + centre: P + /** How far the footprint's centre is from the buildable centre. */ + offCentreM: number + /** + * Metres from each face of the house, square to it, to the lot line; negative past it, null + * when the face's cast misses the lot. Keyed front / rear / left / right (seen from the street) + * when the street is known, else by plan side. + */ + yards: Partial> + fit: 'buildable' | 'lot' | 'outside' + /** How far the footprint reaches past the lot line and into the setbacks, and toward which sides. */ + overhang: { lotM: number; setbacksM: number; sides: PlanSide[] } +} + +export type SiteBrief = { + siteId: string + buildingId: string | null + /** The building's pose on the site: its position in site metres and its yaw in degrees. */ + pose: { position: [number, number, number]; rotationDeg: number } + lot: { + ring: P[] + /** The lot as stored, in site metres. */ + siteRing: P[] + bounds: PlanBounds + areaM2: number + /** The parcel service found no recorded lines and drew a rectangle round the address. */ + approximate: boolean + } + /** The new-project square: no address, no parcel, no setbacks; the real lot is unknown. */ + placeholder: boolean + buildable: BuildableArea + /** The street edge (the site's front edge, else its first street edge), never a guess. */ + street: { + side: PlanSide + edge: number + /** The building rotation, in degrees, that squares the house to the street edge. */ + squareRotationDeg: number + } | null + /** True north, degrees clockwise from plan up (−z) in this building's level frame. */ + northDeg: number + address: string | null + parcel: { apn?: string; lotAreaSqFt?: number; source?: string; notes?: string[] } | null + zone: string | null + footprint: SiteFootprint | null + /** The other buildings on the site with walls, in this building's level frame. */ + others: { buildingId: string; bounds: PlanBounds }[] +} + +const SIDES: Record = { north: [0, -1], east: [1, 0], south: [0, 1], west: [-1, 0] } +const OPPOSITE: Record = { + north: 'south', + south: 'north', + east: 'west', + west: 'east', +} +/** What a footprint may lose to a ring's edge and still count as inside it: rounding, not a wall. */ +const FIT_AREA_M2 = 0.01 +const DEFAULT_SQUARE = 15 +const DEG = 180 / Math.PI + +const round = (n: number, step = 100) => Math.round(n * step) / step || 0 +const roundPt = (p: Pt): P => [round(p[0]), round(p[1])] + +function boundsOf(points: readonly Pt[]): PlanBounds { + 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) { + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minZ = Math.min(minZ, z) + maxZ = Math.max(maxZ, z) + } + return { minX, minZ, maxX, maxZ } +} + +const boundsCentre = (b: PlanBounds): P => [(b.minX + b.maxX) / 2, (b.minZ + b.maxZ) / 2] + +function sideOf(dx: number, dz: number): PlanSide { + if (Math.abs(dx) > Math.abs(dz)) return dx > 0 ? 'east' : 'west' + return dz > 0 ? 'south' : 'north' +} + +function selfIntersects(ring: readonly Pt[]): boolean { + const n = ring.length + for (let i = 0; i < n; i++) { + for (let j = i + 2; j < n; j++) { + if (i === 0 && j === n - 1) continue + const a = ring[i] as P + const b = ring[(i + 1) % n] as P + const c = ring[j] as P + const d = ring[(j + 1) % n] as P + if (segmentsIntersect(a, b, c, d)) return true + } + } + return false +} + +/** A ring's centroid when it lies inside the ring, else its most interior point. */ +function ringCentre(ring: readonly Pt[]): P | null { + if (ring.length < 3 || polygonAreaAbs(ring) <= 0) return null + return polygonInteriorPoint({ polygon: ring.map((p) => [p[0], p[1]]) }, true) +} + +function isPlaceholderSquare(points: readonly Pt[]): boolean { + if (points.length !== 4) return false + const corners = new Set(points.map(([x, z]) => `${Math.sign(x)},${Math.sign(z)}`)) + return ( + corners.size === 4 && + points.every( + ([x, z]) => + Math.abs(Math.abs(x) - DEFAULT_SQUARE) < 1e-6 && + Math.abs(Math.abs(z) - DEFAULT_SQUARE) < 1e-6, + ) + ) +} + +/** The site with its legacy metadata lifted onto the real fields (read-side only). */ +function readSiteNode(node: SiteNode): SiteNode { + const patch = migrateSiteMetadata(node) + return Object.keys(patch).length > 0 ? { ...node, ...patch } : node +} + +/** + * The lot's buildable envelope, in site metres, as the site plan and the 3D site draw it: every + * lot line pushed in by its setback from the front edge's roles, the corner sight triangles kept + * clear. Empty when the site records no setbacks, or when they leave nothing. + */ +export function siteSetbackEnvelope( + site: Pick< + SiteNode, + 'polygon' | 'setbacks' | 'frontEdge' | 'streetEdges' | 'sightTriangleFt' | 'northRotation' + >, +): P[] { + const points = (site.polygon?.points ?? []) as P[] + if (!site.setbacks || points.length < 3) return [] + const front = resolveFrontEdge(points, site.frontEdge, site.northRotation ?? 0) + const sightTriangleM = + typeof site.sightTriangleFt === 'number' && site.sightTriangleFt > 0 + ? site.sightTriangleFt * 0.3048 + : 0 + return setbackEnvelope(points, site.setbacks, front, { + streetEdges: (site.streetEdges ?? []).filter((i) => Number.isFinite(i)), + sightTriangleM, + }).map((p) => [p[0], p[1]]) +} + +/** The buildable area in SITE metres. */ +function buildableInSite(site: SiteNode): { state: BuildableArea['state']; ring: P[] } { + const lot = (site.polygon?.points ?? []) as P[] + if (!site.setbacks) return { state: 'lot', ring: lot } + const envelope = siteSetbackEnvelope(site) + if (envelope.length < 3) return { state: 'none', ring: [] } + return { state: selfIntersects(envelope) ? 'advisory' : 'setbacks', ring: envelope } +} + +/* ------------------------------------------------- what stands where */ + +function sitesOf(nodes: Nodes): SiteNode[] { + return Object.values(nodes).filter((node): node is SiteNode => node.type === 'site') +} + +function buildingsOf(nodes: Nodes): BuildingNode[] { + return Object.values(nodes).filter((node): node is BuildingNode => node.type === 'building') +} + +function buildingLevels(nodes: Nodes, building: BuildingNode): LevelNode[] { + return (building.children ?? []) + .map((id) => nodes[id]) + .filter((node): node is LevelNode => node?.type === 'level') +} + +/** The building a level belongs to. */ +function levelBuilding(nodes: Nodes, levelId: string): BuildingNode | null { + const level = nodes[levelId] + const parent = level?.parentId ? nodes[level.parentId] : undefined + return parent?.type === 'building' ? parent : null +} + +function siteOfBuilding(nodes: Nodes, building: BuildingNode | null): SiteNode | null { + const parent = building?.parentId ? nodes[building.parentId] : undefined + if (parent?.type === 'site') return parent + return sitesOf(nodes)[0] ?? null +} + +/** + * The building a site read or a placement is about: the one named, else the named level's, else + * the scene's only building. `ambiguous` when there are several and nothing names one. + */ +export function resolveSiteBuilding( + nodes: Nodes, + target: { buildingId?: string | null; levelId?: string | null } = {}, +): { building: BuildingNode | null; ambiguous: boolean } { + if (target.buildingId) { + const node = nodes[target.buildingId] + return { building: node?.type === 'building' ? node : null, ambiguous: false } + } + if (target.levelId) { + const building = levelBuilding(nodes, target.levelId) + if (building) return { building, ambiguous: false } + } + const buildings = buildingsOf(nodes) + return { + building: buildings.length === 1 ? (buildings[0] ?? null) : null, + ambiguous: buildings.length > 1, + } +} + +/** + * The building's footprint in its level frame: the union of every storey's wall bands, a + * storey's rooms where it has no walls. + */ +export function buildingFootprintRings(nodes: Nodes, building: BuildingNode): P[][] { + const loops: Pt[][] = [] + for (const level of buildingLevels(nodes, building)) { + const walls = levelFootprintLoops(nodes, level, null) + if (walls.length > 0) { + loops.push(...walls) + continue + } + for (const id of level.children ?? []) { + const child = nodes[id] + if (child?.type === 'zone' && child.polygon.length >= 3) loops.push(child.polygon) + } + } + if (loops.length === 0) return [] + return footprintOutline(loops).map((ring) => ring.map((p) => [p[0], p[1]] as P)) +} + +/* ------------------------------------------------------- measuring */ + +/** The ring edge nearest `p`, or the two edges of the corner nearest it. */ +function nearestEdges(ring: readonly Pt[], p: Pt): number[] { + const n = ring.length + let best = 0 + let bestD = Number.POSITIVE_INFINITY + let bestT = 0 + for (let i = 0; i < n; i++) { + const a = ring[i] as Pt + const b = ring[(i + 1) % n] as Pt + const dx = b[0] - a[0] + const dz = b[1] - a[1] + const l2 = dx * dx + dz * dz + const t = l2 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dz) / l2)) : 0 + const d = Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dz * t) + if (d < bestD - 1e-9) { + bestD = d + best = i + bestT = t + } + } + if (bestT <= 1e-6) return [best, (best - 1 + n) % n] + if (bestT >= 1 - 1e-6) return [best, (best + 1) % n] + return [best] +} + +/** + * What of `rings` lies outside `ring`, by area (an edge across a notch counts, not only its + * corners), how far it reaches past the ring's edge, and toward which sides. + */ +function spill( + rings: readonly (readonly Pt[])[], + ring: readonly Pt[], +): { areaM2: number; reachM: number; sides: PlanSide[] } { + const shapes = rings.map((r) => ({ outer: r.map((p) => [p[0], p[1]] as P), holes: [] })) + if (ring.length < 3) { + const all = area(shapes) + return { areaM2: all, reachM: Number.POSITIVE_INFINITY, sides: [] } + } + const outside = difference( + shapes, + ring.map((p) => [p[0], p[1]] as P), + ) + const areaM2 = area(outside) + if (areaM2 <= FIT_AREA_M2) return { areaM2: 0, reachM: 0, sides: [] } + const boundary = [{ outer: ring.map((p) => [p[0], p[1]] as P), holes: [] }] + let reachM = 0 + const sides = new Set() + for (const piece of outside) { + for (const p of [piece.outer, ...piece.holes].flat()) { + const d = distanceToBoundary(boundary, p) + if (d <= 0.005) continue + reachM = Math.max(reachM, d) + // past the edge nearest it, or past both edges of the corner nearest it + for (const edge of nearestEdges(ring, p)) { + const [nx, nz] = outwardNormal(ring, edge) + const a = ring[edge] as Pt + if ((p[0] - a[0]) * nx + (p[1] - a[1]) * nz > 0.005) sides.add(sideOf(nx, nz)) + } + } + } + return { areaM2, reachM, sides: [...sides] } +} + +/** Signed metres from each face's midpoint, square to the house, to the lot line. */ +function planYards(rings: readonly (readonly Pt[])[], lot: readonly Pt[]) { + const b = boundsOf(rings.flat()) + const [mx, mz] = boundsCentre(b) + const lotShape = [{ outer: lot.map((p) => [p[0], p[1]] as P), holes: [] }] + const from: Record = { + north: [mx, b.minZ], + south: [mx, b.maxZ], + west: [b.minX, mz], + east: [b.maxX, mz], + } + const yards = {} as Record + for (const side of Object.keys(SIDES) as PlanSide[]) { + const [dx, dz] = SIDES[side] + const origin = from[side] + if (distanceToBoundary(lotShape, origin) < 1e-6) yards[side] = 0 + else if (containsPoint(lotShape, origin)) { + yards[side] = rayToPolygon(lot, origin, dx, dz)?.distance ?? null + } else { + const back = rayToPolygon(lot, origin, -dx, -dz) + yards[side] = back ? -back.distance : null + } + } + return yards +} + +/** The plan side of each yard, seen from the street: standing in it, facing the house. */ +function streetFaces(front: PlanSide): Record { + const [fx, fz] = SIDES[front] + // facing the house is −front; its right hand, with z pointing south, is (−dz, dx) + const right = sideOf(fz, -fx) + return { front, rear: OPPOSITE[front], right, left: OPPOSITE[right] } +} + +function streetOf(site: SiteNode, toLevel: (x: number, z: number) => Pt, yaw: number) { + const lot = (site.polygon?.points ?? []) as P[] + const n = lot.length + const named = + typeof site.frontEdge === 'number' && site.frontEdge >= 0 && site.frontEdge < n + ? site.frontEdge + : site.streetEdges?.find((i) => Number.isInteger(i) && i >= 0 && i < n) + if (named === undefined || n < 3) return null + const [nx, nz] = outwardNormal(lot, named) + const origin = toLevel(0, 0) + const tip = toLevel(nx, nz) + const side = sideOf(tip[0] - origin[0], tip[1] - origin[1]) + const p = lot[named] as P + const q = lot[(named + 1) % n] as P + // the house's local x runs along site angle −yaw; square to the edge when −yaw ≡ α (mod 90°) + const alpha = Math.atan2(q[1] - p[1], q[0] - p[0]) + const quarter = Math.PI / 2 + const square = -alpha + Math.round((yaw + alpha) / quarter) * quarter + return { side, edge: named, squareRotationDeg: round(square * DEG, 10) } +} + +function formatAddress(site: SiteNode): string | null { + const a = site.address + if (!a) return null + const tail = [a.city, [a.state, a.zip].filter(Boolean).join(' ')].filter(Boolean).join(', ') + const text = [a.street, tail].filter(Boolean).join(', ') + return text || null +} + +/** + * How a footprint (rings in the building's level frame) sits on the site the brief reads: its + * bounds and centre, how far it is off the buildable centre, its yards, and whether it stays in + * the buildable area, on the lot, or past the lot line. + */ +export function measureFootprint( + rings: readonly (readonly Pt[])[], + site: Pick, +): SiteFootprint { + const { lot, buildable, street } = site + const bounds = boundsOf(rings.flat()) + const centre = boundsCentre(bounds) + const aim = buildable.centre ?? ringCentre(lot.ring) ?? centre + const past = spill(rings, lot.ring) + const inYards = buildable.state === 'lot' ? past : spill(rings, buildable.ring) + const plan = planYards(rings, lot.ring) + const faces = street ? streetFaces(street.side) : null + const yards = faces + ? Object.fromEntries((Object.keys(faces) as YardKey[]).map((key) => [key, plan[faces[key]]])) + : plan + return { + rings: rings.map((ring) => ring.map((p) => [p[0], p[1]] as P)), + bounds, + centre, + offCentreM: Math.hypot(centre[0] - aim[0], centre[1] - aim[1]), + yards, + fit: past.areaM2 > 0 ? 'outside' : inYards.areaM2 > 0 ? 'lot' : 'buildable', + overhang: { + lotM: past.reachM, + setbacksM: Number.isFinite(inYards.reachM) ? inYards.reachM : 0, + sides: past.areaM2 > 0 ? past.sides : inYards.sides, + }, + } +} + +/** + * Where new geometry on a level (rings in its level coordinates) crosses its lot line: null + * while it stays on the lot, or when the level's building has no site. + */ +export function lotOverhang( + nodes: Nodes, + levelId: string, + rings: readonly (readonly Pt[])[], +): { fit: 'outside'; overhangM: number; sides: PlanSide[] } | null { + const brief = siteBrief(nodes, { levelId }) + if (!brief) return null + const past = spill(rings, brief.lot.ring) + if (past.areaM2 === 0) return null + return { fit: 'outside', overhangM: round(past.reachM), sides: past.sides } +} + +/** + * The site a building stands on, in that building's level coordinates. The building is the one + * named, else the named level's, else the scene's only one, else the site's first; null when the + * scene has no site with a lot. + */ +export function siteBrief( + nodes: Nodes, + target: { buildingId?: string | null; levelId?: string | null } = {}, +): SiteBrief | null { + const resolved = resolveSiteBuilding(nodes, target) + let building = resolved.building + if (!building && resolved.ambiguous) { + const site = sitesOf(nodes)[0] + const first = site?.children?.map((id) => nodes[id]).find((node) => node?.type === 'building') + building = (first as BuildingNode | undefined) ?? buildingsOf(nodes)[0] ?? null + } + const stored = siteOfBuilding(nodes, building) + if (!stored) return null + const site = readSiteNode(stored) + const siteRing = ((site.polygon?.points ?? []) as P[]).map((p) => [p[0], p[1]] as P) + if (siteRing.length < 3) return null + + const yaw = building?.rotation?.[1] ?? 0 + const toLevel = levelFrame(building) + const inLevel = (ring: readonly Pt[]) => ring.map((p) => toLevel(p[0], p[1]) as P) + const ring = inLevel(siteRing) + const inSite = buildableInSite(site) + const buildableRing = inLevel(inSite.ring) + const buildable: BuildableArea = { + state: inSite.state, + ring: buildableRing, + centre: inSite.state === 'none' ? null : ringCentre(buildableRing), + ...(site.setbacks ? { setbacks: site.setbacks } : {}), + ...(site.setbacksSource ? { source: site.setbacksSource } : {}), + } + const street = streetOf(site, toLevel, yaw) + const parcel = site.parcel + const lot: SiteBrief['lot'] = { + ring, + siteRing, + bounds: boundsOf(ring), + areaM2: polygonAreaAbs(siteRing), + approximate: Boolean( + parcel && ((parcel.notes ?? []).some((note) => /approximate/i.test(note)) || !parcel.apn), + ), + } + + const rings = building ? buildingFootprintRings(nodes, building) : [] + const footprint = rings.length > 0 ? measureFootprint(rings, { lot, buildable, street }) : null + + const others: SiteBrief['others'] = [] + for (const other of buildingsOf(nodes)) { + if (other.id === building?.id || siteOfBuilding(nodes, other)?.id !== stored.id) continue + const toSite = siteFrame(other) + const ringsOther = buildingFootprintRings(nodes, other) + if (ringsOther.length === 0) continue + const points = ringsOther.flat().map((p) => { + const s = toSite(p[0], p[1]) + return toLevel(s[0], s[1]) + }) + others.push({ buildingId: other.id, bounds: boundsOf(points) }) + } + + return { + siteId: stored.id, + buildingId: building?.id ?? null, + pose: { + position: [...(building?.position ?? [0, 0, 0])] as [number, number, number], + rotationDeg: yaw * DEG, + }, + lot, + placeholder: !formatAddress(site) && !parcel && !site.setbacks && isPlaceholderSquare(siteRing), + buildable, + street, + northDeg: (((((site.northRotation ?? 0) + yaw) * DEG) % 360) + 360) % 360, + address: formatAddress(site), + parcel: parcel + ? { + ...(parcel.apn ? { apn: parcel.apn } : {}), + ...(parcel.lotAreaSqFt ? { lotAreaSqFt: parcel.lotAreaSqFt } : {}), + ...(parcel.source ? { source: parcel.source } : {}), + ...(parcel.notes?.length ? { notes: parcel.notes } : {}), + } + : null, + zone: site.zone ?? null, + footprint, + others, + } +} + +/* ------------------------------------------------------- placing */ + +export type PlacementRefusal = { + ok: false + code: + | 'no_site' + | 'not_a_building' + | 'no_building' + | 'which_building' + | 'needs_footprint' + | 'other_building_on_site' + | 'no_street' + | 'yards_conflict' + | 'yards_infeasible' + message: string +} + +export type BuildingPlacement = { + ok: true + buildingId: string + position: [number, number, number] + rotation: [number, number, number] + /** False when the building already stood there: a replayed call changes nothing. */ + moved: boolean + /** The site read with the building at its new pose. */ + brief: SiteBrief + /** How the footprint placed (the outline, else the walls) sits at the new pose. */ + footprint: SiteFootprint +} + +export type PlaceBuildingInput = { + buildingId?: string + levelId?: string + /** + * The footprint about to be drawn, in the level coordinates it will be drawn in; without it, + * the walls standing. + */ + outline?: readonly Pt[] + /** Metres from the lot line, square to the house: at most one of front / rear and of left / right. */ + yards?: Partial> + /** The building's rotation; absent, it keeps the one it has. */ + rotationDeg?: number +} + +const refusal = (code: PlacementRefusal['code'], message: string): PlacementRefusal => ({ + ok: false, + code, + message, +}) + +/** + * Where a building goes on its site: the building's position (and, when asked, its rotation) + * that puts its footprint's centre on the buildable centre, or at the yards a site plan or the + * person gives. The inputs are pose-independent (an outline in the drawing's own coordinates, + * yards from the lot lines), so a repeated call gives the same pose. The lot is never resized: a + * footprint too big for it is still centred, and the brief reports it outside. + */ +export function placeBuildingPose( + nodes: Nodes, + input: PlaceBuildingInput, +): BuildingPlacement | PlacementRefusal { + if (!sitesOf(nodes).some((site) => (site.polygon?.points?.length ?? 0) >= 3)) + return refusal('no_site', 'The scene has no site with a lot to stand a building on.') + const resolved = resolveSiteBuilding(nodes, input) + if (input.buildingId && !resolved.building) + return refusal('not_a_building', `${input.buildingId} is not a building.`) + if (resolved.ambiguous) + return refusal( + 'which_building', + 'The site has several buildings: name the one to place (its id from list_levels, parentId).', + ) + const building = resolved.building + if (!building) return refusal('no_building', 'The scene has no building to place.') + const site = readSiteNode(siteOfBuilding(nodes, building) as SiteNode) + + const yaw = + input.rotationDeg === undefined ? (building.rotation?.[1] ?? 0) : input.rotationDeg / DEG + const rings: P[][] = + input.outline && input.outline.length >= 3 + ? [input.outline.map((p) => [p[0], p[1]] as P)] + : buildingFootprintRings(nodes, building) + if (rings.length === 0) + return refusal( + 'needs_footprint', + 'The building has no walls or rooms yet: pass the outline of the plan you are about to draw, in the coordinates you will draw it.', + ) + + const yards = input.yards ?? {} + const given = (Object.keys(yards) as YardKey[]).filter((key) => Number.isFinite(yards[key])) + if (given.includes('front') && given.includes('rear')) + return refusal( + 'yards_conflict', + 'Give the front yard or the rear one, not both: the house fills the rest.', + ) + if (given.includes('left') && given.includes('right')) + return refusal( + 'yards_conflict', + 'Give the left yard or the right one, not both: the house fills the rest.', + ) + + const before = siteBrief(nodes, { buildingId: building.id }) as SiteBrief + if (given.length > 0 && !before.street) + return refusal( + 'no_street', + 'The site has no street edge, so front, rear, left and right mean nothing yet: set the site frontEdge first.', + ) + if (given.length === 0 && before.others.length > 0) + return refusal( + 'other_building_on_site', + `Another building stands on this site (${before.others.map((o) => o.buildingId).join(', ')}): centring could put one on the other, so give yards.`, + ) + + const lotSite = before.lot.siteRing + const inSite = buildableInSite(site) + const aimRing = inSite.state === 'none' ? lotSite : inSite.ring + const [cx, cz] = boundsCentre(boundsOf(rings.flat())) + const poseAt = (target: Pt): P => { + const [tx, tz] = turnPlanPoint(cx, cz, yaw) + return [target[0] - tx, target[1] - tz] + } + const siteRings = (at: Pt) => { + const toSite = siteFrame({ position: [at[0], 0, at[1]], rotation: [0, yaw, 0] }) + return rings.map((ring) => ring.map((p) => toSite(p[0], p[1]))) + } + const fits = (at: Pt) => spill(siteRings(at), aimRing).areaM2 === 0 + + const centre = ringCentre(aimRing) ?? boundsCentre(boundsOf(lotSite)) + let at = poseAt(centre) + if (!fits(at)) { + const pole = polygonInteriorPoint({ polygon: aimRing.map((p) => [p[0], p[1]]) }) + const atPole = poseAt(pole) + if (fits(atPole)) at = atPole + } + + if (given.length > 0) { + const faces = streetFaces((before.street as NonNullable).side) + for (let pass = 0; pass < 2; pass++) { + const toLevel = levelFrame({ position: [at[0], 0, at[1]], rotation: [0, yaw, 0] }) + const lot = lotSite.map((p) => toLevel(p[0], p[1])) + const now = planYards(rings, lot) + let shift: P = [0, 0] + for (const key of given) { + const side = faces[key] + const yard = now[side] + if (yard === null) + return refusal('yards_infeasible', `The ${key} face does not look onto a lot line.`) + const [dx, dz] = SIDES[side] + const by = yard - (yards[key] as number) + shift = [shift[0] + dx * by, shift[1] + dz * by] + } + const [sx, sz] = turnPlanPoint(shift[0], shift[1], yaw) + at = [at[0] + sx, at[1] + sz] + } + const toLevel = levelFrame({ position: [at[0], 0, at[1]], rotation: [0, yaw, 0] }) + const placed = planYards( + rings, + lotSite.map((p) => toLevel(p[0], p[1])), + ) + for (const key of given) { + const across = placed[OPPOSITE[faces[key]]] + if (across === null || across < 0) + return refusal( + 'yards_infeasible', + `A ${yards[key]} m ${key} yard pushes the house ${across === null ? 'off' : `${round(-across)} m past`} the lot line opposite: the lot is too shallow for it on that side.`, + ) + } + } + + const position: [number, number, number] = [ + round(at[0], 1e4), + building.position?.[1] ?? 0, + round(at[1], 1e4), + ] + const rotation: [number, number, number] = [ + building.rotation?.[0] ?? 0, + round(yaw, 1e6), + building.rotation?.[2] ?? 0, + ] + const moved = + Math.hypot( + position[0] - (building.position?.[0] ?? 0), + position[2] - (building.position?.[2] ?? 0), + ) > 1e-3 || Math.abs(rotation[1] - (building.rotation?.[1] ?? 0)) > 1e-5 + const placedNodes = { ...nodes, [building.id]: { ...building, position, rotation } } as Nodes + const brief = siteBrief(placedNodes, { buildingId: building.id }) as SiteBrief + return { + ok: true, + buildingId: building.id, + position, + rotation, + moved, + brief, + footprint: measureFootprint(rings, brief), + } +} + +/** A site brief's numbers rounded to the centimetre (angles to a tenth of a degree), as agents read them. */ +export function roundedSiteBrief(brief: SiteBrief) { + const bounds = (b: PlanBounds) => ({ + minX: round(b.minX), + minZ: round(b.minZ), + maxX: round(b.maxX), + maxZ: round(b.maxZ), + }) + const footprint = brief.footprint + return { + ...brief, + pose: { + position: brief.pose.position.map((n) => round(n)) as [number, number, number], + rotationDeg: round(brief.pose.rotationDeg, 10), + }, + lot: { + ...brief.lot, + ring: brief.lot.ring.map(roundPt), + siteRing: brief.lot.siteRing.map(roundPt), + bounds: bounds(brief.lot.bounds), + areaM2: round(brief.lot.areaM2, 10), + }, + buildable: { + ...brief.buildable, + ring: brief.buildable.ring.map(roundPt), + centre: brief.buildable.centre ? roundPt(brief.buildable.centre) : null, + }, + northDeg: round(brief.northDeg, 10), + footprint: footprint + ? { + bounds: bounds(footprint.bounds), + centre: roundPt(footprint.centre), + offCentreM: round(footprint.offCentreM), + yards: Object.fromEntries( + Object.entries(footprint.yards).map(([key, m]) => [ + key, + m === null ? null : round(m ?? 0), + ]), + ), + fit: footprint.fit, + overhang: { + lotM: round(footprint.overhang.lotM), + setbacksM: round(footprint.overhang.setbacksM), + sides: footprint.overhang.sides, + }, + } + : null, + others: brief.others.map((other) => ({ ...other, bounds: bounds(other.bounds) })), + } +} + +const num = (n: number) => { + const r = Math.round(n * 10) / 10 || 0 + return Number.isInteger(r) ? String(r) : r.toFixed(1) +} +const fmtPt = (p: Pt) => `[${num(p[0])}, ${num(p[1])}]` +const span = (b: PlanBounds) => `x ${num(b.minX)}..${num(b.maxX)}, z ${num(b.minZ)}..${num(b.maxZ)}` +const size = (b: PlanBounds) => `${num(b.maxX - b.minX)}×${num(b.maxZ - b.minZ)} m` + +function describeSetbacks(setbacks: SiteSetbacks): string { + const parts = [`front ${num(setbacks.front)}`] + if (setbacks.left !== undefined || setbacks.right !== undefined) + parts.push( + `left ${num(setbacks.left ?? setbacks.side)}`, + `right ${num(setbacks.right ?? setbacks.side)}`, + ) + else parts.push(`sides ${num(setbacks.side)}`) + parts.push(`rear ${num(setbacks.rear)}`) + if (setbacks.streetSide !== undefined) parts.push(`street side ${num(setbacks.streetSide)}`) + return `${parts.join(', ')} m` +} + +/** + * The site in a few lines, as the agent reads it before drawing: the lot (in the building's level + * coordinates), its facts, the buildable area and its centre, the street and north, and where the + * footprint stands, or where a new one goes. + */ +export function describeSiteBrief(brief: SiteBrief): string[] { + const { lot, buildable, street, footprint } = brief + const [px, py, pz] = brief.pose.position + const posed = Math.hypot(px, pz) > 1e-3 || Math.abs(brief.pose.rotationDeg) > 0.05 + const lines = [ + `lot, in this building's level coordinates: [${lot.ring.map(fmtPt).join(', ')}]${ + posed + ? ` (stored in site coordinates; building at [${num(px)}, ${num(py)}, ${num(pz)}], ${num(brief.pose.rotationDeg)}°)` + : '' + }`, + ] + + const facts = [`lot ${size(lot.bounds)} (${Math.round(lot.areaM2)} m²)`] + if (brief.placeholder) + facts.push('the new-project placeholder: no address or parcel, real property lines unknown') + if (brief.address) facts.push(brief.address) + if (brief.parcel?.apn) facts.push(`APN ${brief.parcel.apn}`) + if (brief.zone) facts.push(`zone ${brief.zone}`) + if (lot.approximate) facts.push('approximate rectangle: real property lines not found') + lines.push(facts.join(' · ')) + + const centre = buildable.centre + if (buildable.state === 'none') + lines.push( + `buildable: none — the setbacks (${describeSetbacks(buildable.setbacks as SiteSetbacks)}) leave nothing on this lot`, + ) + else if (buildable.state === 'lot') + lines.push( + `buildable: the whole lot (no setbacks recorded) · centre ${centre ? fmtPt(centre) : 'none'}`, + ) + else + lines.push( + `buildable${buildable.state === 'advisory' ? ' (advisory: the inset of this concave lot crosses itself)' : ''}: setbacks ${describeSetbacks(buildable.setbacks as SiteSetbacks)}${buildable.source ? ` (${buildable.source})` : ''} · ${span(boundsOf(buildable.ring))} · centre ${centre ? fmtPt(centre) : 'none'}`, + ) + + const north = + Math.abs(((brief.northDeg + 180) % 360) - 180) < 0.5 + ? 'plan up' + : `${num(brief.northDeg)}° clockwise of plan up` + lines.push( + `${ + street + ? `street: ${street.side} (lot edge ${street.edge}; rotationDeg ${num(street.squareRotationDeg)} squares the house to it)` + : 'street: not set' + } · north: ${north}`, + ) + + if (!footprint) { + const aim = centre ?? boundsCentre(lot.bounds) + lines.push( + `footprint: none yet — centre it on ${fmtPt(aim)} (a 12×10 m footprint spans ${span({ minX: aim[0] - 6, maxX: aim[0] + 6, minZ: aim[1] - 5, maxZ: aim[1] + 5 })})`, + ) + return lines + } + const yards = Object.entries(footprint.yards) + .map(([key, m]) => `${key} ${m === null || m === undefined ? '?' : num(m)}`) + .join(', ') + const room = buildable.ring.length >= 3 ? boundsOf(buildable.ring) : lot.bounds + const tooBig = + footprint.bounds.maxX - footprint.bounds.minX > room.maxX - room.minX || + footprint.bounds.maxZ - footprint.bounds.minZ > room.maxZ - room.minZ + const centred = footprint.offCentreM < 0.25 + const fix = centred + ? tooBig + ? `: it is bigger than the buildable area (${size(room)}) even centred, so say so` + : ': even centred, the shape of the lot leaves it out: give yards or reshape the plan' + : `: place_building centres it${tooBig ? `, but it is bigger than the buildable area (${size(room)}): say so` : ''}` + const where = + footprint.fit === 'outside' + ? `${num(footprint.overhang.lotM)} m past the lot line (${footprint.overhang.sides.join(', ')})${fix}` + : footprint.fit === 'lot' + ? buildable.state === 'none' + ? 'on the lot, with nothing buildable inside the setbacks' + : `on the lot but ${num(footprint.overhang.setbacksM)} m into the required yards (${footprint.overhang.sides.join(', ')})${fix}` + : centred + ? 'inside the buildable area, on its centre' + : `inside the buildable area, ${num(footprint.offCentreM)} m off its centre` + lines.push( + `footprint: ${size(footprint.bounds)} centred ${fmtPt(footprint.centre)} · yards ${yards} m · ${where}`, + ) + return lines +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index fb69b01901..659073480b 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -538,11 +538,8 @@ export { } from './lib/floorplan/sheet-export' // ── Site plan (WS1) ────────────────────────────────────────────────── export { - boundsInsidePolygon, - buildingRecentreOffset, buildSitePlanDrawing, castYardDimensions, - classifyEdges, computeSiteCoverage, describeSiteEdges, detectFrontEdgeFromRoads, @@ -566,10 +563,8 @@ export { type SitePlanServiceRole, type SitePlanServices, serviceEntranceOf, - setbackEnvelope, streetCore, streetEdgeNames, - type YardDimension, } from './lib/floorplan/site-plan' export { commitFreshPlacementSubtree, diff --git a/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts b/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts index 5a8f50d9a9..57d53daa2b 100644 --- a/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +++ b/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts @@ -2,15 +2,28 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + castYardDimensionsOriented, + classifyEdges, + edgeHeadingDeg, + edgeLength, envelopeFrontEdge, type FloorplanGeometry, + footprintOutline, isRoofLevel, type LevelNode, + levelFootprintLoops, + outwardNormal, + resolveFrontEdge, type SceneSnapshot, type SiteNode, + setbackForRole, sightTriangle, + siteFrame, + siteSetbackEnvelope, streetCorners, terrainFieldOf, + type YardDimension, + yawOf, } from '@pascal-app/core' import { sitePlanContributions } from './contributors' import { @@ -21,21 +34,11 @@ import { } from './coverage' import { type Bounds, - boundsInsidePolygon, - castYardDimensionsOriented, - classifyEdges, compassLabel, - edgeHeadingDeg, - edgeLength, formatFeetInches, METRES_PER_FOOT, - outwardNormal, type Pt, polygonBounds, - resolveFrontEdge, - setbackEnvelope, - setbackForRole, - type YardDimension, } from './geometry' import { contourPrimitives, @@ -52,14 +55,10 @@ import { findBuilding, findLowestLevel, findSite, - footprintOutline, - levelFootprintLoops, roofOutlineRings, - siteFrame, - yawOf, } from './site-parts' -export { footprintOutline, levelFootprintLoops, roofOutlineRings } +export { roofOutlineRings } /** Contract shared by every drawing producer (see docs/construction-documents.md). */ export interface SitePlanDrawing { @@ -318,7 +317,7 @@ export function buildSitePlanDrawing(scene: SceneSnapshot): SitePlanDrawing { const lot = (site?.polygon?.points ?? []) as Pt[] const building = findBuilding(scene, site) const level = findLowestLevel(scene, building) - const footprintLoops = levelFootprintLoops(scene, level, building) + const footprintLoops = levelFootprintLoops(scene.nodes, level, building) const footprintBounds = unionBounds(footprintLoops) const northRotation = site?.northRotation ?? 0 const frontEdge = resolveFrontEdge(lot, site?.frontEdge, northRotation) @@ -395,9 +394,7 @@ export function buildSitePlanDrawing(scene: SceneSnapshot): SitePlanDrawing { typeof site?.sightTriangleFt === 'number' && site.sightTriangleFt > 0 ? site.sightTriangleFt * METRES_PER_FOOT : 0 - const envelope = site?.setbacks - ? setbackEnvelope(lot, site.setbacks, frontEdge, { streetEdges, sightTriangleM }) - : [] + const envelope = site ? siteSetbackEnvelope(site) : [] // the corner sight triangles (clear-vision at a street intersection): // dashed, labelled with the leg — the ordinance's figure, verify locally const sightTriangles: Pt[][] = [] @@ -766,22 +763,3 @@ export function buildSitePlanDrawing(scene: SceneSnapshot): SitePlanDrawing { }, } } - -/** - * Translation (site metres, `[dx, dz]`) that centres the building's footprint - * on the lot centroid. `null` when there is nothing to move or the footprint - * already sits inside the ring. - */ -export function buildingRecentreOffset( - lot: readonly Pt[], - footprintBounds: Bounds | null, -): [number, number] | null { - if (lot.length < 3 || !footprintBounds) return null - if (boundsInsidePolygon(lot, footprintBounds)) return null - const lotB = polygonBounds(lot) - const lotCx = (lotB.minX + lotB.maxX) / 2 - const lotCy = (lotB.minY + lotB.maxY) / 2 - const fpCx = (footprintBounds.minX + footprintBounds.maxX) / 2 - const fpCy = (footprintBounds.minY + footprintBounds.maxY) / 2 - return [lotCx - fpCx, lotCy - fpCy] -} diff --git a/packages/editor/src/lib/floorplan/site-plan/coverage.ts b/packages/editor/src/lib/floorplan/site-plan/coverage.ts index bbf4f7c237..7a1ef82c56 100644 --- a/packages/editor/src/lib/floorplan/site-plan/coverage.ts +++ b/packages/editor/src/lib/floorplan/site-plan/coverage.ts @@ -26,15 +26,13 @@ * drop-in resolved one (`site.parcel.lotAreaSqFt`), else the drawn lot * polygon. Pure: reads a scene snapshot, touches no store. */ -import type { SceneSnapshot } from '@pascal-app/core' +import { footprintOutline, levelFootprintLoops, type SceneSnapshot } from '@pascal-app/core' import { METRES_PER_FOOT, type Pt, pointInPolygon, polygonArea, polygonBounds } from './geometry' import { aboveGradeLevels, findBuilding, findLowestLevel, findSite, - footprintOutline, - levelFootprintLoops, type OutdoorPart, outdoorParts, roofOutlineRings, @@ -133,7 +131,7 @@ export function computeSiteCoverage(scene: Pick): SiteCo const levels = storeys.length > 0 ? storeys : ground ? [ground] : [] // the footprint: every storey's wall bands, unioned, outer rings only - const loops = levels.flatMap((level) => levelFootprintLoops(snapshot, level, building)) + const loops = levels.flatMap((level) => levelFootprintLoops(snapshot.nodes, level, building)) const outline = loops.length > 0 ? footprintOutline(loops) : [] const buildingM2 = outline.reduce((s, ring) => s + polygonArea(ring), 0) diff --git a/packages/editor/src/lib/floorplan/site-plan/front-edge.ts b/packages/editor/src/lib/floorplan/site-plan/front-edge.ts index 812ad6f9e6..6933d82c36 100644 --- a/packages/editor/src/lib/floorplan/site-plan/front-edge.ts +++ b/packages/editor/src/lib/floorplan/site-plan/front-edge.ts @@ -22,7 +22,8 @@ * Coordinates are the site plan frame (metres, x east, y south), the frame * the lot polygon and the OSM roads share. */ -import { edgeLength, outwardNormal, type Pt } from './geometry' +import { edgeLength, outwardNormal } from '@pascal-app/core' +import type { Pt } from './geometry' import { parseStreetName, sameStreet } from './street-name' export interface RoadCenterline { diff --git a/packages/editor/src/lib/floorplan/site-plan/geometry.test.ts b/packages/editor/src/lib/floorplan/site-plan/geometry.test.ts index 79731aff42..8325935668 100644 --- a/packages/editor/src/lib/floorplan/site-plan/geometry.test.ts +++ b/packages/editor/src/lib/floorplan/site-plan/geometry.test.ts @@ -1,20 +1,22 @@ import { describe, expect, it } from 'bun:test' -import { sightTriangle, streetCorners } from '@pascal-app/core' import { - boundsInsidePolygon, - castYardDimensions, classifyEdges, edgeHeadingDeg, - formatFeetInches, - METRES_PER_FOOT, mostNorthFacingEdge, outwardNormal, - type Pt, - polygonArea, rayToPolygon, resolveFrontEdge, setbackEnvelope, setbackForRole, + sightTriangle, + streetCorners, +} from '@pascal-app/core' +import { + castYardDimensions, + formatFeetInches, + METRES_PER_FOOT, + type Pt, + polygonArea, } from './geometry' /** @@ -258,14 +260,6 @@ describe('rayToPolygon + castYardDimensions', () => { }) }) -describe('boundsInsidePolygon', () => { - it('is true only when every bbox corner is inside the lot', () => { - expect(boundsInsidePolygon(LOT, { minX: 5, minY: 5, maxX: 25, maxY: 15 })).toBe(true) - expect(boundsInsidePolygon(LOT, { minX: -5, minY: 5, maxX: 25, maxY: 15 })).toBe(false) - expect(boundsInsidePolygon(LOT, { minX: 100, minY: 100, maxX: 110, maxY: 110 })).toBe(false) - }) -}) - describe('formatFeetInches', () => { it('renders metres as feet-and-inches', () => { expect(formatFeetInches(7.62)).toBe(`25'-0"`) diff --git a/packages/editor/src/lib/floorplan/site-plan/geometry.ts b/packages/editor/src/lib/floorplan/site-plan/geometry.ts index 8e9af39ae8..567e2a4dc7 100644 --- a/packages/editor/src/lib/floorplan/site-plan/geometry.ts +++ b/packages/editor/src/lib/floorplan/site-plan/geometry.ts @@ -1,4 +1,4 @@ -import { arcRuns, insetPolygon, type KeepOut, sightTriangle, streetCorners } from '@pascal-app/core' +import { rayToPolygon, type YardDimension, type YardSide } from '@pascal-app/core' /** * Site-plan geometry — pure functions, no store, no React. * @@ -16,22 +16,6 @@ export interface Bounds { maxY: number } -/** Yard sides in the order the site-plan dimensions are cast. */ -export type YardSide = 'north' | 'south' | 'east' | 'west' - -export interface SetbackInputs { - front: number - side: number - rear: number - left?: number - right?: number - /** A corner lot's second street side; absent = the front setback. */ - streetSide?: number -} - -/** Per-edge classification of a lot polygon relative to its front edge. */ -export type EdgeRole = 'front' | 'rear' | 'left' | 'right' | 'street' | 'street' - const EPS = 1e-9 export function polygonBounds(points: readonly Pt[]): Bounds { @@ -86,46 +70,6 @@ export function polygonCentroid(points: readonly Pt[]): Pt { return [cx / (6 * a), cy / (6 * a)] } -/** True when the ring is wound counter-clockwise in a y-down (south-positive) frame. */ -export function isCounterClockwise(points: readonly Pt[]): boolean { - let a = 0 - for (let i = 0, n = points.length; i < n; i++) { - const p = points[i] as Pt - const q = points[(i + 1) % n] as Pt - a += p[0] * q[1] - q[0] * p[1] - } - return a > 0 -} - -/** - * Unit normal of edge `i` pointing OUT of the polygon, winding-agnostic. - */ -export function outwardNormal(points: readonly Pt[], i: number): Pt { - const n = points.length - const p = points[i % n] as Pt - const q = points[(i + 1) % n] as Pt - const dx = q[0] - p[0] - const dy = q[1] - p[1] - const len = Math.hypot(dx, dy) || 1 - // Left normal of the edge direction. For a CCW ring (positive shoelace in - // this frame) the left normal points inward, so flip. - const sign = isCounterClockwise(points) ? -1 : 1 - return [(sign * -dy) / len, (sign * dx) / len] -} - -/** - * Compass heading of edge `i`'s outward normal, in DEGREES clockwise from - * north, where `northRotation` (radians, clockwise) is true north's offset - * from plan up (−y). This is the direction the edge faces. - */ -export function edgeHeadingDeg(points: readonly Pt[], i: number, northRotation = 0): number { - const [nx, ny] = outwardNormal(points, i) - // Plan up (−y) is 0°, +x (east) is 90°. - const raw = Math.atan2(nx, -ny) - northRotation - const deg = (raw * 180) / Math.PI - return ((deg % 360) + 360) % 360 -} - const COMPASS = [ 'N', 'NNE', @@ -150,294 +94,6 @@ export function compassLabel(headingDeg: number): string { return COMPASS[idx] as string } -/** Length of edge `i`, metres. */ -export function edgeLength(points: readonly Pt[], i: number): number { - const n = points.length - const p = points[i % n] as Pt - const q = points[(i + 1) % n] as Pt - return Math.hypot(q[0] - p[0], q[1] - p[1]) -} - -/** - * Fallback front edge: the edge whose outward normal points closest to true - * north. Ties break toward the longer edge — the street frontage of a typical - * lot is one of its long sides. - */ -export function mostNorthFacingEdge(points: readonly Pt[], northRotation = 0): number { - let best = 0 - let bestScore = Number.POSITIVE_INFINITY - for (let i = 0; i < points.length; i++) { - const heading = edgeHeadingDeg(points, i, northRotation) - const off = Math.min(heading, 360 - heading) - // Sub-degree ties resolve on length so a chamfer corner never wins. - const score = off - Math.min(0.5, edgeLength(points, i) / 1000) - if (score < bestScore) { - bestScore = score - best = i - } - } - return best -} - -/** `frontEdge` if it indexes a real edge, else the most north-facing edge. */ -export function resolveFrontEdge( - points: readonly Pt[], - frontEdge: number | undefined, - northRotation = 0, -): number { - if ( - typeof frontEdge === 'number' && - Number.isInteger(frontEdge) && - frontEdge >= 0 && - frontEdge < points.length - ) { - return frontEdge - } - return mostNorthFacingEdge(points, northRotation) -} - -/** - * Classify every edge as front / rear / left / right. - * - * - front = `frontIndex`. - * - rear = the edge whose outward normal is most anti-parallel to the front's. - * - the rest are sides. LEFT / RIGHT are defined along the FRONT EDGE - * DIRECTION (`points[front] → points[front + 1]`): an edge whose midpoint - * projects behind the front-edge midpoint on that axis is `left`, ahead of - * it is `right`. Deterministic and independent of winding. - */ -export function classifyEdges( - points: readonly Pt[], - frontIndex: number, - streetEdges: readonly number[] = [], -): EdgeRole[] { - const n = points.length - const roles: EdgeRole[] = new Array(n).fill('left') - if (n === 0) return roles - - const front = ((frontIndex % n) + n) % n - const fn = outwardNormal(points, front) - // a corner lot's other street edges (the parcel fabric's frontage) are - // 'street' — they take the street-side setback, never a side yard, and - // the rear is never one of them - const street = new Set(streetEdges.map((i) => ((i % n) + n) % n)) - street.delete(front) - - let rear = -1 - let rearDot = Number.POSITIVE_INFINITY - for (let i = 0; i < n; i++) { - if (i === front || street.has(i)) continue - const ni = outwardNormal(points, i) - const dot = fn[0] * ni[0] + fn[1] * ni[1] - if (dot < rearDot) { - rearDot = dot - rear = i - } - } - - const fp = points[front] as Pt - const fq = points[(front + 1) % n] as Pt - const dx = fq[0] - fp[0] - const dy = fq[1] - fp[1] - const len = Math.hypot(dx, dy) || 1 - const ux = dx / len - const uy = dy / len - const fmx = (fp[0] + fq[0]) / 2 - const fmy = (fp[1] + fq[1]) / 2 - - for (let i = 0; i < n; i++) { - if (i === front) { - roles[i] = 'front' - continue - } - if (street.has(i)) { - roles[i] = 'street' - continue - } - if (i === rear) { - roles[i] = 'rear' - continue - } - const p = points[i] as Pt - const q = points[(i + 1) % n] as Pt - const mx = (p[0] + q[0]) / 2 - fmx - const my = (p[1] + q[1]) / 2 - fmy - roles[i] = mx * ux + my * uy < 0 ? 'left' : 'right' - } - return roles -} - -/** Required setback distance (metres) for each edge role. */ -export function setbackForRole(setbacks: SetbackInputs, role: EdgeRole): number { - switch (role) { - case 'front': - return setbacks.front - case 'street': - return setbacks.streetSide ?? setbacks.front - case 'rear': - return setbacks.rear - case 'left': - return setbacks.left ?? setbacks.side - case 'right': - return setbacks.right ?? setbacks.side - } -} - -/** - * Buildable envelope — every lot edge pushed INWARD by its own setback, with - * the new vertices taken as the intersections of adjacent offset lines - * (variable-distance straight-skeleton-lite). Exact for convex lots; on a - * concave lot a reflex corner can self-intersect, in which case the caller - * still gets a ring but it is advisory, not a legal envelope. - * - * Adjacent offset lines that are parallel (a straight run split by a - * surplus vertex) have no intersection: the shared vertex is then simply - * pushed inward along the current edge's own offset, instead of the whole - * envelope being refused. - * - * Returns `[]` when the polygon has fewer than 3 points or when the result - * inverts (setbacks larger than the lot). - */ -export function setbackEnvelope( - points: readonly Pt[], - setbacks: SetbackInputs, - frontIndex: number, - options: { streetEdges?: readonly number[]; sightTriangleM?: number } = {}, -): Pt[] { - const n = points.length - if (n < 3) return [] - const streetEdges = options.streetEdges ?? [] - const roles = classifyEdges(points, frontIndex, streetEdges) - // A radius drawn as a run of short edges takes ONE role. A curb return - // is street frontage: a run carrying, or touching, a front / street edge - // is front / street all along (otherwise the last sliver of the return - // became a side yard, and the envelope jutted out toward the corner); a - // run carrying the rear is rear; else - // each sliver keeps its own side. - const runs = arcRuns(points) - const runRole = new Map() - const streetLike = (r: EdgeRole) => r === 'front' || r === 'street' - for (let i = 0; i < n; i++) { - const r = roles[i] as EdgeRole - const g = runs[i] as number - const have = runRole.get(g) - if (streetLike(r) || (r === 'rear' && !(have && streetLike(have)))) runRole.set(g, r) - else if (!have) runRole.set(g, r) - } - for (let i = 0; i < n; i++) { - const g = runs[i] as number - const have = runRole.get(g) as EdgeRole - if (streetLike(have)) continue - const before = roles[(i - 1 + n) % n] as EdgeRole - const after = roles[(i + 1) % n] as EdgeRole - const members = runs.filter((x) => x === g).length - if (members > 1) { - if (runs[(i - 1 + n) % n] !== g && streetLike(before)) runRole.set(g, before) - 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 = 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, - ) - if (!Number.isFinite(d) || d < 0) return [] - distances.push(d) - } - // the corner sight triangles keep the envelope out of the clear-vision - // zone at each street intersection (the hypotenuse is a half-plane) - const keepOut: KeepOut[] = [] - if (options.sightTriangleM && options.sightTriangleM > 0) { - for (const [a, b] of streetCorners(points, [frontIndex, ...streetEdges])) { - const tri = sightTriangle(points, a, b, options.sightTriangleM) - if (tri) keepOut.push({ a: tri.a, b: tri.b, inside: tri.corner }) - } - } - // the true inward offset (core setback-envelope.ts): straight where the - // lot is straight, concentric round a radius, clipped where the offsets - // cross — the industry-standard setback for pie shapes, radius corners - // and odd lots - return insetPolygon(points, distances, { keepOut }) -} - -/** - * Distance from `origin` along unit direction `(dx, dy)` to the first crossing - * of the polygon boundary. `null` when the ray never hits (origin outside, or - * a degenerate ring). - */ -export function rayToPolygon( - points: readonly Pt[], - origin: Pt, - dx: number, - dy: number, -): { distance: number; point: Pt } | null { - const n = points.length - let best: { distance: number; point: Pt } | null = null - for (let i = 0; i < n; i++) { - const p = points[i] as Pt - const q = points[(i + 1) % n] as Pt - const ex = q[0] - p[0] - const ey = q[1] - p[1] - const denom = dx * ey - dy * ex - if (Math.abs(denom) < 1e-12) continue - // origin + t*d = p + u*e - const t = ((p[0] - origin[0]) * ey - (p[1] - origin[1]) * ex) / denom - const u = ((p[0] - origin[0]) * dy - (p[1] - origin[1]) * dx) / denom - if (t <= 1e-9 || u < -1e-9 || u > 1 + 1e-9) continue - if (!best || t < best.distance) { - best = { distance: t, point: [origin[0] + dx * t, origin[1] + dy * t] } - } - } - return best -} - -export interface YardDimension { - side: YardSide - /** Bounding-box edge midpoint the dimension starts at. */ - from: Pt - /** Where the cast ray meets the lot line. */ - to: Pt - /** Metres. */ - distance: number -} - /** * The four yard dimensions: from each footprint bbox edge midpoint, straight * out to the lot line. Sides whose ray misses the lot line (footprint outside @@ -462,65 +118,6 @@ export function castYardDimensions(lot: readonly Pt[], footprint: Bounds): YardD return out } -/** - * Yard dimensions for a building TURNED on its lot. The footprint's bounds - * are taken in the building's own frame (`yaw`, the building node's Y - * rotation), and the four edge midpoints cast square to the house's faces - * out to the lot line — the yards a plan checker measures. With `yaw` 0 this - * is the axis-aligned cast above; with a house square to a diagonal lot the - * axis-aligned bbox sticks out past the real corners and reads a front yard - * inches short of the setback it actually meets (Land Park, 2026-09-06). - * `side` is the compass direction nearest the cast, so labels keep reading - * N / S / E / W. - */ -export function castYardDimensionsOriented( - lot: readonly Pt[], - footprintLoops: readonly (readonly Pt[])[], - yaw: number, -): YardDimension[] { - if (lot.length < 3) return [] - const c = Math.cos(yaw) - const s = Math.sin(yaw) - // world = (c·lx + s·lz, −s·lx + c·lz) ⇒ local = (c·wx − s·wz, s·wx + c·wz) - const toLocal = (p: Pt): Pt => [c * p[0] - s * p[1], s * p[0] + c * p[1]] - const toWorld = (p: Pt): Pt => [c * p[0] + s * p[1], -s * p[0] + c * p[1]] - let minX = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY - for (const loop of footprintLoops) { - for (const p of loop) { - const [x, z] = toLocal(p) - if (x < minX) minX = x - if (x > maxX) maxX = x - if (z < minZ) minZ = z - if (z > maxZ) maxZ = z - } - } - if (!Number.isFinite(minX)) return [] - const midX = (minX + maxX) / 2 - const midZ = (minZ + maxZ) / 2 - const casts: { from: Pt; dir: Pt }[] = [ - { from: [midX, minZ], dir: [0, -1] }, - { from: [midX, maxZ], dir: [0, 1] }, - { from: [minX, midZ], dir: [-1, 0] }, - { from: [maxX, midZ], dir: [1, 0] }, - ] - const sides: YardSide[] = ['north', 'east', 'south', 'west'] - const out: YardDimension[] = [] - for (const cast of casts) { - const from = toWorld(cast.from) - const [dx, dy] = toWorld(cast.dir) - const hit = rayToPolygon(lot, from, dx, dy) - if (!hit) continue - // heading of the cast: plan up (−y) is north, +x east - const heading = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360 - const side = sides[Math.round(heading / 90) % 4] as YardSide - out.push({ side, from, to: hit.point, distance: hit.distance }) - } - return out -} - /** Even-odd point-in-polygon. */ export function pointInPolygon(points: readonly Pt[], x: number, y: number): boolean { let inside = false @@ -534,18 +131,6 @@ export function pointInPolygon(points: readonly Pt[], x: number, y: number): boo return inside } -/** True when every corner of `bounds` is inside `lot`. */ -export function boundsInsidePolygon(lot: readonly Pt[], bounds: Bounds): boolean { - if (lot.length < 3) return false - const corners: Pt[] = [ - [bounds.minX, bounds.minY], - [bounds.maxX, bounds.minY], - [bounds.maxX, bounds.maxY], - [bounds.minX, bounds.maxY], - ] - return corners.every((c) => pointInPolygon(lot, c[0], c[1])) -} - export const METRES_PER_FOOT = 0.3048 /** `24'-6"` — the notation a US site plan uses for yard dimensions. */ diff --git a/packages/editor/src/lib/floorplan/site-plan/index.ts b/packages/editor/src/lib/floorplan/site-plan/index.ts index de5796d8c5..df4ceb7136 100644 --- a/packages/editor/src/lib/floorplan/site-plan/index.ts +++ b/packages/editor/src/lib/floorplan/site-plan/index.ts @@ -1,8 +1,6 @@ export { - buildingRecentreOffset, buildSitePlanDrawing, describeSiteEdges, - levelFootprintLoops, type SitePlanDrawing, type SitePlanEdge, } from './build-site-plan-drawing' @@ -37,29 +35,15 @@ export { } from './front-edge' export { type Bounds, - boundsInsidePolygon, castYardDimensions, - castYardDimensionsOriented, - classifyEdges, compassLabel, - edgeHeadingDeg, - edgeLength, formatFeetInches, METRES_PER_FOOT, - mostNorthFacingEdge, - outwardNormal, type Pt, pointInPolygon, polygonArea, polygonBounds, polygonCentroid, - rayToPolygon, - resolveFrontEdge, - type SetbackInputs, - setbackEnvelope, - setbackForRole, - type YardDimension, - type YardSide, } from './geometry' export { formatStreetName, diff --git a/packages/editor/src/lib/floorplan/site-plan/site-annotations.ts b/packages/editor/src/lib/floorplan/site-plan/site-annotations.ts index 1e96b629a6..e073c93ba7 100644 --- a/packages/editor/src/lib/floorplan/site-plan/site-annotations.ts +++ b/packages/editor/src/lib/floorplan/site-plan/site-annotations.ts @@ -13,8 +13,10 @@ import { type FloorplanGeometry, heightAt, type LevelNode, + outwardNormal, type SceneSnapshot, type SiteNode, + siteFrame, type TerrainField, terrainContours, } from '@pascal-app/core' @@ -26,14 +28,13 @@ import { import { formatFeetInches, METRES_PER_FOOT, - outwardNormal, type Pt, pointInPolygon, polygonArea, polygonBounds, polygonCentroid, } from './geometry' -import { flatworkKindOf, type OutdoorPart, siteFrame } from './site-parts' +import { flatworkKindOf, type OutdoorPart } from './site-parts' const INK = '#111827' const INK_SOFT = '#4b5563' diff --git a/packages/editor/src/lib/floorplan/site-plan/site-parts.ts b/packages/editor/src/lib/floorplan/site-plan/site-parts.ts index 9f59bee2bd..c90076c1fd 100644 --- a/packages/editor/src/lib/floorplan/site-plan/site-parts.ts +++ b/packages/editor/src/lib/floorplan/site-plan/site-parts.ts @@ -10,14 +10,14 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, - calculateLevelMiters, - getWallPlanFootprint, type LevelNode, migrateSiteMetadata, type SceneSnapshot, type SiteNode, + siteFrame, + turnPlanPoint, unionPolygons, - type WallNode, + yawOf, } from '@pascal-app/core' import { type Pt, pointInPolygon, polygonArea, polygonBounds } from './geometry' @@ -68,95 +68,6 @@ export function findLowestLevel( return best } -/** - * Per-wall plan footprints for a level, mitred, then transformed by the - * building's site placement. `position` is `[x, y, z]` in site metres (y is - * height, ignored in plan); `rotation[1]` is the yaw in radians. - */ -export function levelFootprintLoops( - scene: SceneSnapshot, - level: LevelNode | null, - building: BuildingNode | null, -): Pt[][] { - if (!level) return [] - const walls: WallNode[] = [] - for (const childId of level.children ?? []) { - const child = scene.nodes[childId as AnyNodeId] as AnyNode | undefined - if (child?.type === 'wall') walls.push(child as WallNode) - } - if (walls.length === 0) return [] - - const miters = calculateLevelMiters(walls) - const yaw = building?.rotation?.[1] ?? 0 - const cos = Math.cos(yaw) - const sin = Math.sin(yaw) - const ox = building?.position?.[0] ?? 0 - const oz = building?.position?.[2] ?? 0 - - const loops: Pt[][] = [] - for (const wall of walls) { - const poly = getWallPlanFootprint(wall, miters) - if (poly.length < 3) continue - // three.js Y rotation, the convention BuildingRenderer applies: - // world = (cos·lx + sin·lz, −sin·lx + cos·lz) + position - loops.push(poly.map((p) => [ox + p.x * cos + p.y * sin, oz - p.x * sin + p.y * cos] as Pt)) - } - return loops -} - -/** - * The outer ring(s) of a set of wall bands: their union, minus any ring that - * lies inside another (a room enclosed by partitions is a hole in the union, - * not a second building). Falls back to the bands themselves when the union - * yields nothing. - */ -export function footprintOutline(loops: readonly Pt[][]): Pt[][] { - const rings = unionPolygons(loops.map((loop) => loop.map((p) => [p[0], p[1]]))) as Pt[][] - if (rings.length === 0) return [...loops] - const inside = (p: Pt, ring: Pt[]): boolean => { - let hit = false - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const a = ring[i] as Pt - const b = ring[j] as Pt - if ( - a[1] > p[1] !== b[1] > p[1] && - p[0] < ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0] - ) { - hit = !hit - } - } - return hit - } - return rings.filter( - (ring, i) => - !rings.some((other, j) => j !== i && ring[0] !== undefined && inside(ring[0], other)), - ) -} - -/** A three.js Y rotation of a plan point: local (x, z) turned by `yaw`. */ -export function turn(x: number, z: number, yaw: number): Pt { - const c = Math.cos(yaw) - const s = Math.sin(yaw) - return [c * x + s * z, -s * x + c * z] -} - -export function yawOf(rotation: unknown): number { - if (typeof rotation === 'number') return rotation - if (Array.isArray(rotation)) return Number(rotation[1] ?? 0) || 0 - return 0 -} - -/** Level-local plan (x, z) → site metres, through the building's placement (see `levelFootprintLoops`). */ -export function siteFrame(building: BuildingNode | null): (x: number, z: number) => Pt { - const yaw = building?.rotation?.[1] ?? 0 - const ox = building?.position?.[0] ?? 0 - const oz = building?.position?.[2] ?? 0 - return (x, z) => { - const [tx, tz] = turn(x, z, yaw) - return [ox + tx, oz + tz] - } -} - /** * The roof's outline on the lot — every roof segment's plan rectangle plus * its overhang, through the segment's, the roof's and the building's turns, @@ -206,8 +117,8 @@ export function roofOutlineRings( ] rects.push( local.map(([lx, lz]) => { - const [ax, az] = turn(lx, lz, segYaw) - const [bx, bz] = turn(ax + sx, az + sz, roofYaw) + const [ax, az] = turnPlanPoint(lx, lz, segYaw) + const [bx, bz] = turnPlanPoint(ax + sx, az + sz, roofYaw) return toSite(bx + rx, bz + rz) }), ) diff --git a/packages/editor/src/lib/floorplan/site-plan/yards.test.ts b/packages/editor/src/lib/floorplan/site-plan/yards.test.ts index b3d6f9d2ee..ea7a9a2773 100644 --- a/packages/editor/src/lib/floorplan/site-plan/yards.test.ts +++ b/packages/editor/src/lib/floorplan/site-plan/yards.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { castYardDimensions, castYardDimensionsOriented, type Pt } from './geometry' +import { castYardDimensionsOriented } from '@pascal-app/core' +import { castYardDimensions, type Pt } from './geometry' // 20 m × 30 m lot centred on the origin, y south const LOT: Pt[] = [ diff --git a/packages/editor/src/lib/lot/clean-ring.test.ts b/packages/editor/src/lib/lot/clean-ring.test.ts index da629e54c7..a6d0ab419a 100644 --- a/packages/editor/src/lib/lot/clean-ring.test.ts +++ b/packages/editor/src/lib/lot/clean-ring.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' +import { classifyEdges, setbackEnvelope } from '@pascal-app/core' import type { Pt } from '../floorplan/site-plan/geometry' -import { classifyEdges, polygonArea, setbackEnvelope } from '../floorplan/site-plan/geometry' +import { polygonArea } from '../floorplan/site-plan/geometry' import { cleanLotRing, describeRingCleanup, diff --git a/packages/editor/src/lib/lot/drop-in.ts b/packages/editor/src/lib/lot/drop-in.ts index fc1acd9fb7..74e90f0bef 100644 --- a/packages/editor/src/lib/lot/drop-in.ts +++ b/packages/editor/src/lib/lot/drop-in.ts @@ -24,15 +24,12 @@ import { type AnyNode, type AnyNodeId, - type BuildingNode, generateId, + placeBuildingPose, SiteNode, + siteBrief, useScene, } from '@pascal-app/core' -import { - buildingRecentreOffset, - buildSitePlanDrawing, -} from '../floorplan/site-plan/build-site-plan-drawing' import { answered, type CodeBasisData, @@ -339,27 +336,15 @@ export async function dropInLot( terrainFailure = 'skipped' } - // Centre the building on the lot when its footprint fell outside the new ring. + // A house the new lot leaves past its lines stands centred on the buildable area again, facing + // as it did. let recentred = false const after = useScene.getState() - const drawing = buildSitePlanDrawing({ - collections: after.collections, - installedPlugins: after.installedPlugins, - materials: after.materials, - nodes: after.nodes, - rootNodeIds: after.rootNodeIds, - }) - const offset = buildingRecentreOffset(drawing.meta.lot, drawing.meta.footprintBounds) - if (offset && drawing.meta.buildingId) { - const building = after.nodes[drawing.meta.buildingId as AnyNodeId] as BuildingNode | undefined - if (building) { - after.updateNode(building.id, { - position: [ - building.position[0] + offset[0], - building.position[1], - building.position[2] + offset[1], - ], - }) + const brief = siteBrief(after.nodes) + if (brief?.buildingId && brief.footprint?.fit === 'outside') { + const placed = placeBuildingPose(after.nodes, { buildingId: brief.buildingId }) + if (placed.ok && placed.moved) { + after.updateNode(placed.buildingId as AnyNodeId, { position: placed.position }) recentred = true } } diff --git a/packages/editor/src/lib/lot/lot-patch.test.ts b/packages/editor/src/lib/lot/lot-patch.test.ts index 35d0e364d3..aafa5e0737 100644 --- a/packages/editor/src/lib/lot/lot-patch.test.ts +++ b/packages/editor/src/lib/lot/lot-patch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { edgeLength } from '../floorplan/site-plan/geometry' +import { edgeLength } from '@pascal-app/core' import { DEFAULT_SETBACKS_M, DEFAULT_SETBACKS_SOURCE, diff --git a/packages/editor/src/lib/lot/lot-patch.ts b/packages/editor/src/lib/lot/lot-patch.ts index 551ec8cbe4..7487121e05 100644 --- a/packages/editor/src/lib/lot/lot-patch.ts +++ b/packages/editor/src/lib/lot/lot-patch.ts @@ -22,13 +22,13 @@ * - `northRotation` is 0: the parcel frame is x east / z south, so plan up * is true north. */ -import type { SiteNode, SiteSetbacks } from '@pascal-app/core' +import { edgeLength, type SiteNode, type SiteSetbacks } from '@pascal-app/core' import { detectFrontEdgeFromRoads, type FrontEdgeMatch, type RoadCenterline, } from '../floorplan/site-plan/front-edge' -import { edgeLength, METRES_PER_FOOT, type Pt } from '../floorplan/site-plan/geometry' +import { METRES_PER_FOOT, type Pt } from '../floorplan/site-plan/geometry' import { sameStreet } from '../floorplan/site-plan/street-name' import { cleanLotRing, describeRingCleanup } from './clean-ring' import { detectFrontEdgeFromFrontage, type FrontageMatch } from './dossier' diff --git a/packages/mcp/README.md b/packages/mcp/README.md index fd073be5e3..11e3e1bf80 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -351,6 +351,8 @@ an optional `height` and uses the same default. | `get_level_summary` | Compact summary of one level: role, counts, walls with openings, zones with areas, slabs, ceilings, items, stairs, roofs, and everything else by type. Omit `levelId` for the lowest storey. | `{ levelId?, level? }` | `{ levelId, role, counts, walls, zones, slabs, ceilings, items, stairs, roofs, other }` | | `get_walls` | Walls on a level with length, stored and resolved height, and child doors/windows. | `{ levelId?, level? }` | `{ levelId, walls[] }` | | `get_zones` | Room/zone polygons with holes, areas (holes taken out), bounds and floor choices. | `{ levelId?, level? }` | `{ levelId, zones[] }` | +| `get_site` | The site a building stands on, in its level coordinates: the lot, the buildable area inside the setbacks and its centre, the street side, north, address, parcel and zoning, whether the lot is the new-project placeholder, and the footprint's fit and yards. | `{ building? }` | `{ siteId, buildingId, pose, lot, placeholder, buildable, street, northDeg, address, parcel, zone, footprint, others, summary }`; refusals `no_site`, `building_required` | +| `place_building` | Stand a building on its site by moving the building node only (walls keep their level coordinates): its footprint, the outline about to be drawn or the walls standing, centred in the buildable area or at the yards given; the rotation kept unless `rotationDeg` is given. | `{ building?, outline?, yards?: { front?, rear?, left?, right? }, rotationDeg? }` | `{ buildingId, position, rotationDeg, moved, footprint, yards, fit, overhang, movedWithIt?, message }`; refusals `no_site`, `needs_footprint`, `other_building_on_site`, `no_street`, `yards_conflict`, `yards_infeasible`, `building_required` | | `measure` | Distance between two nodes' world-space reference points (hosted doors, windows and items resolved through their host; every node kind); area when applicable. | `{ fromId, toId }` | `{ distanceMeters, fromPoint?, toPoint?, areaSqMeters?, units: 'meters' }` | | `view_scene` | Look at the building from a viewpoint the agent picks (a side, a street-height eye, a photo's camera, square-on, from above) and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, everything on it (walls, openings, furniture) cut at 0.65 of the storey's height, for the picture only. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`, `interior_transform_unsupported`, `interior_target_elsewhere`; on the hosted server `editor_tab_required`, `editor_tab_hidden`, `editor_tab_busy` (a tab open but busy or still loading, no link: wait a few seconds and call again, do not ask the user), `editor_tab_timeout` (a tab claimed the view and then stopped answering); what the refusal says about opening the editor follows the person's "Agents may open the editor for me" setting (ask first, allow, never) | | `search_assets` | Search the host's item library (the built-in list on a standalone server), several queries in one call; a query matches name, id, category or tags. A query that finds nothing gets a hint to build the item with `add_object`. | `{ queries: [{ query, category? }] }` | `{ groups: [{ query, total, results[] }], total, hint? }`; refusal `no_catalog` | @@ -383,7 +385,7 @@ an optional `height` and uses the same default. | `export_glb` | Stubbed: GLB export requires the browser renderer. | — | throws `not_implemented` | | `validate_scene` | Zod-validate every node and parent-child integrity. | — | `{ valid, errors: { nodeId, path, message }[] }` | | `validate_design` | Read-only: validate a design (procedural item recipe, object or JSON string) and measure it. The authority over `pascal://schema/design`. | `{ design, parameters? }` | `{ valid, diagnostics[], design, sweep, measurements }` | -| `verify_scene` | High-level check with validation status, per-level counts and roles, empty levels, and typed practical issues (storeys with no stair, openings off their wall, stairs off their slab, blocked doors…). | — | `{ valid, levels[], issues: { type, message }[], hasIssues }` | +| `verify_scene` | High-level check with validation status, per-level counts and roles, empty levels, and typed practical issues (storeys with no stair, openings off their wall, stairs off their slab, blocked doors, a house past its lot line or in its required yards…). | — | `{ valid, levels[], issues: { type, message }[], hasIssues }` | | `check_collisions` | Find overlapping items and out-of-bounds placements. | `{ levelId? }` | `{ collisions: { aId, bId, kind }[] }` | | `analyze_floorplan_image` | Vision tool: extract walls, rooms, and approximate dimensions from a floorplan image. | `{ image, scaleHint? }` | `{ walls, rooms, approximateDimensions, confidence }` | | `analyze_room_photo` | Vision tool: extract approximate dimensions and fixtures from a room photo. | `{ image }` | `{ approximateDimensions, identifiedFixtures, identifiedWindows }` | diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index aa0ddb01b4..a2b48d7015 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -33,6 +33,7 @@ export const AGENT_GUIDE = [ '', '## Scene Creation Rules', '', + '- The site comes first, before the first wall: `get_site` gives the lot, the buildable area inside its setbacks and its centre, the street side and whether the lot is only the new-project placeholder, in the level coordinates you draw in. Then `place_building` with the outline of the plan you are about to draw, in the coordinates you will draw it (and the yards a site plan gives), so the house rises inside the lot, centred. `create_room` and `add_wall` take your coordinates as given and say when they cross the lot line; `verify_scene` reports a house past it.', '- Prefer semantic tools over raw graph patches.', '- Do not hand-write node graphs unless no semantic tool exists.', "- A call whose arguments a tool's schema rejects is refused with the code `invalid_input` before anything runs: read the message, fix the arguments and call again. A level you write without a `height` is given the height the editor would derive from what it holds (an empty level 2.5 m), and `apply_patch` says so in `notes`.", diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index 54059be876..8a792d33c2 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -26,6 +26,7 @@ const TOOL_POLICIES = [ 'get_level_summary', 'get_node', 'get_scene', + 'get_site', 'get_walls', 'get_zones', 'list_levels', @@ -109,6 +110,7 @@ const TOOL_POLICIES = [ 'divide_zone', 'duplicate_zone', 'move_zone', + 'place_building', 'rotate_zone', 'lock_outside_faces', 'merge_windows', diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index fb4c8f143b..c74661d271 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -27,12 +27,14 @@ import { furnishRoomTool, getLevelSummaryTool, getNodeTool, + getSiteTool, getWallsTool, getZonesTool, listLevelsTool, measureStairTool, mergeWindowsTool, paintTool, + placeBuildingTool, placeInRoomTool, placeItemsTool, ROOM_TOOL_CONTRACTS, @@ -385,6 +387,24 @@ const SHARED_TOOLS: SharedTool[] = [ ...liveSyncOutput, }, }, + { + contract: getSiteTool, + operation: AGENT_OPERATIONS.get_site, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + }, + { + contract: placeBuildingTool, + operation: AGENT_OPERATIONS.place_building, + annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, + outputSchema: { + buildingId: z.string(), + moved: z.boolean(), + fit: z.enum(['buildable', 'lot', 'outside']), + message: z.string(), + ...achievedOutput, + ...liveSyncOutput, + }, + }, { contract: searchAssetsTool, operation: AGENT_OPERATIONS.search_assets, diff --git a/packages/nodes/src/site/setbacks.test.ts b/packages/nodes/src/site/line-ribbon.test.ts similarity index 64% rename from packages/nodes/src/site/setbacks.test.ts rename to packages/nodes/src/site/line-ribbon.test.ts index 0e7b5e5016..136e858202 100644 --- a/packages/nodes/src/site/setbacks.test.ts +++ b/packages/nodes/src/site/line-ribbon.test.ts @@ -6,42 +6,6 @@ import { SETBACK_LINE_PATTERN, updateRibbonHeights, } from './line-ribbon' -import { classifyEdges, resolveFrontEdge, setbackEnvelope } from './setbacks' - -// a 20 × 30 m lot; edge 0 runs along z = −15 (north), 1 east, 2 south, 3 west -const LOT: [number, number][] = [ - [-10, -15], - [10, -15], - [10, 15], - [-10, 15], -] - -describe('setbacks (the 3D copy of the site plan rules)', () => { - test('the front edge names the roles and the envelope shrinks by each side', () => { - expect(resolveFrontEdge(LOT, 2)).toBe(2) - expect(resolveFrontEdge(LOT, undefined)).toBe(0) // most north-facing - const roles = classifyEdges(LOT, 2) - expect(roles[2]).toBe('front') - expect(roles[0]).toBe('rear') - expect(new Set([roles[1], roles[3]])).toEqual(new Set(['left', 'right'])) - const env = setbackEnvelope(LOT, { front: 6, side: 1.5, rear: 4.5 }, 2) - expect(env).toHaveLength(4) - const xs = env.map((p) => p[0]) - const zs = env.map((p) => p[1]) - expect(Math.min(...xs)).toBeCloseTo(-8.5, 6) - expect(Math.max(...xs)).toBeCloseTo(8.5, 6) - expect(Math.max(...zs)).toBeCloseTo(9, 6) // the front (south) edge moved in 6 m - expect(Math.min(...zs)).toBeCloseTo(-10.5, 6) // the rear 4.5 m - // left / right overrides - const env2 = setbackEnvelope(LOT, { front: 6, side: 1.5, rear: 4.5, left: 3, right: 1 }, 2) - expect(Math.max(...env2.map((p) => p[0])) - Math.min(...env2.map((p) => p[0]))).toBeCloseTo( - 20 - 4, - 6, - ) - // setbacks bigger than the lot: nothing - expect(setbackEnvelope(LOT, { front: 20, side: 1, rear: 20 }, 2)).toEqual([]) - }) -}) describe('the patterned ribbon', () => { const ring = new Float32Array([0, 0, 0, 10, 0, 0, 10, 0, 10, 0, 0, 10, 0, 0, 0]) diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index bff8f17bb2..5131f66c4c 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -2,7 +2,9 @@ import { type AnyNodeId, + resolveFrontEdge, type SiteNode, + setbackEnvelope, sightTriangle, streetCorners, type TerrainField, @@ -53,7 +55,6 @@ import { updateRibbonHeights, } from './line-ribbon' import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes' -import { resolveFrontEdge, setbackEnvelope } from './setbacks' import { buildDrapedPolyline, type DrapedPolyline, diff --git a/packages/nodes/src/site/setbacks.ts b/packages/nodes/src/site/setbacks.ts deleted file mode 100644 index f4b4788762..0000000000 --- a/packages/nodes/src/site/setbacks.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { arcRuns, insetPolygon, type KeepOut, sightTriangle, streetCorners } from '@pascal-app/core' -/** - * The setback envelope in the 3D site — the same rules the site plan draws - * (packages/editor/src/lib/floorplan/site-plan/geometry.ts: front edge, - * edge roles, per-role setbacks, offset-line intersections), ported here - * because the nodes package cannot import the editor. Pure. When the site - * has setbacks, the 3D view draws them black and dashed. - */ -export type Pt = readonly [number, number] -export type EdgeRole = 'front' | 'rear' | 'left' | 'right' | 'street' -export type SetbackInputs = { - front: number - side: number - rear: number - left?: number - right?: number - /** A corner lot's second street side; absent = the front setback. */ - streetSide?: number -} - -const EPS = 1e-9 - -export function polygonArea(points: readonly Pt[]): number { - let a = 0 - for (let i = 0; i < points.length; i++) { - const p = points[i] as Pt - const q = points[(i + 1) % points.length] as Pt - a += p[0] * q[1] - q[0] * p[1] - } - return Math.abs(a) / 2 -} - -function signedArea(points: readonly Pt[]): number { - let a = 0 - for (let i = 0; i < points.length; i++) { - const p = points[i] as Pt - const q = points[(i + 1) % points.length] as Pt - a += p[0] * q[1] - q[0] * p[1] - } - return a / 2 -} - -export function isCounterClockwise(points: readonly Pt[]): boolean { - return signedArea(points) > 0 -} - -/** Unit normal of edge i pointing OUT of the polygon (away from its centroid). */ -export function outwardNormal(points: readonly Pt[], i: number): Pt { - const n = points.length - const p = points[i] as Pt - const q = points[(i + 1) % n] as Pt - const dx = q[0] - p[0] - const dy = q[1] - p[1] - const len = Math.hypot(dx, dy) || 1 - let nx = -dy / len - let ny = dx / len - let cx = 0 - let cy = 0 - for (const pt of points) { - cx += pt[0] - cy += pt[1] - } - cx /= n - cy /= n - const mx = (p[0] + q[0]) / 2 - const my = (p[1] + q[1]) / 2 - if ((mx - cx) * nx + (my - cy) * ny < 0) { - nx = -nx - ny = -ny - } - return [nx, ny] -} - -/** The most north-facing edge (plan up = −z = north, turned by `northRotation`). */ -export function mostNorthFacingEdge(points: readonly Pt[], northRotation = 0): number { - let best = 0 - let bestScore = Number.POSITIVE_INFINITY - for (let i = 0; i < points.length; i++) { - const [nx, ny] = outwardNormal(points, i) - // heading of the outward normal, degrees clockwise from north - const heading = (((Math.atan2(nx, -ny) - northRotation) * 180) / Math.PI + 360) % 360 - const off = Math.min(heading, 360 - heading) - if (off < bestScore) { - bestScore = off - best = i - } - } - return best -} - -export function resolveFrontEdge( - points: readonly Pt[], - frontEdge: number | undefined, - northRotation = 0, -): number { - if ( - typeof frontEdge === 'number' && - Number.isInteger(frontEdge) && - frontEdge >= 0 && - frontEdge < points.length - ) - return frontEdge - return mostNorthFacingEdge(points, northRotation) -} - -export function classifyEdges( - points: readonly Pt[], - frontIndex: number, - streetEdges: readonly number[] = [], -): EdgeRole[] { - const n = points.length - const roles: EdgeRole[] = new Array(n).fill('left') - if (n === 0) return roles - const front = ((frontIndex % n) + n) % n - const fn = outwardNormal(points, front) - // a corner lot's other street edges (the parcel fabric's frontage) are - // 'street' — they take the street-side setback, never a side yard, and - // the rear is never one of them - const street = new Set(streetEdges.map((i) => ((i % n) + n) % n)) - street.delete(front) - let rear = -1 - let rearDot = Number.POSITIVE_INFINITY - for (let i = 0; i < n; i++) { - if (i === front || street.has(i)) continue - const ni = outwardNormal(points, i) - const dot = fn[0] * ni[0] + fn[1] * ni[1] - if (dot < rearDot) { - rearDot = dot - rear = i - } - } - const fp = points[front] as Pt - const fq = points[(front + 1) % n] as Pt - const dx = fq[0] - fp[0] - const dy = fq[1] - fp[1] - const len = Math.hypot(dx, dy) || 1 - const ux = dx / len - const uy = dy / len - const fmx = (fp[0] + fq[0]) / 2 - const fmy = (fp[1] + fq[1]) / 2 - for (let i = 0; i < n; i++) { - if (i === front) { - roles[i] = 'front' - continue - } - if (street.has(i)) { - roles[i] = 'street' - continue - } - if (i === rear) { - roles[i] = 'rear' - continue - } - const p = points[i] as Pt - const q = points[(i + 1) % n] as Pt - const mx = (p[0] + q[0]) / 2 - fmx - const my = (p[1] + q[1]) / 2 - fmy - roles[i] = mx * ux + my * uy < 0 ? 'left' : 'right' - } - return roles -} - -export function setbackForRole(setbacks: SetbackInputs, role: EdgeRole): number { - switch (role) { - case 'front': - return setbacks.front - case 'street': - return setbacks.streetSide ?? setbacks.front - case 'rear': - return setbacks.rear - case 'left': - return setbacks.left ?? setbacks.side - case 'right': - return setbacks.right ?? setbacks.side - } -} - -/** The buildable envelope: every edge pushed inward by its role's setback (see the editor's copy for the rules). */ -export function setbackEnvelope( - points: readonly Pt[], - setbacks: SetbackInputs, - frontIndex: number, - options: { streetEdges?: readonly number[]; sightTriangleM?: number } = {}, -): Pt[] { - const n = points.length - if (n < 3) return [] - const streetEdges = options.streetEdges ?? [] - const roles = classifyEdges(points, frontIndex, streetEdges) - // A radius drawn as a run of short edges takes ONE role. A curb return - // is street frontage: a run carrying, or touching, a front / street edge - // is front / street all along (otherwise the last sliver of the return - // became a side yard, and the envelope jutted out toward the corner); a - // run carrying the rear is rear; else each sliver keeps its own side. - const runs = arcRuns(points) - const runRole = new Map() - const streetLike = (r: EdgeRole) => r === 'front' || r === 'street' - for (let i = 0; i < n; i++) { - const r = roles[i] as EdgeRole - const g = runs[i] as number - const have = runRole.get(g) - if (streetLike(r) || (r === 'rear' && !(have && streetLike(have)))) runRole.set(g, r) - else if (!have) runRole.set(g, r) - } - for (let i = 0; i < n; i++) { - const g = runs[i] as number - const have = runRole.get(g) as EdgeRole - if (streetLike(have)) continue - const before = roles[(i - 1 + n) % n] as EdgeRole - const after = roles[(i + 1) % n] as EdgeRole - const members = runs.filter((x) => x === g).length - if (members > 1) { - if (runs[(i - 1 + n) % n] !== g && streetLike(before)) runRole.set(g, before) - else if (runs[(i + 1) % n] !== g && streetLike(after)) runRole.set(g, after) - } - } - const distances: number[] = [] - for (let i = 0; i < n; i++) { - const role = runRole.get(runs[i] as number) ?? (roles[i] as EdgeRole) - const d = setbackForRole( - setbacks, - role === 'left' || role === 'right' ? (roles[i] as EdgeRole) : role, - ) - if (!Number.isFinite(d) || d < 0) return [] - distances.push(d) - } - // the corner sight triangles keep the envelope out of the clear-vision - // zone at each street intersection (the hypotenuse is a half-plane) - const keepOut: KeepOut[] = [] - if (options.sightTriangleM && options.sightTriangleM > 0) { - for (const [a, b] of streetCorners(points, [frontIndex, ...streetEdges])) { - const tri = sightTriangle(points, a, b, options.sightTriangleM) - if (tri) keepOut.push({ a: tri.a, b: tri.b, inside: tri.corner }) - } - } - // the true inward offset (core setback-envelope.ts): straight where the - // lot is straight, concentric round a radius, clipped where the offsets - // cross — the industry-standard setback for pie shapes, radius corners - // and odd lots - return insetPolygon(points, distances, { keepOut }) -} diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index 637b30d6a8..0e200f4d3a 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -552,6 +552,19 @@ "openWorldHint": "It operates only on data supplied by or already available inside the connected Pascal service and does not access the public internet." } }, + { + "name": "get_site", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It reads the site a building stands on, its lot, setbacks and footprint, from the connected Pascal scene without changing it.", + "destructiveHint": "It does not create, update, replace, delete, load, or save Pascal scene or project state.", + "openWorldHint": "It operates only on data supplied by or already available inside the connected Pascal service and does not access the public internet." + } + }, { "name": "get_source", "annotations": { @@ -773,6 +786,19 @@ "openWorldHint": "It may send the supplied photo to an externally hosted vision model or provider before generating the scene." } }, + { + "name": "place_building", + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It moves a building node on its site in the connected scene.", + "destructiveHint": "It replaces the building's stored position and rotation, in one undoable edit; what stands on its levels moves with it.", + "openWorldHint": "It operates on the connected scene and its configured live persistence transport without accessing the public internet." + } + }, { "name": "place_design", "annotations": { diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index ee03e24f74..42c482afe4 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -58,6 +58,7 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { get_level_summary: policy(true, false, false), get_node: policy(true, false, false), get_project_status: policy(false, true, false), + get_site: policy(true, false, false), get_scene: policy(true, false, false), get_walls: policy(true, false, false), get_zones: policy(true, false, false), @@ -73,6 +74,7 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { move_zone: policy(false, true, false), paint: policy(false, true, false), photo_to_scene: policy(false, true, true), + place_building: policy(false, true, false), place_design: policy(false, false, false), place_in_room: policy(false, false, false), place_items: policy(false, false, false), From 70a8004118f39b749370c86b45d1d7e09296d8f7 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 20:15:31 -0400 Subject: [PATCH 2/5] refactor(core): the site brief reads the site's facts without the node schema The chat's durable lane serializes the scene snapshot inside a workflow, whose bundle refuses any module that reaches nanoid; the site brief did, through migrateSiteMetadata in the site node's schema. The address, parcel and setbacks schemas and their read-side lift now live in schema/nodes/site-facts.ts (zod only), which site.ts and the brief import, and the brief is a subpath of its own, @pascal-app/core/site-placement. Co-Authored-By: Claude Opus 5.5 --- packages/core/package.json | 5 ++ packages/core/src/lib/site-placement.ts | 34 +++++-- packages/core/src/schema/index.ts | 6 +- packages/core/src/schema/nodes/site-facts.ts | 95 ++++++++++++++++++++ packages/core/src/schema/nodes/site.test.ts | 3 +- packages/core/src/schema/nodes/site.ts | 84 +---------------- 6 files changed, 133 insertions(+), 94 deletions(-) create mode 100644 packages/core/src/schema/nodes/site-facts.ts diff --git a/packages/core/package.json b/packages/core/package.json index d79f330451..3b4784546f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -71,6 +71,11 @@ "import": "./dist/lib/plan-footprint.js", "default": "./dist/lib/plan-footprint.js" }, + "./site-placement": { + "types": "./dist/lib/site-placement.d.ts", + "import": "./dist/lib/site-placement.js", + "default": "./dist/lib/site-placement.js" + }, "./wall": { "types": "./dist/systems/wall/wall-footprint.d.ts", "import": "./dist/systems/wall/wall-footprint.js", diff --git a/packages/core/src/lib/site-placement.ts b/packages/core/src/lib/site-placement.ts index c30148f34c..803cf24c3d 100644 --- a/packages/core/src/lib/site-placement.ts +++ b/packages/core/src/lib/site-placement.ts @@ -10,7 +10,7 @@ * −z, +x, +z and −x; true north is given separately. */ import type { AnyNode, BuildingNode, LevelNode, SiteNode, SiteSetbacks } from '../schema' -import { migrateSiteMetadata } from '../schema/nodes/site' +import { migrateSiteMetadata } from '../schema/nodes/site-facts' import { footprintOutline, levelFootprintLoops, @@ -381,7 +381,7 @@ function streetFaces(front: PlanSide): Record { const [fx, fz] = SIDES[front] // facing the house is −front; its right hand, with z pointing south, is (−dz, dx) const right = sideOf(fz, -fx) - return { front, rear: OPPOSITE[front], right, left: OPPOSITE[right] } + return { front, rear: OPPOSITE[front], left: OPPOSITE[right], right } } function streetOf(site: SiteNode, toLevel: (x: number, z: number) => Pt, yaw: number) { @@ -809,6 +809,22 @@ const fmtPt = (p: Pt) => `[${num(p[0])}, ${num(p[1])}]` const span = (b: PlanBounds) => `x ${num(b.minX)}..${num(b.maxX)}, z ${num(b.minZ)}..${num(b.maxZ)}` const size = (b: PlanBounds) => `${num(b.maxX - b.minX)}×${num(b.maxZ - b.minZ)} m` +/** `18×32 m` for a rectangular lot, whatever its turn; null for any other shape. */ +function rectangleSize(ring: readonly Pt[]): string | null { + if (ring.length !== 4) return null + const side = (i: number) => { + const a = ring[i] as Pt + const b = ring[(i + 1) % 4] as Pt + return [b[0] - a[0], b[1] - a[1]] as const + } + for (let i = 0; i < 4; i++) { + const [ax, az] = side(i) + const [bx, bz] = side((i + 1) % 4) + if (Math.abs(ax * bx + az * bz) > 1e-3 * Math.hypot(ax, az) * Math.hypot(bx, bz)) return null + } + return `${num(Math.hypot(...side(0)))}×${num(Math.hypot(...side(1)))} m` +} + function describeSetbacks(setbacks: SiteSetbacks): string { const parts = [`front ${num(setbacks.front)}`] if (setbacks.left !== undefined || setbacks.right !== undefined) @@ -839,7 +855,8 @@ export function describeSiteBrief(brief: SiteBrief): string[] { }`, ] - const facts = [`lot ${size(lot.bounds)} (${Math.round(lot.areaM2)} m²)`] + const dims = rectangleSize(lot.ring) + const facts = [`lot ${dims ? `${dims}, ` : ''}${Math.round(lot.areaM2)} m²`] if (brief.placeholder) facts.push('the new-project placeholder: no address or parcel, real property lines unknown') if (brief.address) facts.push(brief.address) @@ -881,9 +898,12 @@ export function describeSiteBrief(brief: SiteBrief): string[] { ) return lines } - const yards = Object.entries(footprint.yards) - .map(([key, m]) => `${key} ${m === null || m === undefined ? '?' : num(m)}`) - .join(', ') + const measured = Object.entries(footprint.yards).filter( + (entry): entry is [string, number] => typeof entry[1] === 'number', + ) + const yards = measured.length + ? ` · yards ${measured.map(([key, m]) => `${key} ${num(m)}`).join(', ')} m` + : '' const room = buildable.ring.length >= 3 ? boundsOf(buildable.ring) : lot.bounds const tooBig = footprint.bounds.maxX - footprint.bounds.minX > room.maxX - room.minX || @@ -905,7 +925,7 @@ export function describeSiteBrief(brief: SiteBrief): string[] { ? 'inside the buildable area, on its centre' : `inside the buildable area, ${num(footprint.offCentreM)} m off its centre` lines.push( - `footprint: ${size(footprint.bounds)} centred ${fmtPt(footprint.centre)} · yards ${yards} m · ${where}`, + `footprint: ${size(footprint.bounds)} centred ${fmtPt(footprint.centre)}${yards} · ${where}`, ) return lines } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index d52f999806..f619abdca4 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -331,14 +331,14 @@ export { } from './nodes/scan' export { SeparatorNode } from './nodes/separator' export { ShelfNode } from './nodes/shelf' +export { SiteDossier, SiteNode } from './nodes/site' export { migrateSiteMetadata, SiteAddress, - SiteDossier, - SiteNode, + type SiteMetadataPatch, SiteParcel, SiteSetbacks, -} from './nodes/site' +} from './nodes/site-facts' export { SKYLIGHT_TYPE_ORDER, SKYLIGHT_TYPE_PRESETS, diff --git a/packages/core/src/schema/nodes/site-facts.ts b/packages/core/src/schema/nodes/site-facts.ts new file mode 100644 index 0000000000..13211c07dd --- /dev/null +++ b/packages/core/src/schema/nodes/site-facts.ts @@ -0,0 +1,95 @@ +// The site's facts — its address, parcel and setbacks — and their read-side lift from legacy +// metadata, apart from the node schema so pure readers (the site brief) take no id minting. + +import { z } from 'zod' + +/** + * Mailing / civic address the lot was resolved from. Every field is optional — + * a hand-drawn site has no address and must still parse. + */ +export const SiteAddress = z.object({ + street: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + zip: z.string().optional(), +}) +export type SiteAddress = z.infer + +/** + * Provenance of the lot ring. Written by the site panel's "Find parcel" action + * from the host's parcel service. `originLngLat` is the geocoded point the polygon + * origin sits on (polygon points are metres, x east, z south). + */ +export const SiteParcel = z.object({ + apn: z.string().optional(), + source: z.string().optional(), + county: z.string().optional(), + state: z.string().optional(), + lotAreaSqFt: z.number().optional(), + originLngLat: z.tuple([z.number(), z.number()]).optional(), + resolvedAt: z.string().optional(), + /** GIS layer / registry the polygon came from, e.g. `arcgis-fl`. */ + layer: z.string().optional(), + notes: z.array(z.string()).optional(), +}) +export type SiteParcel = z.infer + +/** Required yards, METRES. `left` / `right` override `side` per-edge when set. */ +export const SiteSetbacks = z.object({ + front: z.number(), + side: z.number(), + rear: z.number(), + left: z.number().optional(), + right: z.number().optional(), + /** A corner lot's second street side, metres; absent = the front setback. */ + streetSide: z.number().optional(), +}) +export type SiteSetbacks = z.infer + +/** The fields `migrateSiteMetadata` lifts onto a site node. */ +export type SiteMetadataPatch = { + setbacks?: SiteSetbacks + setbacksSource?: string + zone?: string + parcel?: SiteParcel +} + +/** + * Read-side migration for scenes written before these fields existed, where + * the plan tooling parked the same values under `metadata`. Returns a patch + * (empty when there is nothing to lift) — callers merge it onto the node. + * + * Only cheap, unambiguous lifts are done here: `metadata.setbacks` (already + * metres), `metadata.setbacksSource`, `metadata.zone`, `metadata.apn` and + * `metadata.source`. Anything richer (full imported project records) is + * left in `metadata` for the workstream that owns it. + */ +export function migrateSiteMetadata(node: { + metadata?: unknown + setbacks?: SiteSetbacks | undefined + setbacksSource?: string | undefined + zone?: string | undefined + parcel?: SiteParcel | undefined +}): SiteMetadataPatch { + const meta = node.metadata as Record | null | undefined + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {} + const patch: SiteMetadataPatch = {} + + if (!node.setbacks) { + const parsed = SiteSetbacks.safeParse(meta.setbacks) + if (parsed.success) patch.setbacks = parsed.data + } + if (!node.setbacksSource && typeof meta.setbacksSource === 'string') { + patch.setbacksSource = meta.setbacksSource + } + if (!node.zone && typeof meta.zone === 'string') patch.zone = meta.zone + + if (!node.parcel && (typeof meta.apn === 'string' || typeof meta.source === 'string')) { + patch.parcel = { + ...(typeof meta.apn === 'string' ? { apn: meta.apn } : {}), + ...(typeof meta.source === 'string' ? { source: meta.source } : {}), + } + } + + return patch +} diff --git a/packages/core/src/schema/nodes/site.test.ts b/packages/core/src/schema/nodes/site.test.ts index f20b072057..7826d73073 100644 --- a/packages/core/src/schema/nodes/site.test.ts +++ b/packages/core/src/schema/nodes/site.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from 'bun:test' import { encodeTerrainField } from '../../lib/terrain-codec' import { createTerrainField } from '../../lib/terrain-field' -import { migrateSiteMetadata, SiteNode } from './site' +import { SiteNode } from './site' +import { migrateSiteMetadata } from './site-facts' describe('SiteNode.terrain', () => { test('a scene saved before terrain existed still parses', () => { diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts index 1b4c2f3005..6a7c427ab1 100644 --- a/packages/core/src/schema/nodes/site.ts +++ b/packages/core/src/schema/nodes/site.ts @@ -4,6 +4,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { TerrainData } from '../terrain' +import { SiteAddress, SiteParcel, SiteSetbacks } from './site-facts' // 2D Polygon const PropertyLineData = z.object({ @@ -11,49 +12,6 @@ const PropertyLineData = z.object({ points: z.array(z.tuple([z.number(), z.number()])), }) -/** - * Mailing / civic address the lot was resolved from. Every field is optional — - * a hand-drawn site has no address and must still parse. - */ -export const SiteAddress = z.object({ - street: z.string().optional(), - city: z.string().optional(), - state: z.string().optional(), - zip: z.string().optional(), -}) -export type SiteAddress = z.infer - -/** - * Provenance of the lot ring. Written by the site panel's "Find parcel" action - * from the host's parcel service. `originLngLat` is the geocoded point the polygon - * origin sits on (polygon points are metres, x east, z south). - */ -export const SiteParcel = z.object({ - apn: z.string().optional(), - source: z.string().optional(), - county: z.string().optional(), - state: z.string().optional(), - lotAreaSqFt: z.number().optional(), - originLngLat: z.tuple([z.number(), z.number()]).optional(), - resolvedAt: z.string().optional(), - /** GIS layer / registry the polygon came from, e.g. `arcgis-fl`. */ - layer: z.string().optional(), - notes: z.array(z.string()).optional(), -}) -export type SiteParcel = z.infer - -/** Required yards, METRES. `left` / `right` override `side` per-edge when set. */ -export const SiteSetbacks = z.object({ - front: z.number(), - side: z.number(), - rear: z.number(), - left: z.number().optional(), - right: z.number().optional(), - /** A corner lot's second street side, metres; absent = the front setback. */ - streetSide: z.number().optional(), -}) -export type SiteSetbacks = z.infer - /** * What the site keeps of a Pascal Map location dossier * (https://map.pascal.app/api/docs): when it was assembled, the point and @@ -195,44 +153,4 @@ export const SiteNode = BaseNode.extend({ `, ) -/** - * Read-side migration for scenes written before these fields existed, where - * the plan tooling parked the same values under `metadata`. Returns a patch - * (empty when there is nothing to lift) — callers merge it onto the node. - * - * Only cheap, unambiguous lifts are done here: `metadata.setbacks` (already - * metres), `metadata.setbacksSource`, `metadata.zone`, `metadata.apn` and - * `metadata.source`. Anything richer (full imported project records) is - * left in `metadata` for the workstream that owns it. - */ -export function migrateSiteMetadata(node: { - metadata?: unknown - setbacks?: SiteSetbacks | undefined - setbacksSource?: string | undefined - zone?: string | undefined - parcel?: SiteParcel | undefined -}): Partial { - const meta = node.metadata as Record | null | undefined - if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {} - const patch: Partial = {} - - if (!node.setbacks) { - const parsed = SiteSetbacks.safeParse(meta.setbacks) - if (parsed.success) patch.setbacks = parsed.data - } - if (!node.setbacksSource && typeof meta.setbacksSource === 'string') { - patch.setbacksSource = meta.setbacksSource - } - if (!node.zone && typeof meta.zone === 'string') patch.zone = meta.zone - - if (!node.parcel && (typeof meta.apn === 'string' || typeof meta.source === 'string')) { - patch.parcel = { - ...(typeof meta.apn === 'string' ? { apn: meta.apn } : {}), - ...(typeof meta.source === 'string' ? { source: meta.source } : {}), - } - } - - return patch -} - export type SiteNode = z.infer From 8e5f261ce566aa046b3ed0144c11551f12f92194 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 20:43:49 -0400 Subject: [PATCH 3/5] fix(core): the site brief's street line names place_building's rotation A Low run read "rotationDeg -20 squares the house" as layout()'s rotationDeg, which turns the rooms inside their level, and broke its first plan; the rotation that squares the house is the building's, which place_building sets. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/lib/site-placement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/site-placement.ts b/packages/core/src/lib/site-placement.ts index 803cf24c3d..8025819362 100644 --- a/packages/core/src/lib/site-placement.ts +++ b/packages/core/src/lib/site-placement.ts @@ -886,7 +886,7 @@ export function describeSiteBrief(brief: SiteBrief): string[] { lines.push( `${ street - ? `street: ${street.side} (lot edge ${street.edge}; rotationDeg ${num(street.squareRotationDeg)} squares the house to it)` + ? `street: ${street.side} (lot edge ${street.edge}; place_building rotationDeg ${num(street.squareRotationDeg)} squares the house to it)` : 'street: not set' } · north: ${north}`, ) From 28ab6058203fe0a104e68889c43c52d815ec5c09 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 21:50:18 -0400 Subject: [PATCH 4/5] fix(core): a new house faces the street square, its yards are met, and its walls are measured as built From the review of the site placement (#1044): - A house about to rise (no walls yet) is turned square to the street by place_building when no rotationDeg is given; a standing house still keeps its facing. On a turned lot a plan that fits the setbacks only squared now lands in them, and the brief names the rotation as the fix for a standing house only: named for one yet to rise, a Low run read it as the rotation to draw at. - Yards are met together: on a lot askew to the house a front and a side yard move each other, so they are solved as one system, and a pose that misses them is refused. The street's side is read at the rotation the house will have, so yards given with a new rotation land on the right faces and a replay moves nothing. - One front edge for the setbacks, the street and the yards: the recorded front edge, else the first street edge, else the north-facing guess. The 3D site and the site plan read it through siteFrontEdge and siteSetbackEnvelope. - Setbacks of zero exclude nothing: the whole lot is buildable, not "none". - A house bridging a notch reports how far its middle reaches past the lot line, not 0. - create_room and add_wall measure the walls they raise, mitred and curved, against the lot line, and read only the lot to do it. Co-Authored-By: Claude Opus 5.5 --- .../__fixtures__/site-cases.ts | 146 ++++++++++++- .../core/src/agent-operations/add-wall.ts | 23 +- .../core/src/agent-operations/create-room.ts | 19 +- packages/core/src/agent-tools/site.ts | 5 +- packages/core/src/index.ts | 1 + packages/core/src/lib/building-footprint.ts | 5 +- packages/core/src/lib/site-placement.ts | 202 +++++++++++++----- .../site-plan/build-site-plan-drawing.ts | 4 +- packages/mcp/README.md | 2 +- packages/nodes/src/site/renderer.tsx | 10 +- 10 files changed, 334 insertions(+), 83 deletions(-) diff --git a/packages/core/src/agent-operations/__fixtures__/site-cases.ts b/packages/core/src/agent-operations/__fixtures__/site-cases.ts index f032480491..84199a4e3f 100644 --- a/packages/core/src/agent-operations/__fixtures__/site-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/site-cases.ts @@ -213,6 +213,54 @@ export const GET_SITE_CASES: AgentToolCase[] = [ }, }, }, + { + name: 'setbacks of zero exclude nothing: the whole lot is buildable', + tool: 'get_site', + scene: () => scene(site({ setbacks: { front: 0, side: 0, rear: 0 } }), building(), level()), + input: {}, + expect: { + result: { + buildable: { + state: 'setbacks', + centre: [0, 0], + ring: [ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ], + }, + }, + }, + }, + { + name: 'a street edge and no front edge: the setbacks and the street both measure from it', + tool: 'get_site', + // the south edge (2, z = 40) is the street: front 6 from it, rear 2 from z = 0, sides 1 + scene: () => + scene( + site({ + polygon: STRAIGHT.polygon, + streetEdges: [2], + setbacks: { front: 6, side: 1, rear: 2 }, + }), + building(), + level(), + ), + input: {}, + expect: { + result: { street: { side: 'south', edge: 2 }, buildable: { state: 'setbacks', centre: [10, 18] } }, + }, + }, + { + name: 'a house bridging the notch of a U lot reaches past it by its middle, not its corners', + tool: 'get_site', + // the piece outside is x 10..20 across the notch; its middle is 5 m from both notch sides + scene: () => + scene(site(U_LOT), building(), level(), ...box('level_ground', 'h', [5, 15], 20, 10)), + input: {}, + expect: { result: { footprint: { fit: 'outside', overhang: { lotM: 5 } } } }, + }, { name: 'no site: refused', tool: 'get_site', @@ -405,6 +453,77 @@ export const PLACE_BUILDING_CASES: AgentToolCase[] = [ result: { moved: true, position: [6.9, 0, 7.1], yards: { front: 7, left: 3 } }, }, }, + { + name: 'a house about to rise on a turned parcel stands square to the street, in the setbacks', + tool: 'place_building', + // 14 × 10 m fits the 15 × 20 m envelope only squared to it + scene: () => scene(site(TILTED), building({ position: [3, 0, 2] }), level()), + input: { + outline: [ + [0, 0], + [14, 0], + [14, 10], + [0, 10], + ], + }, + expect: { result: { moved: true, rotationDeg: -20, fit: 'buildable' } }, + }, + { + name: 'yards with a new rotation are measured from the street as the house will face it', + tool: 'place_building', + // turned 90°, the house still has its front 3 m from the north street: site z 3..7, x centred + scene: () => scene(site(STRAIGHT), building(), level()), + input: { + outline: [ + [-2, -2], + [2, -2], + [2, 2], + [-2, 2], + ], + rotationDeg: 90, + yards: { front: 3 }, + }, + expect: { result: { position: [10, 0, 5], rotationDeg: 90, yards: { front: 3 } } }, + }, + { + name: 'replayed, yards with a new rotation move nothing', + tool: 'place_building', + scene: () => + scene(site(STRAIGHT), building({ position: [10, 0, 5], rotation: [0, Math.PI / 2, 0] }), level()), + input: { + outline: [ + [-2, -2], + [2, -2], + [2, 2], + [-2, 2], + ], + rotationDeg: 90, + yards: { front: 3 }, + }, + expect: { result: { moved: false, position: [10, 0, 5] } }, + }, + { + name: 'a front and a side yard on a lot askew to the house are both met', + tool: 'place_building', + // on a lot turned 30° each yard's move changes the other's + scene: () => + scene( + site({ polygon: { type: 'polygon', points: turnedLot([0, 0], 30, 30, 30) }, frontEdge: 0 }), + building(), + level(), + ), + input: { + outline: [ + [-2, -2], + [2, -2], + [2, 2], + [-2, 2], + ], + rotationDeg: 0, + yards: { front: 10, left: 10 }, + }, + expect: { result: { moved: true, rotationDeg: 0, yards: { front: 10, left: 10 } } }, + }, { name: 'front and rear yards together: refused', tool: 'place_building', @@ -521,7 +640,8 @@ export const SITE_LINE_CASES: AgentToolCase[] = [ expect: { result: { site: { fit: 'outside', sides: ['east'] } }, check: (result) => - near(Number((result.site as { overhangM?: number }).overhangM), 5, 0.01) + // to the outer face of its 0.1 m walls + near(Number((result.site as { overhangM?: number }).overhangM), 5.05, 0.01) ? [] : [`overhang ${(result.site as { overhangM?: number }).overhangM}`], }, @@ -541,6 +661,22 @@ export const SITE_LINE_CASES: AgentToolCase[] = [ }, expect: { result: { ok: true }, check: (result) => ('site' in result ? ['site flag'] : []) }, }, + { + name: 'a room drawn to the lot line has walls past it', + tool: 'create_room', + scene: () => scene(site(), building(), level()), + input: { + name: 'Study', + polygon: [ + [10, 0], + [15, 0], + [15, 4], + [10, 4], + ], + wallThickness: 0.2, + }, + expect: { result: { site: { fit: 'outside', overhangM: 0.1, sides: ['east'] } } }, + }, { name: 'a wall past the lot line says so', tool: 'add_wall', @@ -548,6 +684,14 @@ export const SITE_LINE_CASES: AgentToolCase[] = [ input: { start: [0, 20], end: [8, 20] }, expect: { result: { site: { fit: 'outside', sides: ['south'] } } }, }, + { + name: 'a curved wall whose chord stays on the lot bows past it', + tool: 'add_wall', + // the chord runs at z = 13; bowed 3 m south, the wall reaches z 16.05 + scene: () => scene(site(), building(), level()), + input: { start: [-5, 13], end: [5, 13], curveOffset: -3 }, + expect: { result: { site: { fit: 'outside', overhangM: 1.05, sides: ['south'] } } }, + }, ] export const SITE_VERIFY_CASES: AgentToolCase[] = [ diff --git a/packages/core/src/agent-operations/add-wall.ts b/packages/core/src/agent-operations/add-wall.ts index fd2d877670..503175eced 100644 --- a/packages/core/src/agent-operations/add-wall.ts +++ b/packages/core/src/agent-operations/add-wall.ts @@ -1,10 +1,11 @@ import { refuse } from '../agent-tools/refusal' +import { levelFootprintLoops } from '../lib/building-footprint' import { lotOverhang } from '../lib/site-placement' -import { WallNode } from '../schema' +import { type LevelNode, WallNode } from '../schema' import { isRoofLevel } from '../services/roof-level' import { normalizeWallCurveOffset } from '../systems/wall/wall-curve' -import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' import { WALL_MIN_LENGTH } from '../systems/wall/wall-topology' +import { applySceneChanges } from './apply-changes' import { type LevelTargetInput, targetLevel } from './level-target' import type { AgentOperation, SceneNodes } from './types' @@ -53,17 +54,13 @@ export const addWall: AgentOperation = (nodes, input, context) => ...(input.height === undefined ? {} : { height: input.height }), ...(curveOffset ? { curveOffset } : {}), }) - // the wall's band, square ends: what crosses the lot line, not only its centreline - const half = (wall.thickness ?? DEFAULT_WALL_THICKNESS) / 2 - const [nx, nz] = [(-(end[1] - start[1]) / length) * half, ((end[0] - start[0]) / length) * half] - const site = lotOverhang(nodes, level.id, [ - [ - [start[0] + nx, start[1] + nz], - [end[0] + nx, end[1] + nz], - [end[0] - nx, end[1] - nz], - [start[0] - nx, start[1] - nz], - ], - ]) + // the wall's mitred band, curve and all: what crosses the lot line, not only its centreline + const after = applySceneChanges(nodes, { create: [{ node: wall, parentId: level.id }] }) + const site = lotOverhang( + after, + level.id, + levelFootprintLoops(after, after[level.id] as LevelNode, null, new Set([wall.id])), + ) return { result: { ok: true, diff --git a/packages/core/src/agent-operations/create-room.ts b/packages/core/src/agent-operations/create-room.ts index cd1fbc6803..1faf27fda2 100644 --- a/packages/core/src/agent-operations/create-room.ts +++ b/packages/core/src/agent-operations/create-room.ts @@ -2,9 +2,17 @@ import { isAgentRefusal, refuse } from '../agent-tools/refusal' import { doorFacing, planWallOpening } from '../building/wall-openings' import { createZone, walledRoomAt } from '../commands/structure/create-zone' import { structureChangeBatch } from '../commands/structure/shared' +import { levelFootprintLoops } from '../lib/building-footprint' import { isAllocatedRoomName } from '../lib/room-name' import { lotOverhang } from '../lib/site-placement' -import { type AnyNode, type AnyNodeId, generateId, type WallNode, type ZoneNode } from '../schema' +import { + type AnyNode, + type AnyNodeId, + generateId, + type LevelNode, + type WallNode, + type ZoneNode, +} from '../schema' import { applySceneChanges } from './apply-changes' import { type LevelTargetInput, targetLevel } from './level-target' import { polygonArea, type Vec2 } from './plan-geometry' @@ -223,7 +231,14 @@ export const createRoom: AgentOperation = (nodes, input, contex (a, i) => wallsAlong(scene, level.id, a, polygon[(i + 1) % polygon.length]!)[0]?.id ?? null, ) const reusedWalls = wallIds.filter((id) => id && nodes[id]).length - const site = lotOverhang(nodes, level.id, [polygon]) + // the room and the walls it raised, their mitred bands: what crosses the lot line + const raised = new Set( + roomChanges.create.flatMap((entry) => (entry.node.type === 'wall' ? [entry.node.id] : [])), + ) + const site = lotOverhang(scene, level.id, [ + polygon, + ...levelFootprintLoops(scene, scene[level.id] as LevelNode, null, raised), + ]) const result = { ok: true, zoneId: plan.zoneId, diff --git a/packages/core/src/agent-tools/site.ts b/packages/core/src/agent-tools/site.ts index b3004c7330..d6d97e5804 100644 --- a/packages/core/src/agent-tools/site.ts +++ b/packages/core/src/agent-tools/site.ts @@ -24,7 +24,7 @@ export const placeBuildingTool = { name: 'place_building', title: 'Place building', description: - "Stand a building on its site before or after its walls go up. It moves only the building node, so walls, levels and roofs keep their coordinates and move with it. The footprint (outline, the plan you are about to draw, in the coordinates you will draw it; else the walls standing) lands centred in the buildable area, or at the yards a site plan or the person gives. The building keeps its rotation unless rotationDeg is given (get_site's squareRotationDeg squares it to the street). Fences, paths and plants on its levels move with it.", + "Stand a building on its site before or after its walls go up. It moves only the building node, so walls, levels and roofs keep their coordinates and move with it. The footprint (outline, the plan you are about to draw, in the coordinates you will draw it; else the walls standing) lands centred in the buildable area, or at the yards a site plan or the person gives. A building with walls keeps its rotation unless rotationDeg is given (get_site's squareRotationDeg squares it to the street); one with none yet is turned square to the street. Fences, paths and plants on its levels move with it.", input: { building, outline: z @@ -46,7 +46,8 @@ export const placeBuildingTool = { 'Where the house stands instead of centred: at most one of front and rear, and one of left and right, from a site plan or the person. An axis not given stays centred in the buildable area. Needs the site to know its street (frontEdge).', ), rotationDeg: measurement('angle', 'deg', { - description: "The building's rotation about the vertical. Default: the rotation it has.", + description: + "The building's rotation about the vertical. Default: the rotation it has, or square to the street while it has no walls.", }).optional(), }, } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1a8c595ab3..48550b36b2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -392,6 +392,7 @@ export { type SiteBrief, type SiteFootprint, siteBrief, + siteFrontEdge, siteSetbackEnvelope, type YardKey, } from './lib/site-placement' diff --git a/packages/core/src/lib/building-footprint.ts b/packages/core/src/lib/building-footprint.ts index 4cfe3d4c4e..070c2037e5 100644 --- a/packages/core/src/lib/building-footprint.ts +++ b/packages/core/src/lib/building-footprint.ts @@ -52,12 +52,14 @@ export function levelFrame( /** * Per-wall plan footprints for a level, mitred, then transformed by the building's site - * placement (`siteFrame`); with no building, in the level's own coordinates. + * placement (`siteFrame`); with no building, in the level's own coordinates. `only` keeps the + * named walls, still mitred against every wall on the level. */ export function levelFootprintLoops( nodes: Nodes, level: LevelNode | null, building: BuildingNode | null, + only?: ReadonlySet, ): Pt[][] { if (!level) return [] const walls: WallNode[] = [] @@ -71,6 +73,7 @@ export function levelFootprintLoops( const toSite = siteFrame(building) const loops: Pt[][] = [] for (const wall of walls) { + if (only && !only.has(wall.id)) continue const poly = getWallPlanFootprint(wall, miters) if (poly.length < 3) continue loops.push(poly.map((p) => toSite(p.x, p.y))) diff --git a/packages/core/src/lib/site-placement.ts b/packages/core/src/lib/site-placement.ts index 8025819362..4cbb99f89c 100644 --- a/packages/core/src/lib/site-placement.ts +++ b/packages/core/src/lib/site-placement.ts @@ -179,25 +179,44 @@ function readSiteNode(node: SiteNode): SiteNode { return Object.keys(patch).length > 0 ? { ...node, ...patch } : node } +type SiteEdges = Pick + +/** The street edge the site records: its front edge, else its first street edge; never a guess. */ +function recordedStreetEdge(site: SiteEdges): number | undefined { + const n = site.polygon?.points?.length ?? 0 + const valid = (i: unknown): i is number => + Number.isInteger(i) && (i as number) >= 0 && (i as number) < n + return valid(site.frontEdge) ? site.frontEdge : site.streetEdges?.find(valid) +} + +/** + * The lot's front edge, which the setbacks, the yards and the street are all measured from: the + * street edge the site records, else the edge facing most nearly north. + */ +export function siteFrontEdge(site: SiteEdges): number { + return resolveFrontEdge( + (site.polygon?.points ?? []) as P[], + recordedStreetEdge(site), + site.northRotation ?? 0, + ) +} + /** * The lot's buildable envelope, in site metres, as the site plan and the 3D site draw it: every * lot line pushed in by its setback from the front edge's roles, the corner sight triangles kept - * clear. Empty when the site records no setbacks, or when they leave nothing. + * clear. Empty when the site records no setbacks, when none of them is more than zero, or when + * they leave nothing. */ export function siteSetbackEnvelope( - site: Pick< - SiteNode, - 'polygon' | 'setbacks' | 'frontEdge' | 'streetEdges' | 'sightTriangleFt' | 'northRotation' - >, + site: SiteEdges & Pick, ): P[] { const points = (site.polygon?.points ?? []) as P[] if (!site.setbacks || points.length < 3) return [] - const front = resolveFrontEdge(points, site.frontEdge, site.northRotation ?? 0) const sightTriangleM = typeof site.sightTriangleFt === 'number' && site.sightTriangleFt > 0 ? site.sightTriangleFt * 0.3048 : 0 - return setbackEnvelope(points, site.setbacks, front, { + return setbackEnvelope(points, site.setbacks, siteFrontEdge(site), { streetEdges: (site.streetEdges ?? []).filter((i) => Number.isFinite(i)), sightTriangleM, }).map((p) => [p[0], p[1]]) @@ -208,8 +227,11 @@ function buildableInSite(site: SiteNode): { state: BuildableArea['state']; ring: const lot = (site.polygon?.points ?? []) as P[] if (!site.setbacks) return { state: 'lot', ring: lot } const envelope = siteSetbackEnvelope(site) - if (envelope.length < 3) return { state: 'none', ring: [] } - return { state: selfIntersects(envelope) ? 'advisory' : 'setbacks', ring: envelope } + if (envelope.length >= 3) + return { state: selfIntersects(envelope) ? 'advisory' : 'setbacks', ring: envelope } + // setbacks of zero exclude nothing (the envelope is empty because there is nothing to draw) + const excludes = Object.values(site.setbacks).some((m) => typeof m === 'number' && m > 0) + return excludes ? { state: 'none', ring: [] } : { state: 'setbacks', ring: lot } } /* ------------------------------------------------- what stands where */ @@ -335,7 +357,10 @@ function spill( let reachM = 0 const sides = new Set() for (const piece of outside) { - for (const p of [piece.outer, ...piece.holes].flat()) { + // a piece's corners can all lie on the line it crosses (a house bridging a notch); its most + // interior point is at least as far past the line as it is from the piece's own edges + const pole = polygonInteriorPoint({ polygon: piece.outer, holes: piece.holes }) + for (const p of [...[piece.outer, ...piece.holes].flat(), pole]) { const d = distanceToBoundary(boundary, p) if (d <= 0.005) continue reachM = Math.max(reachM, d) @@ -384,25 +409,27 @@ function streetFaces(front: PlanSide): Record { return { front, rear: OPPOSITE[front], left: OPPOSITE[right], right } } -function streetOf(site: SiteNode, toLevel: (x: number, z: number) => Pt, yaw: number) { +/** + * The recorded street edge seen from a building turned `yaw`: the plan side it lies on, and the + * yaw nearest `yaw` that squares the house to it, in radians. + */ +function streetAt(site: SiteNode, yaw: number) { const lot = (site.polygon?.points ?? []) as P[] const n = lot.length - const named = - typeof site.frontEdge === 'number' && site.frontEdge >= 0 && site.frontEdge < n - ? site.frontEdge - : site.streetEdges?.find((i) => Number.isInteger(i) && i >= 0 && i < n) + const named = recordedStreetEdge(site) if (named === undefined || n < 3) return null const [nx, nz] = outwardNormal(lot, named) - const origin = toLevel(0, 0) - const tip = toLevel(nx, nz) - const side = sideOf(tip[0] - origin[0], tip[1] - origin[1]) + const [lx, lz] = turnPlanPoint(nx, nz, -yaw) const p = lot[named] as P const q = lot[(named + 1) % n] as P // the house's local x runs along site angle −yaw; square to the edge when −yaw ≡ α (mod 90°) const alpha = Math.atan2(q[1] - p[1], q[0] - p[0]) const quarter = Math.PI / 2 - const square = -alpha + Math.round((yaw + alpha) / quarter) * quarter - return { side, edge: named, squareRotationDeg: round(square * DEG, 10) } + return { + side: sideOf(lx, lz), + edge: named, + squareYaw: -alpha + Math.round((yaw + alpha) / quarter) * quarter, + } } function formatAddress(site: SiteNode): string | null { @@ -450,16 +477,22 @@ export function measureFootprint( /** * Where new geometry on a level (rings in its level coordinates) crosses its lot line: null - * while it stays on the lot, or when the level's building has no site. + * while it stays on the lot, or when the level's building has no site. Only the lot is read, so + * a program drawing room by room pays for no footprint. */ export function lotOverhang( nodes: Nodes, levelId: string, rings: readonly (readonly Pt[])[], ): { fit: 'outside'; overhangM: number; sides: PlanSide[] } | null { - const brief = siteBrief(nodes, { levelId }) - if (!brief) return null - const past = spill(rings, brief.lot.ring) + const building = resolveSiteBuilding(nodes, { levelId }).building + const lot = (siteOfBuilding(nodes, building)?.polygon?.points ?? []) as P[] + if (lot.length < 3) return null + const toLevel = levelFrame(building) + const past = spill( + rings, + lot.map((p) => toLevel(p[0], p[1])), + ) if (past.areaM2 === 0) return null return { fit: 'outside', overhangM: round(past.reachM), sides: past.sides } } @@ -499,7 +532,14 @@ export function siteBrief( ...(site.setbacks ? { setbacks: site.setbacks } : {}), ...(site.setbacksSource ? { source: site.setbacksSource } : {}), } - const street = streetOf(site, toLevel, yaw) + const facing = streetAt(site, yaw) + const street = facing + ? { + side: facing.side, + edge: facing.edge, + squareRotationDeg: round(facing.squareYaw * DEG, 10), + } + : null const parcel = site.parcel const lot: SiteBrief['lot'] = { ring, @@ -594,7 +634,7 @@ export type PlaceBuildingInput = { outline?: readonly Pt[] /** Metres from the lot line, square to the house: at most one of front / rear and of left / right. */ yards?: Partial> - /** The building's rotation; absent, it keeps the one it has. */ + /** The building's rotation; absent, a house standing keeps its own, one about to rise is squared to the street. */ rotationDeg?: number } @@ -629,12 +669,19 @@ export function placeBuildingPose( if (!building) return refusal('no_building', 'The scene has no building to place.') const site = readSiteNode(siteOfBuilding(nodes, building) as SiteNode) + const standing = buildingFootprintRings(nodes, building) + const current = building.rotation?.[1] ?? 0 + // a standing house keeps its facing; one about to rise faces the street square const yaw = - input.rotationDeg === undefined ? (building.rotation?.[1] ?? 0) : input.rotationDeg / DEG + input.rotationDeg !== undefined + ? input.rotationDeg / DEG + : standing.length === 0 + ? (streetAt(site, current)?.squareYaw ?? current) + : current const rings: P[][] = input.outline && input.outline.length >= 3 ? [input.outline.map((p) => [p[0], p[1]] as P)] - : buildingFootprintRings(nodes, building) + : standing if (rings.length === 0) return refusal( 'needs_footprint', @@ -689,29 +736,59 @@ export function placeBuildingPose( } if (given.length > 0) { - const faces = streetFaces((before.street as NonNullable).side) - for (let pass = 0; pass < 2; pass++) { - const toLevel = levelFrame({ position: [at[0], 0, at[1]], rotation: [0, yaw, 0] }) - const lot = lotSite.map((p) => toLevel(p[0], p[1])) - const now = planYards(rings, lot) - let shift: P = [0, 0] - for (const key of given) { - const side = faces[key] - const yard = now[side] - if (yard === null) - return refusal('yards_infeasible', `The ${key} face does not look onto a lot line.`) - const [dx, dz] = SIDES[side] - const by = yard - (yards[key] as number) - shift = [shift[0] + dx * by, shift[1] + dz * by] - } - const [sx, sz] = turnPlanPoint(shift[0], shift[1], yaw) - at = [at[0] + sx, at[1] + sz] - } - const toLevel = levelFrame({ position: [at[0], 0, at[1]], rotation: [0, yaw, 0] }) - const placed = planYards( - rings, - lotSite.map((p) => toLevel(p[0], p[1])), + // the street's side as the house will face it, not as it faces now + const faces = streetFaces( + (streetAt(site, yaw) as NonNullable>).side, ) + const yardsAt = (p: Pt) => { + const toLevel = levelFrame({ position: [p[0], 0, p[1]], rotation: [0, yaw, 0] }) + return planYards( + rings, + lotSite.map((q) => toLevel(q[0], q[1])), + ) + } + const misses = (p: Pt) => { + const now = yardsAt(p) + return given.map((key) => { + const yard = now[faces[key]] + return yard === null ? null : yard - (yards[key] as number) + }) + } + // Each yard is moved along its own face's normal; on a turned lot moving one moves the other, + // so both are solved together. Straight lot lines make it exact in one step. + const along = given.map((key) => turnPlanPoint(...SIDES[faces[key]], yaw)) + const step = 0.5 + for (let pass = 0; pass < 6; pass++) { + const miss = misses(at) + const blind = given.find((_, i) => miss[i] === null) + if (blind) + return refusal('yards_infeasible', `The ${blind} face does not look onto a lot line.`) + const [r0 = 0, r1 = 0] = miss as number[] + if (Math.abs(r0) < 1e-3 && Math.abs(r1) < 1e-3) break + // how each miss changes per metre moved along each face's normal (one yard: a 1×1 system) + const slope = along.map(([dx, dz]) => + (misses([at[0] + dx * step, at[1] + dz * step]) as number[]).map( + (m, i) => (m - (miss[i] as number)) / step, + ), + ) + const [[a = 0, c = 0] = [], [b = 0, d = 1] = []] = slope + const det = a * d - b * c + if (Math.abs(det) < 1e-6) break + const moves = [(r1 * b - r0 * d) / det, (r0 * c - r1 * a) / det] + at = along.reduce

( + (p, [dx, dz], i) => [p[0] + dx * (moves[i] ?? 0), p[1] + dz * (moves[i] ?? 0)], + [at[0], at[1]], + ) + } + const placed = yardsAt(at) + for (const key of given) { + const yard = placed[faces[key]] + if (yard === null || Math.abs(yard - (yards[key] as number)) > 0.01) + return refusal( + 'yards_infeasible', + `No place on this lot gives a ${yards[key]} m ${key} yard with the other yards asked: give one of them, or turn the house square to the street (rotationDeg).`, + ) + } for (const key of given) { const across = placed[OPPOSITE[faces[key]]] if (across === null || across < 0) @@ -883,18 +960,27 @@ export function describeSiteBrief(brief: SiteBrief): string[] { Math.abs(((brief.northDeg + 180) % 360) - 180) < 0.5 ? 'plan up' : `${num(brief.northDeg)}° clockwise of plan up` + const squared = !street || Math.abs(street.squareRotationDeg - brief.pose.rotationDeg) < 0.06 + const squaring = street ? `place_building rotationDeg ${num(street.squareRotationDeg)}` : '' + // a house yet to rise is squared by place_building itself: naming a rotation for it reads as + // one to draw the plan at + const facing = !footprint + ? 'a new house is stood square to it' + : squared + ? 'the house square to it' + : `${squaring} squares the house to it` lines.push( - `${ - street - ? `street: ${street.side} (lot edge ${street.edge}; place_building rotationDeg ${num(street.squareRotationDeg)} squares the house to it)` - : 'street: not set' - } · north: ${north}`, + `${street ? `street: ${street.side} (lot edge ${street.edge}; ${facing})` : 'street: not set'} · north: ${north}`, ) if (!footprint) { const aim = centre ?? boundsCentre(lot.bounds) lines.push( - `footprint: none yet — centre it on ${fmtPt(aim)} (a 12×10 m footprint spans ${span({ minX: aim[0] - 6, maxX: aim[0] + 6, minZ: aim[1] - 5, maxZ: aim[1] + 5 })})`, + `footprint: none yet — centre it on ${fmtPt(aim)} (a 12×10 m footprint spans ${span({ minX: aim[0] - 6, maxX: aim[0] + 6, minZ: aim[1] - 5, maxZ: aim[1] + 5 })})${ + squared + ? '' + : '; place_building with its outline first stands it there square to the street' + }`, ) return lines } @@ -912,7 +998,9 @@ export function describeSiteBrief(brief: SiteBrief): string[] { const fix = centred ? tooBig ? `: it is bigger than the buildable area (${size(room)}) even centred, so say so` - : ': even centred, the shape of the lot leaves it out: give yards or reshape the plan' + : squared + ? ': even centred, the shape of the lot leaves it out: give yards or reshape the plan' + : `: even centred, it stands askew to the lot: ${squaring} squares it to the street` : `: place_building centres it${tooBig ? `, but it is bigger than the buildable area (${size(room)}): say so` : ''}` const where = footprint.fit === 'outside' diff --git a/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts b/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts index 57d53daa2b..9daf04d273 100644 --- a/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +++ b/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts @@ -13,12 +13,12 @@ import { type LevelNode, levelFootprintLoops, outwardNormal, - resolveFrontEdge, type SceneSnapshot, type SiteNode, setbackForRole, sightTriangle, siteFrame, + siteFrontEdge, siteSetbackEnvelope, streetCorners, terrainFieldOf, @@ -320,7 +320,7 @@ export function buildSitePlanDrawing(scene: SceneSnapshot): SitePlanDrawing { const footprintLoops = levelFootprintLoops(scene.nodes, level, building) const footprintBounds = unionBounds(footprintLoops) const northRotation = site?.northRotation ?? 0 - const frontEdge = resolveFrontEdge(lot, site?.frontEdge, northRotation) + const frontEdge = site ? siteFrontEdge(site) : 0 const empty: SitePlanDrawing = { primitives: [], diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 11e3e1bf80..e0e4b2a8bb 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -352,7 +352,7 @@ an optional `height` and uses the same default. | `get_walls` | Walls on a level with length, stored and resolved height, and child doors/windows. | `{ levelId?, level? }` | `{ levelId, walls[] }` | | `get_zones` | Room/zone polygons with holes, areas (holes taken out), bounds and floor choices. | `{ levelId?, level? }` | `{ levelId, zones[] }` | | `get_site` | The site a building stands on, in its level coordinates: the lot, the buildable area inside the setbacks and its centre, the street side, north, address, parcel and zoning, whether the lot is the new-project placeholder, and the footprint's fit and yards. | `{ building? }` | `{ siteId, buildingId, pose, lot, placeholder, buildable, street, northDeg, address, parcel, zone, footprint, others, summary }`; refusals `no_site`, `building_required` | -| `place_building` | Stand a building on its site by moving the building node only (walls keep their level coordinates): its footprint, the outline about to be drawn or the walls standing, centred in the buildable area or at the yards given; the rotation kept unless `rotationDeg` is given. | `{ building?, outline?, yards?: { front?, rear?, left?, right? }, rotationDeg? }` | `{ buildingId, position, rotationDeg, moved, footprint, yards, fit, overhang, movedWithIt?, message }`; refusals `no_site`, `needs_footprint`, `other_building_on_site`, `no_street`, `yards_conflict`, `yards_infeasible`, `building_required` | +| `place_building` | Stand a building on its site by moving the building node only (walls keep their level coordinates): its footprint, the outline about to be drawn or the walls standing, centred in the buildable area or at the yards given; a house standing keeps its rotation unless `rotationDeg` is given, one with no walls yet is squared to the street. | `{ building?, outline?, yards?: { front?, rear?, left?, right? }, rotationDeg? }` | `{ buildingId, position, rotationDeg, moved, footprint, yards, fit, overhang, movedWithIt?, message }`; refusals `no_site`, `needs_footprint`, `other_building_on_site`, `no_street`, `yards_conflict`, `yards_infeasible`, `building_required` | | `measure` | Distance between two nodes' world-space reference points (hosted doors, windows and items resolved through their host; every node kind); area when applicable. | `{ fromId, toId }` | `{ distanceMeters, fromPoint?, toPoint?, areaSqMeters?, units: 'meters' }` | | `view_scene` | Look at the building from a viewpoint the agent picks (a side, a street-height eye, a photo's camera, square-on, from above) and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, everything on it (walls, openings, furniture) cut at 0.65 of the storey's height, for the picture only. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`, `interior_transform_unsupported`, `interior_target_elsewhere`; on the hosted server `editor_tab_required`, `editor_tab_hidden`, `editor_tab_busy` (a tab open but busy or still loading, no link: wait a few seconds and call again, do not ask the user), `editor_tab_timeout` (a tab claimed the view and then stopped answering); what the refusal says about opening the editor follows the person's "Agents may open the editor for me" setting (ask first, allow, never) | | `search_assets` | Search the host's item library (the built-in list on a standalone server), several queries in one call; a query matches name, id, category or tags. A query that finds nothing gets a hint to build the item with `add_object`. | `{ queries: [{ query, category? }] }` | `{ groups: [{ query, total, results[] }], total, hint? }`; refusal `no_catalog` | diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 5131f66c4c..d8b117f56a 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -2,10 +2,10 @@ import { type AnyNodeId, - resolveFrontEdge, type SiteNode, - setbackEnvelope, sightTriangle, + siteFrontEdge, + siteSetbackEnvelope, streetCorners, type TerrainField, terrainContours, @@ -385,13 +385,15 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { let envelopeGeometry: BufferGeometry | null = null const triangleGeometries: BufferGeometry[] = [] const points = polygonPoints.map(([x, z]) => [x ?? 0, z ?? 0] as [number, number]) - const front = resolveFrontEdge(points, node.frontEdge, node.northRotation ?? 0) + // the live ring, measured the one way the site plan and the agents measure it + const lot = { ...node, polygon: { type: 'polygon' as const, points } } + const front = siteFrontEdge(lot) const streetEdges = node.streetEdges ?? [] const sightTriangleM = typeof node.sightTriangleFt === 'number' && node.sightTriangleFt > 0 ? node.sightTriangleFt * 0.3048 : 0 - const envelope = setbackEnvelope(points, setbacks, front, { streetEdges, sightTriangleM }) + const envelope = siteSetbackEnvelope(lot) if (envelope.length >= 3) { const draped = buildDrapedPolyline({ points: envelope, From 77931d5b37c99d67762b0ac43d875b8983a85f6b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 22:30:06 -0400 Subject: [PATCH 5/5] fix(core): place_building's message ends its footprint line before the placeholder's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Pro run read "… on its centre The lot is the new-project placeholder …" as one sentence. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/agent-operations/site.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agent-operations/site.ts b/packages/core/src/agent-operations/site.ts index 7728d3d21a..b733a1fe21 100644 --- a/packages/core/src/agent-operations/site.ts +++ b/packages/core/src/agent-operations/site.ts @@ -92,7 +92,7 @@ export const placeBuilding: AgentOperation = (nodes, input, const movedWithIt = placed.moved ? outdoorOnLevels(nodes, building.id, placed.footprint.rings) : [] - const where = describeSiteBrief({ ...placed.brief, footprint: placed.footprint }).at(-1) + const where = `${describeSiteBrief({ ...placed.brief, footprint: placed.footprint }).at(-1)}.` const message = [ placed.moved ? `${building.name ?? 'The building'} now stands at site [${r2(x)}, ${r2(z)}], ${rotationDeg}°. Its walls keep their level coordinates; draw new ones in the same frame.`