diff --git a/packages/core/src/agent-operations/__fixtures__/add-object-cases.ts b/packages/core/src/agent-operations/__fixtures__/add-object-cases.ts index d178106c09..046c40ff03 100644 --- a/packages/core/src/agent-operations/__fixtures__/add-object-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/add-object-cases.ts @@ -66,6 +66,25 @@ export function compiledSolid( } } +/** + * What a compile that could not measure the object leaves in the manifest: a mesh at a position + * that is not a number made every bound NaN. A structured clone (the chat's worker) keeps NaN; a + * JSON hop (a tab job answering the server) turns it to null. + */ +export function compiledUnmeasured( + hop: 'clone' | 'json', + mount: GeometryScriptMount = 'floor', +): CompiledGeometryScript { + const solid = compiledSolid([1.2, 2.8, 0.6], mount) + const nan: [number, number, number] = [Number.NaN, Number.NaN, Number.NaN] + const compiled = { + ...solid, + params: { width: 7.7, display: true }, + manifest: { ...solid.manifest, bounds: { min: nan, max: nan } }, + } + return hop === 'json' ? JSON.parse(JSON.stringify(compiled)) : compiled +} + /** An object add_object built earlier, with the reason it was given. */ export function authoredItem( id: string, @@ -165,6 +184,46 @@ export const ADD_OBJECT_CASES: AddObjectCase[] = [ [1.2, 0.5, 0.4], { refusal: 'reason_required', mentions: ['reason'], beforeCompile: true }, ), + // An agent's cabinet script put a material where a position goes, with one param on: the compile + // answered with NaN bounds and the item schema threw its raw validation array at the model. + { + name: 'an object whose bounds are not numbers is refused before its asset is built', + scene: floorScene, + input: { + code: CODE, + name: 'Library wall', + category: 'cabinet', + reason: 'Pascal has no library wall.', + params: { width: 7.7, display: true }, + }, + compiled: compiledUnmeasured('clone'), + expect: { + refusal: 'unmeasurable_object', + mentions: ['"Library wall" cannot be measured', '"display":true', 'nothing was added'], + }, + }, + { + name: 'bounds that crossed a JSON hop as null are refused the same way', + scene: floorScene, + input: { + code: CODE, + name: 'Library wall', + category: 'cabinet', + reason: 'Pascal has no library wall.', + }, + compiled: compiledUnmeasured('json'), + expect: { refusal: 'unmeasurable_object', mentions: ['"Library wall" cannot be measured'] }, + }, + { + name: 'an edit whose rebuild cannot be measured is refused and leaves the object as it was', + scene: floorScene, + input: { nodeId: 'item_screen', code: CODE, params: { display: true } }, + compiled: compiledUnmeasured('clone'), + expect: { + refusal: 'unmeasurable_object', + mentions: ['item_screen cannot be measured', 'nothing was changed'], + }, + }, // Production saw whole houses of plain custom solids, nothing editable as walls or rooms. create( 'a plain box with a wall’s size is refused: walls exist', @@ -215,6 +274,46 @@ export const ADD_OBJECT_CASES: AddObjectCase[] = [ { result: { mount: 'wall-side' }, node: { parentId: 'wall_south' } }, 'wall-side', ), + create( + 'a back-side wall mount defaults to facing away from its wall', + { parentId: 'wall_south', side: 'back' }, + [1, 1, 0.04], + { result: { mount: 'wall-side' }, node: { side: 'back', rotation: [0, Math.PI, 0] } }, + 'wall-side', + ), + create( + 'a front-side wall mount defaults to facing away from its wall', + { parentId: 'wall_south', side: 'front' }, + [1, 1, 0.04], + { result: { mount: 'wall-side' }, node: { side: 'front', rotation: [0, 0, 0] } }, + 'wall-side', + ), + create( + 'an explicit back-side wall mount rotation remains explicit', + { parentId: 'wall_south', side: 'back', rotation: 0 }, + [1, 1, 0.04], + { result: { mount: 'wall-side' }, node: { side: 'back', rotation: [0, 0, 0] } }, + 'wall-side', + ), + { + name: 'rebuilding a saved back-side wall object preserves its rotation', + scene: () => { + const scene = floorScene() + const previous = ItemNode.parse(scene.nodes.item_screen) + scene.nodes.item_screen = ItemNode.parse({ + ...previous, parentId: 'wall_south', wallId: 'wall_south', side: 'back', + asset: { ...previous.asset, attachTo: 'wall-side' }, + }) + const wall = WallNode.parse(scene.nodes.wall_south) + scene.nodes.wall_south = { ...wall, children: [previous.id] } + const level = LevelNode.parse(scene.nodes.level_0) + scene.nodes.level_0 = { ...level, children: [wall.id] } + return scene + }, + input: { nodeId: 'item_screen', code: CODE }, + compiled: compiledSolid([1, 1, 0.04], 'wall-side'), + expect: { result: { nodeId: 'item_screen' }, node: { rotation: [0, 0, 0], side: 'back' } }, + }, create( 'a name naming a Pascal type is built, with a hint naming the tool for it', { name: 'Spiral staircase', category: 'stair' }, diff --git a/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts b/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts index 740ac8d729..3853ec6517 100644 --- a/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts @@ -10,6 +10,7 @@ import { ZoneNode, } from '../../schema' import { doorFacing } from '../../building/wall-openings' +import { reconcileSceneStructure } from '../../lib/structure-reconcile' import { findBlockedDoors } from '../door-clearance' import { findItemItemCollisions } from '../layout-clearance' import type { AgentToolCase, SceneGraph } from './cases' @@ -117,6 +118,34 @@ function roomsScene(): SceneGraph { return graph(building, ground, upper, roof, wallG, door, window, kitchen, wallU) } +/** + * Run 6's order: walls drawn first, one add_wall each, round CLEAR (and `extra` inside it), then the + * rooms the reconciler made of them, "Room 1" first; `renamed` is a name a person gave it since. + */ +function walledScene({ extra = [], renamed }: { extra?: [Pt, Pt][]; renamed?: string } = {}) { + return (): SceneGraph => { + const base = roomsScene().nodes as Record + const walls = [...CLEAR.map((point, i): [Pt, Pt] => [point, CLEAR[(i + 1) % 4]!]), ...extra].map( + ([start, end], i) => + WallNode.parse({ id: `wall_c${i}`, parentId: 'level_g', start, end, height: 2.5 }), + ) + const level = base.level_g as LevelNode + let nodes: Record = { + ...base, + level_g: { ...level, children: [...level.children, ...walls.map((wall) => wall.id)] }, + ...Object.fromEntries(walls.map((wall) => [wall.id, wall])), + } + const names: Record = { zone: ['zone_walled', 'zone_side'] } + let count = 0 + nodes = reconcileSceneStructure({ + nodes, + mintId: (kind) => names[kind]?.shift() ?? `${kind}_walled${++count}`, + }).nodes as Record + if (renamed) nodes.zone_walled = { ...nodes.zone_walled!, name: renamed } as AnyNode + return graph(...Object.values(nodes)) + } +} + // ─── Checks the table cannot state: minted ids and derived construction ─────────────────────── const problems = (...entries: [boolean, string][]) => @@ -184,6 +213,24 @@ function roomBuilt(levelId: string, name: string, edges: number) { } } +/** The room zones of the ground floor inside CLEAR's box: the one per walled space there. */ +const roomsInClear = (nodes: Nodes) => + Object.values(nodes).filter( + (node) => + node.type === 'zone' && + node.parentId === 'level_g' && + node.spaceRole === 'room' && + node.polygon.every(([x, z]) => x >= -0.01 && x <= 4.01 && z >= 4.99 && z <= 8.01), + ) as ZoneNode[] + +const area = (polygon: readonly (readonly number[])[]) => + Math.abs( + polygon.reduce((sum, [x, z], i) => { + const [nx, nz] = polygon[(i + 1) % polygon.length]! + return sum + x! * nz! - nx! * z! + }, 0) / 2, + ) + const roomOn = (levelId: string) => (result: Record, nodes: Nodes) => problems([ nodes[result.zoneId as string]?.parentId === levelId, @@ -375,7 +422,7 @@ export const CREATE_ROOM_CASES: AgentToolCase[] = [ }, }, { - name: "a window sits at add_window's sill height; a door takes its size, hinge, swing and style", + name: 'a window is centred on its wall, as add_window places it; a door takes its size, hinge, swing and style', tool: 'create_room', scene: roomsScene, input: room({ @@ -396,12 +443,17 @@ export const CREATE_ROOM_CASES: AgentToolCase[] = [ check: (result, nodes) => { const window = nodes[(result.windowIds as string[])[0]!] const door = nodes[(result.doorIds as string[])[0]!] + // Centred on the wall as it stands when the room is made, the storey's 2.8 m: the floor plate + // is derived afterwards, and a later add_window sees the slab (a window 2.5 cm lower). + const wall = window?.type === 'window' && window.wallId ? nodes[window.wallId] : undefined + const level = wall?.parentId ? nodes[wall.parentId] : undefined + const ceiling = level?.type === 'level' ? (level.height ?? Number.NaN) : Number.NaN return problems( [ window?.type === 'window' && - near(window.position[1] - window.height / 2, 0.9) && + near(window.position[1], ceiling / 2) && window.width === 1.5, - `window ${JSON.stringify(window)} is not 1.5 m wide on a 0.9 m sill`, + `window ${JSON.stringify(window)} is not 1.5 m wide and centred on its ${ceiling} m wall`, ], [ door?.type === 'door' && @@ -488,6 +540,119 @@ export const CREATE_ROOM_CASES: AgentToolCase[] = [ }, expect: { refusal: 'outdoor_room_overlap', mentions: ['Kitchen'] }, }, + // Run 6: 54 walls drawn first, then 14 create_room calls, each stacking its named zone on the + // "Room N" the walls had made: 16 twins. + { + name: 'a room over walls that already enclose it names the room they made, never a second zone', + tool: 'create_room', + scene: walledScene(), + input: room({ levelId: 'level_g' }), + expect: { + result: { ok: true, reusedWalls: 4, zoneId: 'zone_walled' }, + after: { zone_walled: { name: 'Bedroom' } }, + check: (result, nodes) => [ + ...roomBuilt('level_g', 'Bedroom', 4)(result, nodes), + ...problems([ + roomsInClear(nodes).length === 1, + `${roomsInClear(nodes).map((zone) => zone.name).join(', ')} stand over one walled room`, + ]), + ], + }, + }, + { + name: 'the walled room is named whichever way round and from whichever corner the polygon runs', + tool: 'create_room', + scene: walledScene(), + input: { + levelId: 'level_g', + name: 'Study', + polygon: [ + [4, 8], + [4, 5], + [0, 5], + [0, 8], + ], + }, + expect: { + result: { ok: true, zoneId: 'zone_walled' }, + check: (_, nodes) => + problems([ + roomsInClear(nodes).map((zone) => zone.name).join() === 'Study', + `${roomsInClear(nodes).map((zone) => zone.name).join(', ')} stand there, not Study alone`, + ]), + }, + }, + { + name: 'a polygon over two walled spaces names the one holding most of it; the other keeps its name', + tool: 'create_room', + scene: walledScene({ + extra: [ + [ + [3, 5], + [3, 8], + ], + ], + }), + input: room({ levelId: 'level_g', name: 'Master Bedroom' }), + expect: { + result: { ok: true }, + check: (result, nodes) => { + const rooms = roomsInClear(nodes) + const master = nodes[result.zoneId as string] as ZoneNode | undefined + return problems( + [rooms.length === 2, `${rooms.length} rooms over two walled spaces`], + [ + master?.name === 'Master Bedroom' && Math.abs(area(master.polygon) - 9) < 0.05, + `the named room is ${master?.name} over ${master && area(master.polygon)} m², not the 9 m² space`, + ], + [ + rooms.some((zone) => /^Room \d+$/.test(zone.name ?? '')), + 'the smaller space lost its own name', + ], + ) + }, + }, + }, + // The owner (7 October): a room a person named is never renamed silently, and never twinned: the + // refusal names it, and the agent either passes rename: true or asks the person. + { + name: 'a room a person named is refused with a choice, never twinned or renamed silently', + tool: 'create_room', + scene: walledScene({ renamed: 'Library' }), + input: room({ levelId: 'level_g' }), + expect: { refusal: 'room_named_by_person', mentions: ['Library', 'rename: true only if the person asked', 'ask them'] }, + }, + { + name: 'rename: true names the room a person named, in place, with no second zone', + tool: 'create_room', + scene: walledScene({ renamed: 'Library' }), + input: room({ levelId: 'level_g', rename: true }), + expect: { + result: { ok: true, zoneId: 'zone_walled' }, + after: { zone_walled: { name: 'Bedroom' } }, + check: (_, nodes) => + problems([roomsInClear(nodes).length === 1, 'a second zone stands beside the renamed room']), + }, + }, + { + name: 'the same name over a room a person named has nothing to create', + tool: 'create_room', + scene: walledScene({ renamed: 'Bedroom' }), + input: room({ levelId: 'level_g' }), + expect: { refusal: 'room_named_by_person', mentions: ['already', 'Bedroom'] }, + }, + { + name: 'outdoor over a space walls already enclose names that room and drops its ceiling', + tool: 'create_room', + scene: walledScene(), + input: room({ levelId: 'level_g', name: 'Courtyard', outdoor: true }), + expect: { + result: { ok: true, zoneId: 'zone_walled', ceilingId: null }, + after: { zone_walled: { name: 'Courtyard', hasCeiling: false } }, + check: (_, nodes) => + problems([roomsInClear(nodes).length === 1, 'the courtyard stands beside its room']), + }, + }, { name: 'a roof level takes no room', tool: 'create_room', diff --git a/packages/core/src/agent-operations/__fixtures__/cases.ts b/packages/core/src/agent-operations/__fixtures__/cases.ts index bbf5aa9af1..1b730dd217 100644 --- a/packages/core/src/agent-operations/__fixtures__/cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/cases.ts @@ -22,6 +22,8 @@ import { PLACE_ITEMS_CASES } from './place-items-cases' import { FIND_BY_TYPE_CASES } from './find-by-type-cases' 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 { ADD_LEVEL_CASES, ADD_WALL_CASES, CREATE_STAIR_CASES } from './structure-cases' import { VERIFY_SCENE_CASES } from './verify-scene-cases' @@ -238,6 +240,40 @@ function houseScene(): SceneGraph { const emptyScene = (): SceneGraph => ({ nodes: {}, rootNodeIds: [] }) +/** Base chains are not the room/region finish, nor a promise that a ref renders. */ +export function finishReadbackScene(): SceneGraph { + const scene = houseScene() + const wall = scene.nodes.wall_ground as WallNode + scene.nodes.wall_ground = { + ...wall, + slots: { a: 'library:concrete-drywall' }, + faceRegions: [{ id: 'wainscot', face: 'a', v1: 0.9, finish: 'library:wood' }], + } + scene.nodes.wall_side = { + ...(scene.nodes.wall_side as WallNode), + legacyFaceMaterials: { + a: { material: { preset: 'white' }, materialPreset: 'library:preset-white' }, + }, + materialPreset: 'library:legacy-wall', + } + scene.nodes.zone_ground = { + ...(scene.nodes.zone_ground as ZoneNode), + boundaryWallIds: [wall.id], + wallMaterial: 'library:preset-white', + wallOverrides: [{ wallId: wall.id, face: 'a', finish: 'library:local-tile' }], + } + return scene +} + +/** Reject non-finite layout endpoints before JSON can silently turn them into null. */ +export function finiteReadbackProblems(value: unknown, path = 'result'): string[] { + if (typeof value === 'number') return Number.isFinite(value) ? [] : [`${path} is not finite`] + if (!value || typeof value !== 'object') return [] + return Object.entries(value).flatMap(([key, entry]) => + finiteReadbackProblems(entry, `${path}.${key}`), + ) +} + /** A level no building holds. */ function orphanLevelScene(): SceneGraph { const level = LevelNode.parse({ id: 'level_orphan', level: 0 }) @@ -442,6 +478,10 @@ export const GET_WALLS_CASES: AgentToolCase[] = [ height: 2.5, resolvedHeight: 2.5, heightIsExplicit: true, + wallFaceChains: { + a: { kind: 'default', ref: 'library:concrete-drywall' }, + b: { kind: 'default', ref: 'library:concrete-drywall' }, + }, openings: [ { id: 'door_ground', type: 'door' }, { id: 'window_ground', type: 'window' }, @@ -452,6 +492,10 @@ export const GET_WALLS_CASES: AgentToolCase[] = [ length: 4, resolvedHeight: 2.8, heightIsExplicit: false, + wallFaceChains: { + a: { kind: 'default', ref: 'library:concrete-drywall' }, + b: { kind: 'default', ref: 'library:concrete-drywall' }, + }, openings: [], }, ], @@ -481,6 +525,80 @@ export const GET_ZONES_CASES: AgentToolCase[] = [ ], areaSqMeters: 12, bounds: { width: 4, depth: 3 }, + wallMaterial: null, + wallOverrides: [], + }, + ], + }, + }, + }, +] + +// Approved readback contract: preserve provenance, including a deliberate choice identical to +// the fallback. Literal oracles deliberately do not call the finish resolver under test. +export const FINISH_READBACK_CASES: AgentToolCase[] = [ + ...(['get_walls', 'get_level_summary'] as const).map((tool): AgentToolCase => ({ + name: 'base face chains distinguish chosen default, unchosen default and legacy finishes', + tool, + scene: finishReadbackScene, + input: { levelId: 'level_ground' }, + expect: { + result: { + walls: [ + { + id: 'wall_ground', + wallFaceChains: { + a: { kind: 'ref', ref: 'library:concrete-drywall' }, + b: { kind: 'default', ref: 'library:concrete-drywall' }, + }, + }, + { + id: 'wall_side', + wallFaceChains: { + a: { + kind: 'legacy', + spec: { material: { preset: 'white' }, materialPreset: 'library:preset-white' }, + }, + b: { kind: 'legacy', spec: { materialPreset: 'library:legacy-wall' } }, + }, + }, + ], + }, + check: (result) => finiteReadbackProblems(result), + }, + })), + { + name: 'a chosen white room default and local face paint are persisted intent', + tool: 'get_zones', + scene: finishReadbackScene, + input: { levelId: 'level_ground' }, + expect: { + result: { + zones: [ + { + id: 'zone_ground', + wallMaterial: 'library:preset-white', + wallOverrides: [{ wallId: 'wall_ground', face: 'a', finish: 'library:local-tile' }], + }, + ], + }, + check: (result) => finiteReadbackProblems(result), + }, + }, + { + name: 'another storey does not inherit the painted storey base chains', + tool: 'get_walls', + scene: finishReadbackScene, + input: { levelId: 'level_upper' }, + expect: { + result: { + walls: [ + { + id: 'wall_upper', + wallFaceChains: { + a: { kind: 'default', ref: 'library:concrete-drywall' }, + b: { kind: 'default', ref: 'library:concrete-drywall' }, + }, }, ], }, @@ -767,6 +885,7 @@ export const AGENT_TOOL_CASES: readonly AgentToolCase[] = [ ...GET_WALLS_CASES, ...GET_ZONES_CASES, ...GET_LEVEL_SUMMARY_CASES, + ...FINISH_READBACK_CASES, ...DUPLICATE_LEVEL_CASES, ...VERIFY_SCENE_CASES, ...DELETE_NODE_CASES, @@ -779,4 +898,6 @@ export const AGENT_TOOL_CASES: readonly AgentToolCase[] = [ ...CREATE_STAIR_CASES, ...PLACE_ITEMS_CASES, ...FIND_BY_TYPE_CASES, + ...CORNER_WINDOW_CASES, + ...MERGE_WINDOWS_CASES, ] diff --git a/packages/core/src/agent-operations/__fixtures__/corner-window-cases.ts b/packages/core/src/agent-operations/__fixtures__/corner-window-cases.ts new file mode 100644 index 0000000000..ec7e4d7c57 --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/corner-window-cases.ts @@ -0,0 +1,91 @@ +import { BuildingNode, LevelNode, WallNode, type WindowNode } from '../../schema' +import type { AgentToolCase, SceneGraph } from './cases' + +/** + * `add_corner_window` (L65): the 290's Bed 4 window wraps the corner of its bay; run 4 built one + * window on one face and recorded it built. What goes wrong, written first: the two sides not + * joined or not reaching the corner; a bay's 135° refused or squared; a side wider than its wall, + * a point no two walls meet at, or a corner too shallow to be one, built anyway. + */ + +/** Wall A runs into the corner at the origin; wall B leaves it at `degrees` from A. */ +function cornerScene(degrees: number): SceneGraph { + const rad = (degrees * Math.PI) / 180 + const a = WallNode.parse({ id: 'wall_ca', parentId: 'level_c', start: [4, 0], end: [0, 0] }) + const b = WallNode.parse({ + id: 'wall_cb', + parentId: 'level_c', + start: [0, 0], + end: [3 * Math.cos(rad), 3 * Math.sin(rad)], + }) + const level = LevelNode.parse({ id: 'level_c', parentId: 'building_c', children: [a.id, b.id] }) + const building = BuildingNode.parse({ id: 'building_c', children: [level.id] }) + const nodes = [building, level, a, b] + return { + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: [building.id], + } +} + +const joined = (result: Record, nodes: Readonly>) => { + const windows = (result.windowIds as string[]).map((id) => nodes[id] as WindowNode | undefined) + const [a, b] = windows + return a?.corner?.partnerId === b?.id && b?.corner?.partnerId === a?.id + ? [] + : [`not joined: ${JSON.stringify(windows.map((w) => w?.corner))}`] +} + +export const CORNER_WINDOW_CASES: AgentToolCase[] = [ + { + name: 'two windows run to a square corner, joined, the glass fused', + tool: 'add_corner_window', + scene: () => cornerScene(90), + input: { corner: [0, 0], width: 1.2 }, + expect: { result: { ok: true, wallIds: ['wall_ca', 'wall_cb'], post: 'none' }, check: joined }, + }, + { + name: "a bay's 135° corner, a side of its own width, with a post", + tool: 'add_corner_window', + scene: () => cornerScene(135), + input: { corner: [0, 0], width: 1.2, widths: { wall_cb: 0.9 }, post: 'post' }, + expect: { + result: { ok: true, angle: 135, post: 'post' }, + check: (result, nodes) => { + const sides = (result.windowIds as string[]).map((id) => nodes[id] as WindowNode) + const onB = sides.find((window) => window.parentId === 'wall_cb') + return [ + ...joined(result, nodes), + ...(onB?.width === 0.9 ? [] : [`wall_cb side ${onB?.width} m`]), + ] + }, + }, + }, + { + name: 'a point where no two walls meet is refused', + tool: 'add_corner_window', + scene: () => cornerScene(90), + input: { corner: [2, 0], width: 1 }, + expect: { refusal: 'no_corner', mentions: ['get_walls'] }, + }, + { + name: 'a corner nearly straight is refused, pointing to add_window', + tool: 'add_corner_window', + scene: () => cornerScene(176), + input: { corner: [0, 0], width: 1 }, + expect: { refusal: 'corner_angle', mentions: ['add_window'] }, + }, + { + name: 'a side wider than its wall is refused, naming the wall', + tool: 'add_corner_window', + scene: () => cornerScene(90), + input: { corner: [0, 0], width: 3.5 }, + expect: { refusal: 'width_exceeds_wall', mentions: ['wall_cb'] }, + }, + { + name: 'a style on a sliding corner window is refused and nothing is written', + tool: 'add_corner_window', + scene: () => cornerScene(90), + input: { corner: [0, 0], width: 1, windowType: 'sliding', style: 'double-hung' }, + expect: { refusal: 'style_needs_fixed_window', mentions: ["'fixed'", 'sliding'] }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/merge-windows-cases.ts b/packages/core/src/agent-operations/__fixtures__/merge-windows-cases.ts new file mode 100644 index 0000000000..ecd57a504a --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/merge-windows-cases.ts @@ -0,0 +1,321 @@ +import { BuildingNode, LevelNode, WallNode, WindowNode } from '../../schema' +import type { AgentToolCase, SceneGraph } from './cases' + +/** + * `merge_windows` (the owner, 8 October): two windows that already exist become one. On two walls + * meeting at a corner, each reaching it within 0.30 m, they become one corner window (adopted, slid + * to the corner, the glass fused, one sill); side by side on one wall with at most 0.30 m between + * them and nothing between, one window spanning both. What goes wrong, written first: a window + * recreated instead of adopted; the sides left on two sills; the second window's type or style + * winning over the first's; a door, a window or an item between them swallowed; two windows that + * are nowhere near each other joined anyway; a window already joined to another stolen. + */ + +type WindowSpec = { + id: string + wall: string + /** Centre along the wall (m). */ + x: number + width: number + /** Bottom edge (m); the height is `height`. */ + sill: number + height: number + extra?: Record +} + +const window = ({ id, wall, x, width, sill, height, extra }: WindowSpec) => + WindowNode.parse({ + id, + parentId: wall, + wallId: wall, + position: [x, sill + height / 2, 0], + width, + height, + ...extra, + }) + +type WallSpec = { id: string; start: [number, number]; end: [number, number]; height?: number } + +function scene(walls: WallSpec[], windows: WindowSpec[]): SceneGraph { + const nodeWalls = walls.map((spec) => + WallNode.parse({ + id: spec.id, + parentId: 'level_m', + start: spec.start, + end: spec.end, + height: spec.height ?? 2.5, + children: windows.filter((w) => w.wall === spec.id).map((w) => w.id), + }), + ) + const level = LevelNode.parse({ + id: 'level_m', + parentId: 'building_m', + children: nodeWalls.map((wall) => wall.id), + }) + const building = BuildingNode.parse({ id: 'building_m', children: [level.id] }) + const nodes = [building, level, ...nodeWalls, ...windows.map(window)] + return { + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: [building.id], + } +} + +/** A 4 m wall running into the corner at the origin and a 3 m wall leaving it at `degrees`. */ +const corner = (degrees: number, windows: WindowSpec[], height?: number) => { + const rad = (degrees * Math.PI) / 180 + return scene( + [ + { id: 'wall_ca', start: [4, 0], end: [0, 0] }, + { + id: 'wall_cb', + start: [0, 0], + end: [3 * Math.cos(rad), 3 * Math.sin(rad)], + ...(height === undefined ? {} : { height }), + }, + ], + windows, + ) +} + +/** On wall_ca: 0.10 m short of the corner end (right edge at 3.9 m), sill 0.5, 1.5 m tall. */ +const nearA: WindowSpec = { id: 'window_a', wall: 'wall_ca', x: 3.3, width: 1.2, sill: 0.5, height: 1.5 } +/** On wall_cb: 0.20 m short of the corner end (left edge at 0.2 m), sill 0.8, 1.2 m tall. */ +const nearB: WindowSpec = { id: 'window_b', wall: 'wall_cb', x: 0.7, width: 1, sill: 0.8, height: 1.2 } + +/** One 5 m wall: the first window 0.5-1.5 m, the second 1.7-2.7 m (0.20 m between), sills differ. */ +const wall5: WallSpec = { id: 'wall_m', start: [0, 0], end: [5, 0] } +const left: WindowSpec = { + id: 'window_l', + wall: 'wall_m', + x: 1, + width: 1, + sill: 0.5, + height: 1.5, + extra: { windowType: 'fixed' }, +} +const right: WindowSpec = { + id: 'window_r', + wall: 'wall_m', + x: 2.2, + width: 1, + sill: 0.9, + height: 1.2, + extra: { windowType: 'casement' }, +} + +const sillOf = (w: WindowNode) => w.position[1] - w.height / 2 +const near = (a: number, b: number) => Math.abs(a - b) < 1e-6 + +export const MERGE_WINDOWS_CASES: AgentToolCase[] = [ + { + name: 'two windows near a square corner become one corner window, adopted and slid to it', + tool: 'merge_windows', + scene: () => corner(90, [nearA, nearB]), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { + result: { + ok: true, + kind: 'corner', + windowIds: ['window_a', 'window_b'], + removedIds: [], + wallIds: ['wall_ca', 'wall_cb'], + angle: 90, + }, + after: { + window_a: { corner: { end: 'end', partnerId: 'window_b', post: 'none' }, width: 1.2 }, + window_b: { corner: { end: 'start', partnerId: 'window_a', post: 'none' }, width: 1 }, + }, + check: (_result, nodes) => { + const [a, b] = ['window_a', 'window_b'].map((id) => nodes[id] as WindowNode) + return [ + ...(a && near(a.position[0], 4 - 0.6) ? [] : [`window_a at ${a?.position[0]}, not 3.4`]), + ...(b && near(b.position[0], 0.5) ? [] : [`window_b at ${b?.position[0]}, not 0.5`]), + ] + }, + }, + }, + { + name: "the second window takes the first's height, sill and type, and the answer says so", + tool: 'merge_windows', + scene: () => corner(90, [nearA, { ...nearB, extra: { windowType: 'casement' } }]), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { + result: { ok: true, kind: 'corner', height: 1.5, sillHeight: 0.5 }, + mentions: ['window_a', 'height', 'sill'], + check: (_result, nodes) => { + const [a, b] = ['window_a', 'window_b'].map((id) => nodes[id] as WindowNode) + return a && b && near(b.height, a.height) && near(sillOf(b), sillOf(a)) && b.windowType === a.windowType + ? [] + : [`not aligned: ${JSON.stringify([b?.height, b && sillOf(b), b?.windowType])}`] + }, + }, + }, + { + name: "the first window named sets the sill: named the other way round, the other's wins", + tool: 'merge_windows', + scene: () => corner(90, [nearA, nearB]), + input: { windowIds: ['window_b', 'window_a'] }, + expect: { + result: { ok: true, kind: 'corner', height: 1.2, sillHeight: 0.8 }, + }, + }, + { + name: "a bay's 135° corner joins like a square one", + tool: 'merge_windows', + scene: () => corner(135, [nearA, nearB]), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { result: { ok: true, kind: 'corner', angle: 135 } }, + }, + { + name: 'two windows side by side on one wall become one spanning both outer edges', + tool: 'merge_windows', + scene: () => scene([wall5], [left, right]), + input: { windowIds: ['window_l', 'window_r'] }, + expect: { + result: { + ok: true, + kind: 'same_wall', + windowIds: ['window_l'], + removedIds: ['window_r'], + wallIds: ['wall_m'], + width: 2.2, + }, + check: (_result, nodes) => { + const merged = nodes.window_l as WindowNode | undefined + return [ + ...(nodes.window_r ? ['window_r is still there'] : []), + ...(merged && + near(merged.position[0], 1.6) && + near(merged.width, 2.2) && + near(sillOf(merged), 0.5) && + near(merged.position[1] + merged.height / 2, 2.1) + ? [] + : [`merged window ${JSON.stringify([merged?.position, merged?.width, merged?.height])}`]), + // The wall no longer lists the window that went. + ...((nodes.wall_m as WallNode).children.includes('window_r') + ? ['wall_m still lists window_r'] + : []), + ] + }, + }, + }, + { + name: "the merged window keeps the first window's type; named the other way, the other's", + tool: 'merge_windows', + scene: () => scene([wall5], [left, right]), + input: { windowIds: ['window_r', 'window_l'] }, + expect: { + result: { ok: true, kind: 'same_wall', windowIds: ['window_r'], removedIds: ['window_l'] }, + after: { window_r: { windowType: 'casement' } }, + }, + }, + { + name: 'windows more than 0.30 m apart on one wall are refused, with the gap', + tool: 'merge_windows', + scene: () => scene([wall5], [left, { ...right, x: 3.2 }]), + input: { windowIds: ['window_l', 'window_r'] }, + expect: { refusal: 'windows_not_adjacent', mentions: ['1.20 m', '0.30 m'] }, + }, + { + name: 'another window between them is refused, named', + tool: 'merge_windows', + scene: () => + scene( + [wall5], + [left, right, { id: 'window_s', wall: 'wall_m', x: 1.6, width: 0.2, sill: 0.9, height: 0.6 }], + ), + input: { windowIds: ['window_l', 'window_r'] }, + expect: { refusal: 'opening_between', mentions: ['window_s'] }, + }, + { + name: 'windows on two walls that do not meet are refused', + tool: 'merge_windows', + scene: () => + scene( + [ + { id: 'wall_p', start: [0, 0], end: [4, 0] }, + { id: 'wall_q', start: [0, 5], end: [4, 5] }, + ], + [ + { id: 'window_a', wall: 'wall_p', x: 3.3, width: 1.2, sill: 0.5, height: 1.5 }, + { id: 'window_b', wall: 'wall_q', x: 0.7, width: 1, sill: 0.5, height: 1.5 }, + ], + ), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { refusal: 'walls_not_meeting', mentions: ['wall_p', 'wall_q'] }, + }, + { + name: 'a corner nearly straight is refused, pointing to the same-wall merge', + tool: 'merge_windows', + scene: () => corner(176, [nearA, nearB]), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { refusal: 'corner_angle', mentions: ['176'] }, + }, + { + name: 'a window more than 0.30 m from the corner is refused, with how far', + tool: 'merge_windows', + scene: () => corner(90, [{ ...nearA, x: 2.9 }, nearB]), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { refusal: 'window_not_at_corner', mentions: ['window_a', '0.30 m'] }, + }, + { + name: 'sliding to the corner onto another window is refused', + tool: 'merge_windows', + scene: () => + corner(90, [ + nearA, + nearB, + { id: 'window_s', wall: 'wall_ca', x: 3.95, width: 0.1, sill: 0.9, height: 0.6 }, + ]), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { refusal: 'opening_overlap', mentions: ['window_s'] }, + }, + { + name: "a window taller than the other wall cannot be matched by it, and the wall is named", + tool: 'merge_windows', + scene: () => corner(90, [nearA, nearB], 1.8), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { refusal: 'height_exceeds_wall', mentions: ['wall_cb'] }, + }, + { + name: 'a window already joined to another is not taken from it', + tool: 'merge_windows', + scene: () => + corner(90, [ + { ...nearA, extra: { corner: { end: 'end', partnerId: 'window_c', post: 'none' } } }, + nearB, + { + id: 'window_c', + wall: 'wall_cb', + x: 0.6, + width: 1.2, + sill: 0.5, + height: 1.5, + extra: { corner: { end: 'start', partnerId: 'window_a', post: 'none' } }, + }, + ]), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { refusal: 'window_already_joined', mentions: ['window_a', 'window_c'] }, + }, + { + name: 'the same window twice is refused', + tool: 'merge_windows', + scene: () => corner(90, [nearA, nearB]), + input: { windowIds: ['window_a', 'window_a'] }, + expect: { refusal: 'same_window' }, + }, + { + name: 'a node that is not a window is refused, naming its kind', + tool: 'merge_windows', + scene: () => corner(90, [nearA, nearB]), + input: { windowIds: ['window_a', 'wall_cb'] }, + expect: { refusal: 'not_a_window', mentions: ['wall'] }, + }, + { + name: 'an unknown window is refused, named', + tool: 'merge_windows', + scene: () => corner(90, [nearA, nearB]), + input: { windowIds: ['window_a', 'window_missing'] }, + expect: { refusal: 'window_not_found', mentions: ['window_missing'] }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/native-materials.ts b/packages/core/src/agent-operations/__fixtures__/native-materials.ts new file mode 100644 index 0000000000..b8a6b77484 --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/native-materials.ts @@ -0,0 +1,85 @@ +import { expect } from 'bun:test' +import { BuildingNode, DoorNode, LevelNode, SiteNode, WallNode, WindowNode } from '../../schema' +import type { AnyNode, SceneMaterial } from '../../schema' + +/** + * Acceptance contract v1.1 (2026-10-10): user-approved exact custom native PBR, + * plus Main's run_batch composition requirement. Derived before candidate edits. + * Four analytical rectangle walls: south runs east, so a is interior and b exterior. + * Public observables are native refs/datablocks, untouched opposite finish/openings, + * serialized inputs and transaction history; random material IDs are not an oracle. + */ +export const MOUNTAIN_NAME = 'Mountain house — warm timber' +export const MOUNTAIN_MATERIAL = { + preset: 'custom' as const, + properties: { color: '#71503a', roughness: 0.86, metalness: 0 }, +} +export const EXPECTED_MOUNTAIN_MATERIAL = { + preset: 'custom', + properties: { + color: '#71503a', roughness: 0.86, metalness: 0, + opacity: 1, transparent: false, side: 'front', + }, +} as const +export const INTERIOR_FINISH = 'library:concrete-drywall' + +export function nativeMaterialScene() { + const site = SiteNode.parse({ id: 'site_materials' }) + const building = BuildingNode.parse({ id: 'building_materials', parentId: site.id }) + const level = LevelNode.parse({ id: 'level_materials', parentId: building.id, level: 0 }) + const south = WallNode.parse({ + id: 'wall_material_south', parentId: level.id, start: [0, 0], end: [6, 0], + height: 3, slots: { a: INTERIOR_FINISH, b: 'library:concrete-raw' }, + faceRegions: [{ id: 'interior_stripe', face: 'a', u0: 0, u1: 2, v0: 0, v1: 1, finish: '#fffafa' }], + }) + const east = WallNode.parse({ id: 'wall_material_east', parentId: level.id, start: [6, 0], end: [6, 4], height: 3 }) + const north = WallNode.parse({ id: 'wall_material_north', parentId: level.id, start: [6, 4], end: [0, 4], height: 3 }) + const west = WallNode.parse({ id: 'wall_material_west', parentId: level.id, start: [0, 4], end: [0, 0], height: 3 }) + const window = WindowNode.parse({ id: 'window_materials', parentId: south.id, position: [4, 1.5, 0], width: 1, height: 1 }) + const door = DoorNode.parse({ id: 'door_materials', parentId: south.id, position: [1, 1, 0], width: 0.9, height: 2 }) + site.children = [building.id] + building.children = [level.id] + level.children = [south.id, east.id, north.id, west.id] + south.children = [window.id, door.id] + return { + nodes: Object.fromEntries([site, building, level, south, east, north, west, window, door].map(node => [node.id, node])) as Record, + rootNodeIds: [site.id], + level, south, east, north, west, window, door, + } +} + +export function mountainPaint(targets = ['wall_material_south']) { + return { targets, role: 'exterior', material: structuredClone(MOUNTAIN_MATERIAL), materialName: MOUNTAIN_NAME } +} + +export function nativePaintReadback(nodes: Readonly>, materials: Readonly>, wallId = 'wall_material_south') { + const wall = nodes[wallId] + if (wall?.type !== 'wall') throw new Error(`Missing wall ${wallId}`) + const ref = wall.slots?.b + expect(ref).toMatch(/^scene:/) + const id = ref!.slice('scene:'.length) + const datablock = materials[id] + expect(datablock).toBeDefined() + expect(datablock!.id).toBe(id) + expect(datablock!.material).toEqual(EXPECTED_MOUNTAIN_MATERIAL) + return { ref: ref!, datablock: datablock! } +} + +export function expectPreserved(before: Readonly>, after: Readonly>) { + for (const [id, node] of Object.entries(before)) { + expect(after[id]?.id).toBe(node.id) + expect(after[id]?.type).toBe(node.type) + expect(after[id]?.parentId).toBe(node.parentId) + } + for (const id of ['window_materials', 'door_materials']) expect(after[id]).toEqual(before[id]) + const original = before.wall_material_south + const painted = after.wall_material_south + if (original?.type !== 'wall' || painted?.type !== 'wall') throw new Error('Fixture wall missing') + expect(painted.id).toBe(original.id) + expect(painted.parentId).toBe(original.parentId) + expect(painted.children).toEqual(original.children) + expect(painted.start).toEqual(original.start) + expect(painted.end).toEqual(original.end) + expect(painted.slots?.a).toBe(original.slots?.a) + expect(painted.faceRegions?.filter(region => region.face === 'a')).toEqual(original.faceRegions?.filter(region => region.face === 'a')) +} diff --git a/packages/core/src/agent-operations/__fixtures__/place-items-cases.ts b/packages/core/src/agent-operations/__fixtures__/place-items-cases.ts index 4c3014c5b9..321ff47839 100644 --- a/packages/core/src/agent-operations/__fixtures__/place-items-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/place-items-cases.ts @@ -214,6 +214,82 @@ const at = (node: Placed | undefined, position: number[], label: string) => : [`${label} at ${node?.position}`] export const PLACE_ITEMS_CASES: AgentToolCase[] = [ + ...([false, true] as const).flatMap((diagonal) => + (['front', 'back'] as const).flatMap((side) => + ([undefined, 270] as const).map((rotation): AgentToolCase => { + // The diagonal is the 3-4-5 triangle: its left normal is (-0.8, 0.6). + const normal = diagonal ? [-0.8, 0.6] : [0, 1] + const midpoint = diagonal ? [1.5, 2] : [3, 0] + const sign = side === 'front' ? 1 : -1 + return { + name: `${diagonal ? 'diagonal' : 'axis'} wall ${side} keeps local planar yaw with ${rotation ?? 'unset'} world rotation`, + tool: 'place_items', + scene: () => { + const scene = hostScene() + const wall = scene.nodes.wall_south as AnyNode + scene.nodes.wall_south = { + ...wall, + ...(diagonal ? { end: [3, 4] } : {}), + } + return scene + }, + input: { + items: [{ + assetId: 'sconce', + targetNodeId: 'wall_south', + x: midpoint[0]! + sign * normal[0]! * 0.4, + z: midpoint[1]! + sign * normal[1]! * 0.4, + y: 1.5, + ...(rotation === undefined ? {} : { rotation }), + }], + }, + context: hosts, + expect: { + result: { ok: true }, + contains: { items: [{ ok: true, side, hostId: 'wall_south' }] }, + check: (result, nodes) => { + const sconce = placed(nodes, 'sconce') + const entry = (result.items as Record[])[0]! + const info = entry.info as { code?: string; message?: string } | undefined + return [ + ...at(sconce, [diagonal ? 2.5 : 3, 1.5, sign * 0.1], 'sconce'), + ...(near(sconce?.rotation[0], 0) && near(sconce?.rotation[2], 0) && + near(Math.abs(sconce?.rotation[1] ?? NaN), side === 'front' ? 0 : Math.PI) + ? [] : ['wall-hosted item is not planar in its local face frame']), + ...(rotation === undefined + ? info === undefined ? [] : ['unset rotation was reported ignored'] + : info?.code === 'rotation_ignored' && Boolean(info.message?.length) + ? [] : ['supplied rotation lacks explanatory rotation_ignored metadata']), + ] + }, + }, + } + }), + ), + ), + { + name: 'floor and item-hosted items retain explicit world yaw', + tool: 'place_items', + scene: hostScene, + input: { + items: [ + { assetId: 'bench', x: 3, z: 2, rotation: 270 }, + { assetId: 'table-lamp', targetNodeId: 'item_nightstand', x: 1, z: 4.1, rotation: 270 }, + ], + }, + context: hosts, + expect: { + result: { ok: true }, + check: (result, nodes) => [ + ...(near(placed(nodes, 'bench')?.rotation[1], 1.5 * Math.PI) ? [] : ['floor yaw changed']), + // The nightstand already supplies 90 degrees of the requested 270. + ...(near(Math.abs(placed(nodes, 'table-lamp')?.rotation[1] ?? NaN), Math.PI) + ? [] : ['item-host world yaw changed']), + ...((result.items as Record[]).every((entry) => entry.info === undefined) + ? [] : ['non-wall rotation was reported ignored']), + ], + }, + }, { name: 'items stand on the floor of the level named, turned in degrees', tool: 'place_items', diff --git a/packages/core/src/agent-operations/__fixtures__/room-cases.ts b/packages/core/src/agent-operations/__fixtures__/room-cases.ts index 2096da91d7..142e7f79ba 100644 --- a/packages/core/src/agent-operations/__fixtures__/room-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/room-cases.ts @@ -1,6 +1,13 @@ import { createZone, cutFloorOpening, divideZone, structureChangeBatch } from '../../commands/structure' import { reconcileSceneStructure } from '../../lib/structure-reconcile' -import { type AnyNode, BuildingNode, ItemNode, LevelNode } from '../../schema' +import { + type AnyNode, + BuildingNode, + ItemNode, + LevelNode, + type WallNode, + type ZoneNode, +} from '../../schema' import { applySceneChanges } from '../apply-changes' import type { AgentToolCase, SceneGraph } from './cases' @@ -84,6 +91,25 @@ function kitchenScene({ divided, hatch, lamp }: Build = {}): SceneGraph { return { nodes, rootNodeIds: [building.id] } } +export function paintedKitchenScene(): SceneGraph { + const scene = kitchenScene({ divided: true }) + scene.nodes.zone_kitchen = { + ...(scene.nodes.zone_kitchen as ZoneNode), + wallMaterial: 'library:preset-white', + wallOverrides: [{ wallId: 'wall_north', face: 'a', finish: 'library:local-tile' }], + } + scene.nodes.zone_pantry = { + ...(scene.nodes.zone_pantry as ZoneNode), + wallMaterial: 'library:pantry-blue', + wallOverrides: [{ wallId: 'wall_south', face: 'a', finish: 'library:pantry-tile' }], + } + scene.nodes.wall_north = { + ...(scene.nodes.wall_north as WallNode), + faceRegions: [{ id: 'wainscot', face: 'a', v1: 0.9, finish: 'library:wood' }], + } + return scene +} + /** The kitchen's floor plate: the reconciler names it from its footprint. */ const plateId = Object.values(kitchenScene().nodes as Nodes).find( (node) => node.type === 'slab' && node.plateRole === 'base', @@ -215,6 +241,60 @@ export const ROOM_CASES: AgentToolCase[] = [ after: { zone_kitchen: { name: 'Studio', floor: { finish: 'wood' } } }, }, }, + // Single public writes: room commands deliberately are not batchable. + ...([ + ['omitting wall intent retains chosen white', { name: 'Studio' }, 'library:preset-white'], + ['a wall intent string replaces chosen white', { wallMaterial: 'library:blue' }, 'library:blue'], + ['an empty wall intent string remains a string', { wallMaterial: '' }, ''], + ['null clears room wall intent without clearing local paint', { wallMaterial: null }, undefined], + ] as const).map(([name, patch, wallMaterial]): AgentToolCase => ({ + name, + tool: 'set_zone_intent', + scene: paintedKitchenScene, + input: { zoneId: 'zone_kitchen', patch }, + expect: { + result: { changes: 1 }, + after: { + zone_kitchen: { + wallOverrides: [{ wallId: 'wall_north', face: 'a', finish: 'library:local-tile' }], + }, + zone_pantry: { + wallMaterial: 'library:pantry-blue', + wallOverrides: [{ wallId: 'wall_south', face: 'a', finish: 'library:pantry-tile' }], + }, + wall_north: { + faceRegions: [{ id: 'wainscot', face: 'a', v1: 0.9, finish: 'library:wood' }], + }, + }, + check: (_result, nodes) => + (nodes.zone_kitchen as ZoneNode).wallMaterial === wallMaterial + ? [] + : [`persisted wallMaterial must be ${String(wallMaterial)}`], + }, + })), + { + name: 'adjacent rooms read independent wall defaults and local paint', + tool: 'get_zones', + scene: paintedKitchenScene, + input: { levelId: 'level_rooms' }, + expect: { + result: {}, + contains: { + zones: [ + { + id: 'zone_kitchen', + wallMaterial: 'library:preset-white', + wallOverrides: [{ wallId: 'wall_north', face: 'a', finish: 'library:local-tile' }], + }, + { + id: 'zone_pantry', + wallMaterial: 'library:pantry-blue', + wallOverrides: [{ wallId: 'wall_south', face: 'a', finish: 'library:pantry-tile' }], + }, + ], + }, + }, + }, { name: 'a floor key no room has is a conflict that says what to choose', tool: 'set_zone_intent', diff --git a/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts b/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts index 4b6c1ff99b..0a0de4b036 100644 --- a/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts @@ -537,6 +537,38 @@ export const VERIFY_SCENE_CASES: AgentToolCase[] = [ ), { contains: ['item_overlap'] }, ), + verify( + 'a fridge standing in the middle of a room is reported', + () => + scene( + building(), + level('level_0', 0), + ...room('level_0', 'ground'), + ItemNode.parse({ + id: 'item_fridge', + parentId: 'level_0', + position: [3, 0, 1.6], + asset: asset('fridge', [0.7, 1.8, 0.72]), + }), + ), + { contains: ['item_mid_room'], mentions: ['item_fridge'] }, + ), + verify( + 'a fridge against a wall is not reported', + () => + scene( + building(), + level('level_0', 0), + ...room('level_0', 'ground'), + ItemNode.parse({ + id: 'item_fridge', + parentId: 'level_0', + position: [3.6, 0, 1.5], + asset: asset('fridge', [0.7, 1.8, 0.72]), + }), + ), + { lacks: ['item_mid_room'] }, + ), verify( 'a node its schema rejects fails validation', () => diff --git a/packages/core/src/agent-operations/__fixtures__/wall-screen.json b/packages/core/src/agent-operations/__fixtures__/wall-screen.json new file mode 100644 index 0000000000..804fa7db7a --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/wall-screen.json @@ -0,0 +1,152 @@ +{ + "item_7j1l6855lxvbemw5": { + "id": "item_7j1l6855lxvbemw5", + "name": "Cinema projection screen and acoustic surround", + "side": "back", + "type": "item", + "asset": { + "id": "script_e7cfd4230ebe68b8", + "src": "artifact://e7cfd4230ebe68b8e53ad95ffede08c327f8bec15c674b57bbd22e4e3b0eac86", + "name": "Cinema projection screen and acoustic surround", + "scale": [ + 1, + 1, + 1 + ], + "offset": [ + 0, + 0, + 0 + ], + "source": "mine", + "attachTo": "wall-side", + "category": "media wall", + "rotation": [ + 0, + 0, + 0 + ], + "thumbnail": "", + "dimensions": [ + 6.6, + 2.5, + 0.3325 + ] + }, + "scale": [ + 1, + 1, + 1 + ], + "slots": { + "bronze": "library:metal-brass", + "acoustic": "library:preset-nearblack" + }, + "object": "node", + "source": { + "kind": "script", + "images": { + "artifact": "e7cfd4230ebe68b8e53ad95ffede08c327f8bec15c674b57bbd22e4e3b0eac86", + "floorPlan": "2a73476859e3049fc748b7698ae05b307d0a65f9499e27170e9a043e745f6712", + "thumbnail": "f3505f5dba792e817a108f99106b609bee9d97de4f27e623b59d8b3e230bc955" + }, + "params": {}, + "script": "cf2085198e5a1deb42f5a7778acb7f2c0a0c149364970eeb22bf5ec19fa53f38", + "artifact": "e7cfd4230ebe68b8e53ad95ffede08c327f8bec15c674b57bbd22e4e3b0eac86", + "language": "three", + "manifest": { + "bounds": { + "max": [ + 3.3, + 2.5, + 0.3325 + ], + "min": [ + -3.3, + 0, + 0 + ] + }, + "parts": [ + { + "id": "screen_surround", + "type": "panel", + "bounds": { + "max": [ + 3.3, + 2.5, + 0.3325 + ], + "min": [ + -3.3, + 0, + 0 + ] + } + } + ], + "cutout": false, + "triangles": 852 + } + }, + "wallId": "wall_fseg8ncv7q8u5kqn", + "visible": true, + "children": [], + "metadata": { + "reason": "A wide projection screen with fluted acoustic panels and integrated speakers completes the private eight-seat cinema" + }, + "parentId": "wall_fseg8ncv7q8u5kqn", + "position": [ + 4.5, + 0.55, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ] + }, + "wall_fseg8ncv7q8u5kqn": { + "id": "wall_fseg8ncv7q8u5kqn", + "end": [ + -12, + 3 + ], + "name": "Wall 9", + "type": "wall", + "slots": { + "a": "scene:mat_14hp4rf0rgo1wpb8", + "b": "scene:mat_14hp4rf0rgo1wpb8" + }, + "start": [ + -3, + 3 + ], + "object": "node", + "visible": true, + "backSide": "interior", + "children": [ + "item_7j1l6855lxvbemw5" + ], + "metadata": {}, + "parentId": "level_qqolzyn207xv0jj9", + "frontSide": "interior", + "thickness": 0.22 + }, + "level_qqolzyn207xv0jj9": { + "id": "level_qqolzyn207xv0jj9", + "name": "Ground \u00b7 Reception & Entertaining", + "type": "level", + "level": 0, + "height": 3.8, + "object": "node", + "visible": true, + "children": [ + "wall_fseg8ncv7q8u5kqn" + ], + "metadata": {}, + "parentId": null, + "baseElevation": 0 + } +} diff --git a/packages/core/src/agent-operations/achieved.ts b/packages/core/src/agent-operations/achieved.ts index ec93d46097..87e2dd425c 100644 --- a/packages/core/src/agent-operations/achieved.ts +++ b/packages/core/src/agent-operations/achieved.ts @@ -38,3 +38,19 @@ export function achievedChanges(before: SceneNodes, changes: SceneChanges): Achi ...(created.length || deleted.length || updated ? {} : { unchanged: true as const }), } } + +/** + * Reads changes made through a host's own write path. `before` must be a copy + * when the host mutates the node map it returns. + */ +export function achievedBetween(before: SceneNodes, after: SceneNodes): Achieved { + const created = Object.keys(after).filter((id) => !(id in before)) + const deleted = Object.keys(before).filter((id) => !(id in after)) + const updated = Object.keys(after).filter((id) => id in before && before[id] !== after[id]).length + return { + created: countByType(created, after), + updated, + deleted: countByType(deleted, before), + ...(created.length || deleted.length || updated ? {} : { unchanged: true as const }), + } +} diff --git a/packages/core/src/agent-operations/add-column.ts b/packages/core/src/agent-operations/add-column.ts index 5550a64bb1..d634009ac6 100644 --- a/packages/core/src/agent-operations/add-column.ts +++ b/packages/core/src/agent-operations/add-column.ts @@ -3,6 +3,7 @@ import type { addColumnTool } from '../agent-tools/columns' import { refuse } from '../agent-tools/refusal' import { matchScriptSlotsToLibrary, + requireMeasured, scriptedSize, scriptSource, withSourceMeta, @@ -62,6 +63,8 @@ export const addColumn: AgentOperation = (nodes, input, context) const { compiled } = input if (compiled && compiled.mount !== 'floor') refuse('wrong_mount', "A column's script uses mount 'floor'.") + if (compiled) + requireMeasured(compiled, { label: input.name, nodeId: input.nodeId, tool: 'add_column' }) const dimensions = compiled ? scriptedSize(compiled.manifest) : undefined const { nodeId: _, diff --git a/packages/core/src/agent-operations/add-object.test.ts b/packages/core/src/agent-operations/add-object.test.ts index 4cbd7bc2d5..f2d4bd63eb 100644 --- a/packages/core/src/agent-operations/add-object.test.ts +++ b/packages/core/src/agent-operations/add-object.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from 'bun:test' import { z } from 'zod' import { addObjectTool } from '../agent-tools' import { type AgentRefusal, isAgentRefusal } from '../agent-tools/refusal' -import type { AnyNode } from '../schema' -import { ADD_OBJECT_CASES } from './__fixtures__/add-object-cases' -import { addObject, applySceneChanges } from './index' +import { type AnyNode, DoorNode, LevelNode, WallNode } from '../schema' +import { ADD_OBJECT_CASES, compiledUnmeasured } from './__fixtures__/add-object-cases' +import { addColumn, addObject, addWallOpening, applySceneChanges, rescriptOpening } from './index' // Layer 1 of 3: the core operation, handed the case's compile in place of a surface's. describe('add_object', () => { @@ -35,3 +35,76 @@ describe('add_object', () => { }) } }) + +// The compile's bounds are the size of everything a script builds: a door, a window or a column +// whose bounds are not numbers is refused with the object's own code, not built with a NaN width. +describe('a scripted door, window or column that cannot be measured', () => { + const wall = WallNode.parse({ + id: 'wall_south', + parentId: 'level_0', + start: [0, 0], + end: [6, 0], + height: 3, + }) + const door = DoorNode.parse({ + id: 'door_south', + parentId: wall.id, + wallId: wall.id, + position: [3, 1.05, 0], + }) + const level = LevelNode.parse({ id: 'level_0', level: 0, height: 3, children: [wall.id] }) + const nodes = Object.fromEntries([level, wall, door].map((node) => [node.id, node])) as Record< + string, + AnyNode + > + const context = { activeLevelId: level.id } + + function refusedWith(run: () => unknown) { + let error: unknown + try { + run() + } catch (thrown) { + error = thrown + } + expect(isAgentRefusal(error)).toBe(true) + return error as AgentRefusal + } + + for (const hop of ['clone', 'json'] as const) { + test(`a new door or window built from bounds that are ${hop === 'json' ? 'null' : 'NaN'}`, () => { + for (const kind of ['door', 'window'] as const) { + const refusal = refusedWith(() => + addWallOpening(nodes, { + kind, + wallId: wall.id, + t: 0.5, + compiled: compiledUnmeasured(hop, 'wall'), + }), + ) + expect(refusal.code).toBe('unmeasurable_object') + expect(refusal.message).toContain(`call add_${kind} again`) + expect(refusal.message).toContain('nothing was added') + } + }) + + test(`a door rebuilt from bounds that are ${hop === 'json' ? 'null' : 'NaN'} stays as it was`, () => { + const refusal = refusedWith(() => + rescriptOpening( + nodes, + { nodeId: door.id, compiled: compiledUnmeasured(hop, 'wall') }, + context, + ), + ) + expect(refusal.code).toBe('unmeasurable_object') + expect(refusal.message).toContain('nothing was changed') + }) + + test(`a column built from bounds that are ${hop === 'json' ? 'null' : 'NaN'}`, () => { + const refusal = refusedWith(() => + addColumn(nodes, { x: 2, z: 2, compiled: compiledUnmeasured(hop) } as never, context), + ) + expect(refusal.code).toBe('unmeasurable_object') + expect(refusal.message).toContain('call add_column again') + }) + } +}) diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index f2c10c0cfc..193469552b 100644 --- a/packages/core/src/agent-operations/add-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -3,6 +3,7 @@ import { artifactUrl } from '../lib/artifact-store' import { isScriptedNode, matchScriptSlotsToLibrary, + requireMeasured, type ScriptedNode, scriptedSize, scriptInteractive, @@ -206,12 +207,13 @@ export function requireAddObjectReason(input: { nodeId?: string; reason?: string if (!input.nodeId && !input.reason?.trim()) refuse( 'reason_required', - 'Say what this object stands in for (reason): why no Pascal tool or catalog item builds it. The scene check lists every authored object with its reason.', + 'Give its design intent (reason): why the authored shape, size or style suits the request. The scene check lists every authored object with its reason; no catalog-exhaustion proof is required.', ) } export const addObject: AgentOperation = (nodes, input, context) => { const { compiled } = input + requireMeasured(compiled, { label: input.name, nodeId: input.nodeId, tool: 'add_object' }) const rotation: Vec3 | undefined = input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] @@ -290,7 +292,11 @@ export const addObject: AgentOperation = (nodes, input, context) parentId: parent.id, ...(parent.type === 'wall' ? { wallId: parent.id, side: input.side ?? 'front' } : {}), position: (input.position as Vec3 | undefined) ?? [0, 0, 0], - rotation: rotation ?? [0, 0, 0], + rotation: rotation ?? [ + 0, + compiled.mount === 'wall-side' && input.side === 'back' ? Math.PI : 0, + 0, + ], source: scriptSource(compiled, itemSourceMeta(input)), slots: matchScriptSlotsToLibrary(compiled.manifest), asset, @@ -343,6 +349,11 @@ export const rescriptOpening: AgentOperation = (nodes, inp refuse('wrong_mount', `A ${previous.type}'s script uses mount 'wall'.`, { mount: compiled.mount, }) + requireMeasured(compiled, { + label: input.name ?? previous.name, + nodeId: input.nodeId, + tool: `add_${previous.type}`, + }) const [width, height] = scriptedSize(compiled.manifest) // Given a position, that is where it goes; otherwise its bottom edge stays put. const [x, y, z] = previous.position diff --git a/packages/core/src/agent-operations/apply-changes.ts b/packages/core/src/agent-operations/apply-changes.ts index e38716cb74..ef3a62596f 100644 --- a/packages/core/src/agent-operations/apply-changes.ts +++ b/packages/core/src/agent-operations/apply-changes.ts @@ -4,6 +4,16 @@ import type { SceneChanges, SceneNodes } from './types' const childrenOf = (node: AnyNode | undefined): string[] | null => node && 'children' in node && Array.isArray(node.children) ? (node.children as string[]) : null +/** + * A node with a patch applied. A field the patch sets to `undefined` is removed, as the store's + * updateNode removes it: optional fields encode a mode by their absence. + */ +function patched(node: AnyNode, data: Partial): AnyNode { + const merged: Record = { ...node, ...data } + for (const [key, value] of Object.entries(data)) if (value === undefined) delete merged[key] + return merged as AnyNode +} + /** * The scene after an operation's changes, without a store: for checks and previews. Same order as * the store's applyNodeChanges — updates, creates, then deletes — so an opening moved off a wall @@ -30,7 +40,7 @@ export function applySceneChanges( detach(current.parentId, id) attach(data.parentId, id) } - next[id] = { ...current, ...data } as AnyNode + next[id] = patched(current, data) } for (const { node, parentId } of changes?.create ?? []) { next[node.id] = parentId ? ({ ...node, parentId } as AnyNode) : node @@ -74,7 +84,7 @@ export function mergeSceneChanges( const entry = created.get(id) if (entry) created.set(id, { - node: { ...entry.node, ...data } as AnyNode, + node: patched(entry.node, data), parentId: (data.parentId as string | undefined) ?? entry.parentId, }) else updated.set(id, { ...updated.get(id), ...data } as Partial) @@ -95,5 +105,6 @@ export function mergeSceneChanges( update: [...updated].map(([id, data]) => ({ id, data })), delete: [...deleted], collections, + ...(materials.size ? { materials: [...materials.values()] } : {}), } } diff --git a/packages/core/src/agent-operations/apply-outcome.ts b/packages/core/src/agent-operations/apply-outcome.ts index d9d8656d7a..d82e8c888a 100644 --- a/packages/core/src/agent-operations/apply-outcome.ts +++ b/packages/core/src/agent-operations/apply-outcome.ts @@ -1,3 +1,4 @@ +import { madeEntries, type SessionKeep } from './scene-checkpoint' import type { AgentOperationOutcome, SceneChanges, SceneNodes } from './types' /** What a surface lends an operation's outcome: its scene, its writes and its reconciler. */ @@ -6,6 +7,11 @@ export type AgentHostRuntime = { applyChanges: (changes: SceneChanges) => void /** Derive construction from the scene as it now is: rooms, auto ceilings, floor plates. */ reconcile: () => void + /** + * Keeps what the agent's session should remember: the checkpoint an operation took and the nodes + * a write created. The host stores it where its sessions live, never in the scene. + */ + keep?: (keep: SessionKeep) => void } /** @@ -17,13 +23,26 @@ export function applyAgentOutcome( outcome: AgentOperationOutcome, runtime: AgentHostRuntime, ): Record { - if (outcome.changes) runtime.applyChanges(outcome.changes) - if (!outcome.afterReconcile) return outcome.result - runtime.reconcile() - const settled = outcome.afterReconcile(runtime.getNodes()) - if (settled.changes) { - runtime.applyChanges(settled.changes) + const written: SceneChanges[] = [] + if (outcome.changes) { + runtime.applyChanges(outcome.changes) + written.push(outcome.changes) + } + let result = outcome.result + if (outcome.afterReconcile) { runtime.reconcile() + const settled = outcome.afterReconcile(runtime.getNodes()) + if (settled.changes) { + runtime.applyChanges(settled.changes) + written.push(settled.changes) + runtime.reconcile() + } + result = settled.result + } + if (runtime.keep) { + const made = madeEntries(runtime.getNodes(), written) + if (outcome.keep?.checkpoint || made.length) + runtime.keep({ ...outcome.keep, ...(made.length ? { made } : {}) }) } - return settled.result + return result } diff --git a/packages/core/src/agent-operations/batch.ts b/packages/core/src/agent-operations/batch.ts new file mode 100644 index 0000000000..861fc4f5b2 --- /dev/null +++ b/packages/core/src/agent-operations/batch.ts @@ -0,0 +1,111 @@ +import { z } from 'zod' +import { isAgentRefusal, refuse } from '../agent-tools/refusal' +import type { SceneMaterialId } from '../schema/scene-material' +import { applySceneChanges, mergeSceneChanges } from './apply-changes' +import { referencedSceneMaterialIds } from './scene-materials' +import type { AgentOperation, SceneChanges } from './types' + +type BatchInput = { + calls: { tool: string; input?: Record }[] + onRefusal?: 'stop' | 'skip' +} +type Contract = { name: string; input: Record } + +type CallReport = + | { index: number; tool: string; status: 'ok'; result: Record } + | { index: number; tool: string; status: 'refused'; code: string; error: string } + +/** + * `run_batch`: shared tools in order, each on the scene the calls before it left, their changes + * merged into one set — one undo step, one write on either surface. The user: the model spent a + * round trip per step on what could be serialised (deleting balcony walls one by one, the same + * build on every floor). Inputs are checked before anything runs. + */ +export function createBatchOperation( + operations: Readonly>>, + contracts: readonly Contract[], +): AgentOperation { + const names = Object.keys(operations).join(', ') + return (nodes, { calls, onRefusal = 'stop' }, context) => { + const prepared = calls.map((call, index) => { + const operation = operations[call.tool] + const contract = contracts.find((candidate) => candidate.name === call.tool) + if (!(operation && contract)) + refuse( + 'unknown_tool', + `The batch's call ${index + 1} names ${call.tool}, which a batch cannot run: call it on its own. A batch runs: ${names}.`, + { index, tool: call.tool }, + ) + const parsed = z.object(contract.input).safeParse(call.input ?? {}) + if (!parsed.success) + refuse( + 'invalid_input', + `The batch's call ${index + 1} (${call.tool}) has an invalid input: ${parsed.error.issues + .map((issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`) + .join('; ')}. Nothing ran.`, + { index, tool: call.tool }, + ) + return { index, tool: call.tool, operation, input: parsed.data } + }) + + let working = nodes + let workingContext = context + const sets: SceneChanges[] = [] + const reports: CallReport[] = [] + for (const call of prepared) { + try { + const { result, changes } = call.operation(working, call.input as never, workingContext) + if (changes) { + sets.push(changes) + working = applySceneChanges(working, changes) + if (changes.materials?.length && workingContext.materials !== undefined) + workingContext = { + ...workingContext, + materials: { + ...workingContext.materials, + ...Object.fromEntries(changes.materials.map((material) => [material.id, material])), + }, + } + } + reports.push({ index: call.index, tool: call.tool, status: 'ok', result }) + } catch (error) { + if (!isAgentRefusal(error)) throw error + if (onRefusal === 'stop') + refuse( + 'batch_call_refused', + `The batch stopped at call ${call.index + 1} (${call.tool}), refused with ${error.code}: ${error.message} Nothing in the batch was applied.`, + { index: call.index, tool: call.tool, refusal: error.code, ...error.details }, + ) + reports.push({ + index: call.index, + tool: call.tool, + status: 'refused', + code: error.code, + error: error.message, + }) + } + } + const applied = reports.filter((report) => report.status === 'ok').length + const merged = mergeSceneChanges(sets) + // A later erase/delete may cancel a new material's only use in this batch. + if (merged.materials) { + const refs = referencedSceneMaterialIds(working) + merged.materials = merged.materials.filter( + (material) => + context.materials?.[material.id as SceneMaterialId] !== undefined || + refs.has(material.id), + ) + if (!merged.materials.length) delete merged.materials + } + const changed = + merged.create.length + + merged.update.length + + merged.delete.length + + (merged.materials?.length ?? 0) > + 0 + return { + result: { status: 'applied', applied, refused: reports.length - applied, calls: reports }, + ...(changed ? { changes: merged } : {}), + } + } +} diff --git a/packages/core/src/agent-operations/ceiling-object-observations.ts b/packages/core/src/agent-operations/ceiling-object-observations.ts new file mode 100644 index 0000000000..9a4e91c8c8 --- /dev/null +++ b/packages/core/src/agent-operations/ceiling-object-observations.ts @@ -0,0 +1,143 @@ +import { liftedManualSlab } from '../lib/floor-construction-lift' +import { withHostedCutterHoles } from '../lib/object-cuts' +import { area, difference, intersection, type Ring } from '../lib/polygon-boolean' +import { getRenderableSlabPolygon, slabPolygonContextForLevel } from '../lib/slab-polygon' +import type { AnyNode } from '../schema' +import { resolveCeilingHeight } from '../services/level-height' +import { CEILING_DRAW_OFFSET, getLevelElevations } from '../services/storey' +import { scriptItemBoundsInLevel } from './layout-clearance' + +type BoundsEvidence = { + severity?: 'info' + itemId: string + ceilingId: string + levelId: string + coordinateFrame: 'building' + bounds: { min: [number, number, number]; max: [number, number, number] } +} + +export type CeilingObjectObservation = + | (BoundsEvidence & { + type: 'item_above_ceiling' + message: string + aboveAttachmentMeters: number + attachmentElevation: number + }) + | (BoundsEvidence & { + type: 'item_covering_slab_overlap' + message: string + slabId: string + slabLevelId: string + slabBottomY: number + slabTopY: number + overlapHeightMeters: number + overlapAreaSquareMeters: number + }) + +function projectedHull(corners: [number, number, number][]): Ring { + const sorted = corners + .map(([x, , z]): [number, number] => [x, z]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]) + .filter((point, i, points) => !i || point.some((v, axis) => v !== points[i - 1]![axis])) + const cross = (a: Ring[number], b: Ring[number], c: Ring[number]) => + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + const half = (points: Ring) => { + const hull: Ring = [] + for (const point of points) { + while (hull.length >= 2 && cross(hull.at(-2)!, hull.at(-1)!, point) <= 0) hull.pop() + hull.push(point) + } + return hull.slice(0, -1) + } + return [...half(sorted), ...half([...sorted].reverse())] +} + +/** Static script bounds, not mesh intersections. All comparisons stay in the shared building frame. */ +export function ceilingObjectObservations( + nodes: Record, +): CeilingObjectObservation[] { + const observations: CeilingObjectObservation[] = [] + const elevations = getLevelElevations(nodes) + for (const item of Object.values(nodes)) { + if (item.type !== 'item' || item.asset.attachTo !== 'ceiling') continue + const ceiling = item.parentId ? nodes[item.parentId] : undefined + if (ceiling?.type !== 'ceiling') continue + const resolved = scriptItemBoundsInLevel(item, nodes) + if (!resolved) continue + const level = elevations.get(resolved.levelId) + if (!level || !Number.isFinite(level.baseY)) continue + const attachment = level.baseY + resolveCeilingHeight(ceiling, nodes) - CEILING_DRAW_OFFSET + const bounds = { + min: [...resolved.bounds.min] as [number, number, number], + max: [...resolved.bounds.max] as [number, number, number], + } + bounds.min[1] += level.baseY + bounds.max[1] += level.baseY + const evidence: BoundsEvidence = { + itemId: item.id, + ceilingId: ceiling.id, + levelId: resolved.levelId, + coordinateFrame: 'building', + bounds, + } + const above = bounds.max[1] - attachment + if (above > 1e-6) + observations.push({ + ...evidence, + type: 'item_above_ceiling', + message: `Object ${item.id} bounds extend ${above.toFixed(3)} m above ceiling attachment ${ceiling.id}`, + aboveAttachmentMeters: above, + attachmentElevation: attachment, + }) + const footprint = projectedHull(resolved.corners) + for (const original of Object.values(nodes)) { + if (original.type !== 'slab' || original.recessed || !original.parentId) continue + const slabLevel = elevations.get(original.parentId) + // Unlinked legacy levels are not evidence that two different storeys share a building. + if ( + !slabLevel || + (original.parentId !== resolved.levelId && + (!level.buildingId || slabLevel.buildingId !== level.buildingId)) + ) + continue + try { + const slab = liftedManualSlab(nodes, original) + const top = slabLevel.baseY + slab.elevation + const bottom = top - slab.thickness + const height = Math.min(bounds.max[1], top) - Math.max(bounds.min[1], bottom) + if (top <= level.baseY + 1e-6 || !Number.isFinite(height) || height <= 1e-6) continue + const slabParent = nodes[original.parentId] + if (slabParent?.type !== 'level') continue + const polygon = getRenderableSlabPolygon( + slab, + slabPolygonContextForLevel(slabParent, (id) => nodes[id]), + ) + const holes = withHostedCutterHoles(original, nodes).holes + // Subtract holes as a union, including overlapping stored and derived openings. + const solid = holes.length + ? difference( + polygon, + holes.map((outer) => ({ outer, holes: [] })), + { throwOnError: true }, + ) + : polygon + const overlap = area(intersection(footprint, solid)) + if (overlap <= 1e-6) continue + observations.push({ + ...evidence, + type: 'item_covering_slab_overlap', + message: `Object ${item.id} bounds overlap covering slab ${slab.id} over ${height.toFixed(3)} m vertically and ${overlap.toFixed(3)} m² in plan; bounds overlap is not an exact mesh intersection`, + slabId: slab.id, + slabLevelId: original.parentId, + slabBottomY: bottom, + slabTopY: top, + overlapHeightMeters: height, + overlapAreaSquareMeters: overlap, + }) + } catch { + // Unsupported or malformed slab geometry is not collision evidence. + } + } + } + return observations +} diff --git a/packages/core/src/agent-operations/corner-window.ts b/packages/core/src/agent-operations/corner-window.ts new file mode 100644 index 0000000000..d0e4d02a08 --- /dev/null +++ b/packages/core/src/agent-operations/corner-window.ts @@ -0,0 +1,82 @@ +import { planCornerWindow } from '../building/corner-window' +import { cornerPair, wallEndPoint, windowCornerEnd } from '../lib/corner-pair' +import type { WallNode, WindowNode } from '../schema' +import { achievedChanges } from './achieved' +import type { AgentOperation, SceneChanges } from './types' +import { registerSceneCheck, type SceneIssue } from './verify-scene' + +type AddCornerWindowInput = Parameters[1] + +/** `add_corner_window` (L65): one window on each of the two walls meeting at a corner, joined. */ +export const addCornerWindow: AgentOperation = (nodes, input) => { + const { windows, wallIds, angle } = planCornerWindow(nodes, input) + const changes: SceneChanges = { + create: windows.map((node) => ({ node: node as never, parentId: node.parentId! })), + } + return { + changes, + result: { + ok: true, + windowIds: windows.map((window) => window.id), + wallIds, + angle: Math.round(angle * 10) / 10, + post: windows[0]!.corner!.post, + message: `Added a corner window across ${wallIds.join(' and ')} (${Math.round(angle)}°), the glass ${windows[0]!.corner!.post === 'none' ? 'fused at the corner' : 'meeting at a post'}.`, + achieved: achievedChanges(nodes, changes), + }, + } +} + +const round = (value: number) => Math.round(value * 100) / 100 + +/** + * Corner windows, checked (L65): two windows running to the same corner unjoined (run 4 built one + * face of the 290's Bed 4 corner window and did not see the wrap), a pair whose sides drifted + * apart, a window naming a partner that is gone. + */ +export function cornerWindowIssues(nodes: Parameters[0]): SceneIssue[] { + const issues: SceneIssue[] = [] + const atCorner = new Map() + for (const node of Object.values(nodes)) { + if (node.type !== 'window') continue + const window = node as WindowNode + const pair = cornerPair(nodes, window) + if (window.corner && !pair) { + issues.push({ + type: 'corner_partner_missing', + message: `Window ${window.id} names corner partner ${window.corner.partnerId}, but one of them is gone, no longer joined back or no longer at the corner: it renders as a plain window. Rebuild the pair with add_corner_window.`, + }) + continue + } + if (pair) { + const sill = (w: WindowNode) => w.position[1] - w.height / 2 + if ( + window.id < pair.partner.id && + (Math.abs(window.height - pair.partner.height) > 1e-3 || + Math.abs(sill(window) - sill(pair.partner)) > 1e-3) + ) + issues.push({ + type: 'corner_out_of_step', + message: `Corner window ${window.id} and ${pair.partner.id} differ (heights ${round(window.height)} and ${round(pair.partner.height)} m, sills ${round(sill(window))} and ${round(sill(pair.partner))} m): a corner window's sides share height and sill.`, + }) + continue + } + const end = windowCornerEnd(nodes, window) + const wall = nodes[window.parentId ?? ''] as WallNode | undefined + if (!end || wall?.type !== 'wall') continue + const [x, z] = wallEndPoint(wall, end) + const key = `${wall.parentId}:${x!.toFixed(3)},${z!.toFixed(3)}` + atCorner.set(key, [...(atCorner.get(key) ?? []), window]) + } + for (const [key, windows] of atCorner) { + if (new Set(windows.map((w) => w.parentId)).size < 2) continue + const corner = key.split(':')[1] + issues.push({ + type: 'corner_unjoined', + message: `Windows ${windows.map((w) => w.id).join(' and ')} both run to the corner (${corner}) without being joined: if the reference wraps the corner, add_corner_window builds them as one corner window, the glass fused or at a post.`, + }) + } + return issues +} + +registerSceneCheck({ name: 'corner windows', order: 50, run: (n) => cornerWindowIssues(n) }) diff --git a/packages/core/src/agent-operations/create-roof.test.ts b/packages/core/src/agent-operations/create-roof.test.ts new file mode 100644 index 0000000000..7ae497ba5e --- /dev/null +++ b/packages/core/src/agent-operations/create-roof.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, test } from 'bun:test' +import { type AgentRefusal, isAgentRefusal } from '../agent-tools/refusal' +import { BuildingNode, LevelNode, WallNode } from '../schema' +import { createRoof, grown, type RoofHost, roofHost } from './create-roof' +import type { SceneNodes } from './types' + +type Pt = [number, number] + +/** A storey whose walls close the polygon `corners`, in the order given. */ +function storey(corners: Pt[]): SceneNodes { + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: 'level_0', + start, + end: corners[(index + 1) % corners.length]!, + }), + ) + const level = LevelNode.parse({ + id: 'level_0', + parentId: 'building_a', + level: 0, + height: 2.8, + children: walls.map((wall) => wall.id), + }) + const building = BuildingNode.parse({ id: 'building_a', children: [level.id] }) + return Object.fromEntries([building, level, ...walls].map((node) => [node.id, node])) +} + +describe('create_roof: a roof outline that stays on the walls', () => { + // A 30 m long, 0.52 m deep sliver ends in a 1° point; mitred, the point reached 11 m past the + // wall and the roof segment came out 41.6 m long (0.2° made it 87 m). + test('a sharp corner does not stretch the roof past the building', () => { + const outcome = createRoof( + storey([ + [0, 0], + [30, 0], + [0, 0.52], + ]), + {}, + { activeLevelId: null }, + ) + const segments = (outcome.changes?.create ?? []) + .map(({ node }) => node) + .filter((node) => node.type === 'roof-segment') + + expect(segments).toHaveLength(1) + const { width, depth } = segments[0] as unknown as { width: number; depth: number } + expect(Math.max(width, depth)).toBeLessThan(30.5) + expect(Math.max(width, depth)).toBeGreaterThan(30) + }) +}) + +describe('create_roof: the level above the covered storey', () => { + const house = (above: ReturnType[]) => { + const nodes = storey([ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ]) + const building = nodes.building_a as ReturnType + return { + ...nodes, + building_a: { ...building, children: [...building.children, ...above.map((l) => l.id)] }, + ...Object.fromEntries(above.map((level) => [level.id, level])), + } as SceneNodes + } + const created = (outcome: ReturnType) => + (outcome.changes?.create ?? []).map(({ node }) => node) + + // add_level always leaves a new storey empty: a person or an agent is about to build on it. + test('a storey added and not built yet is not the roof level: the roof goes above it', () => { + const empty = LevelNode.parse({ + id: 'level_added', + parentId: 'building_a', + level: 1, + height: 2.8, + }) + const outcome = createRoof(house([empty]), {}, { activeLevelId: null }) + + const [roofLevel] = created(outcome).filter((node) => node.type === 'level') + expect(roofLevel).toMatchObject({ + type: 'level', + level: 2, + parentId: 'building_a', + metadata: { role: 'roof' }, + }) + const roof = created(outcome).find((node) => node.type === 'roof') + expect(roof?.parentId).toBe(roofLevel?.id) + expect(roof?.parentId).not.toBe(empty.id) + }) + + test('a roof level already above the storey is the roof level', () => { + const roofLevel = LevelNode.parse({ + id: 'level_roof', + parentId: 'building_a', + level: 1, + metadata: { role: 'roof' }, + }) + const outcome = createRoof(house([roofLevel]), {}, { activeLevelId: null }) + + expect(created(outcome).filter((node) => node.type === 'level')).toHaveLength(0) + expect(created(outcome).find((node) => node.type === 'roof')?.parentId).toBe('level_roof') + }) + + // A roof level between the storey and a built storey above is not the top of the building: the + // walk looks past it, so the roof is not parented under that upper storey. + test('a storey with rooms above a roof level is a storey above the covered one', () => { + const roofLevel = LevelNode.parse({ + id: 'level_roof', + parentId: 'building_a', + level: 1, + metadata: { role: 'roof' }, + }) + const upperWall = WallNode.parse({ + id: 'wall_upper', + parentId: 'level_upper', + start: [0, 0], + end: [8, 0], + }) + const upper = LevelNode.parse({ + id: 'level_upper', + parentId: 'building_a', + level: 2, + children: [upperWall.id], + }) + const nodes = { ...house([roofLevel, upper]), [upperWall.id]: upperWall } as SceneNodes + + let refusal: AgentRefusal | undefined + try { + createRoof(nodes, {}, { activeLevelId: null }) + } catch (error) { + if (isAgentRefusal(error)) refusal = error + else throw error + } + + expect(refusal?.code).toBe('storey_above') + expect(refusal?.details).toMatchObject({ levelId: 'level_0', storeyAboveId: 'level_upper' }) + }) + + test('a storey not built yet above a roof level is a floor to be built: the roof goes above it', () => { + const roofLevel = LevelNode.parse({ + id: 'level_roof', + parentId: 'building_a', + level: 1, + metadata: { role: 'roof' }, + }) + const empty = LevelNode.parse({ id: 'level_added', parentId: 'building_a', level: 2 }) + const outcome = createRoof(house([roofLevel, empty]), {}, { activeLevelId: null }) + + const [roofLevelCreated] = created(outcome).filter((node) => node.type === 'level') + expect(roofLevelCreated).toMatchObject({ level: 3, metadata: { role: 'roof' } }) + expect(created(outcome).find((node) => node.type === 'roof')?.parentId).toBe( + roofLevelCreated?.id, + ) + }) +}) + +// The host rule is one function and a table: each row is a house of one storey (level 0, 8 × 6 m) +// with the levels listed above it, and what the roof is to stand on. +describe('roofHost: the level a roof stands on', () => { + type Spec = { + id: string + level: number + role?: 'roof' | 'support' + rooms?: boolean + building?: string + } + const level = (spec: Spec) => + LevelNode.parse({ + id: spec.id, + parentId: spec.building ?? 'building_a', + level: spec.level, + ...(spec.role ? { metadata: { role: spec.role } } : {}), + ...(spec.rooms ? { children: [`wall_on_${spec.id}`] } : {}), + }) + const scene = (above: Spec[]) => { + const base = storey([ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ]) + const levels = above.map(level) + const walls = above + .filter((spec) => spec.rooms) + .map((spec) => + WallNode.parse({ + id: `wall_on_${spec.id}`, + parentId: spec.id, + start: [0, 0], + end: [8, 0], + }), + ) + const building = base.building_a as ReturnType + return { + ...base, + building_a: { + ...building, + children: [ + ...building.children, + ...levels.filter((l) => l.parentId === 'building_a').map((l) => l.id), + ], + }, + ...Object.fromEntries([...levels, ...walls].map((node) => [node.id, node])), + } as SceneNodes + } + const hostOf = (above: Spec[]): RoofHost => + roofHost(scene(above), scene(above).level_0 as Parameters[1]) + + const rows: { name: string; above: Spec[]; host: Record }[] = [ + { + name: 'no level above: a roof level is built above the storey', + above: [], + host: { kind: 'create', above: { id: 'level_0' } }, + }, + { + name: 'a roof level above: it is the host', + above: [{ id: 'level_roof', level: 1, role: 'roof' }], + host: { kind: 'reuse', level: { id: 'level_roof' } }, + }, + { + name: 'two roof levels above: the lowest is the host', + above: [ + { id: 'level_roof', level: 1, role: 'roof' }, + { id: 'level_roof_2', level: 2, role: 'roof' }, + ], + host: { kind: 'reuse', level: { id: 'level_roof' } }, + }, + { + name: 'a support level above, with nothing on it: not the host, the roof goes above it', + above: [{ id: 'level_support', level: 1, role: 'support' }], + host: { kind: 'create', above: { id: 'level_support' } }, + }, + { + name: 'a support level above, with things on it: not the host either', + above: [{ id: 'level_support', level: 1, role: 'support', rooms: true }], + host: { kind: 'create', above: { id: 'level_support' } }, + }, + { + name: 'an empty occupied storey above: a floor to be built, the roof goes above it', + above: [{ id: 'level_added', level: 1 }], + host: { kind: 'create', above: { id: 'level_added' } }, + }, + { + name: 'several empty storeys and a support level stacked: the roof goes above the highest', + above: [ + { id: 'level_added', level: 1 }, + { id: 'level_added_2', level: 2 }, + { id: 'level_support', level: 3, role: 'support' }, + ], + host: { kind: 'create', above: { id: 'level_support' } }, + }, + { + name: 'a roof level above several floors to be built is the host', + above: [ + { id: 'level_added', level: 1 }, + { id: 'level_support', level: 2, role: 'support' }, + { id: 'level_roof', level: 3, role: 'roof' }, + ], + host: { kind: 'reuse', level: { id: 'level_roof' } }, + }, + { + name: 'a roof level below a floor to be built is not above it: a new roof level goes above', + above: [ + { id: 'level_roof', level: 1, role: 'roof' }, + { id: 'level_added', level: 2 }, + ], + host: { kind: 'create', above: { id: 'level_added' } }, + }, + { + name: 'a storey with rooms above: refused, naming it', + above: [{ id: 'level_upper', level: 1, rooms: true }], + host: { kind: 'refuse', code: 'storey_above', storeyAbove: { id: 'level_upper' } }, + }, + { + name: 'a storey with rooms above a roof level: refused, the roof level does not hide it', + above: [ + { id: 'level_roof', level: 1, role: 'roof' }, + { id: 'level_upper', level: 2, rooms: true }, + ], + host: { kind: 'refuse', code: 'storey_above', storeyAbove: { id: 'level_upper' } }, + }, + { + name: 'a roof level of another building is not reused', + above: [{ id: 'level_other_roof', level: 1, role: 'roof', building: 'building_b' }], + host: { kind: 'create', above: { id: 'level_0' } }, + }, + ] + + for (const row of rows) + test(row.name, () => { + expect(hostOf(row.above)).toMatchObject(row.host) + }) + + test('a storey with no building: refused, there is no roof level to build', () => { + const base = storey([ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ]) + const orphan = { ...base.level_0, parentId: undefined } as unknown as Parameters< + typeof roofHost + >[1] + const nodes = { ...base, level_0: orphan } as SceneNodes + + expect(roofHost(nodes, orphan)).toEqual({ kind: 'refuse', code: 'storey_without_building' }) + expect(() => createRoof(nodes, {}, { activeLevelId: null })).toThrow(/not in a building/) + }) + + test('over a support level the roof stands on a roof level built above it, not on the support', () => { + const outcome = createRoof( + scene([{ id: 'level_support', level: 1, role: 'support' }]), + {}, + { activeLevelId: null }, + ) + const created = (outcome.changes?.create ?? []).map(({ node }) => node) + const [roofLevel] = created.filter((node) => node.type === 'level') + + expect(roofLevel).toMatchObject({ + level: 2, + parentId: 'building_a', + metadata: { role: 'roof' }, + }) + expect(created.find((node) => node.type === 'roof')?.parentId).toBe(roofLevel?.id) + }) +}) + +describe('create_roof: a finish the library lacks', () => { + const house = () => + storey([ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ]) + const context = { activeLevelId: null } + + function refusalOf(run: () => unknown) { + try { + run() + } catch (error) { + expect(isAgentRefusal(error)).toBe(true) + return error as AgentRefusal + } + throw new Error('create_roof did not refuse') + } + + test('a fascia finish is refused as the roof finish is, and nothing is created', () => { + const refusal = refusalOf(() => + createRoof( + house(), + { fasciaHeight: 0.3, fasciaMaterialPreset: 'library:no-such-fascia' }, + context, + ), + ) + + expect(refusal.code).toBe('unknown_material') + expect(refusal.message).toContain('fasciaMaterialPreset') + expect(refusal.message).toContain('library:no-such-fascia') + }) + + test('a library finish is written on every segment of the band', () => { + const outcome = createRoof( + house(), + { fasciaHeight: 0.3, fasciaMaterialPreset: 'library:preset-nearblack' }, + context, + ) + const segments = (outcome.changes?.create ?? []) + .map(({ node }) => node) + .filter((node) => node.type === 'roof-segment') + + expect(segments.length).toBeGreaterThan(0) + for (const segment of segments) + expect(segment).toMatchObject({ + fascia: true, + fasciaHeight: 0.3, + fasciaMaterialPreset: 'library:preset-nearblack', + }) + }) +}) + +describe('the outline grown to the walls’ outer faces', () => { + const finite = (points: Pt[]) => points.every((point) => point.every(Number.isFinite)) + + test('mitres a right angle exactly', () => { + const square: Pt[] = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] + const out = grown(square, 0.1) + + expect(out).toHaveLength(4) + const expected: Pt[] = [ + [-0.1, -0.1], + [4.1, -0.1], + [4.1, 4.1], + [-0.1, 4.1], + ] + for (const [i, [x, z]] of expected.entries()) { + expect(out[i]![0]).toBeCloseTo(x, 9) + expect(out[i]![1]).toBeCloseTo(z, 9) + } + }) + + test('keeps a point of 0.2° within a wall’s width of its corner', () => { + const sliver: Pt[] = [ + [0, 0], + [30, 0], + [0, 0.105], + ] + const out = grown(sliver, 0.1) + + expect(finite(out)).toBe(true) + for (const [x, z] of out) { + expect(x).toBeGreaterThan(-0.5) + expect(x).toBeLessThan(30.5) + expect(Math.abs(z)).toBeLessThan(0.5) + } + }) + + test('gives finite points where an edge folds back exactly on itself', () => { + const out = grown( + [ + [0, 0], + [10, 0], + [5, 0], + ], + 0.1, + ) + + expect(finite(out)).toBe(true) + }) +}) diff --git a/packages/core/src/agent-operations/create-roof.ts b/packages/core/src/agent-operations/create-roof.ts new file mode 100644 index 0000000000..b0674f019d --- /dev/null +++ b/packages/core/src/agent-operations/create-roof.ts @@ -0,0 +1,383 @@ +import { refuse } from '../agent-tools/refusal' +import { gapBridges } from '../building/wall-gaps' +import { exteriorWallLoops, exteriorWallOutlines } from '../lib/exterior-wall-loops' +import { + type AnyNode, + getActiveRoofHeight, + LevelNode, + RoofNode, + RoofSegmentNode, + type RoofType, + type WallNode, +} from '../schema' +import { requireLevel } from './level-target' +import { finishSurface, requireMaterialRef } from './material-refs' +import { buildingFrame, type RoofRectangle, roofRectangles } from './roof-footprint' +import { levelRole, levelsOf } from './scene-queries' +import type { AgentContext, AgentOperation, SceneChanges, SceneNodes } from './types' + +type Pt = [number, number] +const mm = (value: number) => Math.round(value * 1000) / 1000 +type Level = AnyNode & { type: 'level' } + +type CreateRoofInput = { + levelId?: string + level?: string + roofType?: RoofType + pitch?: number + overhang?: number + wallHeight?: number + width?: number + depth?: number + center?: number[] + materialPreset?: string + fasciaHeight?: number + fasciaMaterialPreset?: string + name?: string +} + +/** A storey a roof can cover: rooms, or walls whose outline closes once their gaps are bridged. */ +const hasFootprint = (nodes: SceneNodes, levelId: string) => + Object.values(nodes).some((node) => node.type === 'zone' && node.parentId === levelId) || + wallOutline(nodes, levelId).length > 0 + +const buildingLevels = (nodes: SceneNodes, level: Level) => + levelsOf(nodes).filter((other) => other.parentId === level.parentId) + +/** The segment fields the fascia input writes: none without a band. */ +function fasciaFields({ fasciaHeight, fasciaMaterialPreset }: CreateRoofInput) { + if (fasciaHeight === undefined && !fasciaMaterialPreset) return {} + return { + fascia: true, + ...(fasciaHeight !== undefined ? { fasciaHeight } : {}), + ...(fasciaMaterialPreset ? { fasciaMaterialPreset } : {}), + } +} + +/** + * The storey a roof covers: the one named when it has rooms, else the highest storey of its + * building with rooms; none named, the top storey with rooms of the building in view. A rectangle + * of its own needs no rooms: the storey named, else the top one. The floor in view does not + * decide: a person looking at the ground floor who asks for a roof means the house's. + */ +function coveredStorey( + nodes: SceneNodes, + input: CreateRoofInput, + rectangle: boolean, + context: AgentContext, +) { + const { levelId, level } = input + if (levelId && level && levelId !== level) + refuse( + 'conflicting_level', + `levelId (${levelId}) and level (${level}) disagree; they are the same field, pass one.`, + { levelId, level }, + ) + const named = levelId ?? level + const requested = named ? requireLevel(nodes, named) : null + if (requested && (rectangle || hasFootprint(nodes, requested.id))) return { storey: requested } + const viewed = context.activeLevelId ? nodes[context.activeLevelId] : undefined + const candidates = requested + ? buildingLevels(nodes, requested) + : viewed?.type === 'level' + ? buildingLevels(nodes, viewed) + : levelsOf(nodes) + if (rectangle) { + const top = candidates.filter((candidate) => levelRole(nodes, candidate).role === 'occupied') + if (!top.length) refuse('no_levels', 'The scene has no storey to roof yet.') + return { storey: top.at(-1)! } + } + const storey = candidates.filter((candidate) => hasFootprint(nodes, candidate.id)).at(-1) + if (!storey) + refuse( + 'no_rooms', + `No rooms or closed walls on ${requested ? `${requested.id} or any other level of its building` : 'any level'}: create rooms first, or pass width and depth to roof a rectangle of your own.`, + requested ? { levelId: requested.id } : {}, + ) + return { + storey, + ...(requested && { + note: `Level ${requested.id} has no rooms or closed walls; the roof covers ${storey.name ?? storey.id} (${storey.id}), the highest level with rooms.`, + }), + } +} + +/** + * The storey's outline from its walls, every gap bridged as L5 finds them: a roof covers the + * building, not only the rooms closed so far (the 290 traced from its PNG, its doors and windows + * still gaps, got a roof over a few rooms only). + */ +function wallOutline(nodes: SceneNodes, storeyId: string) { + const walls = Object.values(nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === storeyId, + ) + if (walls.length < 3) return [] + const bridges = gapBridges(walls).map( + (gap, index) => + ({ + ...walls[0], + id: `wall_roof_gap_${index}`, + start: gap.start, + end: gap.end, + thickness: gap.thickness, + curveOffset: 0, + }) as WallNode, + ) + // Ends a few centimetres apart are one corner: a vectorised plan's near-misses. + const closed = [...walls, ...bridges] + const outlines = exteriorWallOutlines(closed, NEAR_MISS) + if (!outlines.length) return [] + // Out to the walls' outer faces, as a floor plate reaches: half the outline walls' thickness. + const thickness = new Map(closed.map((wall) => [wall.id, wall.thickness ?? 0.2])) + const onOutline = exteriorWallLoops(closed, NEAR_MISS) + .flat() + .map((face) => thickness.get(face.wallId) ?? 0.2) + .sort((a, b) => a - b) + const half = (onOutline[Math.floor(onOutline.length / 2)] ?? 0.2) / 2 + return outlines.map((polygon) => grown(polygon, half)) +} + +/** + * How far a mitre may reach, in multiples of the distance moved: past it (a corner under about + * 29°, or an edge folding back on itself) the corner is bevelled, as an SVG stroke's is. + */ +const MITER_LIMIT = 4 + +/** A polygon moved out by `distance` along each edge's outward normal, corners mitred up to the limit. */ +export function grown(polygon: Pt[], distance: number): Pt[] { + const n = polygon.length + let area = 0 + for (let i = 0; i < n; i++) { + const [ax, az] = polygon[i]! + const [bx, bz] = polygon[(i + 1) % n]! + area += ax * bz - bx * az + } + const side = area > 0 ? 1 : -1 + const normal = (a: Pt, b: Pt): Pt => { + const [dx, dz] = [b[0] - a[0], b[1] - a[1]] + const length = Math.hypot(dx, dz) || 1 + return [(side * dz) / length, (-side * dx) / length] + } + return polygon.flatMap((point, i): Pt[] => { + const before = normal(polygon[(i - 1 + n) % n]!, point) + const after = normal(point, polygon[(i + 1) % n]!) + const dot = before[0] * after[0] + before[1] * after[1] + // The mitre reaches distance × √(2 / (1 + dot)): unbounded as the edges fold onto each other. + if (1 + dot < 2 / MITER_LIMIT ** 2) + return [ + [point[0] + before[0] * distance, point[1] + before[1] * distance], + [point[0] + after[0] * distance, point[1] + after[1] * distance], + ] + // Along the bisector, as far as both edges move by `distance`. + const scale = distance / (1 + dot) + return [[point[0] + (before[0] + after[0]) * scale, point[1] + (before[1] + after[1]) * scale]] + }) +} + +const NEAR_MISS = 0.1 + +/** + * Rectangles in the storey's own frame and the turn back to the world: its walls' outline, else + * its plates, else its rooms. + */ +function footprintOf(nodes: SceneNodes, storeyId: string) { + const on = (type: 'slab' | 'zone') => + Object.values(nodes).filter( + (node) => + node.type === type && + node.parentId === storeyId && + !( + node.type === 'slab' && + node.metadata && + (node.metadata as { balcony?: unknown }).balcony + ), + ) as (AnyNode & { polygon: Pt[] })[] + const plates = on('slab') + const outline = wallOutline(nodes, storeyId) + const frame = buildingFrame( + outline.length ? outline : (plates.length ? plates : on('zone')).map((node) => node.polygon), + ) + const { rectangles, roofType } = roofRectangles(frame.polygons) + return { rectangles, roofType, angle: frame.angle, toWorld: frame.toWorld } +} + +/** Where a roof over a storey goes: a level to reuse, a level to build above, or a refusal. */ +export type RoofHost = + | { kind: 'reuse'; level: Level } + | { kind: 'create'; above: Level; buildingId: string } + | { kind: 'refuse'; code: 'storey_above'; storeyAbove: Level } + | { kind: 'refuse'; code: 'storey_without_building' } + +/** + * The roof's host, from the levels above the covered storey in its building. One rule, a table: + * + * storey has no building refused: storey_without_building + * an occupied storey with rooms above refused: storey_above, the lowest such storey + * else, above: empty occupied storeys (floors the roof goes above the highest of them + * to be built) and support levels + * a roof level above all of those reused: the lowest such roof level + * none a new roof level above the highest one (or + * above the storey, when nothing lies above) + * + * Only a level whose role is roof, in the same building, is ever reused: a storey or a support + * level never holds the roof, and a roof level below a floor to be built is not above it. + */ +export function roofHost(nodes: SceneNodes, storey: Level): RoofHost { + if (!storey.parentId) return { kind: 'refuse', code: 'storey_without_building' } + const above = buildingLevels(nodes, storey) + .filter((other) => other.level > storey.level) + .sort((a, b) => a.level - b.level) + .map((level) => ({ level, role: levelRole(nodes, level).role })) + const built = above.find(({ level, role }) => role === 'occupied' && level.children.length > 0) + if (built) return { kind: 'refuse', code: 'storey_above', storeyAbove: built.level } + const top = above.filter(({ role }) => role !== 'roof').at(-1)?.level ?? storey + const roof = above.find(({ level, role }) => role === 'roof' && level.level > top.level) + return roof + ? { kind: 'reuse', level: roof.level } + : { kind: 'create', above: top, buildingId: storey.parentId } +} + +/** + * `create_roof`: a roof over a storey, one segment per rectangle of its footprint (or the one + * rectangle asked for), on a roof level above it. + */ +export const createRoof: AgentOperation = (nodes, input, context) => { + const { width, depth } = input + // A finish the library lacks renders as the default (main's build-off fix, on the shared tool). + const materialPreset = + input.materialPreset === undefined + ? undefined + : requireMaterialRef(input.materialPreset, 'materialPreset', finishSurface('roof')) + const fasciaMaterialPreset = + input.fasciaMaterialPreset === undefined + ? undefined + : requireMaterialRef( + input.fasciaMaterialPreset, + 'fasciaMaterialPreset', + finishSurface('roof'), + ) + if ((width === undefined) !== (depth === undefined)) + refuse( + 'rectangle_incomplete', + 'A rectangle of your own takes width and depth together; pass both, or neither to cover the footprint.', + { width, depth }, + ) + const rectangle = width !== undefined && depth !== undefined + const { storey, note } = coveredStorey(nodes, input, rectangle, context) + + const placement = roofHost(nodes, storey) + if (placement.kind === 'refuse') { + if (placement.code === 'storey_above') { + const above = placement.storeyAbove + refuse( + 'storey_above', + `${storey.name ?? storey.id} (${storey.id}) has a storey above it, ${above.name ?? above.id} (${above.id}): a roof caps the top storey. Pass the top storey (with width and depth when it has no rooms).`, + { levelId: storey.id, storeyAboveId: above.id }, + ) + } + refuse( + 'storey_without_building', + `${storey.name ?? storey.id} (${storey.id}) is not in a building, so no roof level can stand above it. Put the level in a building first.`, + { levelId: storey.id }, + ) + } + + let rectangles: RoofRectangle[] + let chosenType: RoofType + let angle = 0 + let toWorld = ([x, z]: Pt): Pt => [x, z] + if (rectangle) { + const [x = 0, z = 0] = input.center ?? [] + const r = { minX: x - width / 2, maxX: x + width / 2, minZ: z - depth / 2, maxZ: z + depth / 2 } + rectangles = [r] + // The size as asked, the type as the footprint rule would give it. + chosenType = roofRectangles([ + [ + [r.minX, r.minZ], + [r.maxX, r.minZ], + [r.maxX, r.maxZ], + [r.minX, r.maxZ], + ], + ]).roofType + } else { + const footprint = footprintOf(nodes, storey.id) + rectangles = footprint.rectangles + chosenType = footprint.roofType + angle = footprint.angle + toWorld = footprint.toWorld + } + const roofType = input.roofType ?? chosenType + + const minX = Math.min(...rectangles.map((r) => r.minX)) + const maxX = Math.max(...rectangles.map((r) => r.maxX)) + const minZ = Math.min(...rectangles.map((r) => r.minZ)) + const maxZ = Math.max(...rectangles.map((r) => r.maxZ)) + const centerX = (minX + maxX) / 2 + const centerZ = (minZ + maxZ) / 2 + const wallHeight = input.wallHeight ?? 0 + const segments = rectangles.map((r) => { + // To the millimetre: snapped coordinates subtract to 6.199999… + const span = { width: mm(r.maxX - r.minX), depth: mm(r.maxZ - r.minZ) } + // A cone stands on a circle. + const size = + roofType === 'conical' + ? { width: Math.max(span.width, span.depth), depth: Math.max(span.width, span.depth) } + : span + return RoofSegmentNode.parse({ + ...size, + roofType, + wallHeight, + position: [(r.minX + r.maxX) / 2 - centerX, 0, (r.minZ + r.maxZ) / 2 - centerZ], + ...(input.pitch !== undefined && { pitch: input.pitch }), + ...(input.overhang !== undefined && { overhang: input.overhang }), + ...fasciaFields({ ...input, fasciaMaterialPreset }), + }) + }) + + const changes: Required> = { create: [] } + const roofLevel = + placement.kind === 'reuse' + ? placement.level + : LevelNode.parse({ + name: 'Roof', + parentId: placement.buildingId, + level: placement.above.level + 1, + height: Math.max( + wallHeight + Math.max(...segments.map((segment) => getActiveRoofHeight(segment))), + 0.2, + ), + metadata: { role: 'roof', label: 'Roof', referenceLevelId: storey.id }, + }) + const roofLevelId = roofLevel.id + const createdRoofLevelId = placement.kind === 'create' ? roofLevelId : null + if (placement.kind === 'create') + changes.create.push({ node: roofLevel, parentId: placement.buildingId }) + + const [roofX, roofZ] = toWorld([centerX, centerZ]) + const roof = RoofNode.parse({ + name: input.name ?? 'Roof', + parentId: roofLevelId, + position: [roofX, 0, roofZ], + rotation: angle, + children: segments.map((segment) => segment.id), + ...(materialPreset && { materialPreset }), + metadata: { referenceLevelId: storey.id, roofLevelId }, + }) + changes.create.push({ node: roof, parentId: roofLevelId }) + for (const segment of segments) + changes.create.push({ node: { ...segment, parentId: roof.id }, parentId: roof.id }) + + return { + result: { + roofId: roof.id, + roofLevelId, + createdRoofLevelId, + referenceLevelId: storey.id, + segmentIds: segments.map((segment) => segment.id), + roofType, + message: `Created a ${roofType} roof of ${segments.length} segment${segments.length === 1 ? '' : 's'} over ${storey.name ?? storey.id}.`, + ...(note && { note }), + }, + changes, + } +} diff --git a/packages/core/src/agent-operations/create-room.ts b/packages/core/src/agent-operations/create-room.ts index 253c2e2445..19d7271bca 100644 --- a/packages/core/src/agent-operations/create-room.ts +++ b/packages/core/src/agent-operations/create-room.ts @@ -1,8 +1,9 @@ import { isAgentRefusal, refuse } from '../agent-tools/refusal' import { doorFacing, planWallOpening } from '../building/wall-openings' -import { createZone } from '../commands/structure/create-zone' +import { createZone, walledRoomAt } from '../commands/structure/create-zone' import { structureChangeBatch } from '../commands/structure/shared' -import { type AnyNode, generateId, type WallNode } from '../schema' +import { isAllocatedRoomName } from '../lib/room-name' +import { type AnyNode, type AnyNodeId, generateId, 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' @@ -27,6 +28,7 @@ type CreateRoomInput = LevelTargetInput & { wallHeight?: number wallThickness?: number outdoor?: boolean + rename?: boolean doors?: OpeningSpec[] windows?: OpeningSpec[] } @@ -98,6 +100,8 @@ function derivedSurfaces(nodes: SceneNodes, levelId: string, zoneId: string) { return { slabId: slab?.id ?? null, ceilingId: ceiling?.id ?? null } } +const roundArea = (value: number) => Math.round(value * 100) / 100 + const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}` /** @@ -115,6 +119,18 @@ export const createRoom: AgentOperation = (nodes, input, contex ) const polygon = input.polygon as Vec2[] + // A room a person named stands where these walls are: no twin zone, and no silent rename. The + // agent passes rename: true to give it the new name, or asks the person. + const named = walledRoomAt(nodes, level.id, polygon) + if (named && !isAllocatedRoomName(named.name) && !input.rename) + refuse( + 'room_named_by_person', + named.name === input.name + ? `The walls here already enclose the room "${named.name}", with a name of its own, so there is nothing to create: its walls, doors and windows are there. Add what is missing with add_door and add_window.` + : `The walls here already enclose the room "${named.name}", which has a name of its own (not a number the editor gave it). Creating "${input.name}" would stack a second room on it. To give it the name "${input.name}", pass rename: true only if the person asked for the rename; otherwise ask them whether to rename it. To keep it, leave it as it is.`, + { zoneId: named.id, roomName: named.name }, + ) + let plan: ReturnType try { plan = createZone(nodes, { @@ -122,6 +138,7 @@ export const createRoom: AgentOperation = (nodes, input, contex polygon, name: input.name, enclose: !input.outdoor, + ...(input.rename ? { adoptNamed: true } : {}), ...(input.outdoor ? { intent: { hasCeiling: false } } : { @@ -151,7 +168,18 @@ export const createRoom: AgentOperation = (nodes, input, contex ) const roomChanges = structureChangeBatch(plan.changes) - if (input.color) + if (input.color && plan.renamed) + roomChanges.update = [ + ...roomChanges.update.filter((entry) => entry.id !== plan.zoneId), + { + id: plan.zoneId as AnyNodeId, + data: { + ...roomChanges.update.find((entry) => entry.id === plan.zoneId)?.data, + color: input.color, + }, + }, + ] + else if (input.color) roomChanges.create = roomChanges.create.map((entry) => entry.node.id === plan.zoneId ? { ...entry, node: { ...entry.node, color: input.color } as AnyNode } @@ -199,12 +227,16 @@ export const createRoom: AgentOperation = (nodes, input, contex zoneId: plan.zoneId, wallIds, reusedWalls, - areaSqMeters: Math.round(polygonArea(polygon) * 100) / 100, + areaSqMeters: roundArea( + polygonArea(plan.renamed ? (nodes[plan.zoneId] as ZoneNode).polygon : polygon), + ), doorIds, windowIds, ...(skippedOpenings.length ? { skippedOpenings } : {}), message: [ - `Created ${input.outdoor ? 'outdoor room' : 'room'} "${input.name}" on ${level.name || level.id}`, + plan.renamed + ? `Named the ${input.outdoor ? 'outdoor room' : 'room'} the walls already enclose "${input.name}" (it was ${plan.renamed}) on ${level.name || level.id}` + : `Created ${input.outdoor ? 'outdoor room' : 'room'} "${input.name}" on ${level.name || level.id}`, ...(reusedWalls ? [`reused ${plural(reusedWalls, 'wall')} already there`] : []), ...(doorIds.length ? [plural(doorIds.length, 'door')] : []), ...(windowIds.length ? [plural(windowIds.length, 'window')] : []), diff --git a/packages/core/src/agent-operations/door-clearance.ts b/packages/core/src/agent-operations/door-clearance.ts index 90de81934d..437447ce1f 100644 --- a/packages/core/src/agent-operations/door-clearance.ts +++ b/packages/core/src/agent-operations/door-clearance.ts @@ -7,6 +7,8 @@ * See docs/layout-clearance-error-log.md for pitfalls (levels, gap sign, scale). */ +import type { Vec3 } from '../procedural-items/recipe' +import { boxCorners, rotateVector } from '../procedural-items/spatial' import { type AnyNode, getScaledDimensions } from '../schema' import { type Vec2, wallLength } from './plan-geometry' @@ -154,11 +156,45 @@ export function itemNodePlanAabb(node: AnyNode): PlanAabb | null { ) { return null } - return itemPlanAabb( - node.position, - [Math.abs(width), Math.abs(height), Math.abs(depth)], - rotationY, - ) + const corners = itemNodePlanCorners(node) + if (!corners.every((corner) => corner.every(Number.isFinite))) return null + return { + minX: Math.min(...corners.map(([x]) => x)), + maxX: Math.max(...corners.map(([x]) => x)), + minZ: Math.min(...corners.map(([, z]) => z)), + maxZ: Math.max(...corners.map(([, z]) => z)), + } +} + +/** + * Script bounds need not be centred on the item origin, and the asset's own corrective scale, + * rotation and offset sit inside the node's scale, as `scriptItemBoundsInLevel` measures them; + * catalog bounds are normalized. + */ +export function itemNodePlanCorners(node: Extract): Vec2[] { + const yaw = node.rotation?.[1] ?? 0 + const place = (lx: number, lz: number): Vec2 => [ + node.position[0] + lx * Math.cos(yaw) + lz * Math.sin(yaw), + node.position[2] - lx * Math.sin(yaw) + lz * Math.cos(yaw), + ] + const bounds = node.source?.manifest.bounds + if (!bounds) { + const [width, , depth] = node.asset.dimensions + return [-width / 2, width / 2].flatMap((x) => + [-depth / 2, depth / 2].map((z) => place(x * node.scale[0], z * node.scale[2])), + ) + } + const assetScale = node.asset.scale ?? [1, 1, 1] + return boxCorners(bounds.min, bounds.max).map((point) => { + const [x, , z] = rotateVector( + point.map((value, axis) => value * assetScale[axis]!) as Vec3, + node.asset.rotation, + ) + return place( + (x + node.asset.offset[0]) * node.scale[0], + (z + node.asset.offset[2]) * node.scale[2], + ) + }) } export function inspectItemPlanFootprint( @@ -234,9 +270,11 @@ export function inspectItemPlanFootprint( return { ok: false, reason: 'non_positive_plan_dimensions' } } + const aabb = itemNodePlanAabb(node) + if (!aabb) return { ok: false, reason: 'non_finite_dimensions' } return { ok: true, - aabb: itemPlanAabb(node.position, effectiveDimensions, rotationY), + aabb, sourceDimensions: [width, height, depth], effectiveDimensions, rotationY, diff --git a/packages/core/src/agent-operations/furnish-room.ts b/packages/core/src/agent-operations/furnish-room.ts index db78921c8e..841c2960ef 100644 --- a/packages/core/src/agent-operations/furnish-room.ts +++ b/packages/core/src/agent-operations/furnish-room.ts @@ -28,10 +28,14 @@ type FurnishRoomInput = LevelTargetInput & { // A door belongs to a room edge when it stands this close to the edge's line, within its span. const DOOR_EDGE_TOLERANCE = 0.25 // Clearance between a piece's back and the wall's centreline: half a wall and a hand's width. -const WALL_GAP = 0.1 +export const WALL_GAP = 0.1 /** The room asked for: a zone's level and outline, or a polygon on the level named. */ -function roomToFurnish(nodes: SceneNodes, input: FurnishRoomInput, context: AgentContext) { +export function roomToFurnish( + nodes: SceneNodes, + input: LevelTargetInput & Pick, + context: AgentContext, +) { if (input.zoneId) { const zone = nodes[input.zoneId] if (!zone) @@ -81,7 +85,7 @@ function doorsOfRoom(nodes: SceneNodes, levelId: string, polygon: Vec2[]) { } /** A polygon edge seen from inside the room: its middle, its direction, and the way into the room. */ -function edgeFrame(polygon: Vec2[], index: number, center: Vec2) { +export function edgeFrame(polygon: Vec2[], index: number, center: Vec2) { const a = polygon[index % polygon.length]! const b = polygon[(index + 1) % polygon.length]! const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1 diff --git a/packages/core/src/agent-operations/furniture-kind.ts b/packages/core/src/agent-operations/furniture-kind.ts new file mode 100644 index 0000000000..7925de85db --- /dev/null +++ b/packages/core/src/agent-operations/furniture-kind.ts @@ -0,0 +1,80 @@ +import { getScaledDimensions, type ItemNode, LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT } from '../schema' + +/** How a piece of furniture stands in a room, read from what the catalog calls it. */ +export type FurnitureKind = 'chair' | 'free' | 'wall' + +type Named = { id: string; name?: string | undefined } + +const read = (asset: Named) => `${asset.id} ${asset.name ?? ''}`.toLowerCase().replace(/[_]+/g, ' ') + +/** An armchair is a free-standing seat of the living group, not a chair of a table. */ +const ARMCHAIR = /arm ?chair|living ?room|lounge|rocking|recliner/ +const CHAIR = /(?:^|[^a-z])(?:chair|stool)(?![a-z])/ +/** A bedside table stands against the wall like the bed it serves, whatever the word "table" says. */ +const BEDSIDE = /bedside|night ?stand/ +/** A bar, island or peninsula is walked around, and utensils sit on other pieces: none stands against a wall. */ +const WALKED_AROUND = /(?:^|[^a-z])(?:island|peninsula|bar)(?![a-z])|utensil/ +const FREE = /table|rug|carpet|plant|lamp|ottoman|pouf/ +const WALL = + /fridge|refrigerator|counter|kitchen|stove|oven|cooktop|sink|tv[ -]?(?:stand|unit)|television|dresser|wardrobe|closet|sofa|couch|loveseat|(?:^|[^a-z])bed(?![a-z])|toilet|bath|shower|bookcase|bookshelf|cabinet|washing|washer|dryer|coat/ + +/** Only a named, flat floor covering is nonblocking, never an arbitrary short solid or low top surface. */ +export function isFloorUnderlay(item: ItemNode): boolean { + const text = read(item.asset) + if ( + !/(?:^|[^a-z])(?:rug|carpet|(?:floor|parking)[ -]?mat)(?![a-z])/.test(text) || + WALL.test(text) || + CHAIR.test(text) || + /table|desk|ottoman|pouf/.test(text) || + item.asset.attachTo + ) + return false + const dims = getScaledDimensions(item) + if ( + ![...item.position, ...item.rotation, ...item.scale, ...dims].every(Number.isFinite) || + !item.scale.every((value) => value > 0) || + dims[0] <= 0 || + dims[2] <= 0 || + Math.abs(item.position[1]) > 1e-6 || + Math.abs(item.rotation[0]) > 1e-6 || + Math.abs(item.rotation[2]) > 1e-6 + ) + return false + const bounds = item.source?.manifest.bounds + const bottom = bounds ? bounds.min[1] * item.scale[1] : 0 + const top = bounds ? bounds.max[1] * item.scale[1] : dims[1] + return ( + Number.isFinite(bottom) && + Number.isFinite(top) && + bottom >= -1e-6 && + top >= bottom && + top <= LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT + ) +} + +/** + * Wall-bound pieces (a fridge, a counter, a bed, a sofa) stand against a wall and never in the + * middle of a room; free-standing ones (a dining table, a rug, a plant) may take the middle; a chair + * is placed around its table. A name the table does not know is free-standing. + */ +export function furnitureKind(asset: Named): FurnitureKind { + const text = read(asset) + if (ARMCHAIR.test(text)) return 'free' + if (CHAIR.test(text)) return 'chair' + if (BEDSIDE.test(text)) return 'wall' + if (WALKED_AROUND.test(text)) return 'free' + if (FREE.test(text)) return 'free' + return WALL.test(text) ? 'wall' : 'free' +} + +/** + * What a piece faces when it does not stand against a wall, as a pattern for the name of the item + * it belongs with: the coffee table and the TV face the sofa, a chair faces its table. + */ +export function anchorOf(asset: Named): string | null { + const text = read(asset) + if (ARMCHAIR.test(text) || /coffee[ -]?table|tv[ -]?(?:stand|unit)/.test(text)) + return 'sofa|couch' + if (CHAIR.test(text)) return 'table|desk' + return null +} diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index d2622d9124..4f644b44d8 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -1,5 +1,10 @@ +import { AGENT_TOOL_CONTRACTS, BATCHABLE_TOOLS } from '../agent-tools' +import { addColumn } from './add-column' import { addLevel } from './add-level' import { addWall } from './add-wall' +import { createBatchOperation } from './batch' +import { addCornerWindow } from './corner-window' +import { createRoof } from './create-roof' import { createRoom } from './create-room' import { createStair } from './create-stair' import { deleteNode } from './delete-node' @@ -9,13 +14,16 @@ import { furnishRoom } from './furnish-room' import { getNode } from './get-node' import { getLevelSummary, getWalls, getZones } from './level-reads' import { listLevels } from './list-levels' +import { mergeWindowsOperation } from './merge-windows' import { paint } from './paint' +import { placeInRoom } from './place-in-room' import { placeItems } from './place-items' import { ROOM_OPERATIONS } from './room-structure' import { searchAssets } from './search-assets' import { searchMaterials } from './search-materials' +import { setCheckpoint } from './set-checkpoint' import { fitStair, measureStairOperation } from './stairs' -import type { AgentOperations } from './types' +import type { AgentOperation, AgentOperations } from './types' import { verifyScene } from './verify-scene' export * from './achieved' @@ -26,6 +34,7 @@ export * from './add-wall' export * from './apply-changes' export * from './apply-outcome' export * from './collections' +export * from './corner-window' export * from './create-room' export * from './create-stair' export * from './delete-node' @@ -36,15 +45,18 @@ export * from './furnish-room' export * from './get-node' export * from './hosted-services' export * from './layout-clearance' +export * from './level-height-fill' export * from './level-reads' export * from './level-target' export * from './list-levels' export * from './material-preset' export * from './material-refs' +export * from './merge-windows' export * from './node-patch' export * from './paint' export * from './place-items' export * from './plan-geometry' +export * from './point-context' export * from './room-structure' export * from './scene-materials' export * from './scene-measure' @@ -55,10 +67,10 @@ export * from './search-materials' export * from './stairs' export * from './types' export * from './verify-scene' +export * from './wall-object-observations' export * from './wall-opening' -/** Each shared agent tool's operation, by tool name: what every surface executes. */ -export const AGENT_OPERATIONS = { +const SINGLE_OPERATIONS = { measure_stair: measureStairOperation, fit_stair: fitStair, list_levels: listLevels, @@ -68,16 +80,44 @@ export const AGENT_OPERATIONS = { get_zones: getZones, duplicate_level: duplicateLevel, verify_scene: verifyScene, + set_checkpoint: setCheckpoint, add_wall: addWall, + add_corner_window: addCornerWindow, + merge_windows: mergeWindowsOperation, add_level: addLevel, create_stair: createStair, + add_column: addColumn, + create_roof: createRoof, place_items: placeItems, delete_node: deleteNode, find_by_type: findByType, create_room: createRoom, furnish_room: furnishRoom, - search_assets: searchAssets, paint, + place_in_room: placeInRoom, + search_assets: searchAssets, search_materials: searchMaterials, ...ROOM_OPERATIONS, +} as const + +const BATCHED_OPERATIONS = Object.fromEntries( + BATCHABLE_TOOLS.map((name) => [name, SINGLE_OPERATIONS[name]]), +) as Record<(typeof BATCHABLE_TOOLS)[number], AgentOperation> + +/** Each shared agent tool's operation, by tool name: what every surface executes. */ +export const AGENT_OPERATIONS = { + ...SINGLE_OPERATIONS, + run_batch: createBatchOperation(BATCHED_OPERATIONS, AGENT_TOOL_CONTRACTS), } as const satisfies AgentOperations + +export { type PhotoFacePlan, planPhotoElevation } from './photo-elevation' +export { recordMade } from './record-made' +export { + EMPTY_SESSION, + keepInSession, + madeEntries, + parseSessionRecord, + type SessionKeep, + type SessionRecord, + startOfSession, +} from './scene-checkpoint' diff --git a/packages/core/src/agent-operations/item-mid-room.test.ts b/packages/core/src/agent-operations/item-mid-room.test.ts new file mode 100644 index 0000000000..f4e4a4d9f6 --- /dev/null +++ b/packages/core/src/agent-operations/item-mid-room.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'bun:test' +import { type AnyNode, ItemNode, LevelNode, WallNode, ZoneNode } from '../schema' +import { layoutIssuesFromScene } from './layout-clearance' + +/** A 4 × 3 m room (zone and four walls) on one level, with the named pieces standing at the given centres. */ +function room( + ...pieces: { asset: string; at: [number, number]; size?: [number, number, number] }[] +) { + const corners: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: 'level_m', + start, + end: corners[(index + 1) % corners.length]!, + thickness: 0.1, + }), + ) + const zone = ZoneNode.parse({ + id: 'zone_m', + parentId: 'level_m', + name: 'Room', + polygon: corners, + spaceRole: 'room', + }) + const items = pieces.map((piece) => + ItemNode.parse({ + id: `item_${piece.asset}`, + parentId: 'level_m', + position: [piece.at[0], 0, piece.at[1]], + asset: { + id: piece.asset, + name: piece.asset, + category: 'furniture', + thumbnail: '', + src: 'asset://x', + dimensions: piece.size ?? [0.7, 1.8, 0.7], + }, + }), + ) + const level = LevelNode.parse({ + id: 'level_m', + level: 0, + children: [...walls, zone, ...items].map((node) => node.id), + }) + return [level, ...walls, zone, ...items] as AnyNode[] +} + +const midRoom = (nodes: AnyNode[]) => + layoutIssuesFromScene(nodes).filter((issue) => issue.type === 'item_mid_room') + +describe('item_mid_room: a wall-bound piece standing away from every wall', () => { + it('flags a fridge in the middle of the room', () => { + const issues = midRoom(room({ asset: 'fridge', at: [2, 1.5] })) + expect(issues).toHaveLength(1) + expect(issues[0]?.message).toContain('item_fridge') + expect(issues[0]?.message).toMatch(/wall/) + }) + + it('accepts a fridge set against a wall', () => { + expect(midRoom(room({ asset: 'fridge', at: [2, 0.46] }))).toEqual([]) + }) + + it('measures from the wall face, not its centre line', () => { + // A 0.7 m deep piece whose back edge stands 0.35 m off the face of a 0.1 m wall. + expect(midRoom(room({ asset: 'fridge', at: [2, 0.05 + 0.35 + 0.35] }))).toEqual([]) + // ...and one 0.5 m off the face is mid-room. + expect(midRoom(room({ asset: 'fridge', at: [2, 0.05 + 0.5 + 0.35] }))).toHaveLength(1) + }) + + it('leaves free-standing kinds alone: a dining table or a rug may take the middle', () => { + expect( + midRoom( + room( + { asset: 'dining-table', at: [2, 1.5], size: [1.6, 0.75, 0.9] }, + { asset: 'rug', at: [2, 1.5], size: [2, 0.02, 1.4] }, + ), + ), + ).toEqual([]) + }) + + it('does not judge a piece that is not inside any room', () => { + expect(midRoom(room({ asset: 'fridge', at: [9, 9] }))).toEqual([]) + }) + + it('does not judge a level with no walls', () => { + const nodes = room({ asset: 'fridge', at: [2, 1.5] }).filter((node) => node.type !== 'wall') + expect(midRoom(nodes)).toEqual([]) + }) +}) diff --git a/packages/core/src/agent-operations/layout-clearance.test.ts b/packages/core/src/agent-operations/layout-clearance.test.ts index c512202b00..e487decc10 100644 --- a/packages/core/src/agent-operations/layout-clearance.test.ts +++ b/packages/core/src/agent-operations/layout-clearance.test.ts @@ -1,13 +1,35 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode } from '../schema' -import { inspectItemPlanFootprint, itemNodePlanAabb, resolveNodeLevelId } from './door-clearance' +import { + type AnyNode, + BuildingNode, + CeilingNode, + DoorNode, + ItemNode, + LevelNode, + SlabNode, + WallNode, + WindowNode, + ZoneNode, +} from '../schema' +import { CEILING_DRAW_OFFSET } from '../services' +import { authoredItem } from './__fixtures__/add-object-cases' +import savedWallScreen from './__fixtures__/wall-screen.json' +import { + aabbsOverlap, + inspectItemPlanFootprint, + itemNodePlanAabb, + resolveNodeLevelId, +} from './door-clearance' +import { AGENT_OPERATIONS, applySceneChanges, verifyScene } from './index' import { classifyPlacement, + collectOccupiedFootprints, findItemItemCollisions, findValidPlacement, generatePlacementCandidates, itemPlanAabb, layoutIssuesFromScene, + scriptItemBoundsInLevel, } from './layout-clearance' function item( @@ -42,6 +64,791 @@ function item( } describe('layout-clearance', () => { + function wallScreenScene() { + return Object.fromEntries( + Object.entries(structuredClone(savedWallScreen)).map(([id, node]) => [ + id, + node.type === 'item' + ? ItemNode.parse(node) + : node.type === 'wall' + ? WallNode.parse(node) + : LevelNode.parse(node), + ]), + ) + } + + function wallObservations(nodes: Record) { + const before = structuredClone(nodes) + const result = verifyScene(nodes, {}, { activeLevelId: null }).result + expect(nodes).toEqual(before) + return (result.issues as { type: string; message: string }[]).filter((issue) => + /^item_(wall_|through_wall|faces_wall)/.test(issue.type), + ) + } + + test('saved cinema screen bounds penetrate the wall and its front faces its host', () => { + const nodes = wallScreenScene() + const observations = wallObservations(nodes) + expect(observations).toContainEqual( + expect.objectContaining({ + type: 'item_wall_body_overlap', + itemId: 'item_7j1l6855lxvbemw5', + wallId: 'wall_fseg8ncv7q8u5kqn', + overlapDepthMeters: expect.closeTo(0.22, 6), + coordinateFrame: 'building', + bounds: { + min: [expect.closeTo(-10.8), expect.closeTo(0.55), expect.closeTo(2.7775)], + max: [expect.closeTo(-4.2), expect.closeTo(3.05), expect.closeTo(3.11)], + }, + }), + ) + expect(observations).toContainEqual( + expect.objectContaining({ + type: 'item_through_wall', + beyondOppositeFaceMeters: expect.closeTo(0.1125, 6), + }), + ) + expect(observations).toContainEqual( + expect.objectContaining({ type: 'item_faces_wall', frontDotOutward: -1 }), + ) + expect(observations.every((issue) => !/should|must|fix|move|rotate/i.test(issue.message))).toBe( + true, + ) + }) + + test('an item with no side is observed on the face the renderer draws it on, the back', () => { + const saved = wallObservations(wallScreenScene()) + const withoutSide = wallScreenScene() + const front = wallScreenScene() + for (const [nodes, side] of [ + [withoutSide, undefined], + [front, 'front' as const], + ] as const) { + const screen = nodes.item_7j1l6855lxvbemw5! + if (screen.type !== 'item') throw new Error('screen missing') + screen.side = side + } + + expect(saved.length).toBeGreaterThan(0) + expect(wallObservations(withoutSide)).toEqual(saved) + expect(wallObservations(front)).not.toEqual(saved) + }) + + test('turning the saved back-side screen outward leaves its hidden contact face silent', () => { + const nodes = wallScreenScene() + const screen = nodes.item_7j1l6855lxvbemw5! + if (screen.type !== 'item') throw new Error('screen missing') + screen.rotation[1] = Math.PI + expect(wallObservations(nodes)).toEqual([]) + }) + + test('wall observations honor corrective scales, offsets and justified faces', () => { + const nodes = wallScreenScene() + const screen = nodes.item_7j1l6855lxvbemw5! + const wall = nodes.wall_fseg8ncv7q8u5kqn! + if (screen.type !== 'item' || wall.type !== 'wall') throw new Error('fixture missing') + screen.rotation[1] = Math.PI + screen.asset.offset[2] = -0.003 + screen.scale[2] = 2 + wall.justification = 'a' + expect(wallObservations(nodes)).toContainEqual( + expect.objectContaining({ + type: 'item_wall_body_overlap', + overlapDepthMeters: expect.closeTo(0.006, 6), + }), + ) + screen.asset.offset[2] = -0.0004 + expect(wallObservations(nodes)).toEqual([]) + screen.asset.offset[2] = 0 + screen.asset.rotation[1] = Math.PI + expect(wallObservations(nodes)).toContainEqual( + expect.objectContaining({ type: 'item_faces_wall', frontDotOutward: -1 }), + ) + }) + + test('wall observations exclude opening nodes and bounds entirely inside a wall cutout', () => { + const nodes = wallScreenScene() + const wall = nodes.wall_fseg8ncv7q8u5kqn! + if (wall.type !== 'wall') throw new Error('wall missing') + const window = WindowNode.parse({ + id: 'window_screen', + parentId: wall.id, + wallId: wall.id, + position: [4.5, 1.8, 0], + width: 7, + height: 2.8, + }) + wall.children.push(window.id) + nodes[window.id] = window + expect(wallObservations(nodes)).toEqual([]) + delete nodes[window.id] + nodes.door_screen = DoorNode.parse({ + id: 'door_screen', + parentId: wall.id, + wallId: wall.id, + position: [4.5, 1.6, 0], + width: 7, + height: 3.2, + }) + wall.children = ['door_screen'] + expect(wallObservations(nodes)).toEqual([]) + }) + + test('unknown catalog raw origins do not manufacture a wall-depth collision', () => { + const nodes = wallScreenScene() + const screen = nodes.item_7j1l6855lxvbemw5! + if (screen.type !== 'item') throw new Error('screen missing') + delete screen.source + screen.rotation[1] = Math.PI + screen.asset.attachTo = 'wall' + screen.asset.dimensions = [0.8, 0.6, 0.04] + screen.asset.offset = [0.013, 0, 0.024] + expect(wallObservations(nodes)).toEqual([]) + }) + + test('wall bounds stay in the owning building frame through stacked elevations', () => { + const nodes = wallScreenScene() + const level = nodes.level_qqolzyn207xv0jj9! + if (level.type !== 'level') throw new Error('level missing') + const building = BuildingNode.parse({ + id: 'building_wall_bounds', + position: [40, 8, -30], + rotation: [0, 0.7, 0], + }) + const ground = LevelNode.parse({ id: 'level_wall_ground', parentId: building.id, height: 3 }) + level.parentId = building.id + level.level = 1 + level.baseElevation = 0.7 + nodes[building.id] = building + nodes[ground.id] = ground + expect(wallObservations(nodes)).toContainEqual( + expect.objectContaining({ + type: 'item_wall_body_overlap', + bounds: { + min: [expect.closeTo(-10.8), expect.closeTo(4.25), expect.closeTo(2.7775)], + max: [expect.closeTo(-4.2), expect.closeTo(6.75), expect.closeTo(3.11)], + }, + }), + ) + }) + + test('partial openings subtract area without hiding the remaining wall overlap', () => { + const nodes = wallScreenScene() + const wall = nodes.wall_fseg8ncv7q8u5kqn! + if (wall.type !== 'wall') throw new Error('wall missing') + const window = WindowNode.parse({ + id: 'window_partial_screen', + parentId: wall.id, + wallId: wall.id, + position: [4.5, 1.8, 0], + width: 1, + height: 1, + }) + nodes[window.id] = window + wall.children.push(window.id) + expect(wallObservations(nodes)).toContainEqual( + expect.objectContaining({ + type: 'item_wall_body_overlap', + overlapAreaSquareMeters: expect.closeTo(15.5, 4), + }), + ) + }) + + test('bounds wholly beyond the opposite face are observed without inventing body overlap', () => { + const nodes = wallScreenScene() + const screen = nodes.item_7j1l6855lxvbemw5! + if (screen.type !== 'item') throw new Error('screen missing') + screen.rotation[1] = Math.PI + screen.asset.offset[2] = -0.7 + expect(wallObservations(nodes).map((issue) => issue.type)).toEqual(['item_through_wall']) + expect(wallObservations(nodes)[0]).toMatchObject({ + beyondOppositeFaceMeters: expect.closeTo(0.48, 4), + }) + }) + + test('unsupported curved, animated and scripted-cut hosts withhold depth evidence', () => { + for (const mode of ['curved', 'animated', 'cutout'] as const) { + const nodes = wallScreenScene() + const wall = nodes.wall_fseg8ncv7q8u5kqn! + const screen = nodes.item_7j1l6855lxvbemw5! + if (wall.type !== 'wall' || screen.type !== 'item') throw new Error('fixture missing') + if (mode === 'curved') wall.curveOffset = 0.3 + if (mode === 'animated') screen.source!.manifest.animations = [{ name: 'swing', duration: 1 }] + if (mode === 'cutout') screen.source!.manifest.cutout = true + expect(wallObservations(nodes)).toEqual([]) + } + }) + + test.each([ + null, + 'level_missing', + ])('an underlay with unresolved parent %s remains an occupied footprint', (parentId) => { + const rug = ItemNode.parse({ + id: 'item_rug', + parentId, + position: [3, 0, 3], + asset: { + id: 'large-rug', + name: 'Large rug', + category: 'furniture', + thumbnail: '', + src: 'asset://large-rug', + dimensions: [4, 0.02, 4], + }, + }) + expect(collectOccupiedFootprints([rug])).toMatchObject([ + { + id: 'item_rug', + levelId: null, + aabb: { minX: 1, maxX: 5, minZ: 1, maxZ: 5 }, + }, + ]) + const level = LevelNode.parse({ id: 'level_floor' }) + const floorRug = ItemNode.parse({ ...rug, parentId: level.id }) + expect(collectOccupiedFootprints([level, floorRug])).toEqual([]) + }) + + function bedRoom(blocker?: 'large-rug' | 'solid-cabinet' | 'thick-rug' | 'raised-rug') { + const level = LevelNode.parse({ id: 'level_r' }) + const zone = ZoneNode.parse({ + id: 'zone_r', + parentId: level.id, + name: 'Room', + polygon: [ + [0, 0], + [6, 0], + [6, 6], + [0, 6], + ], + }) + const underlay = ItemNode.parse({ + id: 'item_underlay', + parentId: level.id, + position: [3, blocker === 'raised-rug' ? 0.5 : 0, 3], + asset: { + id: blocker ?? 'large-rug', + name: blocker ?? 'large-rug', + category: 'furniture', + thumbnail: '', + src: 'asset://underlay', + dimensions: [4, blocker === 'solid-cabinet' || blocker === 'thick-rug' ? 0.8 : 0.02, 4], + ...(blocker === 'thick-rug' ? { surface: { height: 0.02 } } : {}), + }, + }) + const catalog = [ + { + ...underlay.asset, + id: 'double-bed', + name: 'double-bed', + dimensions: [1.6, 0.6, 2] as [number, number, number], + surface: undefined, + }, + ] + const nodes = Object.fromEntries( + [level, zone, ...(blocker ? [underlay] : [])].map((node) => [node.id, node]), + ) + return { nodes, catalog } + } + + test.each([ + undefined, + 'large-rug', + ] as const)('automatic bed fits against a wall with floor covering %s', (blocker) => { + const { nodes, catalog } = bedRoom(blocker) + const before = structuredClone(nodes) + const outcome = AGENT_OPERATIONS.place_in_room( + nodes, + { assetId: 'double-bed', zoneId: 'zone_r' }, + { catalog }, + ) + const after = applySceneChanges(nodes, outcome.changes!) + const bed = after[outcome.result.itemId as string] + expect(nodes).toEqual(before) + expect(outcome.result).toMatchObject({ ok: true }) + expect(bed).toMatchObject({ type: 'item', parentId: 'level_r' }) + if (bed?.type !== 'item') throw new Error('bed missing') + const footprint = itemNodePlanAabb(bed)! + expect([footprint.minX, footprint.minZ].every((value) => value >= 0)).toBe(true) + expect([footprint.maxX, footprint.maxZ].every((value) => value <= 6)).toBe(true) + expect( + Math.min(footprint.minX, footprint.minZ, 6 - footprint.maxX, 6 - footprint.maxZ), + ).toBeLessThanOrEqual(0.2) + expect(bed.position[1]).toBe(0) + if (blocker) { + expect(after.item_underlay).toEqual(before.item_underlay) + expect(aabbsOverlap(footprint, itemNodePlanAabb(after.item_underlay!)!)).toBe(true) + expect(findItemItemCollisions({ nodes: Object.values(after) })).toEqual([]) + } + }) + + test.each([ + 'solid-cabinet', + 'thick-rug', + 'raised-rug', + ] as const)('automatic bed remains blocked by %s', (blocker) => { + const { nodes, catalog } = bedRoom(blocker) + expect(() => + AGENT_OPERATIONS.place_in_room( + nodes, + { assetId: 'double-bed', zoneId: 'zone_r' }, + { catalog }, + ), + ).toThrow(expect.objectContaining({ code: 'no_free_spot' })) + }) + + test('automatic bed over a rug still respects reserved clearance', () => { + const { nodes, catalog } = bedRoom('large-rug') + expect(() => + AGENT_OPERATIONS.place_in_room( + nodes, + { + assetId: 'double-bed', + zoneId: 'zone_r', + clearanceAreas: [{ id: 'reserved-floor', minX: 0, maxX: 6, minZ: 0, maxZ: 6 }], + }, + { catalog }, + ), + ).toThrow(expect.objectContaining({ code: 'stair_blocked' })) + }) + + test('scripted plan bounds keep their offset through yaw and scale', () => { + const node = authoredItem('item_offset', 'level_1', { + name: 'Offset', + category: 'furniture', + size: [2, 1, 3], + }) + node.position = [10, 0, 20] + node.rotation = [0, Math.PI / 2, 0] + node.scale = [2, 1, 3] + node.source!.manifest.bounds.min = [1, 0, 2] + node.source!.manifest.bounds.max = [3, 1, 5] + const expected = { minX: 16, maxX: 25, minZ: 14, maxZ: 18 } + expect(itemNodePlanAabb(node)).toMatchObject(expected) + expect(inspectItemPlanFootprint(node)).toMatchObject({ ok: true, aabb: expected }) + const blocker = authoredItem('item_blocker', 'level_1', { + name: 'Blocker', + category: 'furniture', + size: [1, 1, 1], + }) + blocker.position = [20, 0, 16] + expect(findItemItemCollisions({ nodes: [node, blocker] })).toHaveLength(1) + }) + + test("scripted plan bounds carry the asset's own corrective scale, offset and rotation", () => { + const level = LevelNode.parse({ id: 'level_1', height: 3 }) + const node = authoredItem('item_asset_pose', 'level_1', { + name: 'Posed', + category: 'furniture', + size: [2, 1, 2], + }) + node.position = [10, 0, 20] + node.source!.manifest.bounds.min = [-1, 0, -1] + node.source!.manifest.bounds.max = [1, 1, 1] + node.asset.scale = [2, 1, 3] + node.asset.rotation = [0, Math.PI / 2, 0] + node.asset.offset = [1, 0, 0.5] + // The 2 x 2 box grows to 4 x 6, turns a quarter about its own origin (3 m along x, 2 m along z) + // and moves 1 m along x and 0.5 m along z, all before the node's own pose. + const expected = { minX: 8, maxX: 14, minZ: 18.5, maxZ: 22.5 } + expect(itemNodePlanAabb(node)).toMatchObject(expected) + expect(inspectItemPlanFootprint(node)).toMatchObject({ ok: true, aabb: expected }) + // The same box the vertical checks measure. + const measured = scriptItemBoundsInLevel(node, { [level.id]: level, [node.id]: node })!.bounds + expect([measured.min[0], measured.max[0], measured.min[2], measured.max[2]]).toEqual([ + expected.minX, + expected.maxX, + expected.minZ, + expected.maxZ, + ]) + const blocker = authoredItem('item_blocker', 'level_1', { + name: 'Blocker', + category: 'furniture', + size: [1, 1, 1], + }) + blocker.position = [13, 0, 22] + expect(findItemItemCollisions({ nodes: [node, blocker] })).toHaveLength(1) + }) + + function ceilingBoundsScene() { + const building = BuildingNode.parse({ + id: 'building_bounds', + position: [30, 7, -20], + rotation: [0, Math.PI / 3, 0], + }) + const level = LevelNode.parse({ + id: 'level_bounds', + parentId: building.id, + height: 3.6, + baseElevation: 0.7, + }) + const upper = LevelNode.parse({ + id: 'level_bounds_upper', + parentId: building.id, + level: 1, + height: 3, + }) + const polygon: [number, number][] = [ + [-12, -8], + [12, -8], + [12, 8], + [-12, 8], + ] + const ceiling = CeilingNode.parse({ + id: 'ceiling_bounds', + parentId: level.id, + polygon, + }) + const slab = SlabNode.parse({ + id: 'slab_bounds', + parentId: upper.id, + polygon, + elevation: 0.05, + thickness: 0.05, + }) + const lamp = authoredItem('item_bounds', ceiling.id, { + name: 'Chef Kitchen alabaster light', + category: 'lighting', + size: [1.528, 0.8285, 1.528], + }) + lamp.position = [8, 0, -1] + lamp.asset.attachTo = 'ceiling' + const nodes = [building, level, upper, ceiling, slab, lamp] + for (const node of nodes) { + const parent = nodes.find((candidate) => candidate.id === node.parentId) + if (parent && 'children' in parent) parent.children.push(node.id as never) + } + return { building, level, upper, ceiling, slab, lamp, nodes } + } + + function boundsObservations(nodes: AnyNode[]) { + const graph = Object.fromEntries(nodes.map((node) => [node.id, node])) + const before = JSON.stringify(graph) + const issues = verifyScene(graph, {}, { activeLevelId: 'level_bounds' }).result.issues + expect(JSON.stringify(graph)).toBe(before) + return issues.filter( + (issue) => issue.type === 'item_above_ceiling' || issue.type === 'item_covering_slab_overlap', + ) + } + + test('ceiling bounds observations measure saved positive-Y lamps without changing them', () => { + const { nodes, lamp, ceiling, slab } = ceilingBoundsScene() + const observations = boundsObservations(nodes) + expect(observations).toContainEqual( + expect.objectContaining({ + type: 'item_above_ceiling', + itemId: lamp.id, + ceilingId: ceiling.id, + aboveAttachmentMeters: expect.closeTo(0.8285, 6), + attachmentElevation: expect.closeTo(4.28, 6), + coordinateFrame: 'building', + }), + ) + expect(observations).toContainEqual( + expect.objectContaining({ + type: 'item_covering_slab_overlap', + itemId: lamp.id, + ceilingId: ceiling.id, + slabId: slab.id, + overlapHeightMeters: expect.closeTo(0.05, 6), + overlapAreaSquareMeters: expect.closeTo(1.528 ** 2, 6), + }), + ) + expect(observations[0]!.message).toContain('0.829 m above ceiling attachment ceiling_bounds') + }) + + test('ceiling bounds observations leave top-anchored negative-Y lamps clear', () => { + const { nodes, lamp } = ceilingBoundsScene() + lamp.source!.manifest.bounds.min[1] = -0.8285 + lamp.source!.manifest.bounds.max[1] = 0 + expect(boundsObservations(nodes)).toEqual([]) + }) + + test('ceiling bounds observations apply asset and node corrective transforms', () => { + const { nodes, lamp } = ceilingBoundsScene() + lamp.source!.manifest.bounds.min[1] = -0.8285 + lamp.source!.manifest.bounds.max[1] = 0 + lamp.asset.offset = [0, 0.1, 0] + lamp.asset.scale = [2, 3, 1] + lamp.asset.rotation = [0, 0, Math.PI / 2] + lamp.scale = [1, 2, 1] + lamp.rotation = [0, Math.PI / 2, 0] + expect(boundsObservations(nodes)).toContainEqual( + expect.objectContaining({ + type: 'item_above_ceiling', + aboveAttachmentMeters: expect.closeTo(3.256, 6), + }), + ) + lamp.asset.offset = [0, 0, 0] + lamp.asset.scale = [1, 1, 1] + lamp.asset.rotation = [0, 0, 0] + lamp.scale = [1, 1, 1] + lamp.rotation = [Math.PI / 2, 0, 0] + expect(boundsObservations(nodes)).toContainEqual( + expect.objectContaining({ + type: 'item_above_ceiling', + aboveAttachmentMeters: expect.closeTo(0.764, 6), + }), + ) + }) + + test('ceiling bounds observations subtract full holes but not just a covered centroid', () => { + const { nodes, slab } = ceilingBoundsScene() + slab.holes = [ + [ + [7, -2], + [9, -2], + [9, 0], + [7, 0], + ], + ] + expect(boundsObservations(nodes).map((issue) => issue.type)).toEqual(['item_above_ceiling']) + slab.holes[0] = [ + [7.8, -1.2], + [8.2, -1.2], + [8.2, -0.8], + [7.8, -0.8], + ] + expect(boundsObservations(nodes)).toContainEqual( + expect.objectContaining({ + type: 'item_covering_slab_overlap', + overlapAreaSquareMeters: expect.closeTo(1.528 ** 2 - 0.16, 6), + }), + ) + }) + + test('ceiling bounds observations subtract derived authored slab cutters', () => { + const { nodes, upper, slab } = ceilingBoundsScene() + const cutter = authoredItem('item_slab_cutter', upper.id, { + name: 'Slab opening', + category: 'opening', + size: [2, 0.2, 2], + }) + cutter.position = [8, 0, -1] + cutter.supportSlabId = slab.id + cutter.source!.manifest.cutters = [ + { + host: 'slab', + polygon: [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + minY: -0.1, + maxY: 0.1, + }, + ] + upper.children.push(cutter.id) + expect(boundsObservations([...nodes, cutter]).map((issue) => issue.type)).toEqual([ + 'item_above_ceiling', + ]) + }) + + test('ceiling bounds observations exclude disjoint, unrelated and nonpenetrating coverings', () => { + const { nodes, slab, lamp, upper } = ceilingBoundsScene() + slab.polygon = [ + [-2, -2], + [2, -2], + [2, 2], + [-2, 2], + ] + expect(boundsObservations(nodes).map((issue) => issue.type)).toEqual(['item_above_ceiling']) + slab.polygon = [ + [7, -2], + [9, -2], + [9, 0], + [7, 0], + ] + const unrelated = BuildingNode.parse({ id: 'building_unrelated' }) + upper.parentId = unrelated.id + expect(boundsObservations([...nodes, unrelated]).map((issue) => issue.type)).toEqual([ + 'item_above_ceiling', + ]) + upper.parentId = 'building_bounds' + lamp.position[1] = 2 + expect(boundsObservations(nodes).map((issue) => issue.type)).toEqual(['item_above_ceiling']) + lamp.position[1] = 0.02 - 0.8285 + expect(boundsObservations(nodes).map((issue) => issue.type)).toEqual(['item_above_ceiling']) + }) + + function verticalScene() { + const level = LevelNode.parse({ id: 'level_vertical', height: 3 }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_vertical', + parentId: level.id, + height: 3, + polygon: [ + [-3, -3], + [3, -3], + [3, 3], + [-3, 3], + ], + }) + const furniture = authoredItem('item_furniture', level.id, { + name: 'Table', + category: 'furniture', + size: [2, 1, 2], + }) + const pendant = authoredItem('item_pendant', ceiling.id, { + name: 'Pendant', + category: 'lighting', + size: [1, 0.4, 1], + }) + pendant.asset.attachTo = 'ceiling' + pendant.source!.manifest.bounds.min[1] = -0.4 + pendant.source!.manifest.bounds.max[1] = 0 + return { level, ceiling, furniture, pendant } + } + + test('proven ceiling pendant height clears floor furniture through scene verification', () => { + const { level, ceiling, furniture, pendant } = verticalScene() + const nodes = [level, ceiling, furniture, pendant] + expect(findItemItemCollisions({ nodes })).toEqual([]) + const result = verifyScene( + Object.fromEntries(nodes.map((node) => [node.id, node])), + {}, + { activeLevelId: level.id }, + ).result + expect(result.issues).not.toContainEqual(expect.objectContaining({ type: 'item_overlap' })) + expect(result.issues).not.toContainEqual(expect.objectContaining({ type: 'item_too_close' })) + pendant.position[0] = 1.53 + expect(findItemItemCollisions({ nodes })).toEqual([]) + }) + + test('low pendants, ceiling peers and floor collisions remain warnings', () => { + const { level, ceiling, furniture, pendant } = verticalScene() + pendant.position[1] = -2 + expect(findItemItemCollisions({ nodes: [level, ceiling, furniture, pendant] })).toHaveLength(1) + pendant.position[1] = 0 + const peer = { ...pendant, id: 'item_peer' as const } + expect(findItemItemCollisions({ nodes: [level, ceiling, pendant, peer] })).toHaveLength(1) + expect( + findItemItemCollisions({ + nodes: [level, furniture, { ...furniture, id: 'item_peer' }], + }), + ).toHaveLength(1) + }) + + test('vertical separation uses corrective transforms and elected slab support', () => { + const { level, ceiling, furniture, pendant } = verticalScene() + const slab = SlabNode.parse({ + parentId: level.id, + polygon: ceiling.polygon, + elevation: 2, + }) + const nodes = [level, ceiling, slab, furniture, pendant] + expect(findItemItemCollisions({ nodes })).toHaveLength(1) + furniture.supportSlabId = 'ground' + expect(findItemItemCollisions({ nodes })).toEqual([]) + pendant.asset.offset[1] = -2 + expect(findItemItemCollisions({ nodes })).toHaveLength(1) + pendant.asset.offset[1] = 0 + pendant.asset.scale[1] = 6 + expect(findItemItemCollisions({ nodes })).toHaveLength(1) + pendant.asset.scale[1] = 1 + pendant.scale[1] = 6 + expect(findItemItemCollisions({ nodes })).toHaveLength(1) + }) + + test('following ceiling height and contact remain conservative', () => { + const { level, ceiling, furniture, pendant } = verticalScene() + const nodes = [level, ceiling, furniture, pendant] + delete ceiling.height + expect(findItemItemCollisions({ nodes })).toEqual([]) + level.height = 1.4 + expect(findItemItemCollisions({ nodes })).toHaveLength(1) + ceiling.height = 1.4 + CEILING_DRAW_OFFSET + expect(findItemItemCollisions({ nodes })).toHaveLength(1) + ceiling.height += 0.5e-6 + expect(findItemItemCollisions({ nodes })).toHaveLength(1) + ceiling.height += 1e-4 + expect(findItemItemCollisions({ nodes })).toEqual([]) + }) + + test('two catalogue recessed lights without raw bounds retain floor and ceiling warnings', () => { + const { level, ceiling, furniture } = verticalScene() + const lights = ['item_recessed_a', 'item_recessed_b'].map((id) => + ItemNode.parse({ + id, + parentId: ceiling.id, + asset: { + id: 'recessed-light', + name: 'Recessed Light', + category: 'furniture', + thumbnail: '', + src: 'asset://recessed-light', + dimensions: [0.23, 0.06, 0.23], + offset: [0, 0.0057, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'ceiling', + recessed: true, + }, + }), + ) + const nodes = [level, ceiling, furniture, ...lights] + const hits = findItemItemCollisions({ nodes }) + expect(hits).toHaveLength(3) + expect(hits.filter((hit) => hit.aId === furniture.id)).toHaveLength(2) + const result = verifyScene( + Object.fromEntries(nodes.map((node) => [node.id, node])), + {}, + { activeLevelId: level.id }, + ).result + expect(result.issues.filter((issue) => issue.type === 'item_overlap')).toHaveLength(3) + }) + + test('unknown bounds and non-planar or malformed transforms cannot waive collisions', () => { + const mutations = [ + (node: ReturnType) => { + node.source = undefined + }, + (node: ReturnType) => { + node.rotation[0] = 0.1 + }, + (node: ReturnType) => { + node.asset.rotation[2] = 0.1 + }, + (node: ReturnType) => { + node.scale[1] = -1 + }, + (node: ReturnType) => { + node.position[1] = Number.NaN + }, + (node: ReturnType) => { + node.asset.offset[1] = Number.NaN + }, + (node: ReturnType) => { + node.source!.manifest.bounds.min[1] = 1 + }, + (node: ReturnType) => { + node.source!.manifest.animations = [{ name: 'swing', duration: 1 }] + }, + (node: ReturnType) => { + node.roofSegmentId = 'rseg_unknown' + }, + (node: ReturnType) => { + node.blockFaceId = 'block_unknown' + }, + (node: ReturnType) => { + node.parentId = 'missing_parent' + }, + (node: ReturnType) => { + node.parentId = 'item_furniture' + }, + ] + for (const mutate of mutations) { + const { level, ceiling, furniture, pendant } = verticalScene() + mutate(pendant) + expect(findItemItemCollisions({ nodes: [level, ceiling, furniture, pendant] })).toHaveLength( + 1, + ) + } + const { level, ceiling, furniture, pendant } = verticalScene() + level.height = Number.NaN + expect(findItemItemCollisions({ nodes: [level, ceiling, furniture, pendant] })).toHaveLength(1) + }) + test('findItemItemCollisions detects rotated footprint overlap', () => { // Two 2x1 footprints; second rotated 90° so it extends along X const a = item('a', [0, 0, 0], [2, 1, 1], 'A', 0) diff --git a/packages/core/src/agent-operations/layout-clearance.ts b/packages/core/src/agent-operations/layout-clearance.ts index e623670b02..a19e154828 100644 --- a/packages/core/src/agent-operations/layout-clearance.ts +++ b/packages/core/src/agent-operations/layout-clearance.ts @@ -8,7 +8,21 @@ * See packages/mcp/docs/layout-clearance-error-log.md for regression checklist. */ -import type { AnyNode } from '../schema' +import { floorConstructionLift, liftedManualSlab } from '../lib/floor-construction-lift' +import { selectSlabSupportForItem } from '../lib/item-slab-support' +import { getRenderableSlabPolygon } from '../lib/slab-polygon' +import { levelBaseElevationAt } from '../lib/terrain-support-query' +import { + type Bounds, + boundsOf, + boxCorners, + frame, + rotateVector, + transformPoint, +} from '../procedural-items/spatial' +import { type AnyNode, getScaledDimensions, type ItemNode } from '../schema' +import { resolveCeilingHeight } from '../services/level-height' +import { CEILING_DRAW_OFFSET } from '../services/storey' import { aabbsOverlap, findBlockedDoors, @@ -17,6 +31,8 @@ import { type PlanAabb, resolveNodeLevelId, } from './door-clearance' +import { furnitureKind, isFloorUnderlay } from './furniture-kind' +import { pointInPolygon, type Vec2 } from './plan-geometry' export { aabbsOverlap, @@ -73,6 +89,7 @@ export function collectOccupiedFootprints( } const levelId = resolveNodeLevelId(node.id, byId) if (options?.levelId && levelId !== options.levelId) continue + if (levelId && node.parentId === levelId && isFloorUnderlay(node)) continue // Floor packing: prefer level-parented items (not wall-hosted children). if (options?.floorOnly && node.parentId && levelId && node.parentId !== levelId) { if (attach === 'wall' || attach === 'wall-side' || attach === 'ceiling') continue @@ -92,13 +109,168 @@ export function collectOccupiedFootprints( return out } +type ItemVerticalInterval = { levelId: string; min: number; max: number } + +function finiteVec3(value: unknown): value is [number, number, number] { + return ( + Array.isArray(value) && + value.length === 3 && + [value[0], value[1], value[2]].every(Number.isFinite) + ) +} + +function planarRotation(value: unknown): value is [number, number, number] { + return finiteVec3(value) && value[0] === 0 && value[2] === 0 +} + +function finiteNumbers(value: unknown): boolean { + if (typeof value === 'number') return Number.isFinite(value) + if (Array.isArray(value)) return value.every(finiteNumbers) + if (value && typeof value === 'object') return Object.values(value).every(finiteNumbers) + return true +} + +/** Snapshot counterpart of the item floorPlaced support election; never reads the live grid. */ +function itemFloorLift(node: ItemNode, nodes: Record): number { + // The renderer's item floorPlaced.applies skips corrective surface attachments. + if (node.asset.attachTo) return 0 + const levelId = node.parentId! + const ground = () => + levelBaseElevationAt(nodes, levelId, node.position[0], node.position[2]) + + floorConstructionLift(nodes, node) + if (node.supportSlabId === 'ground') return ground() + const list = Object.values(nodes) + const slabs = list + .filter( + (other): other is Extract => + other.type === 'slab' && other.parentId === levelId, + ) + .map((slab) => liftedManualSlab(nodes, slab)) + const walls = list.filter((other): other is Extract => { + if (other.type !== 'wall') return false + let parent = other.parentId ? nodes[other.parentId] : undefined + for (let depth = 0; parent && parent.type !== 'level' && depth < 16; depth++) { + parent = parent.parentId ? nodes[parent.parentId] : undefined + } + return parent?.type === 'level' && parent.id === levelId + }) + const support = selectSlabSupportForItem( + slabs, + { position: node.position, dimensions: getScaledDimensions(node), rotation: node.rotation }, + (slab) => + getRenderableSlabPolygon(slab, { + walls, + siblingSlabs: slabs.filter((other) => other.id !== slab.id), + }), + { preferredSlabId: node.supportSlabId }, + ) + return support ? support.elevation : ground() +} + +/** + * Only script manifests supply raw bounds for the renderer's corrective asset pose. + * Catalog dimensions describe a plan box, not proof of the raw model's Y origin. + */ +export function scriptItemBoundsInLevel( + node: AnyNode | undefined, + nodes: Record, +): { levelId: string; bounds: Bounds; corners: [number, number, number][] } | undefined { + if (node?.type !== 'item' || node.roofSegmentId || node.blockFaceId) return + const bounds = node.source?.manifest?.bounds + const assetScale = node.asset.scale ?? [1, 1, 1] + if ( + !bounds || + node.source?.manifest?.animations?.length || + !finiteVec3(bounds.min) || + !finiteVec3(bounds.max) || + bounds.min.some((min, axis) => min > bounds.max[axis]!) || + !finiteVec3(node.position) || + !finiteVec3(node.rotation) || + !finiteVec3(node.scale) || + node.scale.some((scale) => scale <= 0) || + !finiteVec3(node.asset.offset) || + !finiteVec3(node.asset.rotation) || + !finiteVec3(assetScale) || + assetScale.some((scale) => scale <= 0) || + !finiteVec3(node.asset.dimensions) || + node.asset.dimensions.some((size) => size <= 0) + ) + return + const parent = node.parentId ? nodes[node.parentId] : undefined + const level = + parent?.type === 'level' + ? parent + : parent?.type === 'ceiling' && parent.parentId + ? nodes[parent.parentId] + : undefined + if (level?.type !== 'level' || !finiteNumbers(level) || !finiteNumbers(parent)) return + try { + const hostY = + parent?.type === 'ceiling' + ? resolveCeilingHeight(parent, nodes) - CEILING_DRAW_OFFSET + : itemFloorLift(node, nodes) + const pose = frame( + [node.position[0], hostY + node.position[1], node.position[2]], + node.rotation, + ) + // Match the model groups: corrective asset pose is inside the node-scale group. + const corners = boxCorners(bounds.min, bounds.max).map((point) => { + const rotated = rotateVector( + point.map((value, axis) => value * assetScale[axis]!) as [number, number, number], + node.asset.rotation, + ) + return transformPoint( + pose, + rotated.map((value, axis) => (value + node.asset.offset[axis]!) * node.scale[axis]!) as [ + number, + number, + number, + ], + ) + }) + if (corners.every(finiteVec3)) return { levelId: level.id, bounds: boundsOf(corners), corners } + } catch { + // Malformed or unsupported support geometry is not evidence of separation. + } +} + +function itemVerticalInterval( + node: AnyNode | undefined, + nodes: Record, +): ItemVerticalInterval | undefined { + // Keep the existing plan-layout evidence limited to yaw-only model poses. + if ( + node?.type !== 'item' || + !planarRotation(node.rotation) || + !planarRotation(node.asset.rotation) + ) + return + const resolved = scriptItemBoundsInLevel(node, nodes) + if (resolved) + return { + levelId: resolved.levelId, + min: resolved.bounds.min[1], + max: resolved.bounds.max[1], + } +} + export function findItemItemCollisions(args: { nodes: Iterable levelId?: string gap?: number }): ItemCollision[] { const gap = args.gap ?? DEFAULT_ITEM_GAP - const footprints = collectOccupiedFootprints(args.nodes, { levelId: args.levelId }) + const list = [...args.nodes] + const nodes = Object.fromEntries(list.map((node) => [node.id, node])) + const footprints = collectOccupiedFootprints(list, { levelId: args.levelId }) + // Drawing fallbacks on invalid support heights cannot prove collision separation. + const finiteContext = list.every((node) => node.type === 'item' || finiteNumbers(node)) + const intervals = new Map( + footprints.map(({ id }) => [ + id, + finiteContext ? itemVerticalInterval(nodes[id], nodes) : undefined, + ]), + ) const collisions: ItemCollision[] = [] for (let i = 0; i < footprints.length; i++) { for (let j = i + 1; j < footprints.length; j++) { @@ -106,6 +278,16 @@ export function findItemItemCollisions(args: { const b = footprints[j]! if (a.levelId && b.levelId && a.levelId !== b.levelId) continue if (!aabbsOverlap(a.aabb, b.aabb, gap)) continue + const aY = intervals.get(a.id) + const bY = intervals.get(b.id) + // Contact and sub-micrometre separation remain conservative against roundoff. + if ( + aY && + bY && + aY.levelId === bY.levelId && + (bY.min - aY.max > 1e-6 || aY.min - bY.max > 1e-6) + ) + continue collisions.push({ aId: a.id, bId: b.id, @@ -243,12 +425,105 @@ export function findValidPlacement(args: { return { candidate: null, reason: primaryReason ?? bestFailReason } } +/** A wall-bound piece whose nearest edge is further than this from every wall face is not against a wall. */ +export const MID_ROOM_DISTANCE_M = 0.4 +const DEFAULT_WALL_THICKNESS_M = 0.1 + +const cross = (o: Vec2, p: Vec2, q: Vec2) => + (p[0] - o[0]) * (q[1] - o[1]) - (p[1] - o[1]) * (q[0] - o[0]) + +function distanceToSegment(point: Vec2, a: Vec2, b: Vec2) { + const dx = b[0] - a[0] + const dz = b[1] - a[1] + const length2 = dx * dx + dz * dz + const t = + length2 === 0 + ? 0 + : Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dz) / length2)) + return Math.hypot(point[0] - (a[0] + t * dx), point[1] - (a[1] + t * dz)) +} + +/** The shortest distance from a footprint box to a wall's centre line (0 when the line passes through it). */ +function boxToSegment(box: PlanAabb, a: Vec2, b: Vec2) { + const corners: Vec2[] = [ + [box.minX, box.minZ], + [box.maxX, box.minZ], + [box.maxX, box.maxZ], + [box.minX, box.maxZ], + ] + let nearest = Number.POSITIVE_INFINITY + for (let i = 0; i < 4; i++) { + const c = corners[i]! + const d = corners[(i + 1) % 4]! + if (cross(a, b, c) * cross(a, b, d) < 0 && cross(c, d, a) * cross(c, d, b) < 0) return 0 + nearest = Math.min( + nearest, + distanceToSegment(a, c, d), + distanceToSegment(b, c, d), + distanceToSegment(c, a, b), + distanceToSegment(d, a, b), + ) + } + return nearest +} + +/** + * Wall-bound pieces (a fridge, a counter, a bed, a sofa) that stand in a room but away from every + * wall: further than {@link MID_ROOM_DISTANCE_M} from the face of each wall of their level. Pieces + * outside every room, free-standing kinds, and levels with no walls are not judged. + */ +export function findItemsMidRoom(args: { nodes: Iterable; levelId?: string }): { + id: string + message: string +}[] { + const list = [...args.nodes] + const byId = new Map(list.map((node) => [node.id, node])) + const walls: { a: Vec2; b: Vec2; halfThickness: number }[] = [] + const zones: Extract[] = [] + for (const node of list) { + if (resolveNodeLevelId(node.id, byId) !== args.levelId) continue + if (node.type === 'wall') + walls.push({ + a: [node.start[0], node.start[1]], + b: [node.end[0], node.end[1]], + halfThickness: (node.thickness ?? DEFAULT_WALL_THICKNESS_M) / 2, + }) + else if (node.type === 'zone') zones.push(node) + } + if (walls.length === 0 || zones.length === 0) return [] + + const found: { id: string; message: string }[] = [] + for (const footprint of collectOccupiedFootprints(list, { + floorOnly: true, + ...(args.levelId === undefined ? {} : { levelId: args.levelId }), + })) { + const node = byId.get(footprint.id) + if (node?.type !== 'item' || furnitureKind(node.asset) !== 'wall') continue + const room = zones.find((zone) => + pointInPolygon([node.position[0], node.position[2]], zone.polygon as Vec2[], true), + ) + if (!room) continue + const gap = Math.min( + ...walls.map((wall) => + Math.max(0, boxToSegment(footprint.aabb, wall.a, wall.b) - wall.halfThickness), + ), + ) + if (gap <= MID_ROOM_DISTANCE_M) continue + found.push({ + id: node.id, + message: `${footprint.name ?? node.asset.id} (${node.id}) stands in the middle of ${room.name || room.id}, ${gap.toFixed(1)} m from the nearest wall: a ${node.asset.id} belongs against a wall (place_in_room puts it there).`, + }) + } + return found +} + /** * Collect layout issues, scoped per level so stacked floors do not false-positive. */ -export function layoutIssuesFromScene( - nodes: Iterable, -): { type: 'door_blocked' | 'item_overlap' | 'item_too_close'; message: string }[] { +export function layoutIssuesFromScene(nodes: Iterable): { + type: 'door_blocked' | 'item_overlap' | 'item_too_close' | 'item_mid_room' + message: string +}[] { const list = [...nodes] const byId = new Map(list.map((n) => [n.id, n] as const)) const levelIds = new Set() @@ -261,7 +536,10 @@ export function layoutIssuesFromScene( if (lid) levelIds.add(lid) } - const issues = new Map() + const issues = new Map< + string, + 'door_blocked' | 'item_overlap' | 'item_too_close' | 'item_mid_room' + >() const levels = levelIds.size > 0 ? [...levelIds] : [undefined] for (const levelId of levels) { @@ -271,6 +549,9 @@ export function layoutIssuesFromScene( for (const c of findItemItemCollisions({ nodes: list, levelId })) { issues.set(c.message, c.violation === 'overlap' ? 'item_overlap' : 'item_too_close') } + for (const m of findItemsMidRoom({ nodes: list, levelId })) { + issues.set(m.message, 'item_mid_room') + } } // Deduplicate (node may appear under multiple walks) diff --git a/packages/core/src/agent-operations/level-height-fill.test.ts b/packages/core/src/agent-operations/level-height-fill.test.ts new file mode 100644 index 0000000000..c0864a0f0a --- /dev/null +++ b/packages/core/src/agent-operations/level-height-fill.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import { BuildingNode, LevelNode, WallNode } from '../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +import { migrateVerticalSceneNodes } from '../utils/scene-migrations' +import { fillLevelHeights, type LevelHeightWrite } from './level-height-fill' + +// L68: a level written without a height is legacy to the editor's load, which derives its storey +// plane; the session that wrote it read 2.5 m. A write gives it the height the load would derive. +const building = BuildingNode.parse({ id: 'building_main' }) +const scene = { [building.id]: building } as Record +const heightless = (id: string) => { + const { height: _height, ...level } = LevelNode.parse({ id, parentId: building.id, level: 0 }) + return level as unknown as AnyNode +} +const tallWalls = (levelId: string) => + ( + [ + [ + [0, 0], + [4, 0], + ], + [ + [4, 0], + [4, 4], + ], + ] as const + ).map(([start, end], i) => + WallNode.parse({ id: `wall_${i}`, parentId: levelId, start, end, height: 3 }), + ) + +/** What the editor's load makes of the same nodes, the level written without a height. */ +const editorHeight = (levelId: string, nodes: AnyNode[]) => { + const level = { ...heightless(levelId), children: nodes.map((node) => node.id) } + const loaded = migrateVerticalSceneNodes({ + ...scene, + [levelId]: level, + ...Object.fromEntries(nodes.map((node) => [node.id, node])), + } as never).nodes as Record + return loaded[levelId]!.height +} + +describe('a level written without a height', () => { + test('created with its walls, gets the height the editor’s load derives from them', () => { + const walls = tallWalls('level_up') + const ops: LevelHeightWrite[] = [ + { op: 'create', node: heightless('level_up') }, + ...walls.map((node) => ({ op: 'create' as const, node })), + ] + const notes = fillLevelHeights(ops, scene) + const height = (ops[0] as { node: { height?: number } }).node.height + expect(height).toBe(editorHeight('level_up', walls)!) + expect(height).not.toBe(DEFAULT_LEVEL_HEIGHT) + expect(notes).toEqual([ + `level level_up: height ${Math.round(height! * 100) / 100} m derived from its walls and ceilings, as the editor reads a level written without one`, + ]) + }) + + test('created empty, gets the default storey height', () => { + const ops: LevelHeightWrite[] = [{ op: 'create', node: heightless('level_empty') }] + expect(fillLevelHeights(ops, scene)).toEqual([ + `level level_empty: height ${DEFAULT_LEVEL_HEIGHT} m, the default storey height, as nothing on it gives one`, + ]) + expect((ops[0] as { node: { height?: number } }).node.height).toBe(DEFAULT_LEVEL_HEIGHT) + }) + + test('cleared by an update, gets the derived height back', () => { + const walls = tallWalls('level_up') + const level = { + ...LevelNode.parse({ id: 'level_up', parentId: building.id, level: 0, height: 2.8 }), + children: walls.map((wall) => wall.id), + } + const nodes = { + ...scene, + [level.id]: level, + ...Object.fromEntries(walls.map((wall) => [wall.id, wall])), + } as Record + const ops: LevelHeightWrite[] = [{ op: 'update', id: level.id, data: { height: undefined } }] + fillLevelHeights(ops, nodes) + expect((ops[0] as { data: { height?: number } }).data.height).toBe( + editorHeight('level_up', walls)!, + ) + }) + + test('a level that has a height is written as sent', () => { + const level = LevelNode.parse({ id: 'level_set', parentId: building.id, level: 0, height: 2.7 }) + const ops: LevelHeightWrite[] = [{ op: 'create', node: level }] + expect(fillLevelHeights(ops, scene)).toEqual([]) + expect((ops[0] as { node: { height?: number } }).node.height).toBe(2.7) + }) +}) diff --git a/packages/core/src/agent-operations/level-height-fill.ts b/packages/core/src/agent-operations/level-height-fill.ts new file mode 100644 index 0000000000..969431bd1f --- /dev/null +++ b/packages/core/src/agent-operations/level-height-fill.ts @@ -0,0 +1,65 @@ +import type { AnyNode, AnyNodeId } from '../schema' +import { deriveLegacyLevelHeight } from '../services/level-height' + +/** A write's operations, as apply_patch and update_node carry them. */ +export type LevelHeightWrite = + | { op: 'create'; node: AnyNode } + | { op: 'update'; id: string; data: Record } + | { op: 'delete'; id: string } + +const round = (value: number) => Math.round(value * 100) / 100 + +/** + * A level written without a height reads as legacy data: the editor's load derives its storey + * plane from what it holds and rewrites its walls, stairs and ceilings to match, while a session + * that wrote it reads 2.5 m (L68). A write that creates a level without one, or clears it, gets + * the height the editor would derive, from what the level holds once the whole write lands (an + * empty level: the default storey height), so every reader agrees. Fills the operations in place + * and returns a note per level filled, for the write's answer. + */ +export function fillLevelHeights( + ops: LevelHeightWrite[], + scene: Readonly>, +): string[] { + const after: Record = { ...scene } + for (const op of ops) { + if (op.op === 'create') after[op.node.id] = op.node + else if (op.op === 'delete') delete after[op.id] + else if (after[op.id]) after[op.id] = { ...after[op.id], ...op.data } as AnyNode + } + + const notes: string[] = [] + const derive = (levelId: string) => { + const level = after[levelId] + if (level?.type !== 'level') return null + // The level's own list, and what names it as parent: a create lists no children yet. + const held = Object.values(after) + .filter((node) => node.parentId === levelId) + .map((node) => node.id) + const children = [...new Set([...(level.children ?? []), ...held])].filter((id) => after[id]) + const height = deriveLegacyLevelHeight(levelId, { + ...after, + [levelId]: { ...level, children }, + } as Record) + const holds = children.some((id) => after[id]?.type === 'wall' || after[id]?.type === 'ceiling') + notes.push( + holds + ? `level ${levelId}: height ${round(height)} m derived from its walls and ceilings, as the editor reads a level written without one` + : `level ${levelId}: height ${round(height)} m, the default storey height, as nothing on it gives one`, + ) + return height + } + + for (const op of ops) { + if (op.op === 'create') { + if (op.node.type !== 'level' || (op.node as { height?: unknown }).height != null) continue + const height = derive(op.node.id) + if (height !== null) (op.node as { height?: number }).height = height + } else if (op.op === 'update' && 'height' in op.data && op.data.height == null) { + if (after[op.id]?.type !== 'level') continue + const height = derive(op.id) + if (height !== null) op.data.height = height + } + } + return notes +} diff --git a/packages/core/src/agent-operations/level-reads.ts b/packages/core/src/agent-operations/level-reads.ts index 71b8000dac..72eaff2484 100644 --- a/packages/core/src/agent-operations/level-reads.ts +++ b/packages/core/src/agent-operations/level-reads.ts @@ -2,8 +2,9 @@ import { area } from '../lib/polygon-boolean' import { roomFloorChoices } from '../lib/room-floor-choices' import type { AnyNode, AnyNodeId, ItemNode, LevelNode, WallNode, ZoneNode } from '../schema' import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' -import { getWallPlaneTop } from '../services/storey' +import { getWallTop } from '../services/storey' import { computeWallSlabSupport } from '../systems/slab/slab-support' +import { resolveWallFaceChain } from '../systems/wall/wall-finish' import { resolveWallEffectiveHeight } from '../systems/wall/wall-top' import { type LevelTargetInput, targetLevel } from './level-target' import { contentCounts, levelIdOf, levelRole, nodesOnLevel } from './scene-queries' @@ -32,8 +33,10 @@ export function wallResolvedHeight(nodes: SceneNodes, wall: WallNode): number { 0, nodes as Record, ) - const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) - return resolveWallEffectiveHeight(wall, planeTop, support.elevation) + return ( + getWallTop(wall, levelId, nodes as Record, support.elevation) - + support.elevation + ) } export function wallSummary(nodes: SceneNodes, wall: WallNode) { @@ -47,6 +50,10 @@ export function wallSummary(nodes: SceneNodes, wall: WallNode) { resolvedHeight: wallResolvedHeight(nodes, wall), heightIsExplicit: wall.height !== undefined, thickness: wall.thickness, + wallFaceChains: { + a: resolveWallFaceChain(wall, 'a'), + b: resolveWallFaceChain(wall, 'b'), + }, openings: wall.children.flatMap((id) => { const child = nodes[id] if (child?.type !== 'door' && child?.type !== 'window') return [] @@ -74,6 +81,8 @@ const zoneSummary = (nodes: SceneNodes, zone: ZoneNode) => ({ color: zone.color, polygon: zone.polygon, holes: zone.holes ?? [], + wallMaterial: zone.wallMaterial ?? null, + wallOverrides: zone.wallOverrides ?? [], ...zoneSize(zone), floor_choices: roomFloorChoices(nodes, zone.id), }) diff --git a/packages/core/src/agent-operations/merge-windows.ts b/packages/core/src/agent-operations/merge-windows.ts new file mode 100644 index 0000000000..a0e452b11d --- /dev/null +++ b/packages/core/src/agent-operations/merge-windows.ts @@ -0,0 +1,33 @@ +import { mergeWindows } from '../building/merge-windows' +import { achievedChanges } from './achieved' +import type { AgentOperation, SceneChanges } from './types' + +const round = (value: number) => Math.round(value * 1000) / 1000 + +/** `merge_windows`: two windows that exist become one, a corner window or one wide window. */ +export const mergeWindowsOperation: AgentOperation<{ windowIds: [string, string] }> = ( + nodes, + input, +) => { + const merge = mergeWindows(nodes, input.windowIds[0], input.windowIds[1]) + const changes: SceneChanges = { + update: merge.changes.update.map(({ id, data }) => ({ id, data: data as never })), + delete: merge.changes.delete, + } + return { + changes, + result: { + ok: true, + kind: merge.kind === 'corner' ? 'corner' : 'same_wall', + windowIds: merge.windowIds, + removedIds: merge.removedIds, + wallIds: merge.wallIds, + sillHeight: round(merge.sillHeight), + height: round(merge.height), + ...(merge.width === undefined ? {} : { width: round(merge.width) }), + ...(merge.angle === undefined ? {} : { angle: merge.angle }), + message: merge.message, + achieved: achievedChanges(nodes, changes), + }, + } +} diff --git a/packages/core/src/agent-operations/native-ceiling-paint.test.ts b/packages/core/src/agent-operations/native-ceiling-paint.test.ts new file mode 100644 index 0000000000..389e16f4d8 --- /dev/null +++ b/packages/core/src/agent-operations/native-ceiling-paint.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from 'bun:test' +import { isAgentRefusal } from '../agent-tools/refusal' +import { type AnyNode, CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' +import { applySceneChanges } from './apply-changes' +import { paint } from './paint' + +// Independent oracle: Main's approved native-ceiling finish contract, derived +// before candidate inspection. The existing ceiling renderer consumes +// slots.surface. Erase parity was clarified by Main against the unchanged UI +// slot painter: buildPatch/commit remove only the slot, retaining raw legacy +// material fields (which can become visible again). Load-time migration is a +// different seam, not a reset-to-default guarantee of paint. No renderer, store, +// DB or provider is invoked here: only pure Core operations on an in-memory scene. +const requestedFinish = 'library:concrete-raw' +const previousFinish = 'library:preset-nearblack' +const context = { activeLevelId: 'level_finish' } + +function fixture() { + const ceiling = CeilingNode.parse({ + id: 'ceiling_finish', + parentId: context.activeLevelId, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + height: 2.65, + regions: [ + { + id: 'accent', + polygon: [ + [0, 0], + [1, 0], + [1, 1], + ], + finish: previousFinish, + }, + ], + slots: { surface: previousFinish, retained: requestedFinish }, + materialPreset: 'white', + material: { preset: 'custom', properties: { color: '#123456' } }, + metadata: { authoredBy: 'person' }, + }) + const wall = WallNode.parse({ + id: 'wall_finish', + parentId: context.activeLevelId, + start: [0, 0], + end: [4, 0], + slots: { a: previousFinish, b: previousFinish }, + }) + const slab = SlabNode.parse({ + id: 'slab_finish', + parentId: context.activeLevelId, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + slots: { surface: previousFinish }, + }) + const level = LevelNode.parse({ + id: context.activeLevelId, + children: [ceiling.id, wall.id, slab.id], + }) + const nodes: Record = { + [level.id]: level, + [ceiling.id]: ceiling, + [wall.id]: wall, + [slab.id]: slab, + } + return { nodes, ceiling, wall, slab } +} + +function withoutFinish(ceiling: CeilingNode) { + const { slots: _slots, material: _material, materialPreset: _preset, ...rest } = ceiling + return rest +} + +function freeze(value: T): T { + if (value && typeof value === 'object') { + Object.freeze(value) + for (const child of Object.values(value)) freeze(child) + } + return value +} + +describe('native ceiling paint writes the renderable surface, not metadata or custom geometry', () => { + test.each([ + undefined, + 'surface', + ])('default/explicit surface role %s preserves scene identity and other surfaces', (role) => { + const { nodes, ceiling, wall, slab } = fixture() + const before = structuredClone(nodes) + freeze(nodes) + const result = paint( + nodes, + { + targets: [ceiling.id], + material: requestedFinish, + ...(role ? { role } : {}), + }, + context, + ) + const after = applySceneChanges(nodes, result.changes) + const painted = CeilingNode.parse(after[ceiling.id]) + expect(result.result).toMatchObject({ + ok: true, + painted: [{ id: ceiling.id, roles: ['surface'] }], + }) + expect(painted.slots).toEqual({ surface: requestedFinish, retained: requestedFinish }) + expect(withoutFinish(painted)).toEqual(withoutFinish(ceiling)) + expect(Object.keys(after)).toEqual(Object.keys(nodes)) + expect(after[wall.id]).toEqual(before[wall.id]) + expect(after[slab.id]).toEqual(before[slab.id]) + expect(after[context.activeLevelId]).toEqual(before[context.activeLevelId]) + expect(nodes).toEqual(before) + }) + + test.each([ + 'slot-and-legacy', + 'legacy-only', + ] as const)('erase %s matches UI slot removal while preserving legacy fields and regions', (mode) => { + const { nodes, ceiling } = fixture() + if (mode === 'legacy-only') ceiling.slots = { retained: requestedFinish } + const before = structuredClone(nodes) + freeze(nodes) + const result = paint(nodes, { targets: [ceiling.id], erase: true }, context) + const after = applySceneChanges(nodes, result.changes) + const erased = CeilingNode.parse(after[ceiling.id]) + expect(erased.slots?.surface).toBeUndefined() + expect(erased.slots?.retained).toBe(requestedFinish) + expect(erased.material).toEqual(ceiling.material) + expect(erased.materialPreset).toBe(ceiling.materialPreset) + expect(withoutFinish(erased)).toEqual(withoutFinish(ceiling)) + expect(Object.keys(after)).toEqual(Object.keys(nodes)) + for (const [id, node] of Object.entries(before)) { + if (id !== ceiling.id) expect(after[id]).toEqual(node) + } + expect(nodes).toEqual(before) + }) + + test.each([ + { + name: 'missing later target', + targets: ['ceiling_finish', 'wall_retired'], + code: 'target_not_found', + target: 'wall_retired', + }, + { + name: 'later ceiling lacks a wall-face role', + targets: ['wall_finish', 'ceiling_finish'], + role: 'a', + code: 'unknown_role', + target: 'ceiling_finish', + }, + ])('$name refuses atomically, including earlier valid targets', ({ + targets, + role, + code, + target, + }) => { + const { nodes } = fixture() + const before = structuredClone(nodes) + freeze(nodes) + let refusal: unknown + try { + paint(nodes, { targets, material: requestedFinish, ...(role ? { role } : {}) }, context) + } catch (error) { + refusal = error + } + expect(isAgentRefusal(refusal)).toBe(true) + expect(refusal).toMatchObject({ code, details: { target } }) + expect(nodes).toEqual(before) + }) +}) diff --git a/packages/core/src/agent-operations/native-materials.test.ts b/packages/core/src/agent-operations/native-materials.test.ts new file mode 100644 index 0000000000..db994101e3 --- /dev/null +++ b/packages/core/src/agent-operations/native-materials.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { AGENT_TOOL_CONTRACTS } from '../agent-tools' +import { isAgentRefusal } from '../agent-tools/refusal' +import { slotPaintMaterial } from '../lib/slots' +import { ColumnNode, RoofNode, RoofSegmentNode, type SceneMaterial } from '../schema' +import { + EXPECTED_MOUNTAIN_MATERIAL, + expectPreserved, + MOUNTAIN_MATERIAL, + MOUNTAIN_NAME, + mountainPaint, + nativeMaterialScene, + nativePaintReadback, +} from './__fixtures__/native-materials' +import { applySceneChanges } from './apply-changes' +import { AGENT_OPERATIONS } from './index' +import type { AgentContext, SceneChanges, SceneNodes } from './types' + +// Contract v1.1 provenance is in the shared fixture. No paint or mutation oracle is mocked. +const contract = (name: string) => + z.object(AGENT_TOOL_CONTRACTS.find((tool) => tool.name === name)!.input) +function run( + name: 'paint' | 'run_batch', + nodes: SceneNodes, + input: unknown, + materials?: Readonly>, +) { + return AGENT_OPERATIONS[name]( + nodes, + contract(name).parse(input) as never, + { + activeLevelId: 'level_materials', + ...(materials === undefined ? {} : { materials }), + } as AgentContext, + ) +} +const registryAfter = ( + materials: Readonly>, + changes?: SceneChanges, +) => ({ + ...materials, + ...Object.fromEntries((changes?.materials ?? []).map((material) => [material.id, material])), +}) +function refusal(run: () => unknown) { + let thrown: unknown + try { + run() + } catch (error) { + thrown = error + } + expect(isAgentRefusal(thrown)).toBe(true) + return thrown as Error & { code: string } +} + +describe('native custom paint: contract v1.1', () => { + test('scripted column with no default shaft slot refuses a new dangling finish, but preserves a persisted-ref no-op', () => { + // Contract v1.3: legitimate script-manifest slots are not required to use "shaft". + const fixture = nativeMaterialScene() + const column = ColumnNode.parse({ + id: 'column_material_script', + parentId: fixture.level.id, + source: { + kind: 'script', + script: 'a'.repeat(64), + artifact: 'b'.repeat(64), + manifest: { + bounds: { min: [-0.2, 0, -0.2], max: [0.2, 3, 0.2] }, + triangles: 12, + slots: [ + { id: 'stone', color: '#dddddd' }, + { id: 'band', color: '#555555' }, + ], + }, + }, + }) + const nodes = { ...fixture.nodes, [column.id]: column } + const before = structuredClone(nodes) + const materials = {} + refusal(() => + run('paint', nodes, { targets: [column.id], material: MOUNTAIN_MATERIAL }, materials), + ) + expect(nodes).toEqual(before) + expect(materials).toEqual({}) + + const persisted: SceneMaterial = { + id: 'mat_persisted', + name: MOUNTAIN_NAME, + material: EXPECTED_MOUNTAIN_MATERIAL, + } + const painted = { + ...nodes, + [column.id]: { ...column, slots: { stone: 'scene:mat_persisted' } }, + } + const existing = { [persisted.id]: persisted } + const noOp = run( + 'paint', + painted, + { targets: [column.id], role: 'stone', material: 'scene:mat_persisted' }, + existing, + ) + expect(noOp.result).toMatchObject({ ok: true, finish: 'scene:mat_persisted' }) + expect(applySceneChanges(painted, noOp.changes ?? {})).toEqual(painted) + expect(registryAfter(existing, noOp.changes)).toEqual(existing) + expect(existing).toEqual({ [persisted.id]: persisted }) + expect(painted[column.id]!.slots).toEqual({ stone: 'scene:mat_persisted' }) + }) + + test('exact PBR datablock and exterior face use one native ref; interior/openings survive', () => { + const { nodes } = nativeMaterialScene() + const before = structuredClone(nodes) + const materials = {} + const outcome = run('paint', nodes, mountainPaint(), materials) + const after = applySceneChanges(nodes, outcome.changes!) + const registry = registryAfter(materials, outcome.changes) + expect(outcome.result).toMatchObject({ ok: true }) + expect(outcome.changes?.materials).toHaveLength(1) + const { ref, datablock } = nativePaintReadback(after, registry) + expect(datablock.name).toBe(MOUNTAIN_NAME) + expect(outcome.result.finish).toBe(ref) + expect(Object.keys(registry)).toHaveLength(1) + expectPreserved(before, after) + expect(nodes).toEqual(before) + expect(materials).toEqual({}) + // Native eyedropper resolution follows the actual scene ref to the original PBR. + expect(slotPaintMaterial(ref, registry)).toEqual({ + material: EXPECTED_MOUNTAIN_MATERIAL, + materialPreset: undefined, + }) + const reloaded = JSON.parse(JSON.stringify({ nodes: after, materials: registry })) + nativePaintReadback(reloaded.nodes, reloaded.materials) + expectPreserved(before, reloaded.nodes) + }) + + test('same schema deduplicates against existing registry, including reordered input keys', () => { + const { nodes } = nativeMaterialScene() + const first = run('paint', nodes, mountainPaint(), {}) + const after = applySceneChanges(nodes, first.changes!) + const materials = registryAfter({}, first.changes) + const firstRef = nativePaintReadback(after, materials).ref + const second = run( + 'paint', + after, + { + ...mountainPaint(['wall_material_east']), + material: { + properties: { metalness: 0, roughness: 0.86, color: '#71503a' }, + preset: 'custom', + }, + }, + materials, + ) + const final = applySceneChanges(after, second.changes!) + const finalMaterials = registryAfter(materials, second.changes) + expect(nativePaintReadback(final, finalMaterials, 'wall_material_east').ref).toBe(firstRef) + expect(Object.keys(finalMaterials)).toHaveLength(1) + }) + + test('existing texture is retained, and different texture inputs do not deduplicate', () => { + const { nodes } = nativeMaterialScene() + const texture = { url: '/material/wood/existing-timber.jpg', repeat: [2, 3], scale: 0.5 } + const outcome = run( + 'paint', + nodes, + { ...mountainPaint(), material: { ...MOUNTAIN_MATERIAL, texture } }, + {}, + ) + expect(outcome.changes?.materials).toHaveLength(1) + expect(outcome.changes!.materials![0]!.material).toEqual({ + ...EXPECTED_MOUNTAIN_MATERIAL, + texture, + }) + const materials = registryAfter({}, outcome.changes) + const second = run( + 'paint', + applySceneChanges(nodes, outcome.changes!), + { + ...mountainPaint(['wall_material_east']), + material: { ...MOUNTAIN_MATERIAL, texture: { ...texture, repeat: [1, 1] } }, + }, + materials, + ) + expect(Object.keys(registryAfter(materials, second.changes))).toHaveLength(2) + const serialized = JSON.parse(JSON.stringify(registryAfter(materials, second.changes))) + expect(serialized[outcome.changes!.materials![0]!.id].material.texture).toEqual(texture) + }) + + test('undefined registry is an unsupported host, not permission to author an orphan', () => { + const { nodes } = nativeMaterialScene() + const before = structuredClone(nodes) + const error = refusal(() => run('paint', nodes, mountainPaint())) + expect(`${error.code} ${error.message}`).toMatch(/support|registry|host/i) + expect(nodes).toEqual(before) + }) + + for (const [label, input] of [ + [ + 'dangling material id', + { targets: ['wall_material_south'], role: 'exterior', material: 'scene:mat_missing' }, + ], + ['missing later target', mountainPaint(['wall_material_south', 'wall_missing'])], + [ + 'invalid later role', + { ...mountainPaint(['wall_material_south', 'door_materials']), role: 'a' }, + ], + ] as const) { + test(`${label} refuses atomically without orphan registry data`, () => { + const { nodes } = nativeMaterialScene() + const materials = {} + const before = structuredClone(nodes) + refusal(() => run('paint', nodes, input, materials)) + expect(nodes).toEqual(before) + expect(materials).toEqual({}) + }) + } + + test('existing scene ref paints without remapping or minting another material', () => { + const { nodes } = nativeMaterialScene() + const first = run('paint', nodes, mountainPaint(), {}) + const materials = registryAfter({}, first.changes) + const ref = `scene:${Object.keys(materials)[0]}` + const second = run( + 'paint', + nodes, + { targets: ['wall_material_south'], role: 'exterior', material: ref }, + materials, + ) + expect( + nativePaintReadback( + applySceneChanges(nodes, second.changes!), + registryAfter(materials, second.changes), + ).ref, + ).toBe(ref) + expect(Object.keys(registryAfter(materials, second.changes))).toHaveLength(1) + }) + + test('material schema and name bounds validate before any operation', () => { + const input = contract('paint') + expect(input.safeParse({ ...mountainPaint(), materialName: 'M'.repeat(120) }).success).toBe( + true, + ) + for (const bad of [ + { ...mountainPaint(), materialName: 'M'.repeat(121) }, + { ...mountainPaint(), materialName: ' ' }, + { ...mountainPaint(), material: { properties: { roughness: 1.01 } } }, + { ...mountainPaint(), material: { properties: { metalness: -0.01 } } }, + { ...mountainPaint(), material: { properties: { roughness: '0.86' } } }, + { ...mountainPaint(), material: null }, + ]) + expect(input.safeParse(bad).success).toBe(false) + }) + + test('run_batch carries upserts and a later call sees the first material for dedupe', () => { + const { nodes } = nativeMaterialScene() + const outcome = run( + 'run_batch', + nodes, + { + calls: [ + { tool: 'paint', input: mountainPaint() }, + { tool: 'paint', input: mountainPaint(['wall_material_east']) }, + ], + }, + {}, + ) + expect(outcome.result).toMatchObject({ status: 'applied', applied: 2, refused: 0 }) + const materials = registryAfter({}, outcome.changes) + const after = applySceneChanges(nodes, outcome.changes!) + expect(Object.keys(materials)).toHaveLength(1) + expect(nativePaintReadback(after, materials).ref).toBe( + nativePaintReadback(after, materials, 'wall_material_east').ref, + ) + }) + + test('a batch on a host with no materials map refuses a native finish and applies nothing', () => { + const { nodes } = nativeMaterialScene() + const before = structuredClone(nodes) + const error = refusal(() => + run('run_batch', nodes, { + calls: [ + { tool: 'paint', input: mountainPaint() }, + { tool: 'paint', input: mountainPaint(['wall_material_east']) }, + ], + }), + ) + + expect(error.code).toBe('batch_call_refused') + expect(error.message).toContain('materials_unavailable') + expect(nodes).toEqual(before) + }) + + test('a refused later batch call leaks neither node edits nor created materials', () => { + const { nodes } = nativeMaterialScene() + const before = structuredClone(nodes) + const materials = {} + refusal(() => + run( + 'run_batch', + nodes, + { + calls: [ + { tool: 'paint', input: mountainPaint() }, + { tool: 'paint', input: mountainPaint(['wall_missing']) }, + ], + }, + materials, + ), + ) + expect(nodes).toEqual(before) + expect(materials).toEqual({}) + }) + + test('legacy catalog, nearest-color and erase remain available without registry support', () => { + const { nodes } = nativeMaterialScene() + const catalog = run('paint', nodes, { + targets: ['wall_material_south'], + role: 'b', + material: 'library:concrete-raw', + }) + expect(catalog.result.finish).toBe('library:concrete-raw') + expect(catalog.changes?.materials ?? []).toHaveLength(0) + const color = run('paint', nodes, { + targets: ['wall_material_south'], + role: 'b', + color: '#71503a', + }) + expect(color.result.finish).toMatch(/^library:/) + expect(color.result.note).toMatch(/nearest/i) + expect(color.changes?.materials ?? []).toHaveLength(0) + const painted = applySceneChanges(nodes, color.changes!) + const erased = run('paint', painted, { + targets: ['wall_material_south'], + role: 'b', + erase: true, + }) + const after = applySceneChanges(painted, erased.changes!) + expect(erased.result.finish).toBe('default') + const wall = after.wall_material_south + if (wall?.type !== 'wall') throw new Error('Paint lost the wall') + expect(wall.slots?.b).toBeUndefined() + expectPreserved(nodes, after) + expect(erased.changes?.materials ?? []).toHaveLength(0) + }) + + test("erasing a roof's finish takes the field off the node, as the store does", () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_erase', + parentId: 'roof_erase', + topMaterialPreset: 'library:concrete-raw', + }) + const roof = RoofNode.parse({ id: 'roof_erase', children: [segment.id] }) + const nodes: SceneNodes = { [roof.id]: roof, [segment.id]: segment } + const erased = run('paint', nodes, { targets: [segment.id], role: 'top', erase: true }) + + const after = applySceneChanges(nodes, erased.changes!)[segment.id]! + + expect('topMaterialPreset' in after).toBe(false) + expect(erased.result.finish).toBe('default') + }) +}) diff --git a/packages/core/src/agent-operations/operations.test.ts b/packages/core/src/agent-operations/operations.test.ts index 6f41211b7d..2cdb2fec61 100644 --- a/packages/core/src/agent-operations/operations.test.ts +++ b/packages/core/src/agent-operations/operations.test.ts @@ -2,9 +2,19 @@ import { describe, expect, test } from 'bun:test' import { z } from 'zod' import { AGENT_TOOL_CONTRACTS } from '../agent-tools' import { type AgentRefusal, isAgentRefusal } from '../agent-tools/refusal' +import { BuildingNode, SlabNode, WallNode } from '../index' import { reconcileSceneStructure } from '../lib/structure-reconcile' -import { type AnyNode, generateId, LevelNode, StairNode, StairSegmentNode } from '../schema' -import { AGENT_TOOL_CASES } from './__fixtures__/cases' +import { nodeRegistry, registerNode } from '../registry' +import { + type AnyNode, + BaseNode, + generateId, + LevelNode, + StairNode, + StairSegmentNode, +} from '../schema' +import { AGENT_TOOL_CASES, finiteReadbackProblems } from './__fixtures__/cases' +import { paintedKitchenScene } from './__fixtures__/room-cases' import { applyAgentOutcome } from './apply-outcome' import { AGENT_OPERATIONS, applySceneChanges } from './index' @@ -14,6 +24,9 @@ describe('agent operations', () => { if (c.surfaces && !c.surfaces.includes('core')) continue test(`${c.tool}: ${c.name}`, () => { const nodes = c.scene().nodes as Record + const readBefore = ['get_walls', 'get_zones', 'get_level_summary'].includes(c.tool) + ? structuredClone(nodes) + : undefined const operation = AGENT_OPERATIONS[c.tool as keyof typeof AGENT_OPERATIONS] expect(operation).toBeDefined() const context = { @@ -37,7 +50,13 @@ describe('agent operations', () => { } // Applied as both surfaces apply it: a scene without a store, derived by the pure reconciler. let after: Record = nodes - const result = applyAgentOutcome(operation(nodes, input as never, context), { + const outcome = operation(nodes, input as never, context) + if (readBefore) { + expect(outcome.changes).toBeUndefined() + expect(outcome.afterReconcile).toBeUndefined() + expect(nodes).toEqual(readBefore) + } + const result = applyAgentOutcome(outcome, { getNodes: () => after, applyChanges: (changes) => { after = applySceneChanges(after, changes) @@ -48,6 +67,11 @@ describe('agent operations', () => { }, }) expect(result).toMatchObject(c.expect.result) + if (readBefore) { + expect(after).toEqual(readBefore) + expect(finiteReadbackProblems(result)).toEqual([]) + expect(JSON.parse(JSON.stringify(result))).toMatchObject(c.expect.result) + } for (const id of c.expect.present ?? []) expect(after[id]).toBeDefined() for (const id of c.expect.absent ?? []) expect(after[id]).toBeUndefined() for (const [id, fields] of Object.entries(c.expect.after ?? {})) @@ -68,6 +92,49 @@ describe('agent operations', () => { } }) +test('room intent writes round-trip through readback without deleting local paint', () => { + let nodes = paintedKitchenScene().nodes as Record + const context = { activeLevelId: 'level_rooms' } + const expectedOverrides = [{ wallId: 'wall_north', face: 'a', finish: 'library:local-tile' }] + const transitions = [ + [{ wallMaterial: null }, null], + [{ wallMaterial: 'library:preset-white' }, 'library:preset-white'], + [{ name: 'Studio' }, 'library:preset-white'], + [{ wallMaterial: '' }, ''], + [{ wallMaterial: null }, null], + ] as const + for (const [patch, expected] of transitions) { + const contract = AGENT_TOOL_CONTRACTS.find((entry) => entry.name === 'set_zone_intent')! + const input = z.object(contract.input).parse({ zoneId: 'zone_kitchen', patch }) + applyAgentOutcome(AGENT_OPERATIONS.set_zone_intent(nodes, input as never, context), { + getNodes: () => nodes, + applyChanges: (changes) => { + nodes = applySceneChanges(nodes, changes) + }, + reconcile: () => { + nodes = reconcileSceneStructure({ nodes, mintId: (kind) => generateId(kind) }) + .nodes as Record + }, + }) + const before = structuredClone(nodes) + const outcome = AGENT_OPERATIONS.get_zones(nodes, { levelId: 'level_rooms' }, context) + const result = outcome.result as { zones: Record[] } + expect(result.zones.find((zone) => zone.id === 'zone_kitchen')).toMatchObject({ + wallMaterial: expected, + wallOverrides: expectedOverrides, + }) + expect(result.zones.find((zone) => zone.id === 'zone_pantry')).toMatchObject({ + wallMaterial: 'library:pantry-blue', + wallOverrides: [{ wallId: 'wall_south', face: 'a', finish: 'library:pantry-tile' }], + }) + expect(nodes.wall_north).toMatchObject({ + faceRegions: [{ id: 'wainscot', face: 'a', v1: 0.9, finish: 'library:wood' }], + }) + expect(outcome.changes).toBeUndefined() + expect(nodes).toEqual(before) + } +}) + test('stair design targets stay informational in scene verification', () => { const level = LevelNode.parse({}) const flight = StairSegmentNode.parse({ height: 3, length: 3, stepCount: 10 }) @@ -79,5 +146,141 @@ test('stair design targets stay informational in scene verification', () => { expect(verified.issues).toContainEqual( expect.objectContaining({ type: 'stair_riser_target', severity: 'info' }), ) - expect(verified.hasIssues).toBe(false) + // The circulation check also names a stair with no landing in this bare scene: what must hold is + // that the design targets are informational, never an issue that fails the scene. + expect( + verified.issues.filter((issue) => issue.type.endsWith('_target') && issue.severity !== 'info'), + ).toEqual([]) +}) + +test('scene verification names wall and covering slab contacts but not underside seating or holes', () => { + const polygon: [number, number][] = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] + const building = BuildingNode.parse({ + id: 'building_contact', + children: ['level_contact0', 'level_contact1'], + }) + const ground = LevelNode.parse({ + id: 'level_contact0', + parentId: building.id, + level: 0, + height: 3.6, + children: ['slab_contact0', 'wall_contact'], + }) + const upper = LevelNode.parse({ + id: 'level_contact1', + parentId: building.id, + level: 1, + height: 3.3, + children: ['slab_contact1'], + }) + const floor = SlabNode.parse({ + id: 'slab_contact0', + parentId: ground.id, + polygon, + elevation: 0.05, + thickness: 0.05, + }) + const cover = SlabNode.parse({ + id: 'slab_contact1', + parentId: upper.id, + polygon, + elevation: 0.05, + thickness: 0.05, + }) + const wall = WallNode.parse({ + id: 'wall_contact', + parentId: ground.id, + start: [1, 2], + end: [3, 2], + height: 3.61, + }) + const nodes = Object.fromEntries( + [building, ground, upper, floor, cover, wall].map((node) => [node.id, node]), + ) + const contacts = (scene: Record) => + AGENT_OPERATIONS.verify_scene(scene, {}, { activeLevelId: ground.id }).result.issues.filter( + (issue) => issue.type === 'wall_covering_slab_contact', + ) + expect(contacts(nodes)).toContainEqual( + expect.objectContaining({ + wallId: wall.id, + slabId: cover.id, + wallTop: expect.closeTo(3.66, 6), + slabUnderside: expect.closeTo(3.6, 6), + slabTop: expect.closeTo(3.65, 6), + coplanar: false, + }), + ) + const coplanar = { ...wall, height: 3.6 - 0.0000001, supportSlabId: floor.id } + const lowerStorey = { ...ground, height: 3.5 } + const loweredCover = { ...cover, elevation: 0.15 } + expect( + contacts({ + ...nodes, + [ground.id]: lowerStorey, + [cover.id]: loweredCover, + [wall.id]: coplanar, + }), + ).toContainEqual(expect.objectContaining({ wallId: wall.id, slabId: cover.id, coplanar: true })) + expect(contacts({ ...nodes, [wall.id]: { ...wall, height: 3.6 } })).toEqual([]) + expect(contacts({ ...nodes, [cover.id]: { ...cover, recessed: true } })).toEqual([]) + expect( + contacts({ + ...nodes, + [cover.id]: { + ...cover, + holes: [ + [ + [0.5, 1], + [3.5, 1], + [3.5, 3], + [0.5, 3], + ], + ], + }, + }), + ).toEqual([]) + expect(contacts({ ...nodes, [wall.id]: { ...wall, start: [6, 0], end: [6, 4] } })).toEqual([]) +}) + +test('scene verification validates registered plugin kinds without accepting malformed or unknown nodes', () => { + const restore = nodeRegistry._snapshot() + const schema = BaseNode.extend({ + type: z.literal('diagnostic-pool').default('diagnostic-pool'), + depth: z.number().positive(), + }) + try { + registerNode({ + kind: 'diagnostic-pool', + schemaVersion: 1, + schema, + category: 'utility', + defaults: () => schema.parse({ id: 'pool_test', depth: 1.5 }), + capabilities: {}, + }) + const pool = schema.parse({ id: 'pool_test', depth: 1.5 }) + const verify = (node: unknown) => + AGENT_OPERATIONS.verify_scene( + { [pool.id]: node } as Record, + {}, + { activeLevelId: null }, + ).result + expect(verify(pool)).toMatchObject({ valid: true, issues: [], hasIssues: false }) + for (const node of [ + { ...pool, depth: -1 }, + { ...pool, type: 'unknown-pool' }, + { ...pool, type: 'wall' }, + ]) { + const result = verify(node) + expect(result.valid).toBe(false) + expect(result.issues).toContainEqual(expect.objectContaining({ type: 'schema_invalid' })) + } + } finally { + restore() + } }) diff --git a/packages/core/src/agent-operations/paint-achieved.test.ts b/packages/core/src/agent-operations/paint-achieved.test.ts new file mode 100644 index 0000000000..2450074d4a --- /dev/null +++ b/packages/core/src/agent-operations/paint-achieved.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, CeilingNode, LevelNode, WallNode } from '../schema' +import { achievedChanges } from './achieved' +import { applySceneChanges } from './apply-changes' +import { paint } from './paint' + +const finish = 'library:concrete-raw' +const context = { activeLevelId: 'level_paint_delta' } + +function fixture() { + const makeCeiling = (id: string, surface?: string) => + CeilingNode.parse({ + id, + parentId: context.activeLevelId, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + height: 2.65, + regions: [ + { + id: 'accent', + polygon: [ + [0, 0], + [1, 0], + [1, 1], + ], + finish: 'library:preset-nearblack', + }, + ], + slots: { retained: 'library:preset-nearblack', ...(surface ? { surface } : {}) }, + materialPreset: 'white', + material: { preset: 'custom', properties: { color: '#123456' } }, + metadata: { authoredBy: 'person' }, + }) + const same = makeCeiling('ceiling_same', finish) + const fresh = makeCeiling('ceiling_fresh') + const wall = WallNode.parse({ + id: 'wall_paint_delta', + parentId: context.activeLevelId, + start: [0, 0], + end: [4, 0], + }) + const level = LevelNode.parse({ + id: context.activeLevelId, + children: [same.id, fresh.id, wall.id], + }) + const nodes: Record = Object.fromEntries( + [level, same, fresh, wall].map((node) => [node.id, node]), + ) + return { nodes, same, fresh } +} + +function freeze(value: T): T { + if (value && typeof value === 'object') { + Object.freeze(value) + for (const child of Object.values(value)) freeze(child) + } + return value +} + +describe('paint reports only real authored finish changes', () => { + test('identical finish is an unchanged operation', () => { + const { nodes, same } = fixture() + const before = structuredClone(nodes) + freeze(nodes) + const outcome = paint(nodes, { targets: [same.id], material: finish }, context) + expect(outcome.result).toMatchObject({ ok: true }) + expect(achievedChanges(nodes, outcome.changes!)).toEqual({ + created: {}, + updated: 0, + deleted: {}, + unchanged: true, + }) + expect(outcome.changes?.update ?? []).toEqual([]) + const after = applySceneChanges(nodes, outcome.changes) + expect(after).toEqual(before) + for (const [id, node] of Object.entries(nodes)) expect(after[id]).toBe(node) + expect(nodes).toEqual(before) + }) + + test('mixed identical and first paints count only the changed target', () => { + const { nodes, same, fresh } = fixture() + const before = structuredClone(nodes) + freeze(nodes) + const outcome = paint(nodes, { targets: [same.id, fresh.id], material: finish }, context) + expect(achievedChanges(nodes, outcome.changes!)).toEqual({ + created: {}, + updated: 1, + deleted: {}, + }) + expect(outcome.changes?.update?.map(({ id }) => id)).toEqual([fresh.id]) + const after = applySceneChanges(nodes, outcome.changes) + expect(after[fresh.id]).toEqual({ + ...before[fresh.id], + slots: { ...fresh.slots, surface: finish }, + }) + expect(Object.keys(after)).toEqual(Object.keys(nodes)) + for (const [id, node] of Object.entries(nodes)) { + if (id !== fresh.id) expect(after[id]).toBe(node) + } + expect(nodes).toEqual(before) + }) + + test('first paint remains positive and preserves other authored fields', () => { + const { nodes, fresh } = fixture() + const before = structuredClone(nodes) + freeze(nodes) + const outcome = paint(nodes, { targets: [fresh.id], material: finish }, context) + expect(achievedChanges(nodes, outcome.changes!)).toEqual({ + created: {}, + updated: 1, + deleted: {}, + }) + const after = applySceneChanges(nodes, outcome.changes) + expect(after[fresh.id]).toEqual({ + ...before[fresh.id], + slots: { ...fresh.slots, surface: finish }, + }) + expect(Object.keys(after)).toEqual(Object.keys(nodes)) + for (const [id, node] of Object.entries(nodes)) { + if (id !== fresh.id) expect(after[id]).toBe(node) + } + expect(nodes).toEqual(before) + }) +}) diff --git a/packages/core/src/agent-operations/photo-elevation.ts b/packages/core/src/agent-operations/photo-elevation.ts new file mode 100644 index 0000000000..fc2d0de4a3 --- /dev/null +++ b/packages/core/src/agent-operations/photo-elevation.ts @@ -0,0 +1,1113 @@ +import { refuse } from '../agent-tools/refusal' +import { + applyHomography, + type CameraFace, + type CameraFailure, + cameraFaceHomography, + type FaceAxes, + faceAtWidth, + faceCoordinates, + faceThroughCorner, + type Homography, + invertHomography, + lineIntersection, + multiplyHomography, + type PhotoCamera, + pointOnFace, + solveHomography, + solvePhotoCamera, + type Vec3, + vanishingPoint, + vanishingResidualPx, +} from '../building/photo-elevation' +import type { AnyNode, AnyNodeId, WallNode } from '../schema' +import { getLevelElevations, type LevelElevation } from '../services/storey' +import type { SceneNodes } from './types' + +type Pt = [number, number] +type Segment = [Pt, Pt] + +export type PhotoFaceInput = { + name?: string + /** The walls of the face on one storey, end to end: their extent is the width. */ + wallIds: string[] + /** + * Two points on each vertical edge of the face as seen, as fractions of the image. A face whose + * far edge leaves the photo gives only the corner it shares with another face of the call. + */ + edges: { left?: Pt[]; right?: Pt[] } + /** + * The same feature on at least two storeys, two points along each, with its storey, and the + * feature's height above that floor when known (window heads 2.1 m): it sets the camera's height. + */ + rows: { levelId: string; points: Pt[]; height?: number }[] + /** More horizontal lines on the face, two points each: they steady its vanishing point. */ + lines?: Pt[][] + /** + * Points at known places on the face (window and door corners, eave ends): metres along it from + * its left end as seen, metres up from a floor (the face's, by default), and where the photo + * shows them. Each one steadies the pose. + */ + points?: { along: number; height: number; levelId?: string; point: Pt }[] +} + +export type PhotoElevationInput = { + faces: PhotoFaceInput[] + /** + * The camera's focal length on a 36 × 24 mm frame, when known: a frontal photo does not show + * it. A phone's main camera is about 26 mm. + */ + focal35mm?: number + /** More vertical lines anywhere on the building, two points each: they steady the camera's tilt. */ + verticals?: Pt[][] +} + +export type PhotoFacePlan = { + name: string + widthMeters: number + /** + * Metres along the face from its left end at the drawing's left and right sides: 0 and the + * width, unless the face runs out of the photo. + */ + leftMeters: number + rightMeters: number + /** Metres above the lowest row's feature at the top and bottom of the drawing. */ + topMeters: number + bottomMeters: number + pxPerMetre: number + outWidth: number + outHeight: number + /** Drawing pixel to photo pixel. */ + toSource: Homography + /** Photo pixels per metre up each side of the drawing: few, and panes blur. */ + sourcePxPerMetre: { left: number; right: number } + /** Root mean square of the picked points against the fit, in photo pixels. */ + residualPx: number + /** The picked feature on every storey the drawing shows, in drawing pixels. */ + featureLines: { levelId: string; name: string; y: number }[] + /** Where each row meets each edge in the photo, for a close-up to check. */ + corners: { + edge: 'left' | 'right' + levelId: string + point: Pt + rowLine: [Pt, Pt] + edgeLine: [Pt, Pt] + }[] + /** + * Floor to floor between the rows' storeys, as the photo measures it and as the scene has it. + * Only with the camera: without it, the storeys are the drawing's vertical scale. + */ + storeyHeight?: { photo: number; scene: number } + warnings: string[] +} + +/** The camera several faces of the photo share. */ +export type PhotoCameraSummary = { + focalPx: number + /** The focal length on a 36 × 24 mm frame: a phone's main camera is about 24–28 mm. */ + focal35mm: number + horizontalFovDeg: number + /** + * Where the optical axis meets the photo, down from its top, in photo pixels: about half the + * photo's height for a phone photo as taken, near the horizon for a perspective-corrected one. + */ + principalPointY: number + /** The optical axis above the horizon (up is positive), and how far the photo is turned. */ + pitchDeg: number + rollDeg: number + /** Camera from world, row-major: world x along the first face, y up, z out of it. */ + rotation: number[] + /** The camera in the scene, for view_scene; null when the faces leave its side unknown. */ + pose: PhotoPose | null + assumptions: string + warnings: string[] +} + +/** Where the photo was taken from, in the scene's coordinates (y up), as view_scene takes it. */ +export type PhotoPose = { + projection: 'perspective' + position: Vec3 + target: Vec3 + /** The camera's up, which carries the photo's roll. */ + up: Vec3 + /** Vertical field of view, degrees. */ + fov: number + /** The photo's width over its height. */ + aspect: number + /** How far below the photo's middle its optical axis meets it, as a share of its height. */ + shift: number + /** What set the camera's height: the rows' feature height, or the edges' foot taken for the ground. */ + anchoredBy: 'row height' | 'edge base' + /** The focal length was taken (focal35mm, else a phone's), not read from the photo. */ + focalAssumed: boolean + /** The picked edges against the walls' ends projected through the pose, in photo pixels. */ + edgeResidualPx: number + /** How far the eye moves, in metres, when the picks move by their own error. */ + spread?: number +} + +/** Longest side of a drawing, and the finest scale worth drawing a facade at. */ +const MAX_SIDE = 1200 +const MAX_PX_PER_METRE = 60 +/** Below this, windows of a photo carry too few pixels for panes. */ +const FEW_PX_PER_METRE = 25 +/** A floor-to-floor height under this is a modelling default, not a building's: it squashes the drawing. */ +const LOW_STOREY = 2.6 +/** Faces whose walls are this close to square are taken to meet at a right angle. */ +const SQUARE = 0.1 +/** How far apart, as a share of the photo's longer side, two picks of one corner may be. */ +const SAME_CORNER = 0.03 +/** Storeys the photo measures this much off the scene's are flagged. */ +const STOREY_MISMATCH = 0.1 +/** Two faces putting their shared corner this much apart in depth disagree. */ +const CORNER_MISMATCH = 0.05 + +const ASSUMPTIONS = + "Square pixels, no skew, no lens distortion, plumb walls, and faces whose walls meet square meet square in the photo. The principal point is taken at the photo's centre across (a photo cropped sideways breaks it); its height is fitted from the lines, since a perspective-corrected photo keeps its verticals parallel by moving it toward the horizon." + +const inside = ([x, y]: Pt) => x >= 0 && x <= 1 && y >= 0 && y <= 1 +const mean = (values: readonly number[]) => values.reduce((sum, v) => sum + v, 0) / values.length + +function faceWalls(nodes: SceneNodes, wallIds: readonly string[]) { + const walls = wallIds.map((id) => { + const wall = nodes[id] + if (!wall) refuse('wall_not_found', `Wall not found: ${id}.`, { wallId: id }) + if (wall.type !== 'wall') + refuse('not_a_wall', `Node ${id} is a ${wall.type}, not a wall.`, { wallId: id }) + return wall as WallNode + }) + const [first] = walls + if (!first || walls.some((wall) => wall.parentId !== first.parentId)) + refuse('walls_not_one_face', 'Pass the walls of one face on one storey.', { wallIds }) + const length = Math.hypot(first!.end[0] - first!.start[0], first!.end[1] - first!.start[1]) + const axis: Pt = [ + (first!.end[0] - first!.start[0]) / length, + (first!.end[1] - first!.start[1]) / length, + ] + const origin = first!.start as Pt + const points = walls.flatMap((wall) => [wall.start, wall.end] as Pt[]) + const off = ([x, z]: Pt) => Math.abs(-(x - origin[0]) * axis[1] + (z - origin[1]) * axis[0]) + if (points.some((point) => off(point) > 0.15)) + refuse( + 'walls_not_one_face', + 'These walls are not one straight face: pass the walls along one side only, end to end.', + { wallIds }, + ) + const along = points.map(([x, z]) => (x - origin[0]) * axis[0] + (z - origin[1]) * axis[1]) + const ends: [Pt, Pt] = [ + points[along.indexOf(Math.min(...along))]!, + points[along.indexOf(Math.max(...along))]!, + ] + return { width: Math.max(...along) - Math.min(...along), axis, ends, levelId: first!.parentId! } +} + +/** A face as picked, in photo pixels, its rows lowest storey first. */ +type Face = { + name: string + width: number + axis: Pt + /** The walls' two ends in plan, along `axis` first to last, and their storey. */ + ends: [Pt, Pt] + levelId: string + left?: Segment + right?: Segment + /** Points at known places on the face: (along, height in the scene) and their pixel. */ + points: { from: Pt; to: Pt }[] + /** `y`: the row's height in the scene, its floor plus its height when given. */ + rows: { levelId: string; points: Segment; baseY: number; height?: number; y: number }[] + lines: Segment[] +} + +type Elevations = Map + +function readFace( + nodes: SceneNodes, + elevations: Elevations, + face: PhotoFaceInput, + index: number, + px: (point: Pt) => Pt, +): Face { + const name = face.name ?? `Face ${index + 1}` + const picked = [ + ...(face.edges.left ?? []), + ...(face.edges.right ?? []), + ...face.rows.flatMap((r) => r.points), + ...(face.lines ?? []).flat(), + ] + if (picked.some((point) => !inside(point))) + refuse( + 'point_outside_image', + `${name}: every point is a fraction of the image, 0 to 1 across and down.`, + { face: name }, + ) + if (!face.edges.left && !face.edges.right) + refuse( + 'face_needs_an_edge', + `${name}: pick at least one of its vertical edges; a face whose far edge leaves the photo gives the corner it shares with another face.`, + { face: name }, + ) + const { width, axis, ends, levelId } = faceWalls(nodes, face.wallIds) + const rows = face.rows.map((row) => { + const level = elevations.get(row.levelId) + if (!level || nodes[row.levelId]?.type !== 'level') + refuse('level_not_found', `Level not found: ${row.levelId}.`, { levelId: row.levelId }) + return { + levelId: row.levelId, + points: segment(row.points, px), + baseY: level!.baseY, + ...(row.height !== undefined ? { height: row.height } : {}), + y: level!.baseY + (row.height ?? 0), + } + }) + // Lowest first, whatever order the rows came in: the close-ups show the extremes. + rows.sort((a, b) => a.y - b.y) + if (new Set(rows.map((row) => Math.round(row.y * 1000))).size < 2) + refuse( + 'rows_one_storey', + `${name}: pick the same feature on at least two storeys, or two lines at heights you know on one (the floor at 0 and the eaves at the wall's height, say): the drawing needs a height to scale by.`, + { face: name }, + ) + return { + name, + width, + axis, + ends, + levelId, + ...(face.edges.left ? { left: segment(face.edges.left, px) } : {}), + ...(face.edges.right ? { right: segment(face.edges.right, px) } : {}), + rows, + points: (face.points ?? []).map((point) => { + if (!inside(point.point)) + refuse( + 'point_outside_image', + `${name}: every point is a fraction of the image, 0 to 1 across and down.`, + { face: name }, + ) + const floor = elevations.get(point.levelId ?? levelId) + if (!floor) + refuse('level_not_found', `Level not found: ${point.levelId}.`, { levelId: point.levelId }) + return { from: [point.along, floor!.baseY + point.height] as Pt, to: px(point.point) } + }), + lines: (face.lines ?? []).map((line) => segment(line, px)), + } +} + +const segment = (points: readonly Pt[], px: (point: Pt) => Pt): Segment => [ + px(points[0]!), + px(points[1]!), +] + +/** Where each row meets each picked edge, lowest row first. */ +function cornersOf(face: Face) { + const corners: PhotoFacePlan['corners'] = [] + for (const row of face.rows) + for (const [edge, edgeLine] of [ + ['left', face.left], + ['right', face.right], + ] as const) { + if (!edgeLine) continue + const point = lineIntersection(row.points, edgeLine) + if (!point) + refuse('face_degenerate', `${face.name}: a row runs parallel to an edge.`, { + face: face.name, + }) + corners.push({ edge, levelId: row.levelId, point: point!, rowLine: row.points, edgeLine }) + } + return corners +} + +/** The scene's storeys in the drawing: the picked feature on each, from the lowest row's. */ +function featureLinesOf( + nodes: SceneNodes, + elevations: Elevations, + face: Face, + extent: { bottom: number; top: number; pxPerMetre: number }, +) { + const lowest = face.rows[0]!.baseY + const buildingId = elevations.get(face.rows[0]!.levelId)?.buildingId ?? null + return [...elevations.entries()] + .filter(([, level]) => level.buildingId === buildingId) + .map(([levelId, level]) => ({ levelId, y: level.baseY - lowest, level })) + .filter(({ y }) => y >= extent.bottom && y <= extent.top) + .sort((a, b) => a.level.ordinal - b.level.ordinal) + .map(({ levelId, y }) => ({ + levelId, + name: (nodes[levelId] as { name?: string } | undefined)?.name ?? levelId, + y: (extent.top - y) * extent.pxPerMetre, + })) +} + +/** The rows' storeys: how many lie between the lowest and the highest, and their mean height. */ +function sceneStorey(elevations: Elevations, face: Face) { + const ordinals = face.rows.map((row) => elevations.get(row.levelId)!.ordinal) + const storeys = Math.max(...ordinals) - Math.min(...ordinals) + return storeys > 0 + ? { storeys, height: (face.rows.at(-1)!.baseY - face.rows[0]!.baseY) / storeys } + : null +} + +function commonWarnings(residualPx: number, sourcePxPerMetre: { left: number; right: number }) { + const fewest = Math.min(sourcePxPerMetre.left, sourcePxPerMetre.right) + return [ + ...(residualPx > 3 + ? [ + `The picked points disagree by ${residualPx.toFixed(1)} px: check the close-ups and pick again.`, + ] + : []), + ...(fewest < FEW_PX_PER_METRE + ? [ + `Only ${fewest.toFixed(0)} photo pixels per metre at one edge: panes and thin panels may not be readable there.`, + ] + : []), + ] +} + +/** + * A face from its own picks alone: its homography from the rows × edges correspondences, its + * heights from the storeys. The way for one face, and for several when no camera fits them. + */ +function planFromOwnPicks( + nodes: SceneNodes, + elevations: Elevations, + face: Face, +): { plan: PhotoFacePlan; toPhoto: Homography; bottom: number; top: number } { + const { name, width, rows } = face + const lowest = rows[0]!.y + const left = face.left! + const right = face.right! + const corners = cornersOf(face) + // Each row against each edge: face metres from the left end and the lowest row, to pixels. + const pairs = rows.flatMap((row) => + ( + [ + [0, left], + [width, right], + ] as const + ).map(([x, edge]) => ({ + from: [x, row.y - lowest] as Pt, + to: lineIntersection(row.points, edge)!, + })), + ) + pairs.push( + ...face.points.map(({ from, to }) => ({ from: [from[0], from[1] - lowest] as Pt, to })), + ) + const toPhoto = solveHomography(pairs) + const fromPhoto = toPhoto && invertHomography(toPhoto) + if (!toPhoto || !fromPhoto) + refuse('face_degenerate', `${name}: the edges and rows do not span a face.`, { face: name }) + + const residualPx = Math.sqrt( + pairs.reduce((sum, { from, to }) => { + const [u, v] = applyHomography(toPhoto!, from)! + return sum + (u - to[0]) ** 2 + (v - to[1]) ** 2 + }, 0) / pairs.length, + ) + const heights = [...left, ...right].map((point) => applyHomography(fromPhoto!, point)?.[1] ?? 0) + const bottom = Math.max(Math.min(...heights), -30) + const top = Math.min(Math.max(...heights), bottom + 60) + if (!(width > 0.5) || !(top - bottom > 0.5)) + refuse('face_degenerate', `${name}: the face is too small to draw.`, { face: name }) + const pxPerMetre = Math.min(MAX_PX_PER_METRE, MAX_SIDE / Math.max(width, top - bottom)) + // Drawing pixel (u, v) is the face point (u / s, top − v / s). + const toFace: Homography = [1 / pxPerMetre, 0, 0, 0, -1 / pxPerMetre, top, 0, 0, 1] + + const density = (edge: 'left' | 'right') => { + const on = corners.filter((corner) => corner.edge === edge) + const byHeight = on.map((corner, i) => ({ corner, y: rows[i]!.y })).sort((a, b) => a.y - b.y) + const [low, high] = [byHeight[0]!, byHeight.at(-1)!] + const metres = high.y - low.y + return metres > 0 + ? Math.hypot( + high.corner.point[0] - low.corner.point[0], + high.corner.point[1] - low.corner.point[1], + ) / metres + : 0 + } + const sourcePxPerMetre = { left: density('left'), right: density('right') } + + // The rows' storeys are the vertical scale: their mean floor-to-floor height, lowest to highest. + const storey = sceneStorey(elevations, face)?.height ?? null + const warnings = [ + ...(storey !== null && storey < LOW_STOREY + ? [ + `The storeys here average ${storey.toFixed(2)} m floor to floor, and they set this drawing's vertical scale. A residential storey is usually 2.8–3.2 m: if the photo's windows fill most of a storey, raise the storeys first, then straighten again.`, + ] + : []), + ...commonWarnings(residualPx, sourcePxPerMetre), + ] + + const plan: PhotoFacePlan = { + name, + widthMeters: width, + leftMeters: 0, + rightMeters: width, + topMeters: top, + bottomMeters: bottom, + pxPerMetre, + outWidth: Math.round(width * pxPerMetre), + outHeight: Math.round((top - bottom) * pxPerMetre), + toSource: multiplyHomography(toPhoto!, toFace), + sourcePxPerMetre, + residualPx, + featureLines: featureLinesOf(nodes, elevations, face, { bottom, top, pxPerMetre }), + corners, + warnings, + } + return { plan, toPhoto: toPhoto!, bottom, top } +} + +const FAILURES: Record = { + needs_second_direction: { + code: 'camera_needs_second_direction', + message: + 'One face gives the camera one horizontal direction, and it needs a second: the other face of the corner in the same call, or vertical lines that converge in the photo.', + }, + at_infinity: { + code: 'vanishing_point_at_infinity', + message: + "The picked lines run parallel in the photo, so their vanishing points are at infinity and do not fix the camera's focal length: pick each face's rows on storeys far apart, on faces seen at an angle.", + }, + unsolved: { + code: 'camera_unsolved', + message: + 'These lines fit no camera with square pixels and its principal point centred across the photo: a photo cropped sideways, a corner that is not square, or a line picked off.', + }, +} + +/** Mean distance, in photo pixels, of a segment's ends from the line through another. */ +function offLine(points: Segment, line: Segment) { + const [[x1, y1], [x2, y2]] = line + const length = Math.hypot(x2 - x1, y2 - y1) || 1 + return mean( + points.map(([x, y]) => Math.abs((x2 - x1) * (y1 - y) - (x1 - x) * (y2 - y1)) / length), + ) +} + +function solveCamera( + faces: Face[], + verticals: Segment[], + image: { width: number; height: number }, +) { + const edges = faces.flatMap((face) => [face.left, face.right].filter((edge) => !!edge)) + const vertical = vanishingPoint([...edges, ...verticals], image) + const vanishing = faces.map((face) => { + const point = vanishingPoint([...face.rows.map((row) => row.points), ...face.lines], image) + if (!point) + refuse('face_degenerate', `${face.name}: its rows lie on one line.`, { face: face.name }) + return point! + }) + const perpendicular: [number, number][] = [] + for (let i = 0; i < faces.length; i++) + for (let j = i + 1; j < faces.length; j++) { + const [a, b] = [faces[i]!.axis, faces[j]!.axis] + if (Math.abs(a[0] * b[0] + a[1] * b[1]) < SQUARE) perpendicular.push([i, j]) + } + const solved = solvePhotoCamera({ + photo: image, + vertical, + faces: faces.map((face, index) => ({ + vanishing: vanishing[index]!, + probe: face.rows[0]!.points[0], + })), + perpendicular, + }) + return 'failure' in solved ? FAILURES[solved.failure] : solved +} + +function cameraSummary( + camera: PhotoCamera, + first: FaceAxes, + focalSpread: number, +): Omit { + const degrees = (radians: number) => (radians * 180) / Math.PI + const columns = [first.along, first.up, first.normal] + return { + focalPx: camera.focal, + focal35mm: (camera.focal * Math.hypot(36, 24)) / Math.hypot(camera.width, camera.height), + horizontalFovDeg: degrees(2 * Math.atan(camera.width / 2 / camera.focal)), + principalPointY: camera.centre[1], + pitchDeg: degrees(Math.asin(camera.up[2])), + rollDeg: degrees(Math.atan2(camera.up[0], -camera.up[1])), + rotation: [0, 1, 2].flatMap((row) => columns.map((column) => column[row]!)), + assumptions: ASSUMPTIONS, + warnings: + focalSpread > 1.1 + ? [ + `The picked lines disagree on the camera by ${((focalSpread - 1) * 100).toFixed(0)}%: pick each line longer, far apart from the others, and check the faces meet square.`, + ] + : [], + } +} + +/** + * Each face from the shared camera: its plane from its walls' width between its two edges, or, + * when its far edge leaves the photo, through the corner it shares with such a face. The drawing + * is metric both ways, so the rows' storeys are measured rather than assumed. + */ +function planWithCamera( + nodes: SceneNodes, + elevations: Elevations, + faces: Face[], + camera: PhotoCamera, + axes: FaceAxes[], + anchors: FaceAnchor[] = [], +): PhotoFacePlan[] { + const tolerance = SAME_CORNER * Math.max(camera.width, camera.height) + const planes: (CameraFace | null)[] = faces.map(() => null) + const cornerWarnings: string[][] = faces.map(() => []) + for (const [index, face] of faces.entries()) { + if (!face.left || !face.right) continue + const plane = faceAtWidth( + camera, + axes[index]!, + { left: face.left, right: face.right }, + face.width, + ) + if (!plane) + refuse( + 'face_degenerate', + `${face.name}: its edges and the camera disagree: is its left edge the photo's left?`, + { face: face.name }, + ) + planes[index] = plane + // Two faces sharing a corner should put it at one depth: wrong walls or a stray edge do not. + for (const [other, otherFace] of faces.entries()) { + const otherPlane = planes[other] + if (other === index || !otherPlane) continue + for (const edge of [face.left, face.right]) + for (const otherEdge of [otherFace.left!, otherFace.right!]) { + if (offLine(edge, otherEdge) > tolerance) continue + const ratio = mean( + edge.map((pixel) => { + const here = pointOnFace(camera, plane!, pixel) + const there = pointOnFace(camera, otherPlane, pixel) + return here && there ? Math.hypot(...here) / Math.hypot(...there) : 1 + }), + ) + if (Math.abs(ratio - 1) > CORNER_MISMATCH) + cornerWarnings[index]!.push( + `Its corner with ${otherFace.name} comes out ${(Math.abs(ratio - 1) * 100).toFixed(0)}% ${ratio > 1 ? 'farther' : 'nearer'} than ${otherFace.name} puts it: the walls of one of the two faces, or one of their edges, is off.`, + ) + } + } + } + for (const [index, face] of faces.entries()) { + if (planes[index]) continue + const corner = (face.left ?? face.right)! + let partner: { plane: CameraFace; distance: number } | null = null + for (const [other, otherFace] of faces.entries()) { + const plane = otherFace.left && otherFace.right ? planes[other] : null + if (!plane) continue + for (const edge of [otherFace.left!, otherFace.right!]) { + const distance = offLine(corner, edge) + if (distance <= tolerance && (!partner || distance < partner.distance)) + partner = { plane, distance } + } + } + if (!partner) + refuse( + 'corner_not_shared', + `${face.name} shows one edge: it is placed from the corner it shares with a face of this call whose two edges show. Pick that corner the same on both faces.`, + { face: face.name }, + ) + const plane = faceThroughCorner(camera, axes[index]!, partner!.plane, corner) + if (!plane) + refuse('face_degenerate', `${face.name}: its corner is behind the camera.`, { + face: face.name, + }) + planes[index] = plane + } + + return faces.map((face, index) => { + const plane = planes[index]! + const { name, width, rows } = face + const at = (pixel: Pt) => { + const point = faceCoordinates(camera, plane, pixel) + if (!point) + refuse('face_degenerate', `${name}: a picked point falls behind the camera.`, { + face: name, + }) + return point! + } + const rowHeights = rows.map((row) => mean(row.points.map((pixel) => at(pixel)[1]))) + const datum = rowHeights[0]! + const across = (edge: Segment) => mean(edge.map((pixel) => at(pixel)[0])) + const origin: Pt = [face.left ? across(face.left) : across(face.right!) - width, datum] + const toPhoto = cameraFaceHomography(camera, plane, origin) + anchors[index] = { + plane, + origin, + bottom: Math.min( + ...[face.left, face.right] + .filter((edge) => !!edge) + .flat() + .map((pixel) => at(pixel)[1] - datum), + ), + } + + const edges = [face.left, face.right].filter((edge) => !!edge) + const heights = edges.flat().map((pixel) => at(pixel)[1] - datum) + const bottom = Math.max(Math.min(...heights), -30) + const top = Math.min(Math.max(...heights), bottom + 60) + + // A face with one edge is drawn from that corner to where it leaves the photo. + let [left, right] = [0, width] + if (!face.left || !face.right) { + const seen = (x: number, z: number) => { + const w = toPhoto[6]! * x + toPhoto[7]! * z + toPhoto[8]! + if (!(w > 0)) return false + const u = (toPhoto[0]! * x + toPhoto[1]! * z + toPhoto[2]!) / w + const v = (toPhoto[3]! * x + toPhoto[4]! * z + toPhoto[5]!) / w + return u >= 0 && u <= camera.width && v >= 0 && v <= camera.height + } + const [cornerX, farX] = face.left ? [0, width] : [width, 0] + let reach = cornerX + for (const z of [bottom, top, (bottom + top) / 2, ...rowHeights.map((h) => h - datum)]) { + if (!seen(cornerX, z)) continue + let [shown, hidden] = [cornerX, farX] + if (seen(farX, z)) shown = farX + else + for (let step = 0; step < 40; step++) { + const middle = (shown + hidden) / 2 + if (seen(middle, z)) shown = middle + else hidden = middle + } + if (Math.abs(shown - cornerX) > Math.abs(reach - cornerX)) reach = shown + } + ;[left, right] = face.left ? [0, reach] : [reach, width] + } + if (!(right - left > 0.5) || !(top - bottom > 0.5)) + refuse('face_degenerate', `${name}: the face is too small to draw.`, { face: name }) + const pxPerMetre = Math.min(MAX_PX_PER_METRE, MAX_SIDE / Math.max(right - left, top - bottom)) + // Drawing pixel (u, v) is the face point (left + u / s, top − v / s). + const toFace: Homography = [1 / pxPerMetre, 0, left, 0, -1 / pxPerMetre, top, 0, 0, 1] + + const residualPx = Math.sqrt( + mean([ + ...[...rows.map((row) => row.points), ...face.lines].map( + (line) => vanishingResidualPx(camera, plane.along, [line]) ** 2, + ), + ...edges.map((edge) => vanishingResidualPx(camera, plane.up, [edge]) ** 2), + ]), + ) + const highest = rowHeights.at(-1)! - datum + const density = (x: number) => { + const low = applyHomography(toPhoto, [x, 0]) + const high = applyHomography(toPhoto, [x, highest]) + return low && high && highest > 0 + ? Math.hypot(high[0] - low[0], high[1] - low[1]) / highest + : 0 + } + const sourcePxPerMetre = { left: density(left), right: density(right) } + + const scene = sceneStorey(elevations, face) + const storeyHeight = scene ? { photo: highest / scene.storeys, scene: scene.height } : undefined + const warnings = [ + ...(storeyHeight && + Math.abs(storeyHeight.photo - storeyHeight.scene) > STOREY_MISMATCH * storeyHeight.photo + ? [ + `The photo measures ${storeyHeight.photo.toFixed(2)} m floor to floor on this face, the scene's storeys ${storeyHeight.scene.toFixed(2)} m. The photo's figure comes from the picked lines: if a close-up is off or the lines are short, pick again before changing anything. This drawing takes its scale from the camera and the face's walls, not from the storeys: if those are the right walls, set the storeys to the photo's height before building the facade, or its units will not fit.`, + ] + : []), + ...commonWarnings(residualPx, sourcePxPerMetre), + ...cornerWarnings[index]!, + ] + + return { + name, + widthMeters: width, + leftMeters: left, + rightMeters: right, + topMeters: top, + bottomMeters: bottom, + pxPerMetre, + outWidth: Math.round((right - left) * pxPerMetre), + outHeight: Math.round((top - bottom) * pxPerMetre), + toSource: multiplyHomography(toPhoto, toFace), + sourcePxPerMetre, + residualPx, + featureLines: featureLinesOf(nodes, elevations, face, { bottom, top, pxPerMetre }), + corners: cornersOf(face), + ...(storeyHeight ? { storeyHeight } : {}), + warnings, + } + }) +} + +/** + * Plans the straightening of each face of a photo, pixels being the host's. Several faces, or one + * whose far edge leaves the photo, share the photo's one camera, found from the vanishing points + * of the picked lines: each face is then K·[along up origin], metric from its walls' width, and + * the storeys are measured. One face with both edges, or faces no camera fits, are each fitted + * from their own rows × edges, their heights from the storeys. + */ +export function planPhotoElevation( + nodes: SceneNodes, + input: PhotoElevationInput, + image: { width: number; height: number }, +): { faces: PhotoFacePlan[]; camera: PhotoCameraSummary | null } { + const plan = planOnce(nodes, input, image) + const pose = plan.camera?.pose + if (!pose) return plan + // How far the pose can be trusted: solved again with every pick moved by about the picks' own + // error, the same draws every time. A minimal set of picks fits exactly and shows no error, so + // an agent's pick is taken to be off by a few pixels at least. + const sigma = Math.max( + PICK_ERROR * Math.max(image.width, image.height), + mean(plan.faces.map((face) => face.residualPx)), + ) + let state = 1 + const random = () => { + state = (state * 1103515245 + 12345) % 2147483648 + return state / 2147483648 + } + const move = ([x, y]: Pt): Pt => [ + Math.min(1, Math.max(0, x + ((random() * 2 - 1) * sigma) / image.width)), + Math.min(1, Math.max(0, y + ((random() * 2 - 1) * sigma) / image.height)), + ] + const offsets: number[] = [] + for (let draw = 0; draw < SPREAD_DRAWS; draw++) { + const moved: PhotoElevationInput = { + ...input, + faces: input.faces.map((face) => ({ + ...face, + edges: { + ...(face.edges.left ? { left: face.edges.left.map(move) } : {}), + ...(face.edges.right ? { right: face.edges.right.map(move) } : {}), + }, + rows: face.rows.map((row) => ({ ...row, points: row.points.map(move) })), + ...(face.lines ? { lines: face.lines.map((line) => line.map(move)) } : {}), + ...(face.points + ? { points: face.points.map((point) => ({ ...point, point: move(point.point) })) } + : {}), + })), + ...(input.verticals ? { verticals: input.verticals.map((line) => line.map(move)) } : {}), + } + try { + const other = planOnce(nodes, moved, image).camera?.pose + if (other) + offsets.push( + Math.hypot(...([0, 1, 2] as const).map((k) => other.position[k] - pose.position[k])), + ) + } catch { + // A draw the picks cannot hold gives no pose. + } + } + const spread = Math.round(Math.sqrt(mean(offsets.map((d) => d * d))) * 100) / 100 + const distance = Math.hypot(...([0, 1, 2] as const).map((k) => pose.target[k] - pose.position[k])) + return { + ...plan, + camera: { + ...plan.camera!, + pose: { ...pose, spread }, + warnings: [ + ...plan.camera!.warnings, + ...(input.faces.every((face) => !face.points?.length && !face.lines?.length) + ? [ + "Only the face's ends and its rows were picked: they fit exactly and say nothing of their own error, so the spread assumes a pick is off by 0.5% of the photo. Place window and door corners (points) to measure it, and to steady the pose.", + ] + : []), + ...(spread > SHAKY * distance + ? [ + `The pose moves about ${spread} m when the picks move by their own error, ${Math.round((spread / distance) * 100)}% of the camera's distance: a render from it will not line up. Place more points on the face (points: window and door corners, eave ends), and pick them closer.`, + ] + : []), + ], + }, + } +} + +/** + * How far off an agent's pick is, at least, as a share of the photo's longer side: the measuring + * session's ±4 px on a 1600 px photo was under 0.2%, and the spread came out a third too small. + */ +const PICK_ERROR = 0.005 +/** Draws of the picks the pose's spread is measured on. */ +const SPREAD_DRAWS = 24 +/** A pose moving more than this share of its distance is too unsure to render from. */ +const SHAKY = 0.1 + +function planOnce( + nodes: SceneNodes, + input: PhotoElevationInput, + image: { width: number; height: number }, +): { faces: PhotoFacePlan[]; camera: PhotoCameraSummary | null } { + const elevations = getLevelElevations(nodes as Record) + const px = ([fx, fy]: Pt): Pt => [fx * image.width, fy * image.height] + const faces = input.faces.map((face, index) => readFace(nodes, elevations, face, index, px)) + const verticals = input.verticals ?? [] + if (verticals.flat().some((point) => !inside(point))) + refuse( + 'point_outside_image', + 'Every point of the verticals is a fraction of the image, 0 to 1 across and down.', + ) + + const oneEdge = faces.some((face) => !face.left || !face.right) + if (faces.length === 1 && !oneEdge) { + const face = faces[0]! + const { plan, toPhoto, bottom, top } = planFromOwnPicks(nodes, elevations, face) + const seen = faceCamera( + toPhoto, + image, + { width: face.width, height: top - bottom }, + input.focal35mm, + ) + return { + faces: [plan], + camera: { + ...cameraSummary(seen.camera, seen.axes, 1), + ...(seen.focalAssumed ? { assumptions: `${ASSUMPTIONS} ${FRONTAL_ASSUMPTION}` } : {}), + pose: photoPose( + nodes, + elevations, + [face], + seen.camera, + [{ ...seen.anchor, bottom }], + seen.focalAssumed, + ), + }, + } + } + const solved = solveCamera( + faces, + verticals.map((line) => segment(line, px)), + image, + ) + if ('code' in solved) { + if (oneEdge) refuse(solved.code, solved.message) + const warning = `No camera fits this photo (${solved.message}) Each face is straightened from its own picks instead, its heights from the storeys.` + return { + faces: faces.map((face) => { + const { plan } = planFromOwnPicks(nodes, elevations, face) + return { ...plan, warnings: [warning, ...plan.warnings] } + }), + camera: null, + } + } + const anchors: FaceAnchor[] = [] + const plans = planWithCamera(nodes, elevations, faces, solved.camera, solved.faces, anchors) + return { + faces: plans, + camera: { + ...cameraSummary(solved.camera, solved.faces[0]!, solved.focalSpread), + pose: photoPose(nodes, elevations, faces, solved.camera, anchors, false), + }, + } +} + +/** A face's depth changing less than this share across it shows no focal length. */ +const FRONTAL = 0.05 +/** A phone's main camera, on a 36 × 24 mm frame. */ +const PHONE_35MM = 26 +const FRONTAL_ASSUMPTION = + "One face seen square on does not show the focal length: it was taken (focal35mm, else a phone camera's 26 mm), and the camera's distance follows from it." + +/** + * The camera that sees a face through `toPhoto` (face metres from its left end and lowest row, to + * photo pixels), its principal point at the photo's centre: H = K·[r₁ r₂ t]. The focal length + * comes from r₁ ⟂ r₂ and |r₁| = |r₂| when the face recedes; square on, it is taken. + */ +function faceCamera( + toPhoto: Homography, + image: { width: number; height: number }, + extent: { width: number; height: number }, + focal35mm?: number, +): { + camera: PhotoCamera + axes: FaceAxes + anchor: Omit + focalAssumed: boolean +} { + const [cx, cy] = [image.width / 2, image.height / 2] + const column = (k: number): Vec3 => { + const z = toPhoto[6 + k]! + return [toPhoto[k]! - cx * z, toPhoto[3 + k]! - cy * z, z] + } + const [a, b, c] = [column(0), column(1), column(2)] + const recede = + Math.max(Math.abs(a[2] * extent.width), Math.abs(b[2] * extent.height)) / Math.abs(c[2]) + const longer = Math.max(image.width, image.height) + // r₁ ⟂ r₂ and |r₁| = |r₂| each give f²; each is only as good as its denominator, which a face + // turned but not tilted (or tilted but not turned) drives to nought for the first. + const conditioned = [ + { squared: -(a[0] * b[0] + a[1] * b[1]) / (a[2] * b[2]), weight: Math.abs(a[2] * b[2]) }, + { + squared: (a[0] ** 2 + a[1] ** 2 - b[0] ** 2 - b[1] ** 2) / (b[2] ** 2 - a[2] ** 2), + weight: Math.abs(b[2] ** 2 - a[2] ** 2), + }, + ] + .filter(({ squared }) => Number.isFinite(squared) && squared > 0) + .map(({ squared, weight }) => ({ focal: Math.sqrt(squared), weight })) + .filter(({ focal }) => focal >= 0.25 * longer && focal <= 10 * longer) + .sort((p, q) => q.weight - p.weight) + const read = focal35mm === undefined && recede > FRONTAL ? conditioned[0]?.focal : undefined + const focalAssumed = read === undefined + const focal = + read ?? ((focal35mm ?? PHONE_35MM) * Math.hypot(image.width, image.height)) / Math.hypot(36, 24) + const ray = (h: Vec3): Vec3 => [h[0] / focal, h[1] / focal, h[2]] + const [g1, g2, g3] = [ray(a), ray(b), ray(c)] + const norm = (v: Vec3) => Math.hypot(...v) + const scale = ((g3[2] > 0 ? 1 : -1) * 2) / (norm(g1) + norm(g2)) + const along = v3.scale(g1, scale / norm(v3.scale(g1, scale))) + const upRaw = v3.scale(g2, scale) + const up = v3.scale( + v3.add(upRaw, v3.scale(along, -v3.dot(upRaw, along))), + 1 / norm(v3.add(upRaw, v3.scale(along, -v3.dot(upRaw, along)))), + ) + const normal: Vec3 = [ + along[1] * up[2] - along[2] * up[1], + along[2] * up[0] - along[0] * up[2], + along[0] * up[1] - along[1] * up[0], + ] + const t = v3.scale(g3, scale) + return { + camera: { ...image, focal, centre: [cx, cy], up }, + axes: { along, up, normal }, + anchor: { + plane: { along, up, normal, distance: v3.dot(normal, t) }, + origin: [v3.dot(along, t), v3.dot(up, t)], + }, + focalAssumed, + } +} + +/** A face placed by the camera: its plane, and where its walls' left end sits on it at the rows' datum. */ +type FaceAnchor = { plane: CameraFace; origin: Pt; bottom: number } + +const v3 = { + add: (a: Vec3, b: Vec3): Vec3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]], + scale: (a: Vec3, k: number): Vec3 => [a[0] * k, a[1] * k, a[2] * k], + dot: (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2], +} + +/** + * The camera in world coordinates. Each face's axes are known in the camera (along, up, out) and + * in the world (its walls' direction, up, out), and so is one of its points: the walls' left end + * at the rows' datum. The side the camera stands on is the one every face agrees on; one face + * alone leaves it open. The datum's height is the rows' feature height above their floor, or else + * the edges' foot is taken for the ground. + */ +function photoPose( + nodes: SceneNodes, + elevations: Elevations, + faces: Face[], + camera: PhotoCamera, + anchors: FaceAnchor[], + focalAssumed: boolean, +): PhotoPose | null { + if (!faces.length) return null + const ground = Math.min(...[...elevations.values()].map((level) => level.baseY)) + const rowHeight = faces[0]!.rows[0]!.height + const anchoredBy = rowHeight !== undefined ? 'row height' : 'edge base' + // Each face's camera and world axes for one side of it (sign ±1 along its walls). + const place = (index: number, sign: number) => { + const face = faces[index]! + const { plane, origin, bottom } = anchors[index]! + const along: Vec3 = [sign * face.axis[0], 0, sign * face.axis[1]] + const up: Vec3 = [0, 1, 0] + const normal: Vec3 = [-along[2], 0, along[0]] + const left = sign > 0 ? face.ends[0] : face.ends[1] + const row = face.rows[0]! + const datumY = row.height !== undefined ? row.baseY + row.height : ground - bottom + const world: Vec3 = [left[0], datumY, left[1]] + const position = v3.add( + world, + v3.add( + v3.add(v3.scale(along, -origin[0]), v3.scale(up, -origin[1])), + v3.scale(normal, -plane.distance), + ), + ) + // Camera axes (x right, y down, z forward) in the world. + const axis = (k: 0 | 1 | 2): Vec3 => + v3.add( + v3.add(v3.scale(along, plane.along[k]), v3.scale(up, plane.up[k])), + v3.scale(normal, plane.normal[k]), + ) + return { position, right: axis(0), down: axis(1), forward: axis(2) } + } + let best: { spread: number; places: ReturnType[] } | null = null + if (faces.length === 1) { + // One face: the camera stands outside the building, away from its walls' middle. + const walls = Object.values(nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === faces[0]!.levelId, + ) + const middle = walls.flatMap((wall) => [wall.start, wall.end]) + const cx = mean(middle.map((p) => p[0])) + const cz = mean(middle.map((p) => p[1])) + const away = (p: ReturnType) => Math.hypot(p.position[0] - cx, p.position[2] - cz) + const [a, b] = [place(0, 1), place(0, -1)] + best = { spread: 0, places: [away(a) >= away(b) ? a : b] } + } else + for (const first of [1, -1]) { + const head = place(0, first) + const rest = faces.slice(1).map((_, i) => { + const [a, b] = [place(i + 1, 1), place(i + 1, -1)] + const gap = (p: ReturnType) => + Math.hypot(...([0, 1, 2] as const).map((k) => p.position[k] - head.position[k])) + return gap(a) <= gap(b) ? a : b + }) + const places = [head, ...rest] + const spread = Math.max( + ...rest.map((p) => + Math.hypot(...([0, 1, 2] as const).map((k) => p.position[k] - head.position[k])), + ), + ) + if (!best || spread < best.spread) best = { spread, places } + } + const chosen = best!.places[0]! + const depth = v3.dot( + v3.add( + v3.add( + v3.scale(anchors[0]!.plane.along, anchors[0]!.origin[0]), + v3.scale(anchors[0]!.plane.up, anchors[0]!.origin[1]), + ), + v3.scale(anchors[0]!.plane.normal, anchors[0]!.plane.distance), + ), + [0, 0, 1], + ) + const project = (point: Vec3): Pt => { + const d: Vec3 = [ + point[0] - chosen.position[0], + point[1] - chosen.position[1], + point[2] - chosen.position[2], + ] + const z = v3.dot(d, chosen.forward) + return [ + (camera.focal * v3.dot(d, chosen.right)) / z + camera.centre[0], + (camera.focal * v3.dot(d, chosen.down)) / z + camera.centre[1], + ] + } + // Each picked edge against the vertical through its walls' end, projected. + const residuals = faces.flatMap((face, index) => { + const sign = best!.places[index]!.right + const leftFirst = v3.dot(sign, [face.axis[0], 0, face.axis[1]]) > 0 + const [leftEnd, rightEnd] = leftFirst ? face.ends : [face.ends[1], face.ends[0]] + return ( + [ + [face.left, leftEnd], + [face.right, rightEnd], + ] as const + ).flatMap(([edge, end]) => { + if (!edge) return [] + const line: Segment = [ + project([end[0], ground, end[1]]), + project([end[0], ground + 10, end[1]]), + ] + return [offLine(edge, line)] + }) + }) + const round = (value: number) => Math.round(value * 1000) / 1000 + const r3 = (v: Vec3): Vec3 => [round(v[0]), round(v[1]), round(v[2])] + return { + projection: 'perspective', + position: r3(chosen.position), + target: r3(v3.add(chosen.position, v3.scale(chosen.forward, depth))), + up: r3(v3.scale(chosen.down, -1)), + fov: round((2 * Math.atan(camera.height / 2 / camera.focal) * 180) / Math.PI), + aspect: round(camera.width / camera.height), + shift: round((camera.centre[1] - camera.height / 2) / camera.height), + anchoredBy, + focalAssumed, + edgeResidualPx: round(Math.sqrt(mean(residuals.map((r) => r * r)))), + } +} diff --git a/packages/core/src/agent-operations/place-in-room.ts b/packages/core/src/agent-operations/place-in-room.ts new file mode 100644 index 0000000000..a86793d988 --- /dev/null +++ b/packages/core/src/agent-operations/place-in-room.ts @@ -0,0 +1,549 @@ +import { refuse } from '../agent-tools/refusal' +import { type AnyNode, type AssetInput, ItemNode } from '../schema' +import { + aabbsOverlap, + collectDoorKeepouts, + itemNodePlanCorners, + itemPlanAabb, + type PlanAabb, +} from './door-clearance' +import { edgeFrame, roomToFurnish, WALL_GAP } from './furnish-room' +import { anchorOf, furnitureKind } from './furniture-kind' +import { classifyPlacement, collectOccupiedFootprints } from './layout-clearance' +import type { LevelTargetInput } from './level-target' +import { pointInPolygon, polygonBounds, type Vec2 } from './plan-geometry' +import { levelRole } from './scene-queries' +import type { AgentOperation } from './types' + +export type ClearanceArea = PlanAabb & { id: string } + +export type PlaceInRoomInput = LevelTargetInput & { + assetId: string + zoneId?: string + polygon?: number[][] + edge?: number + allowDuplicate?: boolean + rotationDeg?: number + facing?: 'in' | 'out' + align?: 'center' | 'start' | 'end' + offset?: number + inset?: number + clearanceAreas?: ClearanceArea[] +} + +type Pose = { x: number; z: number; rotationDeg: number } +type Dims = [number, number, number] +type ItemOnLevel = Extract + +/** How far along a wall the sweep moves between the positions it tries. */ +const STEP = 0.05 +/** A wall is sampled at no more than this many positions (30 m at STEP): a longer one gets a coarser step. */ +const MAX_POSITIONS_PER_WALL = 600 +/** No room has more corners than this; the outline comes from the caller, and the search is cubic in it. */ +const MAX_CORNERS = 128 +/** A chair sits this far off its table's edge: the clearance every item keeps (DEFAULT_ITEM_GAP) and a hair more. */ +const CHAIR_GAP = 0.09 + +const rad = (deg: number) => (deg / 180) * Math.PI +const round = (value: number) => Math.round(value * 100) / 100 +const facingOf = (dx: number, dz: number) => (Math.atan2(dx, dz) * 180) / Math.PI + +/** Where a piece's front points: it is authored facing +Z, and turns counter-clockwise from above. */ +const frontOf = (rotationDeg: number): Vec2 => [ + Math.sin(rad(rotationDeg)), + Math.cos(rad(rotationDeg)), +] + +/** The piece's four corners on the plan, for a room that is not a rectangle. */ +function corners(pose: Pose, [width, , depth]: Dims): Vec2[] { + const front = frontOf(pose.rotationDeg) + const side: Vec2 = [front[1], -front[0]] + return ([-1, 1] as const).flatMap((s) => + ([-1, 1] as const).map( + (f): Vec2 => [ + pose.x + side[0] * s * (width / 2) + front[0] * f * (depth / 2), + pose.z + side[1] * s * (width / 2) + front[1] * f * (depth / 2), + ], + ), + ) +} + +type Room = { + bounds: ReturnType + center: Vec2 + doorKeepouts: PlanAabb[] + occupied: PlanAabb[] + polygon: Vec2[] + options: PlaceInRoomInput + blockedClearanceIds: Set +} + +/** Whether the piece may stand there: inside the room, clear of every door and every other item. */ +function fits(room: Room, pose: Pose, dims: Dims, itemGap?: number, occupied = room.occupied) { + const aabb = itemPlanAabb([pose.x, 0, pose.z], dims, rad(pose.rotationDeg)) + const reason = classifyPlacement({ + aabb, + doorKeepouts: room.doorKeepouts, + occupied, + roomBounds: { + maxX: room.bounds.maxX, + maxZ: room.bounds.maxZ, + minX: room.bounds.minX, + minZ: room.bounds.minZ, + }, + ...(itemGap === undefined ? {} : { itemGap }), + ...(room.options.inset === undefined ? {} : { padding: 0 }), + }) + if (reason !== 'ok') return false + if (!corners(pose, dims).every((corner) => pointInPolygon(corner, room.polygon, true))) + return false + const blocked = (room.options.clearanceAreas ?? []).filter((area) => aabbsOverlap(aabb, area, 0)) + for (const area of blocked) room.blockedClearanceIds.add(area.id) + return blocked.length === 0 +} + +const chosenYaw = (input: PlaceInRoomInput, preferred: number) => + (input.rotationDeg ?? preferred) + (input.facing === 'out' ? 180 : 0) + +/** Project the rotated footprint onto the wall tangent and normal, not its authored axes. */ +function wallGeometry(room: Room, edge: number, dims: Dims) { + const frame = edgeFrame(room.polygon, edge, room.center) + const rotationDeg = chosenYaw(room.options, frame.facing) + const relative = rad(rotationDeg - frame.facing) + const half = (Math.abs(Math.cos(relative)) * dims[0] + Math.abs(Math.sin(relative)) * dims[2]) / 2 + const reach = + (Math.abs(Math.sin(relative)) * dims[0] + Math.abs(Math.cos(relative)) * dims[2]) / 2 + + (room.options.inset ?? WALL_GAP) + return { frame, rotationDeg, half, reach } +} + +function wallPoseAt(room: Room, edge: number, dims: Dims, s: number): Pose { + const { frame, rotationDeg, reach } = wallGeometry(room, edge, dims) + const start = room.polygon[edge % room.polygon.length]! + return { + rotationDeg, + x: start[0] + frame.along.x * s + frame.inward.x * reach, + z: start[1] + frame.along.z * s + frame.inward.z * reach, + } +} + +function desiredAlong(room: Room, edge: number, dims: Dims) { + if (room.options.align === undefined && room.options.offset === undefined) return undefined + const { frame, half } = wallGeometry(room, edge, dims) + const base = + room.options.align === 'start' + ? half + : room.options.align === 'end' + ? frame.length - half + : frame.length / 2 + return Math.max(half, Math.min(frame.length - half, base + (room.options.offset ?? 0))) +} + +type Run = { + edge: number + from: number + to: number + span: number + frame: ReturnType +} + +/** The free stretches of one wall for a piece: where its centre may stand along the edge, in runs. */ +function runsAlong(room: Room, edge: number, dims: Dims): Run[] { + const { frame, half } = wallGeometry(room, edge, dims) + const width = half * 2 + const runs: Run[] = [] + let from: number | null = null + let to = 0 + // Reserve two samples for the end and the caller's desired alignment. + const step = Math.max(STEP, (frame.length - width) / (MAX_POSITIONS_PER_WALL - 3)) + if (half > frame.length - half) return runs + const positions: number[] = [] + for ( + let s = half; + s <= frame.length - half + 1e-9 && positions.length < MAX_POSITIONS_PER_WALL - 2; + s += step + ) + positions.push(s) + positions.push(frame.length - half) + const desired = desiredAlong(room, edge, dims) + if (desired !== undefined) positions.push(desired) + for (const s of [...new Set(positions)].sort((a, b) => a - b)) { + const pose = wallPoseAt(room, edge, dims, s) + if (fits(room, pose, dims)) { + from ??= s + to = s + } else if (from !== null) { + runs.push({ edge, frame, from, span: to - from + width, to }) + from = null + } + } + if (from !== null) runs.push({ edge, frame, from, span: to - from + width, to }) + return runs +} + +/** The desired alignment, clamped to a free run, or its centre when no alignment was requested. */ +function poseInRun(room: Room, run: Run, dims: Dims): Pose { + const desired = desiredAlong(room, run.edge, dims) ?? (run.from + run.to) / 2 + return wallPoseAt(room, run.edge, dims, Math.max(run.from, Math.min(run.to, desired))) +} + +/** The widest free stretch of wall that any piece of this depth could use, for the refusal. */ +function widestFree(room: Room, dims: Dims, edges: number[]) { + const probe: Dims = [0.1, dims[1], dims[2]] + let widest: Run | null = null + for (const edge of edges) + for (const run of runsAlong(room, edge, probe)) + if (!widest || run.span > widest.span) widest = run + return widest +} + +const nameOf = (node: ItemOnLevel) => + `${node.asset.id} ${node.name ?? node.asset.name ?? ''}`.toLowerCase() + +function itemDims(node: ItemOnLevel): Dims { + const [w = 1, h = 1, d = 1] = node.asset.dimensions ?? [1, 1, 1] + return [w, h, d] +} + +/** Slots around a table for chairs, best first: the middle of the long sides, then the ends, then beside. */ +function chairSlots(table: ItemOnLevel, chair: Dims, input: PlaceInRoomInput): Pose[] { + const tableDims = itemDims(table) + const tw = tableDims[0] * Math.abs(table.scale[0]) + const td = tableDims[2] * Math.abs(table.scale[2]) + const turn = table.rotation[1] ?? 0 + const [chairWidth] = chair + const tableCorners = itemNodePlanCorners(table) + const center: Vec2 = [ + tableCorners.reduce((sum, [x]) => sum + x, 0) / tableCorners.length, + tableCorners.reduce((sum, [, z]) => sum + z, 0) / tableCorners.length, + ] + /** A direction or a point in the table's own frame (x along its width, z along its front), on the plan. */ + const world = (x: number, z: number): Vec2 => [ + x * Math.cos(turn) + z * Math.sin(turn), + -x * Math.sin(turn) + z * Math.cos(turn), + ] + const slots: Pose[] = [] + /** On the side whose outward normal is (nx, nz) in the table's frame, `lateral` along that side. */ + const slot = (nx: number, nz: number, lateral = 0) => { + const [outX, outZ] = world(nx, nz) + const rotationDeg = chosenYaw(input, facingOf(-outX, -outZ)) + // Separate the actual rectangles on this table-side normal, after the caller's yaw. + const tableReach = Math.max( + ...tableCorners.map(([x, z]) => (x - center[0]) * outX + (z - center[1]) * outZ), + ) + const chairReach = -Math.min( + ...corners({ x: 0, z: 0, rotationDeg }, chair).map(([x, z]) => x * outX + z * outZ), + ) + const reach = tableReach + chairReach + CHAIR_GAP + const [x, z] = world( + nx * reach + (nz !== 0 ? lateral : 0), + nz * reach + (nz === 0 ? lateral : 0), + ) + slots.push({ + // Straight across its side, front to the table: facing the centre would turn a chair set beside the middle one. + rotationDeg, + x: center[0] + x, + z: center[1] + z, + }) + } + const long: [number, number][] = + tw >= td + ? [ + [0, 1], + [0, -1], + ] + : [ + [1, 0], + [-1, 0], + ] + const ends: [number, number][] = + tw >= td + ? [ + [1, 0], + [-1, 0], + ] + : [ + [0, 1], + [0, -1], + ] + for (const [nx, nz] of long) slot(nx, nz) + for (const [nx, nz] of ends) slot(nx, nz) + const shift = chairWidth + 0.12 + for (const lateral of [shift, -shift, 2 * shift, -2 * shift]) + for (const [nx, nz] of long) slot(nx, nz, lateral) + return slots +} + +/** + * `place_in_room`: one floor-standing catalog item where it fits. A wall-bound piece (fridge, counter, bed, sofa, + * toilet) is set against a wall, centred in the widest free stretch of wall that clears every door + * and every other item, facing into the room, and is never set in the middle: when no wall has room + * the answer says how wide the widest stretch is. A free-standing piece (table, rug, plant) takes + * the middle of the room, or goes with what it belongs with and faces it (a coffee table and an + * armchair face the sofa). A chair is placed around its table facing it, and refused with no table. + */ +export const placeInRoom: AgentOperation = (nodes, input, context) => { + for (const key of ['rotationDeg', 'offset', 'inset'] as const) + if (input[key] !== undefined && !Number.isFinite(input[key])) + refuse('invalid_placement_options', `${key} must be a finite number.`) + if ( + (input.facing !== undefined && !['in', 'out'].includes(input.facing)) || + (input.align !== undefined && !['center', 'start', 'end'].includes(input.align)) + ) + refuse('invalid_placement_options', 'Use facing in/out and align center/start/end.') + if ( + input.clearanceAreas !== undefined && + (!Array.isArray(input.clearanceAreas) || + input.clearanceAreas.length > 128 || + !input.clearanceAreas.every( + (area) => + area && + typeof area.id === 'string' && + area.id.length > 0 && + [area.minX, area.maxX, area.minZ, area.maxZ].every(Number.isFinite) && + area.minX <= area.maxX && + area.minZ <= area.maxZ, + )) + ) + refuse( + 'invalid_clearance_areas', + 'Supply at most 128 finite normalized clearance rectangles with ids.', + ) + const catalog = context.catalog + if (!catalog) refuse('no_catalog', 'This host has no item catalog to place from.') + const asset: AssetInput | undefined = catalog.find((entry) => entry.id === input.assetId) + if (!asset) + refuse( + 'asset_not_found', + `${input.assetId} is not in the item catalog: use an id search_assets returned.`, + { assetId: input.assetId }, + ) + if (asset.attachTo !== undefined) + refuse( + 'unsupported_attachment', + `${asset.id} attaches to a ${asset.attachTo === 'ceiling' ? 'ceiling' : 'wall'}, not the room floor. Use place_items with targetNodeId naming its host${asset.attachTo === 'ceiling' ? '' : ' and y giving its bottom height above the floor'}.`, + { assetId: asset.id, attachTo: asset.attachTo }, + ) + const { levelId, polygon } = roomToFurnish(nodes, input, context) + if (polygon.length > MAX_CORNERS) + refuse( + 'polygon_too_large', + `A room outline has at most ${MAX_CORNERS} corners; this one has ${polygon.length}.`, + { corners: polygon.length }, + ) + if (!polygon.every((corner) => corner.length === 2 && corner.every(Number.isFinite))) + refuse('polygon_invalid', 'Every corner of the room outline must be a pair of finite numbers.') + const level = nodes[levelId] + if (level && levelRole(nodes, level).role === 'roof') + refuse('roof_level', `${level.name || level.id} is a roof level, not a storey.`, { levelId }) + if ( + input.edge !== undefined && + (!Number.isInteger(input.edge) || input.edge < 0 || input.edge >= polygon.length) + ) + refuse( + 'edge_out_of_range', + `The room has ${polygon.length} edges (0–${polygon.length - 1}); there is no edge ${input.edge}.`, + ) + + const all = Object.values(nodes) + const bounds = polygonBounds(polygon) + const room: Room = { + bounds, + center: [bounds.centerX, bounds.centerZ], + doorKeepouts: collectDoorKeepouts(all, { levelId }).map((door) => door.aabb), + occupied: collectOccupiedFootprints(all, { floorOnly: true, levelId }).map( + (footprint) => footprint.aabb, + ), + polygon, + options: input, + blockedClearanceIds: new Set(), + } + const inRoom = all.filter( + (node): node is ItemOnLevel => + node.type === 'item' && + node.parentId === levelId && + pointInPolygon([node.position[0], node.position[2]], polygon, true), + ) + const dims = itemDims({ asset } as ItemOnLevel) + const kind = furnitureKind(asset) + const wallTargeted = + input.edge !== undefined || + input.align !== undefined || + input.offset !== undefined || + input.inset !== undefined + + if ( + kind !== 'chair' && + !input.allowDuplicate && + inRoom.some((node) => node.asset.id === asset.id) + ) + refuse( + 'duplicate_item', + `The room already has a ${asset.id}. Pass allowDuplicate to add another.`, + { assetId: asset.id }, + ) + + const named = (pattern: string) => new RegExp(pattern) + const refuseClearance = () => { + if (room.blockedClearanceIds.size) + refuse( + 'stair_blocked', + `No free spot for ${asset.id} clears the supplied clearance areas: ${[...room.blockedClearanceIds].join(', ')}.`, + { assetId: asset.id, clearanceAreaIds: [...room.blockedClearanceIds] }, + ) + } + const place = (pose: Pose, where: Record) => { + const node = ItemNode.parse({ + asset, + name: asset.name, + parentId: levelId, + position: [pose.x, 0, pose.z], + rotation: [0, rad(pose.rotationDeg), 0], + }) + return { + changes: { create: [{ node, parentId: levelId }] }, + result: { + ok: true, + itemId: node.id, + kind, + placed: { position: [round(pose.x), round(pose.z)], rotationDeg: round(pose.rotationDeg) }, + ...where, + message: `Placed ${asset.id} ${Object.keys(where).includes('around') ? 'around its table' : 'in the room'}.`, + }, + } + } + + // Without an explicit wall target, a chair goes around its table, or nowhere. + if (kind === 'chair' && !wallTargeted) { + const table = inRoom + .filter((node) => named('table|desk').test(nameOf(node))) + .sort( + (a, b) => + Math.hypot(a.position[0] - room.center[0], a.position[2] - room.center[1]) - + Math.hypot(b.position[0] - room.center[0], b.position[2] - room.center[1]), + )[0] + if (!table) + refuse( + 'no_table', + `A ${asset.id} goes around a table, and this room has none: place the table first.`, + { assetId: asset.id }, + ) + // The table is cleared by the oriented slot geometry; every other item still blocks. + const others = collectOccupiedFootprints(all, { + floorOnly: true, + levelId, + excludeIds: new Set([table.id]), + }).map((footprint) => footprint.aabb) + for (const slot of chairSlots(table, dims, input)) { + if (fits(room, slot, dims, undefined, others)) return place(slot, { around: table.id }) + } + refuseClearance() + refuse( + 'no_free_spot', + `No free place is left around the table for another ${asset.id}: every side of ${table.id} that is clear of doors and walls is taken.`, + { assetId: asset.id }, + ) + } + + const edges = input.edge !== undefined ? [input.edge] : polygon.map((_, index) => index) + const wallPose = () => { + const runs = edges.flatMap((edge) => runsAlong(room, edge, dims)) + const distance = (run: Run) => { + const desired = desiredAlong(room, run.edge, dims) + return desired === undefined ? 0 : Math.max(run.from - desired, desired - run.to, 0) + } + runs.sort((a, b) => distance(a) - distance(b) || b.span - a.span) + for (const run of runs) { + const poses = [ + poseInRun(room, run, dims), + wallPoseAt(room, run.edge, dims, run.from), + wallPoseAt(room, run.edge, dims, run.to), + ] + for (const pose of poses) if (fits(room, pose, dims)) return { against: run.edge, pose } + } + return null + } + + if (kind === 'free' && !wallTargeted) { + const anchorPattern = anchorOf(asset) + const anchor = anchorPattern + ? inRoom + .filter((node) => named(anchorPattern).test(nameOf(node))) + .sort( + (a, b) => + Math.hypot(a.position[0] - room.center[0], a.position[2] - room.center[1]) - + Math.hypot(b.position[0] - room.center[0], b.position[2] - room.center[1]), + )[0] + : undefined + const candidates: Pose[] = [] + const faceToward = (x: number, z: number, target: Vec2): Pose => ({ + rotationDeg: facingOf(target[0] - x, target[1] - z), + x, + z, + }) + if (anchor) { + const [aw, , ad] = itemDims(anchor) + const front = frontOf(((anchor.rotation[1] ?? 0) * 180) / Math.PI) + const target: Vec2 = [anchor.position[0], anchor.position[2]] + const reach = ad / 2 + 0.4 + dims[2] / 2 + candidates.push( + faceToward(target[0] + front[0] * reach, target[1] + front[1] * reach, target), + ) + const side: Vec2 = [front[1], -front[0]] + for (const sign of [1, -1]) + candidates.push( + faceToward( + target[0] + + front[0] * (ad / 2 + 0.5 + dims[2] / 2) + + side[0] * sign * (aw / 2 + dims[0] / 2 + 0.2), + target[1] + + front[1] * (ad / 2 + 0.5 + dims[2] / 2) + + side[1] * sign * (aw / 2 + dims[0] / 2 + 0.2), + target, + ), + ) + } + // The middle of the room: facing what it belongs with, else its width along the room's long + // axis; and when that does not fit (a door's clear zone, a counter), the other turns. + const preferred = anchor + ? facingOf(anchor.position[0] - room.center[0], anchor.position[2] - room.center[1]) + : room.bounds.width >= room.bounds.depth + ? 0 + : 90 + for (const turn of anchor ? [0] : [0, 90, 180, 270]) + candidates.push({ + rotationDeg: anchor ? preferred : (preferred + turn) % 360, + x: room.center[0], + z: room.center[1], + }) + for (const candidate of candidates) { + const pose = { ...candidate, rotationDeg: chosenYaw(input, candidate.rotationDeg) } + if (fits(room, pose, dims)) return place(pose, anchor ? { facing: anchor.id } : {}) + } + } + + const found = wallPose() + if (found) + return place(found.pose, { + against: { + edge: found.against, + wall: `edge ${found.against} of ${polygon.length}`, + }, + }) + + refuseClearance() + const widest = widestFree(room, dims, edges) + refuse( + 'no_free_spot', + `No wall has room for ${asset.id} (${round(dims[0])} × ${round(dims[2])} m)${ + widest + ? `: the widest free stretch of wall is ${round(widest.span)} m, on edge ${widest.edge}` + : ': no wall of the room is free' + }. A smaller one, another room, or remove something.`, + { + assetId: asset.id, + widestFreeRunM: widest ? round(widest.span) : 0, + ...(widest ? { edge: widest.edge } : {}), + }, + ) +} diff --git a/packages/core/src/agent-operations/place-items.ts b/packages/core/src/agent-operations/place-items.ts index d3f80a30c6..fce8e961be 100644 --- a/packages/core/src/agent-operations/place-items.ts +++ b/packages/core/src/agent-operations/place-items.ts @@ -40,6 +40,7 @@ type Placed = side?: 'front' | 'back' /** The authored host's part it came to rest on, or hangs from. */ restingOn?: string + info?: { code: 'rotation_ignored'; message: string } } | { ok: false; assetId: string; code: string; error: string } @@ -151,7 +152,7 @@ function onWall(wall: WallNode, entry: Entry, asset: AssetInput): Pose | Placed const left = -dz * (entry.x - wall.start[0]) + dx * (entry.z - wall.start[1]) >= 0 const side = left ? 'front' : 'back' const z = asset.attachTo === 'wall-side' ? getWallLocalFaceZ(wall, left ? 'a' : 'b') : 0 - const turn = (left ? 0 : Math.PI) + ((entry.rotation ?? 0) * Math.PI) / 180 + const turn = left ? 0 : Math.PI return { parentId: wall.id, position: [x, y, z], @@ -267,6 +268,15 @@ export const placeItems: AgentOperation = (nodes, input, contex hostId: host.id, ...(pose.side ? { side: pose.side } : {}), ...(pose.restingOn ? { restingOn: pose.restingOn } : {}), + ...(host.type === 'wall' && entry.rotation !== undefined + ? { + info: { + code: 'rotation_ignored' as const, + message: + 'Rotation is ignored on a wall host: the item faces its selected wall side in the wall’s local frame.', + }, + } + : {}), } } diff --git a/packages/core/src/agent-operations/point-context.ts b/packages/core/src/agent-operations/point-context.ts new file mode 100644 index 0000000000..a3c1c379d9 --- /dev/null +++ b/packages/core/src/agent-operations/point-context.ts @@ -0,0 +1,119 @@ +/** + * What a person pointed at in the scene, and from where: ids first, the picture as evidence. + * + * One shape for every surface that can carry it. The editor's Point and ask mode builds it; the + * chat sends it on the message (`metadata.pointContext`) and tells the agent to act on the ids; the + * hosted MCP can take the same object as a tool input later. `camera` is `view_scene`'s own + * `camera` input plus the projection, so the agent can look from the person's eye. + */ +export type PointContext = { + kind: 'scene-point' + version: 1 + /** "ask_…", minted where the person pointed: the pin, the chat chip and the turn share it. */ + askId: string + /** ISO time of the moment of pointing (pointer-down), not of sending. */ + capturedAt: string + source: 'editor' | 'mcp' + gesture: 'click' | 'multi' | 'region' + /** The level in view. */ + level: { id: string; name: string } + /** Every pointed element, with no cap: a preview limit is for display, not for the agent. */ + targets: PointTarget[] + /** A region's visible elements, by screen area, top 12; `more` counts the rest. */ + inRegion?: { ids: string[]; more: number } + camera: PointCamera + /** [left, top, right, bottom], each 0–1 of the full frame. */ + region?: [number, number, number, number] + /** The crop. `marked`: the targets are outlined in it. */ + image?: PointImage + /** The id of the suggestion chip when one was sent as it is. */ + suggestion?: string +} + +export type PointTarget = { + /** Authoritative: read it with `get_node` before changing it. */ + id: string + /** The node type: 'zone' | 'wall' | 'window' | 'door' | 'item' | … */ + type: string + /** "Kitchen", "Wall, north side of Kitchen", "Sofa, Oslo 3-seat". */ + name: string + levelId: string + /** The room it is in or bounds. */ + zoneId?: string + /** The wall an opening is in, the item an item stands on. */ + parentId?: string + /** Where the finger landed, in world metres. */ + hit?: { + point: [number, number, number] + normal: [number, number, number] + face?: 'interior' | 'exterior' | 'top' | 'bottom' + } + box: { min: [number, number, number]; max: [number, number, number] } + /** Metres: wall {length, height, thickness}; zone {area, width, depth}; opening {width, height, sill}; item {width, depth, height}. */ + size: Record +} + +export type PointCamera = { + position: [number, number, number] + target: [number, number, number] + fov: number + aspect: number + projection: 'perspective' | 'orthographic' + /** Orthographic only. */ + viewWidth?: number +} + +export type PointImage = { + /** Where the crop is stored, once the chat has uploaded it. */ + url?: string + /** The crop as the editor captured it, before it is uploaded. */ + dataUrl?: string + width: number + height: number + marked: boolean +} + +/** What the editor hands the chat when the person sends an ask. */ +export type PointAskSubmit = { + askId: string + /** The person's words. */ + text: string + context: PointContext + /** The bubble text's screen rect in CSS px, where the message's flight starts. */ + from: { x: number; y: number; width: number; height: number } +} + +/** + * The chat's answer to a submit. `busy`: Pascal is on a turn and the host does not queue asks, so + * the bubble keeps its words and says so. + */ +export type PointAskSubmitResult = + | { ok: true } + | { ok: false; code: 'busy' | 'out_of_credits' | 'unavailable' | 'error'; message: string } + +/** What the chat tells the pin about the ask's turn. */ +export type PointAskStatus = { + askId: string + turnId?: string +} /** Pascal is on another turn: the ask waits as its own turn, never steered into it. */ & ( + | { status: 'queued' } + /** The turn started or its live sentence changed. */ + | { status: 'working'; sentence?: string } + /** The turn is waiting for the person's answer to a question. */ + | { status: 'asking'; question: string } + | { + status: 'ended' + outcome: 'completed' | 'cancelled' | 'errored' + /** The turn ended while still waiting on a question. */ + asked: boolean + /** The turn made at least one successful scene-writing tool call. */ + wroteScene: boolean + /** The final reply text. */ + answer?: string + /** What went wrong, in words. */ + error?: string + } +) + +/** What the pin tells the chat: its turn lights on hover and opens on click. */ +export type PointAskPin = { askId: string; action: 'hover' | 'leave' | 'open' } diff --git a/packages/core/src/agent-operations/record-made.ts b/packages/core/src/agent-operations/record-made.ts new file mode 100644 index 0000000000..012a9617ae --- /dev/null +++ b/packages/core/src/agent-operations/record-made.ts @@ -0,0 +1,42 @@ +import { + createdBetween, + type SessionKeep, + type SessionRecord, + startOfSession, +} from './scene-checkpoint' +import type { SceneNodes } from './types' + +/** What a surface lends the recorder: its scene and the session record it keeps. */ +export type MadeRecorderHost = { + getNodes: () => SceneNodes + readSession: () => SessionRecord + keep: (keep: SessionKeep) => void +} + +/** + * Runs a call and keeps what it made in the agent's session: every node in the scene after it that + * was not before, whichever way the call wrote it (a tool of its own, a patch, a shell). Writers that + * report their own creations (`applyAgentOutcome`) say the same, and a node is made once. + * + * The session's first call also takes `start`, before anything is written. A call that replaces the + * scene (a load, a clear) begins another session, and what it lays down is not the agent's. + */ +export async function recordMade( + host: MadeRecorderHost, + run: () => Promise, +): Promise { + // A copy: a host may write its own map in place, and a "before" that grows reads nothing as new. + const before = { ...host.getNodes() } + const first = startOfSession(host.readSession(), before) + if (first) host.keep(first) + const began = host.readSession().checkpoints.start + try { + return await run() + } finally { + // The session of a scene too large to remember has no start, and nothing to look back from. + if (began && host.readSession().checkpoints.start === began) { + const made = createdBetween(before, host.getNodes()) + if (made.length) host.keep({ made }) + } + } +} diff --git a/packages/core/src/agent-operations/roof-footprint.ts b/packages/core/src/agent-operations/roof-footprint.ts new file mode 100644 index 0000000000..3e7ab59e3d --- /dev/null +++ b/packages/core/src/agent-operations/roof-footprint.ts @@ -0,0 +1,288 @@ +// The rectangles a roof is built from, one roof segment each: the footprint's wings, each as long +// as it runs inside the footprint, so they overlap where they meet. The roof renders their union, +// so a wing's hips meet the body's slopes in valleys; wings cut where they meet put a hip end each +// side of the cut, a notch along the joint (L41, Hawkesbury's hip-roof facade). +// Coordinates scaled from a plan carry float noise that sliced the footprint into slivers (468 hip +// segments on the Victor's top floor, which froze the page), so the footprint is snapped first, +// balcony-thin strips join their larger neighbour, and a pathological footprint falls back to its +// bounding box. + +type Pt = [number, number] +export type RoofRectangle = { minX: number; maxX: number; minZ: number; maxZ: number } +export type RoofFootprint = { rectangles: RoofRectangle[]; roofType: 'gable' | 'hip' | 'flat' } + +/** Coordinates snap to 10 cm. */ +const snap = (value: number) => Math.round(value * 10) / 10 +/** Parts thinner than this (balconies, slivers) get no roof of their own. */ +const MIN_PART = 1.5 +const MAX_SEGMENTS = 24 +/** A house has up to this many wings under hips; past it the roof is flat. */ +const MAX_HIP_WINGS = 6 +/** A footprint longer than this is a block, not a house: its roof is flat. */ +const BLOCK_LENGTH = 30 + +function insideAny(polygons: readonly (readonly Pt[])[], x: number, z: number) { + let inside = false + for (const polygon of polygons) + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [ax, az] = polygon[i]! + const [bx, bz] = polygon[j]! + if (az > z !== bz > z && x < ((bx - ax) * (z - az)) / (bz - az) + ax) inside = !inside + } + return inside +} + +/** + * A strip of cells thinner than a part keeps only what its larger neighbour along the other axis + * also has: balconies drop out and the wings they stand on stay whole. Along z, then along x. + */ +function dropThinStrips(cells: boolean[][], xs: number[], zs: number[]) { + const sweep = ( + rows: number, + columns: number, + at: (r: number, c: number) => boolean, + set: (r: number, c: number) => void, + size: (r: number) => number, + ) => { + for (let r = 0; r < rows; r++) { + if (size(r) >= MIN_PART) continue + const neighbours = [r - 1, r + 1].filter((n) => n >= 0 && n < rows && size(n) >= MIN_PART) + const larger = neighbours.sort((a, b) => size(b) - size(a))[0] + if (larger === undefined) continue + for (let c = 0; c < columns; c++) if (at(r, c) && !at(larger, c)) set(r, c) + } + } + const nx = xs.length - 1 + const nz = zs.length - 1 + sweep( + nz, + nx, + (j, i) => cells[i]![j]!, + (j, i) => { + cells[i]![j] = false + }, + (j) => zs[j + 1]! - zs[j]!, + ) + sweep( + nx, + nz, + (i, j) => cells[i]![j]!, + (i, j) => { + cells[i]![j] = false + }, + (i) => xs[i + 1]! - xs[i]!, + ) +} + +export function roofRectangles(polygons: readonly (readonly Pt[])[]): RoofFootprint { + const snapped = polygons.map((polygon) => polygon.map(([x, z]): Pt => [snap(x), snap(z)])) + const all = snapped.flat() + const bounds: RoofRectangle = { + minX: Math.min(...all.map((point) => point[0])), + maxX: Math.max(...all.map((point) => point[0])), + minZ: Math.min(...all.map((point) => point[1])), + maxZ: Math.max(...all.map((point) => point[1])), + } + const xs = [...new Set(all.map((point) => point[0]))].sort((a, b) => a - b) + const zs = [...new Set(all.map((point) => point[1]))].sort((a, b) => a - b) + const nx = xs.length - 1 + const nz = zs.length - 1 + const cells = Array.from({ length: nx }, (_, i) => + Array.from({ length: nz }, (_, j) => + insideAny(snapped, (xs[i]! + xs[i + 1]!) / 2, (zs[j]! + zs[j + 1]!) / 2), + ), + ) + dropThinStrips(cells, xs, zs) + + // Inside cells counted from the corner, to test a rectangle in one step. + const count = Array.from({ length: nx + 1 }, () => new Array(nz + 1).fill(0)) + for (let i = 0; i < nx; i++) + for (let j = 0; j < nz; j++) + count[i + 1]![j + 1] = + (cells[i]![j] ? 1 : 0) + count[i]![j + 1]! + count[i + 1]![j]! - count[i]![j]! + const filled = (i0: number, i1: number, j0: number, j1: number) => + i0 >= 0 && + j0 >= 0 && + i1 <= nx && + j1 <= nz && + count[i1]![j1]! - count[i0]![j1]! - count[i1]![j0]! + count[i0]![j0]! === (i1 - i0) * (j1 - j0) + + // The wings: rectangles of footprint that no row or column of footprint can extend. + type Wing = { i0: number; i1: number; j0: number; j1: number } + const wings: Wing[] = [] + for (let i0 = 0; i0 < nx; i0++) + for (let i1 = i0 + 1; i1 <= nx; i1++) { + if (!filled(i0, i1, 0, 0) && xs[i1]! - xs[i0]! < MIN_PART && i1 < nx) continue + for (let j0 = 0; j0 < nz; j0++) + for (let j1 = j0 + 1; j1 <= nz; j1++) { + if (!filled(i0, i1, j0, j1)) break + if (xs[i1]! - xs[i0]! < MIN_PART || zs[j1]! - zs[j0]! < MIN_PART) continue + const extends_ = + filled(i0 - 1, i1, j0, j1) || + filled(i0, i1 + 1, j0, j1) || + filled(i0, i1, j0 - 1, j1) || + filled(i0, i1, j0, j1 + 1) + if (!extends_) wings.push({ i0, i1, j0, j1 }) + } + } + + // Greedy: the wing covering most of what is left, the longer through the body on a tie, until + // what is left is smaller than a part. + const covered = cells.map((column) => column.map(() => false)) + const area = (w: Wing) => (xs[w.i1]! - xs[w.i0]!) * (zs[w.j1]! - zs[w.j0]!) + const gain = (w: Wing) => { + let total = 0 + for (let i = w.i0; i < w.i1; i++) + for (let j = w.j0; j < w.j1; j++) + if (!covered[i]![j]) total += (xs[i + 1]! - xs[i]!) * (zs[j + 1]! - zs[j]!) + return total + } + const rectangles: RoofRectangle[] = [] + while (rectangles.length <= MAX_SEGMENTS) { + let best: Wing | null = null + let bestGain = 0 + for (const wing of wings) { + const g = gain(wing) + if ( + g > bestGain + 1e-9 || + (best && Math.abs(g - bestGain) <= 1e-9 && area(wing) > area(best)) + ) { + best = wing + bestGain = g + } + } + if (!best || bestGain < MIN_PART * MIN_PART) break + for (let i = best.i0; i < best.i1; i++) + for (let j = best.j0; j < best.j1; j++) covered[i]![j] = true + rectangles.push({ + minX: xs[best.i0]!, + maxX: xs[best.i1]!, + minZ: zs[best.j0]!, + maxZ: zs[best.j1]!, + }) + } + + // Outside the footprint within a rectangle on the grid: what a roof over it adds past the house. + const inside = Array.from({ length: nx + 1 }, () => new Array(nz + 1).fill(0)) + for (let i = 0; i < nx; i++) + for (let j = 0; j < nz; j++) + inside[i + 1]![j + 1] = + (cells[i]![j] ? (xs[i + 1]! - xs[i]!) * (zs[j + 1]! - zs[j]!) : 0) + + inside[i]![j + 1]! + + inside[i + 1]![j]! - + inside[i]![j]! + const at = (values: number[], value: number) => values.indexOf(value) + const outside = (r: RoofRectangle) => { + const [i0, i1, j0, j1] = [at(xs, r.minX), at(xs, r.maxX), at(zs, r.minZ), at(zs, r.maxZ)] + const within = inside[i1]![j1]! - inside[i0]![j1]! - inside[i1]![j0]! + inside[i0]![j0]! + return (r.maxX - r.minX) * (r.maxZ - r.minZ) - within + } + // Of a rectangle, how much the others cover: on the grid, cell by cell. + const coveredBy = (r: RoofRectangle, others: readonly RoofRectangle[]) => { + let total = 0 + for (let i = at(xs, r.minX); i < at(xs, r.maxX); i++) + for (let j = at(zs, r.minZ); j < at(zs, r.maxZ); j++) { + const [cx, cz] = [(xs[i]! + xs[i + 1]!) / 2, (zs[j]! + zs[j + 1]!) / 2] + if (others.some((o) => cx > o.minX && cx < o.maxX && cz > o.minZ && cz < o.maxZ)) + total += (xs[i + 1]! - xs[i]!) * (zs[j + 1]! - zs[j]!) + } + return total + } + const absorbed = mergeWings(rectangles, outside, coveredBy) + + const chosen = absorbed.length === 0 || absorbed.length > MAX_SEGMENTS ? [bounds] : absorbed + const long = Math.max(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ) + const roofType = + long > BLOCK_LENGTH || chosen.length > MAX_HIP_WINGS + ? 'flat' + : chosen.length === 1 + ? 'gable' + : 'hip' + return { rectangles: chosen, roofType } +} + +/** + * A merge may add roof outside the house up to this share of what the wing it takes in covered on + * its own: a jog's corners against its band, not a projection's notches against its own hip. + */ +const MERGE_SHARE = 0.5 + +/** + * A roof runs over a jog: two wings become their bounding rectangle when that adds little roof + * outside the house against what the wing taken in covered on its own (a strip along a jog, a band + * a few decimetres proud). A garage, a projection, a courtyard's arms cover more on their own than a + * merge would add outside, and keep their hips. + */ +function mergeWings( + wings: RoofRectangle[], + outside: (rectangle: RoofRectangle) => number, + covered: (rectangle: RoofRectangle, others: readonly RoofRectangle[]) => number, +): RoofRectangle[] { + const area = (r: RoofRectangle) => (r.maxX - r.minX) * (r.maxZ - r.minZ) + const union = (a: RoofRectangle, b: RoofRectangle): RoofRectangle => ({ + minX: Math.min(a.minX, b.minX), + maxX: Math.max(a.maxX, b.maxX), + minZ: Math.min(a.minZ, b.minZ), + maxZ: Math.max(a.maxZ, b.maxZ), + }) + const contains = (a: RoofRectangle, b: RoofRectangle) => + a.minX <= b.minX && a.maxX >= b.maxX && a.minZ <= b.minZ && a.maxZ >= b.maxZ + let current = [...wings] + while (true) { + let best: { + keep: RoofRectangle + take: RoofRectangle + grown: RoofRectangle + ratio: number + } | null = null + for (const keep of current) + for (const take of current) { + if (keep === take) continue + // What the wing taken in covers that no other wing does. + const own = + area(take) - + covered( + take, + current.filter((r) => r !== take), + ) + if (own <= 0) continue + const grown = union(keep, take) + const ratio = (outside(grown) - outside(keep)) / own + if (ratio <= MERGE_SHARE + 1e-9 && (!best || ratio < best.ratio)) + best = { keep, take, grown, ratio } + } + if (!best) return current + const { keep, take, grown } = best + current = current + .map((r) => (r === keep ? grown : r)) + .filter((r) => r === grown || (r !== take && !contains(grown, r))) + } +} + +/** + * The frame a roof is swept in: the footprint's own axes, from its longest edge. Swept along the + * world axes, a building turned 20° got a staircase of segments (Victor run 5). The rotation + * convention is a roof node's: local (x, z) → (x c + z s, -x s + z c). + */ +export function buildingFrame(polygons: readonly (readonly Pt[])[]) { + let longest = { length: 0, dx: 1, dz: 0 } + for (const polygon of polygons) + for (let i = 0; i < polygon.length; i++) { + const [ax, az] = polygon[i]! + const [bx, bz] = polygon[(i + 1) % polygon.length]! + const length = Math.hypot(bx - ax, bz - az) + if (length > longest.length) longest = { length, dx: bx - ax, dz: bz - az } + } + // The local x axis runs along the longest edge, turned to the nearest quarter of the world's. + const edge = Math.atan2(-longest.dz, longest.dx) + const angle = edge - Math.round(edge / (Math.PI / 2)) * (Math.PI / 2) + const c = Math.cos(angle) + const s = Math.sin(angle) + return { + angle, + polygons: polygons.map((polygon) => + polygon.map(([x, z]): Pt => [x * c - z * s, x * s + z * c]), + ), + toWorld: ([x, z]: Pt): Pt => [x * c + z * s, -x * s + z * c], + } +} diff --git a/packages/core/src/agent-operations/scene-checkpoint.ts b/packages/core/src/agent-operations/scene-checkpoint.ts new file mode 100644 index 0000000000..905113e50b --- /dev/null +++ b/packages/core/src/agent-operations/scene-checkpoint.ts @@ -0,0 +1,215 @@ +import { z } from 'zod' +import { type AnyNode, isDefaultGutterNode } from '../schema' +import { isDerivedNode } from '../store/derived-node-guard' +import { measureScene, type SceneMeasure } from './scene-measure' +import { levelIdOf } from './scene-queries' +import type { SceneChanges, SceneNodes } from './types' + +/** + * What an agent session keeps about its own work: the moments it named, and what it made. The core + * computes and reads these and stores none: each host keeps its record where its sessions live, so + * a checkpoint is never scene data (it would dirty the scene, enter undo and change its hash). + */ + +/** One node as a checkpoint remembers it: enough to say what it was when it is gone. */ +export type CheckpointNode = { type: string; levelId: string | null; hash: string; name?: string } + +/** A node the agent created, kept when it is later deleted: the proof it was there. */ +export type MadeEntry = { id: string; type: string; levelId: string | null; name?: string } + +export type SessionCheckpoint = { + name: string + /** The registered families' places, for `changesSince` (windows, lit rooms…). */ + measure: SceneMeasure + nodes: Record + /** Issue ids an older version recorded; records taken now write none. */ + issues: string[] + /** How many entries of the session's `made` list there were: what follows was made after it. */ + madeAt: number +} + +export type SessionRecord = { + checkpoints: Record + made: MadeEntry[] +} + +/** What an operation or a write asks its host to keep. */ +export type SessionKeep = { checkpoint?: SessionCheckpoint; made?: readonly MadeEntry[] } + +export const EMPTY_SESSION: SessionRecord = { checkpoints: {}, made: [] } + +/** A session names at most this many moments, `start` included. */ +export const MAX_CHECKPOINTS = 8 +/** A checkpoint of a larger scene is refused: it is about 60 bytes a node, kept with the session. */ +export const MAX_CHECKPOINT_NODES = 5000 +/** The made list stops growing here; the session record stays bounded. */ +export const MAX_MADE = 20_000 + +/** + * The record after a keep: a checkpoint of the same name is replaced, made entries accumulate. A node + * is made once, however many writers report it, and the list only ever grows at its end (a + * checkpoint's `madeAt` indexes into it). What the scene had when the session began was never made + * by the agent: an undo that puts it back does not make it. + */ +export function keepInSession(session: SessionRecord, keep: SessionKeep): SessionRecord { + const known = new Set(session.made.map((entry) => entry.id)) + const began = session.checkpoints.start?.nodes + const fresh = (keep.made ?? []).filter((entry) => { + if (known.has(entry.id) || began?.[entry.id]) return false + known.add(entry.id) + return true + }) + return { + checkpoints: keep.checkpoint + ? { ...session.checkpoints, [keep.checkpoint.name]: keep.checkpoint } + : session.checkpoints, + made: fresh.length ? [...session.made, ...fresh].slice(0, MAX_MADE) : session.made, + } +} + +const FNV_PRIME = 0x01000193 + +/** Plain JSON in a fixed key order, numbers to a tenth of a millimetre, undefined dropped. */ +function stable(value: unknown): string { + if (typeof value === 'number') return String(Math.round(value * 1e4) / 1e4) + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]` + const record = value as Record + return `{${Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stable(record[key])}`) + .join(',')}}` +} + +/** + * A node's fingerprint, eight hex digits. Its children are left out (a wall that gained a door did + * not change; the door is new) and so are the notes older versions kept in `metadata.coherence`: a + * session record taken before them must not read them as an edit of the building. + */ +export function hashNode(node: AnyNode): string { + const { + children: _children, + metadata, + ...rest + } = node as AnyNode & { + children?: unknown + metadata?: Record + } + const { coherence: _notes, ...kept } = metadata ?? {} + let hash = 0x811c9dc5 + const text = stable({ ...rest, metadata: kept }) + for (let index = 0; index < text.length; index++) { + hash ^= text.charCodeAt(index) + hash = Math.imul(hash, FNV_PRIME) >>> 0 + } + return hash.toString(16).padStart(8, '0') +} + +const nameOf = (node: AnyNode) => + typeof node.name === 'string' && node.name ? { name: node.name } : {} + +/** The scene as a checkpoint remembers it: every node's fingerprint and the registered families' places. */ +export function takeCheckpoint(nodes: SceneNodes, name: string, madeAt: number): SessionCheckpoint { + const remembered: Record = {} + for (const node of Object.values(nodes)) + remembered[node.id] = { + type: node.type, + levelId: levelIdOf(nodes, node.id), + hash: hashNode(node), + ...nameOf(node), + } + return { + name, + measure: measureScene(nodes), + nodes: remembered, + issues: [], + madeAt, + } +} + +/** + * Construction the editor derives from what the agent drew: floor plates, auto ceilings, default + * gutters. Their ids change as the reconciler rebuilds them, so none is a thing the agent made. + */ +const isDerivedConstruction = (node: AnyNode) => isDerivedNode(node) || isDefaultGutterNode(node) + +const madeEntry = (nodes: SceneNodes, node: AnyNode): MadeEntry => ({ + id: node.id, + type: node.type, + levelId: levelIdOf(nodes, node.id), + ...nameOf(node), +}) + +/** The nodes a write created, as the session remembers them. */ +export function madeEntries(after: SceneNodes, written: readonly SceneChanges[]): MadeEntry[] { + return written + .flatMap((changes) => (changes.create ?? []).map(({ node }) => after[node.id] ?? node)) + .filter((node) => !isDerivedConstruction(node)) + .map((node) => madeEntry(after, node)) +} + +/** + * The nodes that are in `after` and were not in `before`: what a writer that diffs made, which is + * every tool that writes, whatever it calls to do so. + */ +export function createdBetween(before: SceneNodes, after: SceneNodes): MadeEntry[] { + return Object.values(after) + .filter((node) => !(node.id in before) && !isDerivedConstruction(node)) + .map((node) => madeEntry(after, node)) +} + +/** + * What a host keeps at a session's first call, before anything is written: the scene as it stood, + * under the name `start`. The beginning of the agent's work is always a checkpoint. Nothing when + * the session has one, or the scene is too large to remember. + */ +export function startOfSession(session: SessionRecord, nodes: SceneNodes): SessionKeep | undefined { + if (session.checkpoints.start || Object.keys(nodes).length > MAX_CHECKPOINT_NODES) + return undefined + return { checkpoint: takeCheckpoint(nodes, 'start', session.made.length) } +} + +const Place = z.object({ x: z.number(), z: z.number(), name: z.string().optional() }) +const Measure = z.object({ + levels: z.record( + z.string(), + z.object({ name: z.string(), families: z.record(z.string(), z.array(Place)) }), + ), +}) +const Remembered = z.object({ + type: z.string(), + levelId: z.string().nullable(), + hash: z.string().regex(/^[0-9a-f]{8}$/), + name: z.string().optional(), +}) +const Checkpoint = z.object({ + name: z.string().min(1), + measure: Measure, + nodes: z + .record(z.string(), Remembered) + .refine((nodes) => Object.keys(nodes).length <= MAX_CHECKPOINT_NODES), + issues: z.array(z.string()), + madeAt: z.number().int().min(0), +}) +const Made = z.object({ + id: z.string(), + type: z.string(), + levelId: z.string().nullable(), + name: z.string().optional(), +}) +const Record_ = z.object({ + checkpoints: z + .record(z.string(), Checkpoint) + .refine((checkpoints) => Object.keys(checkpoints).length <= MAX_CHECKPOINTS), + made: z.array(Made).max(MAX_MADE), +}) + +/** + * A session record read back from where a host stored it. Anything that is not one is no record: + * the session starts empty rather than the host failing on what is only a convenience. + */ +export function parseSessionRecord(input: unknown): SessionRecord { + const parsed = Record_.safeParse(input) + return parsed.success ? (parsed.data as SessionRecord) : EMPTY_SESSION +} diff --git a/packages/core/src/agent-operations/scene-review.ts b/packages/core/src/agent-operations/scene-review.ts new file mode 100644 index 0000000000..e7d34917a3 --- /dev/null +++ b/packages/core/src/agent-operations/scene-review.ts @@ -0,0 +1,157 @@ +import { refuse } from '../agent-tools/refusal' +import { getLevelDisplayName } from '../lib/level-name' +import type { AnyNode } from '../schema' +import { hashNode as hashOf, type SessionRecord } from './scene-checkpoint' +import { changesSince, type LostSinceCheckpointIssue, type MeasureChange } from './scene-measure' +import { levelIdOf, levelsOf } from './scene-queries' +import type { SceneNodes } from './types' + +/** A node of these kinds can be the target of a view_scene; the others are seen through their level. */ +const VIEWABLE = new Set(['wall', 'zone', 'door', 'window', 'item']) +/** A group lists this many ids of each kind; the counts are exact. */ +const IDS_PER_KIND = 12 + +export type ReviewGroup = { + levelId: string | null + level: string + kind: string + created: number + changed: number + deleted: number + ids: { created: string[]; changed: string[]; deleted: string[] } + /** A ready view_scene call that shows the group. */ + view?: { target: string; from: 'south-west'; elevation: 35 } +} + +export type LostElement = { + id: string + kind: string + level: string + name?: string + when: 'there at the checkpoint' | 'made since the checkpoint' +} + +export type SinceReview = { + checkpoint: string + totals: { created: number; changed: number; deleted: number } + groups: ReviewGroup[] + /** What the agent made and is gone now, since the checkpoint. */ + lost: LostElement[] + /** The registered families, by place: windows, lit rooms… that are gone or new. */ + places?: { changes: MeasureChange[]; issues: LostSinceCheckpointIssue[] } +} + +type Bucket = { + levelId: string | null + kind: string + created: string[] + changed: string[] + deleted: string[] +} + +/** + * The scene now against a checkpoint the session named: what was created, changed and deleted, + * grouped by level and kind with a view each, and what the agent made that is gone. Pure: the + * session record comes from the host. + */ +export function reviewSince(session: SessionRecord, name: string, nodes: SceneNodes): SinceReview { + const checkpoint = session.checkpoints[name] + if (!checkpoint) { + const names = Object.keys(session.checkpoints) + refuse( + 'checkpoint_not_found', + names.length + ? `There is no checkpoint "${name}". The session has: ${names.join(', ')}.` + : `There is no checkpoint "${name}", and the session has none: name this moment with set_checkpoint.`, + { checkpoints: names }, + ) + } + + const buckets = new Map() + const bucket = (levelId: string | null, kind: string) => { + const key = `${levelId ?? ''}|${kind}` + let found = buckets.get(key) + if (!found) { + found = { levelId, kind, created: [], changed: [], deleted: [] } + buckets.set(key, found) + } + return found + } + const levelName = (levelId: string | null) => { + if (!levelId) return 'The building' + const level = nodes[levelId] + if (level?.type === 'level') return getLevelDisplayName(level) + return checkpoint.nodes[levelId]?.name ?? levelId + } + + for (const node of Object.values(nodes)) { + const was = checkpoint.nodes[node.id] + const levelId = levelIdOf(nodes, node.id) + if (!was) bucket(levelId, node.type).created.push(node.id) + else if (was.hash !== hashOf(node)) bucket(levelId, node.type).changed.push(node.id) + } + for (const [id, was] of Object.entries(checkpoint.nodes)) + if (!nodes[id]) bucket(was.levelId, was.type).deleted.push(id) + + const order = new Map(levelsOf(nodes).map((level, index) => [level.id, index])) + const groups: ReviewGroup[] = [...buckets.values()] + .sort( + (a, b) => + (order.get(a.levelId ?? '') ?? 1e6) - (order.get(b.levelId ?? '') ?? 1e6) || + a.kind.localeCompare(b.kind), + ) + .map((entry) => { + const touched = [...entry.created, ...entry.changed] + const only = touched.length === 1 && entry.deleted.length === 0 ? touched[0] : undefined + const target = + only && VIEWABLE.has(entry.kind) ? only : (entry.levelId ?? firstBuilding(nodes)) + const cap = (ids: string[]) => ids.slice().sort().slice(0, IDS_PER_KIND) + return { + levelId: entry.levelId, + level: levelName(entry.levelId), + kind: entry.kind, + created: entry.created.length, + changed: entry.changed.length, + deleted: entry.deleted.length, + ids: { + created: cap(entry.created), + changed: cap(entry.changed), + deleted: cap(entry.deleted), + }, + ...(target + ? { view: { target, from: 'south-west' as const, elevation: 35 as const } } + : {}), + } + }) + + const lost: LostElement[] = [] + session.made.forEach((entry, index) => { + if (nodes[entry.id]) return + const there = entry.id in checkpoint.nodes + if (!there && index < checkpoint.madeAt) return + lost.push({ + id: entry.id, + kind: entry.type, + level: levelName(entry.levelId), + ...(entry.name ? { name: entry.name } : {}), + when: there ? 'there at the checkpoint' : 'made since the checkpoint', + }) + }) + + const places = changesSince({ name: checkpoint.name, measure: checkpoint.measure }, nodes) + + return { + checkpoint: name, + totals: { + created: groups.reduce((sum, group) => sum + group.created, 0), + changed: groups.reduce((sum, group) => sum + group.changed, 0), + deleted: groups.reduce((sum, group) => sum + group.deleted, 0), + }, + groups, + lost, + ...(places.changes.length ? { places } : {}), + } +} + +const firstBuilding = (nodes: SceneNodes): string | undefined => + Object.values(nodes).find((node: AnyNode) => node.type === 'building')?.id diff --git a/packages/core/src/agent-operations/scene-view.test.ts b/packages/core/src/agent-operations/scene-view.test.ts index 54133836f0..190a9adf97 100644 --- a/packages/core/src/agent-operations/scene-view.test.ts +++ b/packages/core/src/agent-operations/scene-view.test.ts @@ -23,6 +23,7 @@ import { photoCropSize, type SceneViewBox, sceneViewBounds, + sceneViewFacing, sceneViewInterior, sceneViewNote, sceneViewPlan, @@ -372,6 +373,8 @@ describe("the photo's camera", () => { }) }) +// Run 3 (2026-10-05) compared whole facades with view_scene and never recorded what the photo +// showed: the note a view comes with invites the inventory until one exists (L15). describe('the note a view comes with', () => { test('says a view is a picture to compare, not a measure', () => { expect(sceneViewNote()).toContain('not a measure') @@ -850,3 +853,28 @@ describe('a building away from the origin', () => { expect(pose.position[0]).toBeLessThan(box.min[0]) }) }) + +// Follow Pascal looks at an opening from the way it faces; the agent's own view of it takes the same side. +describe('which way an opening faces', () => { + test('outward, in the site plan, whichever way its wall was drawn', () => { + for (const outside of ['back', 'front'] as const) { + expect(sceneViewFacing(facadeScene(outside), 'door_face')).toEqual([0, -1]) + expect(sceneViewFacing(facadeScene(outside), 'window_face')).toEqual([0, -1]) + } + }) + + test('turned with the building', () => { + const facing = sceneViewFacing( + facadeScene('back', { position: [5.7, 0, 16.5], rotation: [0, Math.PI / 2, 0] }), + 'door_face', + )! + expect(facing[0]).toBeCloseTo(-1, 9) + expect(facing[1]).toBeCloseTo(0, 9) + }) + + test('nothing for what is not an opening', () => { + expect(sceneViewFacing(facadeScene('back'), 'wall_face')).toBeNull() + expect(sceneViewFacing(facadeScene('back'), 'item_lamp')).toBeNull() + expect(sceneViewFacing(facadeScene('back'), 'nothing_here')).toBeNull() + }) +}) diff --git a/packages/core/src/agent-operations/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts index 75d634cd3f..b27bde27f5 100644 --- a/packages/core/src/agent-operations/scene-view.ts +++ b/packages/core/src/agent-operations/scene-view.ts @@ -429,6 +429,20 @@ function outwardOf(opening: AnyNode & { rotation?: number[] }, wall: WallNode, y return [ux * Math.cos(yaw) + uz * Math.sin(yaw) + 0, -ux * Math.sin(yaw) + uz * Math.cos(yaw) + 0] } +/** + * Which way a door or a window faces, outward, as a unit vector in the site's plan (x, z): what a + * view of it is taken from, and what a camera that follows the build looks at it along. Null for + * anything that is not an opening in a wall. + */ +export function sceneViewFacing( + nodes: Readonly>, + id: string, +): [number, number] | null { + const opening = openingOf(nodes, nodes[id]) + if (!opening) return null + return outwardOf(opening.opening, opening.wall, standingOf(nodes, opening.wall.parentId).yaw) +} + /** * Where a building stands on its site. What is under a building (levels, walls, openings, roofs) * is drawn in the building's own frame, which the editor puts at the building's position and yaw: diff --git a/packages/core/src/agent-operations/set-checkpoint.ts b/packages/core/src/agent-operations/set-checkpoint.ts new file mode 100644 index 0000000000..0200997cd8 --- /dev/null +++ b/packages/core/src/agent-operations/set-checkpoint.ts @@ -0,0 +1,45 @@ +import { refuse } from '../agent-tools/refusal' +import { + EMPTY_SESSION, + MAX_CHECKPOINT_NODES, + MAX_CHECKPOINTS, + takeCheckpoint, +} from './scene-checkpoint' +import type { AgentOperation } from './types' + +/** + * `set_checkpoint`: the scene now, remembered under a name. The operation takes the checkpoint and + * hands it back for the host to keep with the session; nothing in the scene changes. + */ +export const setCheckpoint: AgentOperation<{ name: string }> = (nodes, input, context) => { + const name = input.name.trim() + if (!name) refuse('name_required', 'Give the checkpoint a name, e.g. "walls done".') + const session = context.session ?? EMPTY_SESSION + const names = Object.keys(session.checkpoints) + const replaced = names.includes(name) + if (!replaced && names.length >= MAX_CHECKPOINTS) + refuse( + 'too_many_checkpoints', + `A session keeps at most ${MAX_CHECKPOINTS} checkpoints and has ${names.join(', ')}. Use one of those names to replace it.`, + { checkpoints: names }, + ) + const size = Object.keys(nodes).length + if (size > MAX_CHECKPOINT_NODES) + refuse( + 'scene_too_large', + `The scene has ${size} nodes; a checkpoint is kept for at most ${MAX_CHECKPOINT_NODES}.`, + { nodes: size }, + ) + const checkpoint = takeCheckpoint(nodes, name, session.made.length) + return { + result: { + ok: true, + name, + replaced, + nodes: size, + checkpoints: replaced ? names : [...names, name], + message: `Checkpoint "${name}" kept with this session. verify_scene({ since: "${name}" }) lists what changes after it.`, + }, + keep: { checkpoint }, + } +} diff --git a/packages/core/src/agent-operations/types.ts b/packages/core/src/agent-operations/types.ts index bce8b99a06..d86f2514eb 100644 --- a/packages/core/src/agent-operations/types.ts +++ b/packages/core/src/agent-operations/types.ts @@ -1,19 +1,23 @@ import type { AnyNode, AssetInput, Collection, SceneMaterial, SceneMaterialId } from '../schema' +import type { SessionKeep, SessionRecord } from './scene-checkpoint' export type SceneNodes = Readonly> /** - * What a surface knows beyond the scene: the chat knows the floor a person is viewing; a host that - * keeps checkpoints passes the one asked for, and verify_scene compares the scene with it. A host - * with an item library passes it as `catalog` (the chat and the hosted MCP: the published library; - * the standalone MCP: its built-in list), resolved before the call, as the operations are sync. - * A host that can store scene materials with the nodes that use them passes the scene's - * `materials`: paint then checks scene refs against them and keeps a native material as one. + * What a surface knows beyond the scene: the chat knows the floor a person is viewing; a host + * passes the agent's session so far (the moments it named, what it made), which verify_scene + * compares the scene with. A host with an item library passes it as `catalog` (the chat and the + * hosted MCP: the published library; the standalone MCP: its built-in list), resolved before the + * call, as the operations are sync. A host that can store scene materials with the nodes that use + * them passes the scene's `materials`: paint then checks scene refs against them and keeps a native + * material as one. */ export type AgentContext = { activeLevelId: string | null /** Opaque here: its measure is the measure module's (scene-measure), which reads it. */ checkpoint?: { name: string; measure: unknown } + /** The agent's session so far. The host keeps it where its sessions live; it is never scene data. */ + session?: SessionRecord catalog?: readonly AssetInput[] materials?: Readonly> } @@ -32,6 +36,8 @@ export type SceneChanges = { export type AgentOperationOutcome = { result: Record + /** What the operation asks the host to keep with the session: a checkpoint it took. */ + keep?: SessionKeep changes?: SceneChanges /** * For an edit that depends on construction the host derives from `changes` (rooms re-derived diff --git a/packages/core/src/agent-operations/verify-scene.ts b/packages/core/src/agent-operations/verify-scene.ts index 9ca3ed26a3..f82af58ae4 100644 --- a/packages/core/src/agent-operations/verify-scene.ts +++ b/packages/core/src/agent-operations/verify-scene.ts @@ -1,15 +1,22 @@ 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 { nodeRegistry } from '../registry' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema' -import { getStoredLevelHeight } from '../services/storey' +import { getStoredLevelHeight, getWallCoveringSlabs, getWallTop } from '../services/storey' import { computeSegmentTransforms, rotateXZ } from '../systems/stair/stair-footprint' import { resolveStairTotalRise } from '../systems/stair/stair-rise-query' import { measureStair } from '../systems/stair/stair-sizing' import { checkOpeningWithinWall, formatOpeningBoundsIssue } from '../validation/opening-bounds' +import { + type CeilingObjectObservation, + ceilingObjectObservations, +} from './ceiling-object-observations' import { layoutIssuesFromScene } from './layout-clearance' import { wallResolvedHeight } from './level-reads' import { pointInPolygon, polygonContainsPolygon, type Vec2 } from './plan-geometry' +import { EMPTY_SESSION } from './scene-checkpoint' import { changesSince, type SceneCheckpoint } from './scene-measure' import { type ContentCounts, @@ -19,11 +26,28 @@ import { levelsOf, nodesOnLevel, } from './scene-queries' +import { reviewSince } from './scene-review' import type { AgentOperation, SceneNodes } from './types' +import { type WallObjectObservation, wallObjectObservations } from './wall-object-observations' /** A problem verify_scene found, typed so it can be counted and acted on. */ export type SceneIssue = + | CeilingObjectObservation + | WallObjectObservation | { type: string; message: string; severity?: 'info' } + | { + type: 'wall_covering_slab_contact' + message: string + severity?: 'info' + wallId: string + slabId: string + levelId: string + wallTop: number + slabUnderside: number + slabTop: number + penetration: number + coplanar: boolean + } | { type: 'wall_open_end' message: string @@ -198,7 +222,8 @@ function parentListsChild(parent: AnyNode, childId: string): boolean { function schemaErrors(nodes: SceneNodes) { const errors: { nodeId: string; path: string; message: string }[] = [] for (const [id, node] of Object.entries(nodes)) { - const parsed = AnyNodeSchema.safeParse(node) + const schema = nodeRegistry.get(node.type)?.schema ?? AnyNodeSchema + const parsed = schema.safeParse(node) if (parsed.success) continue for (const issue of parsed.error.issues) errors.push({ nodeId: id, path: issue.path.join('.'), message: issue.message }) @@ -299,6 +324,34 @@ export const verifyScene: AgentOperation = ( ) } + for (const level of levels) { + for (const wall of ofType(onLevel(level.levelId), 'wall')) { + const base = wallSupportForNodes(wall, nodes).elevation + const wallTop = getWallTop(wall, level.levelId, nodes, base) + for (const { slab, top: slabTop, underside: slabUnderside } of getWallCoveringSlabs( + wall, + level.levelId, + nodes, + )) { + const penetration = Math.max(0, Math.min(wallTop, slabTop) - Math.max(base, slabUnderside)) + const coplanar = Math.abs(wallTop - slabTop) <= 1e-6 + if (penetration <= 1e-6 && !coplanar) continue + issues.push({ + type: 'wall_covering_slab_contact', + message: `Wall ${wall.id} top at ${wallTop.toFixed(3)}m intersects covering slab ${slab.id} [${slabUnderside.toFixed(3)}, ${slabTop.toFixed(3)}]m${coplanar ? ' and is coplanar with its walking surface' : ''}; check the wall height or the slab opening.`, + wallId: wall.id, + slabId: slab.id, + levelId: level.levelId, + wallTop, + slabUnderside, + slabTop, + penetration, + coplanar, + }) + } + } + } + // Storeys are counted per building: separate buildings need no stair between them, and a wall // can only span the storeys of its own building. const storeysByBuilding = new Map() @@ -461,13 +514,20 @@ export const verifyScene: AgentOperation = ( // Door keep-outs and item–item footprint overlaps (rotation-aware). issues.push(...layoutIssuesFromScene(Object.values(nodes))) + issues.push(...ceilingObjectObservations(nodes)) + issues.push(...wallObjectObservations(nodes)) for (const check of checks) if (check.order < CHECKPOINT_ORDER) issues.push(...check.run(nodes, input ?? {})) // What the edits since the host's checkpoint lost, by place. - const since = context.checkpoint + const hosted = context.checkpoint ? changesSince(context.checkpoint as SceneCheckpoint, nodes) : null - if (since) issues.push(...since.issues) + if (hosted) issues.push(...hosted.issues) + // The look back: what the agent did since a moment it named. The places the registered families + // lost count as issues, as a window gone from its place always did. + const session = context.session ?? EMPTY_SESSION + const since = typeof input?.since === 'string' ? reviewSince(session, input.since, nodes) : null + if (since?.places) issues.push(...since.places.issues) for (const check of checks) if (check.order >= CHECKPOINT_ORDER) issues.push(...check.run(nodes, input ?? {})) @@ -487,9 +547,13 @@ export const verifyScene: AgentOperation = ( hasIssues: issues.some((issue) => issue.severity !== 'info'), ...authoredObjects(nodes), ...Object.assign({}, ...reports.map((report) => report.run(nodes))), - ...(since && { - sinceCheckpoint: { name: context.checkpoint!.name, changes: since.changes }, + ...(Object.keys(session.checkpoints).length + ? { checkpoints: Object.keys(session.checkpoints) } + : {}), + ...(hosted && { + sinceCheckpoint: { name: context.checkpoint!.name, changes: hosted.changes }, }), + ...(since && { since }), }, } } diff --git a/packages/core/src/agent-operations/wall-object-observations.ts b/packages/core/src/agent-operations/wall-object-observations.ts new file mode 100644 index 0000000000..00eb2df0e7 --- /dev/null +++ b/packages/core/src/agent-operations/wall-object-observations.ts @@ -0,0 +1,331 @@ +import { getOpeningWallCut, wallSupportForNodes } from '../lib/opening-floor-datum' +import { + area, + difference, + intersection, + type MultiPolygon, + type Ring, + union, +} from '../lib/polygon-boolean' +import { + boundsOf, + boxCorners, + frame, + rotateVector, + transformPoint, +} from '../procedural-items/spatial' +import type { AnyNode, ItemNode, WallNode } from '../schema' +import { getLevelElevations, getWallTop } from '../services/storey' +import { getWallBodyCenterOffset, getWallLocalFaceZ } from '../systems/wall/wall-frame' + +type Vec3 = [number, number, number] +export type WallObjectFace = { normal: Vec3; depth: number; polygon: Ring } +/** Geometry evidence in the host wall's local frame, after all corrective model transforms. */ +export type WallObjectGeometry = { corners: Vec3[]; faces: WallObjectFace[] } +type Evidence = { + severity?: 'info' + itemId: string + wallId: string + levelId: string + side: 'front' | 'back' + coordinateFrame: 'building' + bounds?: { min: Vec3; max: Vec3 } + evidence: 'script_bounds' | 'rendered_bounds' | 'rendered_faces' | 'mount_orientation' +} +export type WallObjectObservation = Evidence & { + type: + | 'item_wall_body_overlap' + | 'item_through_wall' + | 'item_faces_wall' + | 'item_wall_coplanar_face' + | 'item_wall_object_coplanar_face' + message: string + overlapDepthMeters?: number + beyondOppositeFaceMeters?: number + overlapAreaSquareMeters?: number + frontDotOutward?: number + otherItemId?: string + separationMeters?: number + toleranceMeters?: number +} + +const TOLERANCE = 0.001 +const MIN_FACE_AREA = 0.04 +const finiteVector = (v: readonly number[]) => v.length === 3 && v.every(Number.isFinite) +const rectangle = (left: number, bottom: number, right: number, top: number): Ring => [ + [left, bottom], + [right, bottom], + [right, top], + [left, top], +] + +function projectedHull(corners: Vec3[]): Ring { + const points = corners + .map(([x, y]): [number, number] => [x, y]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]) + .filter((p, i, all) => !i || p[0] !== all[i - 1]![0] || p[1] !== all[i - 1]![1]) + const cross = (a: Ring[number], b: Ring[number], c: Ring[number]) => + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + const half = (input: Ring) => { + const hull: Ring = [] + for (const p of input) { + while (hull.length > 1 && cross(hull.at(-2)!, hull.at(-1)!, p) <= 0) hull.pop() + hull.push(p) + } + hull.pop() + return hull + } + return [...half(points), ...half([...points].reverse())] +} + +function scriptGeometry(item: ItemNode, wall: WallNode): WallObjectGeometry | undefined { + const bounds = item.source?.manifest.bounds + const assetScale = item.asset.scale ?? [1, 1, 1] + if ( + !bounds || + item.source?.manifest.animations?.length || + ![ + bounds.min, + bounds.max, + item.position, + item.rotation, + item.scale, + item.asset.offset, + item.asset.rotation, + assetScale, + ].every(finiteVector) || + bounds.min.some((v, axis) => v > bounds.max[axis]!) || + [...item.scale, ...assetScale].some((v) => v <= 0) + ) + return + const z = + item.asset.attachTo === 'wall-side' + ? getWallLocalFaceZ(wall, item.side === 'front' ? 'a' : 'b') + : item.position[2] + getWallBodyCenterOffset(wall) + const pose = frame([item.position[0], item.position[1], z], item.rotation) + const corners = boxCorners(bounds.min, bounds.max).map((point) => { + const rotated = rotateVector( + point.map((v, axis) => v * assetScale[axis]!) as Vec3, + item.asset.rotation, + ) + return transformPoint( + pose, + rotated.map((v, axis) => (v + item.asset.offset[axis]!) * item.scale[axis]!) as Vec3, + ) + }) + return corners.every(finiteVector) ? { corners, faces: [] } : undefined +} + +function wallSolid( + wall: WallNode, + nodes: Readonly>, +): MultiPolygon | undefined { + const support = wallSupportForNodes(wall, nodes).elevation + const height = getWallTop(wall, wall.parentId!, nodes, support) - support + const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + if ( + ![height, length, support, wall.thickness ?? 0.1].every(Number.isFinite) || + height <= 0 || + length <= 0 + ) + return + const holes: Ring[] = [] + for (const child of Object.values(nodes)) { + if (child.parentId !== wall.id) continue + // Wall cutters have no persisted XY outline: their bounds cannot prove solid contact. + if ( + (child.type === 'item' || child.type === 'door' || child.type === 'window') && + child.source?.manifest.cutout + ) + return + if (child.type !== 'door' && child.type !== 'window') continue + const cut = getOpeningWallCut(wall, child, nodes) + // For shaped openings the enclosing rectangle conservatively withholds evidence. + holes.push( + rectangle( + child.position[0] - child.width / 2, + cut.bottom - support, + child.position[0] + child.width / 2, + cut.top - support, + ), + ) + } + return difference(rectangle(0, 0, length, height), union(holes)) +} + +function facePatches(faces: WallObjectFace[]) { + const patches: { depth: number; sign: number; rings: Ring[] }[] = [] + for (const face of faces) { + if ( + !finiteVector(face.normal) || + !Number.isFinite(face.depth) || + Math.abs(face.normal[2]) < 0.999 || + face.polygon.length < 3 || + face.polygon.some((p) => !p.every(Number.isFinite)) + ) + continue + const sign = Math.sign(face.normal[2]) + const existing = patches.find( + (patch) => patch.sign === sign && Math.abs(patch.depth - face.depth) < 1e-5, + ) + if (existing) existing.rings.push(face.polygon) + else patches.push({ depth: face.depth, sign, rings: [face.polygon] }) + } + return patches + .map(({ rings, ...patch }) => ({ ...patch, polygons: union(rings) })) + .filter((patch) => area(patch.polygons) >= MIN_FACE_AREA) +} + +/** Bounds contact is not exact mesh intersection; rendered face patches are assessed only when supplied. */ +export function wallObjectObservations( + nodes: Readonly>, + rendered: Readonly> = {}, +): WallObjectObservation[] { + const observations: WallObjectObservation[] = [] + const elevations = getLevelElevations(nodes) + const solids = new Map() + const peers: { evidence: Evidence; patches: ReturnType }[] = [] + for (const item of Object.values(nodes)) { + if ( + item.type !== 'item' || + !item.visible || + item.roofSegmentId || + item.blockFaceId || + (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') || + item.source?.manifest.animations?.length || + item.source?.manifest.cutout + ) + continue + const wall = item.parentId ? nodes[item.parentId] : undefined + const level = wall?.parentId ? nodes[wall.parentId] : undefined + if ( + wall?.type !== 'wall' || + !wall.visible || + wall.curveOffset || + level?.type !== 'level' || + !level.visible || + !finiteVector(item.rotation) || + !finiteVector(item.scale) || + item.scale.some((v) => v <= 0) || + ![...wall.start, ...wall.end, wall.thickness ?? 0.1, elevations.get(level.id)?.baseY].every( + Number.isFinite, + ) || + (wall.thickness ?? 0.1) <= 0 + ) + continue + if (!solids.has(wall.id)) solids.set(wall.id, wallSolid(wall, nodes)) + const solid = solids.get(wall.id) + const geometry = rendered[item.id] ?? scriptGeometry(item, wall) + // A wall-side item with no `side` is drawn on the back face (the item system and the plan + // symbols read `side === 'front' ? a : b`): observed where it is drawn. + const side = item.side === 'front' ? 'front' : 'back' + const sign = side === 'front' ? 1 : -1 + const hostFace = getWallLocalFaceZ(wall, sign > 0 ? 'a' : 'b') + const oppositeFace = getWallLocalFaceZ(wall, sign > 0 ? 'b' : 'a') + const evidence: Evidence = { + itemId: item.id, + wallId: wall.id, + levelId: level.id, + side, + coordinateFrame: 'building', + evidence: geometry + ? rendered[item.id] + ? 'rendered_bounds' + : 'script_bounds' + : 'mount_orientation', + } + if (geometry?.corners.length && geometry.corners.every(finiteVector)) { + const local = boundsOf(geometry.corners) + const projection = projectedHull(geometry.corners) + const solidArea = solid ? area(intersection(projection, solid)) : undefined + // Entirely in a cutout (or outside the finite host) is not wall contact evidence. + if (solidArea !== undefined && solidArea <= 1e-6) continue + const support = wallSupportForNodes(wall, nodes).elevation + const wallPose = frame( + [wall.start[0], (elevations.get(level.id)?.baseY ?? 0) + support, wall.start[1]], + [0, -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]), 0], + ) + const world = boundsOf(geometry.corners.map((p) => transformPoint(wallPose, p))) + evidence.bounds = { min: world.min, max: world.max } + const overlap = + Math.min(local.max[2], Math.max(hostFace, oppositeFace)) - + Math.max(local.min[2], Math.min(hostFace, oppositeFace)) + if (solidArea !== undefined && overlap > TOLERANCE) { + observations.push({ + ...evidence, + type: 'item_wall_body_overlap', + overlapDepthMeters: overlap, + overlapAreaSquareMeters: solidArea, + toleranceMeters: TOLERANCE, + message: `Object ${item.id} transformed bounds overlap wall ${wall.id} body by ${overlap.toFixed(3)} m in depth over ${solidArea.toFixed(3)} m² in projection; bounds overlap is not an exact mesh intersection`, + }) + } + const beyond = sign > 0 ? oppositeFace - local.min[2] : local.max[2] - oppositeFace + if (solidArea !== undefined && beyond > TOLERANCE) + observations.push({ + ...evidence, + type: 'item_through_wall', + beyondOppositeFaceMeters: beyond, + toleranceMeters: TOLERANCE, + message: `Object ${item.id} transformed bounds extend ${beyond.toFixed(3)} m beyond the opposite face of wall ${wall.id}`, + }) + const patches = facePatches(geometry.faces) + peers.push({ evidence, patches }) + if (solid) + for (const patch of patches) { + // Opposed normals describe hidden back-to-wall contact, not two competing visible faces. + if (patch.sign !== sign || Math.abs(patch.depth - hostFace) > TOLERANCE) continue + const overlapArea = area(intersection(patch.polygons, solid)) + if (overlapArea < MIN_FACE_AREA) continue + observations.push({ + ...evidence, + evidence: 'rendered_faces', + type: 'item_wall_coplanar_face', + overlapAreaSquareMeters: overlapArea, + separationMeters: Math.abs(patch.depth - hostFace), + toleranceMeters: TOLERANCE, + message: `Object ${item.id} has ${overlapArea.toFixed(3)} m² of rendered outward face within ${TOLERANCE} m of host wall ${wall.id} face; coplanar visible faces can depth-fight`, + }) + } + } + if (item.source && !finiteVector(item.asset.rotation)) continue + const correctedFront = item.source + ? rotateVector([0, 0, 1], item.asset.rotation) + : ([0, 0, 1] as Vec3) + const front = rotateVector( + correctedFront.map((v, axis) => v * item.scale[axis]!) as Vec3, + item.rotation, + ) + const dot = (front[2] * sign) / Math.hypot(...front) + if (dot < -0.5) + observations.push({ + ...evidence, + type: 'item_faces_wall', + evidence: 'mount_orientation', + frontDotOutward: dot, + message: `Object ${item.id} mounted on wall ${wall.id} ${side} side has its corrected +Z front toward the wall (front·outward ${dot.toFixed(3)})`, + }) + } + for (let i = 0; i < peers.length; i++) + for (const other of peers.slice(i + 1)) { + const item = peers[i]! + if (item.evidence.wallId !== other.evidence.wallId) continue + for (const a of item.patches) + for (const b of other.patches) { + if (a.sign !== b.sign || Math.abs(a.depth - b.depth) > TOLERANCE) continue + const overlap = area(intersection(a.polygons, b.polygons)) + if (overlap < MIN_FACE_AREA) continue + observations.push({ + ...item.evidence, + evidence: 'rendered_faces', + type: 'item_wall_object_coplanar_face', + otherItemId: other.evidence.itemId, + overlapAreaSquareMeters: overlap, + separationMeters: Math.abs(a.depth - b.depth), + toleranceMeters: TOLERANCE, + message: `Wall-mounted objects ${item.evidence.itemId} and ${other.evidence.itemId} have ${overlap.toFixed(3)} m² of same-facing rendered faces within ${TOLERANCE} m; coplanar visible faces can depth-fight`, + }) + } + } + return observations +} diff --git a/packages/core/src/agent-operations/wall-opening.test.ts b/packages/core/src/agent-operations/wall-opening.test.ts index a6d9c1c459..0b6c8a9df6 100644 --- a/packages/core/src/agent-operations/wall-opening.test.ts +++ b/packages/core/src/agent-operations/wall-opening.test.ts @@ -34,14 +34,14 @@ describe('add_door and add_window answer once, for every surface', () => { expect(after[result.doorId!]).toMatchObject({ type: 'door', parentId: OPENING_SCENE.main }) }) - test('a window says its sill height, in the same shape', () => { + test('a window says its sill height, centred on the wall by default, in the same shape', () => { const { result } = addWallOpening(scene(), { kind: 'window', wallId: OPENING_SCENE.main, t: 0.5, }) expect(z.object(addWindowOutput).strict().parse(result)).toEqual(result) - expect(result.sillHeight).toBeCloseTo(0.9, 6) + expect(result.sillHeight).toBeCloseTo(0.5, 6) expect(result.achieved.created).toEqual({ window: 1 }) }) }) diff --git a/packages/core/src/agent-tools/add-object.ts b/packages/core/src/agent-tools/add-object.ts index fbbe2f02fa..e33d7690c4 100644 --- a/packages/core/src/agent-tools/add-object.ts +++ b/packages/core/src/agent-tools/add-object.ts @@ -4,7 +4,7 @@ import { GeometryScriptLanguage } from '../schema/geometry-source' import { measurement } from './measurement' import { NodeId } from './node-id' -const DESCRIPTION = `Build an object by writing a plain three.js module, the way you would in any three.js project. Use it for what the catalog and the structure tools cannot reproduce faithfully: custom columns and capitals, mouldings and trim, panels, lanterns and fixtures, exposed beams, vaulted or tray ceiling bodies, canopies, a porch, railings, built-ins. Pascal runs the module in a sandbox, stores the result and places it as one object the user can move, paint, and ask you to edit again. +const DESCRIPTION = `Author parametric Three.js furniture, decor and custom objects to match requested shapes, dimensions or style. A requested look or custom fit can justify authoring even when generic catalog equivalents exist; catalog items remain suitable when they fit. Examples include a low oak table, a slatted bench, a paper lantern, built-ins, custom columns and capitals, mouldings and trim, panels, exposed beams, ceiling bodies, canopies, a porch and railings. Pascal runs the module in a sandbox, stores the result and places it as one object the user can move, paint, and ask you to edit again. Module shape (import THREE from the three package as usual; the addons below too): // also available: three/addons/utils/BufferGeometryUtils.js, three/addons/geometries/{RoundedBoxGeometry,ConvexGeometry,LoftGeometry,ParametricGeometry}.js, three-bvh-csg (Brush, Evaluator, SUBTRACTION, ADDITION, INTERSECTION) @@ -12,10 +12,10 @@ Module shape (import THREE from the three package as usual; the addons below too export const mount = 'floor' // 'floor' | 'wall-side' (on a wall face) | 'wall' (through a wall, like a window) | 'ceiling' export default function build({ params, THREE }) { const group = new THREE.Group(); /* … */ return group } -One object is one feature that changes together: a porch, a railing run, a fireplace surround, a ceiling with its beams. Never a whole house, and never walls, rooms, floors, roofs, stairs, doors or windows: those have their own tools. A new object says what it stands in for (reason): the scene check lists every authored object with its reason, so each names something Pascal has no type for. On a floor, a plain box with a wall's size or a floor plate is refused with the tool to use (use_walls, use_slab); a detailed object with a wall's size (a bookcase, a screen), or one named after something Pascal builds, is built with a hint naming the tool. +One object is one feature that changes together: a table, a bench, a porch, a railing run, a fireplace surround, a ceiling with its beams. Never a whole house, and never walls, rooms, floors, roofs, stairs, doors or windows: those have their own tools. A new object's reason records its design intent: what the authored shape, dimensions or style contributes to the request. The scene check lists every authored object with its reason; no catalog-exhaustion proof is required. On a floor, a plain box with a wall's size or a floor plate is refused with the tool to use (use_walls, use_slab); a detailed object with a wall's size (a bookcase, a screen), or one named after something Pascal builds, is built with a hint naming the tool. Conventions (they make the object work in Pascal; follow them): -- Metres, Y up, modelled as it stands. Pascal puts the bottom-centre of the bounds at the placement point; for wall-side the back face sits on the wall and the object faces +Z. +- Metres, Y up, modelled in its installed orientation. Floor and wall mounts put the bottom-centre of the visible bounds at the placement point. Ceiling mounts put the top-centre there: visible geometry extends down in negative local Y, with its top at Y = 0. Wall-side mounts put the bottom-centre of the back face there (back at Z = 0, facing +Z). Normalization translates geometry; it does not flip it. - Paint: name every material slot_ (slot_trim, slot_frame, slot_metal) and reuse one material per finish; a material named "glass" renders as glass. - Parts: name the few groups a person would point at part: (part:column_left, part:canopy, part:landing), usually 2–24, a group per part, not every mesh. Set userData.type on parts someone would look for: column, beam, slab, roof, railing, panel, trim, step, light. - Lights: add a THREE.PointLight or SpotLight named light: where the bulb is; it becomes a switchable light. @@ -31,7 +31,7 @@ Blender instead (language 'bpy'): when the shape is easier with Blender's modell def build(params): ... # add objects to the empty scene; everything in it is exported, modifiers applied The same conventions in Blender terms: metres, Z up, the front faces -Y (Blender's Front view); part:, light: (a light object; its custom property intensity sets the brightness, default 2), anchor: (an empty), cutout, cut:wall|ceiling|slab and collider are object names; userData is the object's custom properties (obj["type"] = "panel"); slot_ are material names. A clip is one action named open, close, loop or anything else, assigned to every object that moves in it. Use the data API and bmesh, not operators that need a 3D viewport. A Blender build takes a few seconds. -Edit: pass nodeId with new code and/or params (params alone rebuild the stored script; read it first with get_source to change the code); identity, placement, paint and the params you leave out are kept. The result lists the size, parts, slots, lights, animations and params.` +Edit: pass nodeId with new code and/or params (params alone rebuild the stored script; get_source provides the current code and params when needed); identity, placement, paint and the params you leave out are kept. The result lists the size, parts, slots, lights, animations and params.` /** The `params` field every scripted tool shares (add_object, add_window, add_door, add_column). */ export const scriptParams = z @@ -72,19 +72,20 @@ export const addObjectTool = { .length(3) .optional() .describe( - 'Placement in the host frame, metres. Level: [x, y, z] (y = 0 on the floor). Wall: [distance along the wall from its start, height of the bottom, 0].', + 'Placement of the mount origin in the host frame, metres. Floor: [x, y, z] locates the bottom-centre (y = 0 on the level floor). Ceiling: [x, y, z] locates the top-centre (y = 0 on the drawn ceiling attachment plane; negative y lowers it). Wall / wall-side: [distance along the wall from its start, height of the bottom, 0]; wall-side also anchors the back face.', ), rotation: measurement('angle', 'deg', { - description: 'Y-axis rotation (default: 0).', + description: + 'Y-axis rotation in the host frame. New wall-side back mounts default to 180° so +Z faces away from the wall; other new objects default to 0°. An edit keeps its saved rotation unless given.', }).optional(), side: z .enum(['front', 'back']) .optional() .describe('Which wall face a wall-side object sits on.'), ...geometryMetaFields, - name: geometryMetaFields.name.describe('What the user would call it ("Front porch").'), + name: geometryMetaFields.name.describe('What the user would call it ("Low oak table").'), category: geometryMetaFields.category.describe( - 'What it is, one word or two ("porch", "lantern", "ceiling", "trim").', + 'What it is, one word or two ("table", "bench", "porch", "lantern", "trim").', ), reason: z .string() @@ -92,7 +93,7 @@ export const addObjectTool = { .max(200) .optional() .describe( - 'What it stands in for: why no Pascal tool or catalog item builds it ("no cornice type"). Required to create; an edit keeps it unless given.', + 'Design intent: why this authored shape, size or style suits the request ("low oak table for the Japandi seating area"). Required to create; an edit keeps it unless given. No catalog-exhaustion proof is required.', ), }, } @@ -101,7 +102,7 @@ export const getSourceTool = { name: 'get_source', title: 'Read object script', description: - 'The module (three.js or Blender bpy, named by language) an object (or a window, door or column built from code) runs, with its params and their current values. Read it before changing its code, then pass the edited module back with the same nodeId: to add_object for an object, to add_window / add_door / add_column for their respective kinds.', + 'The module (three.js or Blender bpy, named by language) an object (or a window, door or column built from code) runs, with its params and their current values. Optional source evidence when the current module is needed. An edited module can be passed back with the same nodeId: to add_object for an object, to add_window / add_door / add_column for their respective kinds.', input: { nodeId: NodeId.describe('An object, window, door or column built from code.'), }, diff --git a/packages/core/src/agent-tools/assets.ts b/packages/core/src/agent-tools/assets.ts index af9d7b18be..2c16f77cd8 100644 --- a/packages/core/src/agent-tools/assets.ts +++ b/packages/core/src/agent-tools/assets.ts @@ -4,7 +4,7 @@ export const searchAssetsTool = { name: 'search_assets', title: 'Search assets', description: - "Search the item library (furniture, fixtures, lights, plants, outdoor) by keyword, several queries in one call; call it before placing an item to get a valid asset id. A query matches the items whose name, id, category or tags hold every one of its words. Returns one group per query, each item with its id, name, category, dimensions [width, height, depth] in metres and attachTo (null on the floor, else 'wall', 'wall-side' or 'ceiling'). Batch related lookups (sofa, coffee table, tv stand) instead of separate calls.", + "Search the item library (furniture, fixtures, lights, plants, outdoor) by keyword, with one or several queries in a call. Results provide valid asset ids and placement facts when these are not already known. A query matches the items whose name, id, category or tags hold every one of its words. Returns one group per query, each item with its id, name, category, dimensions [width, height, depth] in metres and attachTo (null on the floor, else 'wall', 'wall-side' or 'ceiling'). Related lookups (sofa, coffee table, tv stand) can share a call.", input: { queries: z .array( diff --git a/packages/core/src/agent-tools/batch.ts b/packages/core/src/agent-tools/batch.ts new file mode 100644 index 0000000000..e0222a1013 --- /dev/null +++ b/packages/core/src/agent-tools/batch.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' + +/** The shared tools a batch runs; a test keeps this in step with the operations registry. */ +export const BATCHABLE_TOOLS = [ + 'paint', + 'search_materials', + 'list_levels', + 'get_node', + 'get_level_summary', + 'get_walls', + 'get_zones', + 'duplicate_level', + 'verify_scene', + 'delete_node', + 'measure_stair', + 'fit_stair', + 'find_by_type', + 'create_roof', + 'add_wall', + 'add_level', + 'create_stair', + 'add_corner_window', + 'merge_windows', + 'add_column', + 'place_in_room', + // Not search_assets: it reads the host's item library, which a host hands that one tool, and + // already runs several queries per call. Not furnish_room or place_items, for the same library; + // not create_room, whose answer names the floor plate and ceiling the host derives after it. +] as const + +export const runBatchTool = { + name: 'run_batch', + title: 'Run a batch of scene tools', + description: `Optionally run several scene tools in one call: in order, each on the scene the calls before it left, applied as one undo step, with a report per call. Supports repeated or known-in-advance work; individual calls remain available. A call cannot use an id another call of the same batch creates; created ids are available in the report for subsequent calls. onRefusal stop (default): a refused call stops the batch and nothing is applied; skip: the others are applied and each refusal is reported. Tools: ${BATCHABLE_TOOLS.join(', ')}.`, + input: { + calls: z + .array( + z.object({ + tool: z.string().describe('One of the tools listed in the description.'), + input: z + .record(z.string(), z.unknown()) + .optional() + .describe("That tool's input, as when calling it on its own."), + }), + ) + .min(1) + .max(64), + onRefusal: z + .enum(['stop', 'skip']) + .optional() + .describe('stop (default): nothing is applied if a call is refused. skip: apply the rest.'), + }, +} diff --git a/packages/core/src/agent-tools/checkpoints.ts b/packages/core/src/agent-tools/checkpoints.ts new file mode 100644 index 0000000000..38e6647e4b --- /dev/null +++ b/packages/core/src/agent-tools/checkpoints.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +/** + * `set_checkpoint`'s contract: the agent names a moment of its build, to look back from. + */ +export const setCheckpointTool = { + name: 'set_checkpoint', + title: 'Set a checkpoint', + description: + 'Name this moment of your build before you start something you may need to look back on: a roof, a floor, a batch of walls. verify_scene({ since: name }) then lists what was created, changed and deleted since, grouped by level and kind with a view each, and what you made that is gone. "start", the beginning of your work, is always one; a name used again replaces the first; a session keeps at most 8. A checkpoint is kept with your session, not in the project: it changes nothing in the scene, enters no undo, and does not restore anything. Refused: too_many_checkpoints (naming them), scene_too_large, name_required.', + input: { + name: z + .string() + .min(1) + .max(40) + .describe('A short name for this moment, e.g. "walls done" or "before roof".'), + }, +} diff --git a/packages/core/src/agent-tools/columns.ts b/packages/core/src/agent-tools/columns.ts index 5e72ad833d..00102a68c1 100644 --- a/packages/core/src/agent-tools/columns.ts +++ b/packages/core/src/agent-tools/columns.ts @@ -8,7 +8,7 @@ export const addColumnTool = { name: 'add_column', title: 'Add column', description: - 'Add a structural or decorative column at (x, z), or rebuild one by nodeId. Native fields first; optional code uses the same module and conventions as add_object, with floor mount and its bottom centre at the support point. Params named height, width and depth drive the native size controls. Only add columns when requested.', + 'Add a structural or decorative column at (x, z), or rebuild one by nodeId. Supports native fields or optional code using the same module and conventions as add_object, with floor mount and its bottom centre at the support point. Params named height, width and depth drive the native size controls. Only add columns when requested.', input: { x: z.number().optional().describe('X position of the column center (meters)'), z: z.number().optional().describe('Z position of the column center (meters)'), @@ -53,7 +53,7 @@ export const addColumnTool = { .describe('Support point height above the level, in metres (default 0).'), ...geometryMetaFields, nodeId: NodeId.optional().describe( - 'Rebuild this column with code, params or native size fields; get_source first when changing code.', + 'Rebuild this column with code, params or native size fields; get_source provides current code when needed.', ), code: z .string() diff --git a/packages/core/src/agent-tools/corner-window.ts b/packages/core/src/agent-tools/corner-window.ts new file mode 100644 index 0000000000..70a4ff49af --- /dev/null +++ b/packages/core/src/agent-tools/corner-window.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { WINDOW_STYLES } from '../building/opening-style-presets' +import { WindowType } from '../schema/nodes/opening-types' +import { measurement } from './measurement' + +export const addCornerWindowTool = { + name: 'add_corner_window', + title: 'Add corner window', + description: + "A window that wraps the corner where two walls meet: one window on each wall, both running to the corner and joined, the glass fused at the corner (post none, the default) or meeting at a thin post (post post). Any angle the walls make, a bay's 135° as well as a square or an acute corner. The corner is the point the two walls end at ([x, z], as get_walls gives their ends); width is how far the glass runs along each wall from the corner, the same on both unless widths names a wall. Each side is placed by add_window's rules (height, sillHeight, windowType, style the same on both sides; with no sillHeight, centred on the lower of the two walls). Refused with a code: no_corner where no two walls end at the point, corner_ambiguous where more than two do (name two with wallIds), corner_angle for a corner nearly folded back or nearly straight, width_exceeds_wall. Returns windowIds and wallIds; the editor's window panel shows the pair under Corner.", + input: { + corner: z + .array(z.number()) + .length(2) + .describe("The point where the two walls meet, [x, z] in the level's plan (m)."), + levelId: z + .string() + .optional() + .describe('The level of the walls (default: any level with walls ending there).'), + wallIds: z + .array(z.string()) + .length(2) + .optional() + .describe('The two walls, when more than two end at the corner.'), + width: measurement('length', 'm', { + positive: true, + description: 'How far the glass runs along each wall from the corner (default 1.2 m).', + }).optional(), + widths: z + .record(z.string(), z.number().positive()) + .optional() + .describe('A width for a wall by its id, over width (e.g. { "wall_a": 1.6 }).'), + height: measurement('length', 'm', { + positive: true, + description: 'Window height (default 1.5 m).', + }).optional(), + sillHeight: measurement('length', 'm', { + min: 0, + description: + 'Height from the floor to the bottom of the window (default: centred vertically on the lower of the two walls, the same on both sides).', + }).optional(), + post: z + .enum(['none', 'post']) + .optional() + .describe( + 'none: the glass fused at the corner (default); post: the glass meets at a thin post.', + ), + windowType: WindowType.optional().describe( + "How it opens (default fixed), the window panel's Type row; the fused corner is a fixed window's.", + ), + style: z + .enum(WINDOW_STYLES) + .optional() + .describe("The panes' look on each side (rows and columns), the window panel's Style row."), + }, +} diff --git a/packages/core/src/agent-tools/create-room.ts b/packages/core/src/agent-tools/create-room.ts index fd56f47ea7..e4cfa75b9b 100644 --- a/packages/core/src/agent-tools/create-room.ts +++ b/packages/core/src/agent-tools/create-room.ts @@ -24,7 +24,7 @@ export const createRoomTool = { name: 'create_room', title: 'Create room', description: - "Create a room on a level from its polygon: a wall per edge, an edge a wall already runs along reusing that wall (rooms share their boundary with their neighbour), and the room zone that names it. The floor plate and the ceiling are derived from the room — never author a slab or a ceiling for it. Declare the room's doors and windows in the same call by polygon edge (wallIndex) and t along it, no wall ids needed; each is placed with add_door's and add_window's rules, and one that cannot be is skipped and listed in skippedOpenings with its code. Returns zoneId, slabId, ceilingId, wallIds in edge order (null where no wall), doorIds, windowIds. outdoor: true draws a terrace instead: separators where no wall runs, no walls of its own and no ceiling.", + "Create a room on a level from its polygon: a wall per edge, an edge a wall already runs along reusing that wall (rooms share their boundary with their neighbour), and the room zone that names it. The floor plate and the ceiling are derived from the room — never author a slab or a ceiling for it. Declare the room's doors and windows in the same call by polygon edge (wallIndex) and t along it, no wall ids needed; each is placed with add_door's and add_window's rules, and one that cannot be is skipped and listed in skippedOpenings with its code. Returns zoneId, slabId, ceilingId, wallIds in edge order (null where no wall), doorIds, windowIds. outdoor: true draws a terrace instead: separators where no wall runs, no walls of its own and no ceiling. Where the walls already enclose a room the reconciler numbered, the call names that room instead of adding a second one; where they enclose a room with a name of its own (not a number the editor gave it), it is refused (room_named_by_person) unless rename is true, which you pass only when the person asked for the rename, so a name is never changed silently.", input: { ...levelTarget, name: z.string().min(1).describe('Room name, e.g. "Bedroom", "Kitchen".'), @@ -37,12 +37,19 @@ export const createRoomTool = { ), wallHeight: measurement('length', 'm', { positive: true, - description: "Height of the walls it builds (default: the storey's).", + description: + "Omit to follow the storey and covering-slab underside. Equal to the stored storey height also follows that plane; other values are body heights above the wall's support, including deliberately taller walls.", }).optional(), wallThickness: measurement('length', 'm', { positive: true, description: 'Thickness of the walls it builds.', }).optional(), + rename: z + .boolean() + .optional() + .describe( + 'Only where the walls already enclose a room with a name of its own (not a number the editor gave it): true names that room with `name` instead of the refusal room_named_by_person. Pass it only when the person asked for the rename; otherwise ask them first.', + ), outdoor: z .boolean() .optional() @@ -90,7 +97,8 @@ export const createRoomTool = { }).optional(), sillHeight: measurement('length', 'm', { min: 0, - description: 'Height from the floor to the bottom of the window (default 0.9 m).', + description: + 'Height from the floor to the bottom of the window (default: centred vertically on the wall).', }).optional(), style: z .enum(WINDOW_STYLES) diff --git a/packages/core/src/agent-tools/furnish-room.ts b/packages/core/src/agent-tools/furnish-room.ts index ba8a5cff2e..1fd281ec1a 100644 --- a/packages/core/src/agent-tools/furnish-room.ts +++ b/packages/core/src/agent-tools/furnish-room.ts @@ -18,7 +18,7 @@ export const furnishRoomTool = { name: 'furnish_room', title: 'Furnish room', description: - "Furnish a room for its type from the host's item catalog: the bed, sofa, counters or toilet against the wall facing the door, the rest along a side wall, each set off its wall by its own depth. Name the room by zoneId (its level and outline come with it), or give its polygon and level. The door wall is the edge a door of the room stands on, else doorWallIndex, else edge 0. Nothing lands in a door's clear zone or on another item: a piece that does not fit is nudged, else skipped, and skipped says why. Items stand on the room's level.", + "Place a fixed catalog furniture set for a room type; this is generic furnishing, not style matching or custom furniture. The bed, sofa, counters or toilet go against the wall facing the door, the rest along a side wall, each set off its wall by its own depth. Name the room by zoneId (its level and outline come with it), or give its polygon and level. The door wall is the edge a door of the room stands on, else doorWallIndex, else edge 0. Nothing lands in a door's clear zone or on another item: a piece that does not fit is nudged, else skipped, and skipped says why. Items stand on the room's level.", input: { zoneId: NodeId.optional().describe( 'The room: zoneId from create_room, or an id from get_zones.', diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 8d3b6dec9d..745c0e1b54 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,9 +1,12 @@ import { addObjectTool, getSourceTool } from './add-object' import { searchAssetsTool } from './assets' +import { runBatchTool } from './batch' +import { setCheckpointTool } from './checkpoints' import { clearSceneTool } from './clear-scene' import { editCollectionTool, listCollectionsTool } from './collections' import { addColumnTool } from './columns' import type { AgentToolContract } from './contract' +import { addCornerWindowTool } from './corner-window' import { createRoomTool } from './create-room' import { findByTypeTool } from './find-by-type' import { furnishRoomTool } from './furnish-room' @@ -15,9 +18,12 @@ import { getZonesTool, listLevelsTool, } from './levels' +import { mergeWindowsTool } from './merge-windows' import { deleteNodeTool, getNodeTool } from './nodes' import { paintTool } from './paint' +import { placeInRoomTool } from './place-in-room' import { placeItemsTool } from './place-items' +import { createRoofTool } from './roofs' import { ROOM_TOOL_CONTRACTS } from './room-structure' import { searchMaterialsTool } from './search-materials' import { createStairTool, fitStairTool, measureStairTool } from './stairs' @@ -33,12 +39,14 @@ export * from './clear-scene' export * from './collections' export * from './columns' export * from './contract' +export * from './corner-window' export * from './create-room' export * from './find-by-type' export * from './furnish-room' export * from './hosted-services' export * from './levels' export * from './measurement' +export * from './merge-windows' export { NodeId } from './node-id' export * from './nodes' export * from './paint' @@ -72,10 +80,14 @@ export const AGENT_TOOL_CONTRACTS = [ getZonesTool, duplicateLevelTool, verifySceneTool, + setCheckpointTool, addWallTool, + addCornerWindowTool, + mergeWindowsTool, addLevelTool, placeItemsTool, createStairTool, + createRoofTool, deleteNodeTool, addObjectTool, getSourceTool, @@ -85,8 +97,16 @@ export const AGENT_TOOL_CONTRACTS = [ clearSceneTool, createRoomTool, furnishRoomTool, - searchAssetsTool, paintTool, + placeInRoomTool, + searchAssetsTool, searchMaterialsTool, ...ROOM_TOOL_CONTRACTS, + runBatchTool, ] as const satisfies readonly AgentToolContract[] + +export { BATCHABLE_TOOLS, runBatchTool } from './batch' +export { setCheckpointTool } from './checkpoints' +export { straightenFacadePhotoTool } from './photo-elevation' +export { placeInRoomTool } from './place-in-room' +export { createRoofTool } from './roofs' diff --git a/packages/core/src/agent-tools/levels.ts b/packages/core/src/agent-tools/levels.ts index 8514c6df82..c2bbd8a9f9 100644 --- a/packages/core/src/agent-tools/levels.ts +++ b/packages/core/src/agent-tools/levels.ts @@ -30,7 +30,7 @@ export const getWallsTool = { name: 'get_walls', title: 'Get walls', description: - 'Get the walls of a level: start and end in metres, length, stored and resolved height, thickness, and their doors and windows.', + 'Get the walls of a level: start and end in metres, length, stored and resolved height, thickness, and their doors and windows. wallFaceChains gives base-chain provenance for geometric faces a (left of start → end) and b (right): ref (authored face slot), legacy (inline spec), or default (declared fallback ref). These are not final rendered finishes: room intent, face paint regions, exterior assembly cladding and reference availability can change appearance. A chosen white or default-matching ref is legitimate; fallback alone is not a defect or a requirement to paint.', input: levelTarget, } @@ -38,7 +38,7 @@ export const getZonesTool = { name: 'get_zones', title: 'Get zones', description: - 'Get the zones (rooms) of a level: name, colour, polygon and holes in metres, area in m² (holes taken out), bounding size, and floor_choices: the floor plates the room can take (key, plateId, name, current; drawn and mezzanine flags). A room shares its floor plate with the rooms next to it unless detached.', + 'Get the zones (rooms) of a level: name, colour, polygon and holes in metres, area in m² (holes taken out), bounding size, and floor_choices: the floor plates the room can take (key, plateId, name, current; drawn and mezzanine flags). A room shares its floor plate with the rooms next to it unless detached. Persisted authored wall intent is wallMaterial (string, or null when absent) and wallOverrides (records of wallId, geometric face a or b, and finish; [] when absent). This is not a final rendered finish or visual verification: local overrides and face paint can take precedence, and absent room intent is not a defect or a requirement to paint.', input: levelTarget, } diff --git a/packages/core/src/agent-tools/merge-windows.ts b/packages/core/src/agent-tools/merge-windows.ts new file mode 100644 index 0000000000..29d69f0724 --- /dev/null +++ b/packages/core/src/agent-tools/merge-windows.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const mergeWindowsTool = { + name: 'merge_windows', + title: 'Merge windows', + description: + "Make two windows that exist one window, as the editor's Merge windows does: nothing is recreated. On two walls that meet at a corner, each window within 0.30 m of that corner, they become one corner window (the add_corner_window result): each is slid to the corner keeping its width, the glass fused (post none), and both take the first window's height, sill and type, so name first the window whose look you want; the corner is refused over 170° or under 10° (walls_not_meeting, corner_angle, window_not_at_corner, height_exceeds_wall). On one wall, two windows with at most 0.30 m between them and nothing between become one window spanning both outer edges, from the lower sill to the higher head, with the first window's type and style (windows_not_adjacent, opening_between). Refused with a code and a sentence that says why: window_not_found, not_a_window, same_window, window_not_on_wall, window_already_joined (unwrap it first), windows_already_joined, opening_overlap (sliding to the corner would land on another opening). Returns kind (corner or same_wall), the windows that stay (windowIds) and the one that went (removedIds). Use it for two windows already built that open a corner or one wide opening; add_corner_window builds a new pair, and verify_scene's corner_unjoined names two windows this joins.", + input: { + windowIds: z + .array(z.string()) + .length(2) + .describe( + 'The two windows, the one whose height, sill and type the result keeps named first.', + ), + }, +} diff --git a/packages/core/src/agent-tools/photo-elevation.ts b/packages/core/src/agent-tools/photo-elevation.ts new file mode 100644 index 0000000000..ce28df172f --- /dev/null +++ b/packages/core/src/agent-tools/photo-elevation.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' +import { NodeId } from './node-id' + +const fraction = z.number().min(0).max(1) +const point = z + .array(fraction) + .length(2) + .describe('A point of the photo as [across, down], fractions of its width and height (0 to 1).') +const segment = z.array(point).length(2) + +export const straightenFacadePhotoTool = { + name: 'straighten_facade_photo', + title: 'Straighten a facade photo', + description: + "Straighten faces of a building photo into elevations at a known scale, to measure the facade instead of guessing it: bay pitch, pier and window widths, pane grids, side panels, spandrels, sill and head heights. Pass every face the photo shows in one call: they share the photo's camera, found from the lines picked on them, so each elevation is metric from its walls' width and the storey height is measured from the photo rather than taken from the scene. Per face: its vertical edges (two points on each, top and bottom as seen), the same feature on at least two storeys (the window heads of a low and a high floor, say: two points far apart along each, with that storey's level), and the face's walls on one storey (their extent is the width). A face whose far edge leaves the photo gives only the corner it shares with another face. Points are fractions of the photo, [0,0] its top left and [1,1] its bottom right. Returns per face the straightened elevation with a metre grid and the scene's storeys drawn at the picked feature, a close-up of each picked corner to check (call again with corrected points if one is off), the photo pixels per metre at each side, the fit error and the photo's storey height beside the scene's; and the camera: its focal length (a phone's main camera is about 26 mm), its tilt, what it assumes, and its pose in the scene (position, target, up, vertical fov, the photo's aspect): view_scene from that pose shows the scene as the photo does. One face seen square on (a frontal photo) gives a pose too, its focal length taken from focal35mm when it cannot be read from the photo. A single face with both edges is fitted from its own picks, its heights from the storeys. Only what lies on the wall plane measures true: balconies, overhangs and anything standing proud are smeared, and a face seen at a grazing angle has too few pixels per metre for panes.", + input: { + source: z.string().describe("The photo: its attachment URL, as the user's message gives it."), + faces: z + .array( + z.object({ + name: z.string().optional().describe('What the face is, e.g. "east, corner to recess".'), + wallIds: z + .array(NodeId) + .min(1) + .max(32) + .describe('The walls of this face on one storey, end to end (get_walls).'), + edges: z + .object({ left: segment.optional(), right: segment.optional() }) + .describe( + 'Two points on each vertical edge of the face as seen in the photo. When its far edge leaves the photo, give only the corner it shares with another face of this call, picked the same on both: the shared camera places the face from that corner, and its walls give the rest of its width.', + ), + rows: z + .array( + z.object({ + levelId: NodeId, + points: segment, + height: z + .number() + .min(0) + .max(20) + .optional() + .describe( + "The feature's height above that storey's floor when you know it (window heads about 2.1 m): it sets the camera's height. With heights, two rows on one storey are enough (the floor line at 0 and the eaves at the wall's height), which a single-storey house needs. Without it the edges' foot is taken for the ground.", + ), + }), + ) + .min(2) + .max(8) + .describe( + 'The same feature on several storeys (window heads, spandrel tops): two points far apart along it on each, and the level it belongs to. Their lines meet where the face recedes, which fixes the camera: long lines on storeys far apart hold it steadier.', + ), + points: z + .array( + z.object({ + along: z + .number() + .describe("Metres along the face from its left end as seen (the walls' extent)."), + height: z.number().describe('Metres up from the floor of levelId.'), + levelId: NodeId.optional().describe( + "The floor it is measured from. Default: the face walls'.", + ), + point: point, + }), + ) + .max(40) + .optional() + .describe( + "Points at places you know on the face: a window's or a door's corners from the scene (get_walls), an eave's end. On one face seen square on they steady the camera's pose most: two edges and two lines alone move it metres for a few pixels of error.", + ), + lines: z + .array(segment) + .max(8) + .optional() + .describe( + 'More horizontal lines on this face, no level needed: sills, heads, spandrel or storey lines, two points far apart along each. They steady the camera: on two short rows a pick a pixel off can move a measure by several per cent, with two more lines it stays within a few.', + ), + }), + ) + .min(1) + .max(4), + focal35mm: z + .number() + .min(8) + .max(800) + .optional() + .describe( + "The camera's focal length on a 36 × 24 mm frame, when you know it (the photo's metadata, or a render's camera): a face seen square on does not show it, and the pose then takes a phone's 26 mm and says so (focalAssumed).", + ), + verticals: z + .array(segment) + .max(12) + .optional() + .describe( + "Vertical lines on the building besides the face edges (pier sides, window jambs, another corner), two points far apart on each. They give the camera's tilt and roll more surely than the face edges alone.", + ), + }, +} diff --git a/packages/core/src/agent-tools/place-in-room.ts b/packages/core/src/agent-tools/place-in-room.ts new file mode 100644 index 0000000000..db73b5187c --- /dev/null +++ b/packages/core/src/agent-tools/place-in-room.ts @@ -0,0 +1,80 @@ +import { z } from 'zod' +import { levelTarget } from './levels' +import { NodeId } from './node-id' + +export const placeInRoomTool = { + name: 'place_in_room', + title: 'Place an item in a room', + description: + 'Put one floor-standing catalog item in a room where it fits, without working out a position. Wall/ceiling-attached assets need a host: place_items accepts targetNodeId and, for a wall, y for the bottom height. A wall-bound floor piece (fridge, counter, stove, sink, TV stand, dresser, wardrobe, sofa, bed, bedside table, toilet, bath, shower, bookcase, cabinet) stands against a wall, centred in the widest free stretch of that wall (clear of every door and every other item), its front toward the room; it is never put in the middle, and when no wall has room the answer says how wide the widest free stretch is. A free-standing piece (dining table, rug, coffee table, plant, armchair) takes the middle of the room, or goes with what it belongs with: a coffee table and an armchair face the sofa. A chair goes around its table, one call per chair, facing the table, and is refused when the room has no table. Explicit edge, align, offset or inset instead requests a wall free run for any floor piece. Name the room by its zoneId (create_room, get_zones), or by its polygon and level. The item is a catalog id from search_assets. To furnish a whole room by its type in one call, use furnish_room.', + input: { + assetId: z.string().min(1).describe('A catalog id from search_assets.'), + zoneId: NodeId.optional().describe( + 'The room: zoneId from create_room, or an id from get_zones.', + ), + ...levelTarget, + polygon: z + .array(z.array(z.number()).length(2)) + .min(3) + .optional() + .describe("The room's corners as [x, z] in metres, when there is no zoneId."), + edge: z + .number() + .int() + .min(0) + .optional() + .describe('Stand it against this polygon edge (i → i + 1) instead of the best one.'), + rotationDeg: z + .number() + .finite() + .optional() + .describe( + 'World yaw in degrees, overriding the automatic inward, group or table-facing yaw.', + ), + facing: z + .enum(['in', 'out']) + .optional() + .describe('In by default. Out adds 180 degrees after any explicit rotationDeg.'), + align: z + .enum(['center', 'start', 'end']) + .optional() + .describe( + 'Desired wall alignment, clamped into a valid free run. Defaults to the widest run’s center.', + ), + offset: z + .number() + .finite() + .optional() + .describe('Signed metres along the wall from the desired alignment; defaults to zero.'), + inset: z + .number() + .finite() + .optional() + .describe( + 'Normal gap in metres between the rotated footprint and the wall centerline; defaults to 0.1 m.', + ), + clearanceAreas: z + .array( + z + .object({ + id: z.string().min(1), + minX: z.number().finite(), + maxX: z.number().finite(), + minZ: z.number().finite(), + maxZ: z.number().finite(), + }) + .refine((area) => area.minX <= area.maxX && area.minZ <= area.maxZ, { + message: 'Clearance rectangles must have minX <= maxX and minZ <= maxZ.', + }), + ) + .max(128) + .optional() + .describe('Existing clearance rectangles, in world metres, to keep free before placing.'), + allowDuplicate: z + .boolean() + .optional() + .describe( + 'Place it even though the room already has this item. Off by default (a second toilet or stove is a mistake); chairs are always allowed.', + ), + }, +} diff --git a/packages/core/src/agent-tools/place-items.ts b/packages/core/src/agent-tools/place-items.ts index 2a6e4e4d76..a6e3cd1fc7 100644 --- a/packages/core/src/agent-tools/place-items.ts +++ b/packages/core/src/agent-tools/place-items.ts @@ -7,12 +7,12 @@ export const placeItemsTool = { name: 'place_items', title: 'Place items', description: - 'Place one or more catalog items (furniture, fixtures, plants, art, lights) in one call; batch them rather than calling once per item. Each assetId comes from search_assets, never guessed. Positions are level (x, z) metres. An item stands on the floor of the level, or on the host it names (targetNodeId): a wall (art, a sconce: y is the height of its bottom above the floor, required; it goes on the side of the wall the point is on), a ceiling (a pendant, a downlight: it hangs flush under it), or an item standing on the floor (a lamp on a nightstand: it rests on its top; on an object built with add_object it rests on the real surface under the point, such as a porch landing, or a ceiling item hangs from the underside above it, such as a sloped vault, and the result names it in restingOn). A room, a slab or a level as the target means its floor. Each item is placed or refused on its own (asset_not_found; outside_rooms: an indoor item on the floor outside every room of a level that has rooms — garden and outdoor items may stand outside; height_required, item_too_tall, unsupported_host, host_not_found, host_not_on_level; blocks_door: a floor item standing where a door needs space, with a spot in its room that clears every door; too_large_for_room: a floor item its room cannot hold in any turn).', + 'Place one or more catalog items (furniture, fixtures, plants, art, lights) in one call. Each assetId is a known catalog id, available from search_assets results or existing scene item data, never guessed. Positions are level (x, z) metres. An item stands on the floor of the level, or on the host it names (targetNodeId): a wall (art, a sconce: y is the height of its bottom above the floor, required; it goes on the side of the wall the point is on), a ceiling (a pendant, a downlight: it hangs flush under it), or an item standing on the floor (a lamp on a nightstand: it rests on its top; on an object built with add_object it rests on the real surface under the point, such as a porch landing, or a ceiling item hangs from the underside above it, such as a sloped vault, and the result names it in restingOn). A room, a slab or a level as the target means its floor. Each item is placed or refused on its own (asset_not_found; outside_rooms: an indoor item on the floor outside every room of a level that has rooms — garden and outdoor items may stand outside; height_required, item_too_tall, unsupported_host, host_not_found, host_not_on_level; blocks_door: a floor item standing where a door needs space, with a spot in its room that clears every door; too_large_for_room: a floor item its room cannot hold in any turn).', input: { items: z .array( z.object({ - assetId: z.string().min(1).describe('A catalog id from search_assets.'), + assetId: z.string().min(1).describe('A known catalog id; search_assets can find it.'), x: z.number().describe('X in level coordinates (metres).'), z: z.number().describe('Z in level coordinates (metres).'), y: z diff --git a/packages/core/src/agent-tools/roofs.ts b/packages/core/src/agent-tools/roofs.ts new file mode 100644 index 0000000000..615ec0f784 --- /dev/null +++ b/packages/core/src/agent-tools/roofs.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import { measurement } from './measurement' +import { NodeId } from './node-id' + +// The schema's RoofType, restated: the schema module pulls in packages a contract may not. +const ROOF_TYPES = [ + 'hip', + 'gable', + 'shed', + 'gambrel', + 'dutch', + 'mansard', + 'flat', + 'conical', +] as const + +export const createRoofTool = { + name: 'create_roof', + title: 'Create roof', + description: + "Cap a storey with a roof over its footprint (its floor plates, else its rooms), swept in the building's own axes into rectangles, one roof segment each. Without a roofType: a gable for one rectangle, hips for a few wings, flat for a large block. The roof goes on a roof level of its own above the storey: the level directly above when it is a roof level, else a new one above any storey that is still empty. Call it once the storeys and their exterior walls stand. Refused with a code: a storey with another storey above it (a roof caps the top one), and no rooms on any level of the building (pass width and depth to roof a rectangle of your own).", + input: { + levelId: NodeId.optional().describe( + 'The storey to cover, by an id list_levels returned. Default: the top storey with rooms. A level without rooms stands for the highest storey of its building that has some.', + ), + level: NodeId.optional().describe('Same as levelId.'), + roofType: z + .enum(ROOF_TYPES) + .optional() + .describe('The shape of every segment. Default: chosen from the footprint.'), + pitch: measurement('angle', 'deg', { + min: 0, + max: 85, + description: 'Slope of the roof planes (default 40°).', + }).optional(), + overhang: measurement('length', 'm', { + min: 0, + description: + 'Eave overhang: how far the roof reaches past the walls. Read it from the photo: a deep modern overhang is 1–2 m. Default 0.3.', + }).optional(), + wallHeight: measurement('length', 'm', { + min: 0, + description: 'Knee walls under the eaves (default 0: the roof starts at the wall tops).', + }).optional(), + width: measurement('length', 'm', { + positive: true, + description: + 'With depth: roof a rectangle of your own instead of the footprint (a wing, or walls drawn without rooms), along x.', + }).optional(), + depth: measurement('length', 'm', { + positive: true, + description: "The rectangle's depth, along z.", + }).optional(), + center: z + .array(z.number()) + .length(2) + .optional() + .describe("The rectangle's centre [x, z] in metres (default [0, 0])."), + materialPreset: z.string().optional().describe('The roof finish, a library material.'), + // Hawkesbury's flat roofs wear a black fascia about 0.3 m tall, which only apply_patch could + // draw (2026-10-03). + fasciaHeight: measurement('length', 'm', { + positive: true, + description: + "A fascia band of that height round the roof's eaves and rakes; on a flat roof it reaches the roof's top, a coping over its edge. Absent (and no fasciaMaterialPreset): no band.", + }).optional(), + fasciaMaterialPreset: z + .string() + .optional() + .describe( + "The band's finish, a library material (a black fascia: library:preset-nearblack); the roof's wall finish when absent.", + ), + name: z.string().max(120).optional().describe('What the user would call it (default "Roof").'), + }, +} diff --git a/packages/core/src/agent-tools/verify-scene.ts b/packages/core/src/agent-tools/verify-scene.ts index 5b3d0d8d11..52f1b0bce2 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 = - 'Check the whole scene after complex edits, and before retrying a failed tool: 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, openings off their wall, stairs off their slab or blocked, furniture blocking a door or overlapping, nodes their schema rejects.' + '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.' export const verifySceneTool = { name: 'verify_scene', diff --git a/packages/core/src/agent-tools/view-scene.ts b/packages/core/src/agent-tools/view-scene.ts index 51c664e0f7..2b3b9c6b3d 100644 --- a/packages/core/src/agent-tools/view-scene.ts +++ b/packages/core/src/agent-tools/view-scene.ts @@ -18,7 +18,7 @@ export const viewSceneTool = { name: 'view_scene', title: 'Look at the scene', description: - "Look at the building in 3D from a viewpoint you pick and get the picture back. Use it to compare what you built with a reference and say what differs before you fix it: the facade from the photo's own camera (camera: its position, aim, field of view and aspect), rendered at the photo's aspect to lay beside it; or from the photo's side at street height (from, eyeHeight 1.7); one face square on (projection orthographic); the massing from above. North is the plan's top edge (z grows south, x east). The view frames the target (a building, a level, a wall or a zone; the whole building by default) from outside, unless you place the eye yourself with position or camera. To see inside a floor, give interior {levelId}: the other floors and that floor's roofs and ceilings are left out of the picture, and everything on it, walls, openings and furniture alike, is cut at 0.65 of the storey's height (about two thirds), so a tall wardrobe or a door head is cut, not short; a roof level has no inside; with no eye given it looks down into the floor, or into the room or zone you target. The live view is not changed. A door, a window or an item on a floor frames at detail scale, an opening seen from its outside face: a close-up to lay beside the photo's crop of the same element, to see what differs. Over the MCP the user's editor tab open on the project renders the picture: with none open it is refused (editor_tab_required), and a tab in the background must be brought to the front (editor_tab_hidden). A picture is not a measure: take sizes and counts from the tools.", + "Look at the building in 3D from a viewpoint you pick and get the picture back. Use it to compare what you built with a reference and say what differs before you fix it: the facade from the photo's own camera (camera: its position, aim, field of view and aspect), rendered at the photo's aspect to lay beside it; or from the photo's side at street height (from, eyeHeight 1.7); one face square on (projection orthographic); the massing from above. North is the plan's top edge (z grows south, x east). The view frames the target (a building, a level, a wall or a zone; the whole building by default) from outside, unless you place the eye yourself with position or camera. To see inside a floor, give interior {levelId}: the other floors and that floor's roofs and ceilings are left out of the picture, and everything on it, walls, openings and furniture alike, is cut at 0.65 of the storey's height (about two thirds), so a tall wardrobe or a door head is cut, not short; a roof level has no inside; with no eye given it looks down into the floor, or into the room or zone you target. The live view is not changed. A door, a window or an item on a floor frames at detail scale, an opening seen from its outside face: a close-up to lay beside the photo's crop of the same element, to see what differs. Over the MCP the user's editor tab open on the project renders the picture: with none open it is refused (editor_tab_required), a tab in the background must be brought to the front (editor_tab_hidden), and a tab that is open but busy or still loading answers editor_tab_busy: call view_scene again in a few seconds, the user need not be asked. A picture is not a measure: take sizes and counts from the tools.", input: { target: z .string() diff --git a/packages/core/src/agent-tools/wall-openings.ts b/packages/core/src/agent-tools/wall-openings.ts index ebc1558951..f6d7005456 100644 --- a/packages/core/src/agent-tools/wall-openings.ts +++ b/packages/core/src/agent-tools/wall-openings.ts @@ -30,7 +30,7 @@ const script = (kind: string) => ({ .max(48_000) .optional() .describe( - `A module (three.js, or bpy with language) for a ${kind} the fields cannot express (a fan grille, tracery, carved trim): the same module and conventions as add_object, with mount 'wall'. Its size is what it builds (name params width and height so the ${kind}'s size controls edit them), and its cutout mesh cuts the wall. Make it open like a real ${kind}: add an \`open\` clip (${kind === 'door' ? 'the leaves swinging or sliding' : 'the sash sliding or swinging'}; \`close\` is optional, \`open\` reversed by default) unless it is fixed. Fields first; code only beyond them.`, + `An optional module (three.js, or bpy with language) for a ${kind} (a fan grille, tracery, carved trim): the same module and conventions as add_object, with mount 'wall'. Its size is what it builds (name params width and height so the ${kind}'s size controls edit them), and its cutout mesh cuts the wall. Make it open like a real ${kind}: add an \`open\` clip (${kind === 'door' ? 'the leaves swinging or sliding' : 'the sash sliding or swinging'}; \`close\` is optional, \`open\` reversed by default) unless it is fixed. Native fields and code are available to express the requested shape.`, ), language: scriptLanguage, params: scriptParams, @@ -62,7 +62,7 @@ export const addDoorTool = { input: { wallId: NodeId.optional().describe('The wall to add the door to.'), nodeId: NodeId.optional().describe( - 'Rebuild this door instead of adding one: new code and/or params (get_source first to change its code). Its native fields change with update_node.', + 'Rebuild this door instead of adding one: new code and/or params (get_source provides current code when needed). Its native fields change with update_node.', ), ...placement, width: measurement('length', 'm', { @@ -102,11 +102,11 @@ export const addWindowTool = { name: 'add_window', title: 'Add window', description: - "Add a window to an existing straight wall at t (0..1 along it), on sillHeight above the floor. It slides to stay on the wall and under the wall's ceiling, and reports clamped. Refused with a code, as in the editor: curved walls, walls shorter than the window, overlapping another door, window or wall item unless force is set, and a style on a window that is not Fixed (style_needs_fixed_window). Match the reference with the outline (rectangle, rounded, arch), windowType and panes (columns × rows, or a style); what they cannot express (glass strips, leaded lites, a feature frame) is written as a script in code, never left as not possible.", + "Add a window to an existing straight wall at t (0..1 along it), centred vertically on the wall, or on sillHeight above the floor when given. It slides to stay on the wall and under the wall's ceiling, and reports clamped. Refused with a code, as in the editor: curved walls, walls shorter than the window, overlapping another door, window or wall item unless force is set, and a style on a window that is not Fixed (style_needs_fixed_window). Match the reference with the outline (rectangle, rounded, arch), windowType and panes (columns × rows, or a style); what they cannot express (glass strips, leaded lites, a feature frame) is written as a script in code, never left as not possible.", input: { wallId: NodeId.optional().describe('The wall to add the window to.'), nodeId: NodeId.optional().describe( - 'Rebuild this window instead of adding one: new code and/or params (get_source first to change its code). Its native fields change with update_node.', + 'Rebuild this window instead of adding one: new code and/or params (get_source provides current code when needed). Its native fields change with update_node.', ), ...placement, width: measurement('length', 'm', { @@ -119,7 +119,8 @@ export const addWindowTool = { }).optional(), sillHeight: measurement('length', 'm', { min: 0, - description: 'Height from the floor to the bottom of the window (default 0.9 m).', + description: + 'Height from the floor to the bottom of the window (default: centred vertically on the wall).', }).optional(), ...outline('0.35 m'), ...script('window'), diff --git a/packages/core/src/building/__fixtures__/wall-opening-cases.ts b/packages/core/src/building/__fixtures__/wall-opening-cases.ts index e8db362da6..c1c61096e3 100644 --- a/packages/core/src/building/__fixtures__/wall-opening-cases.ts +++ b/packages/core/src/building/__fixtures__/wall-opening-cases.ts @@ -225,10 +225,36 @@ export const WALL_OPENING_CASES: readonly WallOpeningCase[] = [ input: { wallId: main, t: 0.5, style: 'glass' }, expect: { localX: 2, centerY: 1.05, clamped: false, glassPanels: true }, }, + // The agents' windows are centred on the wall unless a sill is given: a 1.5 m window on the + // 2.5 m wall spans 0.5–2.0 m. (A 0.9 m default put the same window at 0.9–2.4 m, under the top.) { - name: 'a window sits on a 0.9 m sill by default', + name: 'a window with no sill is centred on its wall', tool: 'add_window', input: { wallId: main, t: 0.5 }, + expect: { localX: 2, centerY: 1.25, clamped: false }, + }, + { + name: 'a window of another height is centred on the wall too', + tool: 'add_window', + input: { wallId: main, t: 0.5, height: 1 }, + expect: { localX: 2, centerY: 1.25, clamped: false }, + }, + { + name: 'a wall with no height of its own centres the window on the storey', + tool: 'add_window', + input: { wallId: storey, t: 0.5 }, + expect: { localX: 2, centerY: 1.4, clamped: false }, + }, + { + name: 'a tall window is centred with room above and below', + tool: 'add_window', + input: { wallId: main, t: 0.5, height: 2.4 }, + expect: { localX: 2, centerY: 1.25, clamped: false }, + }, + { + name: 'a window given a 0.9 m sill keeps it', + tool: 'add_window', + input: { wallId: main, t: 0.5, sillHeight: 0.9 }, expect: { localX: 2, centerY: 1.65, clamped: false }, }, // A style shapes a Fixed window's panes; an operable window draws its own sashes and ignores them. @@ -236,7 +262,7 @@ export const WALL_OPENING_CASES: readonly WallOpeningCase[] = [ name: 'a Fixed window takes a style', tool: 'add_window', input: { wallId: main, t: 0.5, windowType: 'fixed', style: 'double-hung' }, - expect: { localX: 2, centerY: 1.65, clamped: false }, + expect: { localX: 2, centerY: 1.25, clamped: false }, }, { name: 'a style on a window that is not Fixed is refused, naming the Fixed type', diff --git a/packages/core/src/building/corner-window.test.ts b/packages/core/src/building/corner-window.test.ts new file mode 100644 index 0000000000..c0305ef5eb --- /dev/null +++ b/packages/core/src/building/corner-window.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from 'bun:test' +import { cornerWindowIssues } from '../agent-operations/corner-window' +import { isAgentRefusal } from '../agent-tools/refusal' +import { type AnyNode, LevelNode, WallNode, WindowNode } from '../schema' +import { + cornerPair, + cornerPartnerUpdates, + cornerSideWidth, + planCornerWindow, + unwrapCorner, + wrapCorner, +} from './corner-window' + +/** + * L65: the 290's Bed 4 window wraps the corner of its bay; Pascal had no corner window (a window + * ends at its wall and the corner stays solid). A corner window is two windows, one per wall, both + * running to the corner and joined, the glass fused at the corner (or meeting at a thin post), at + * any angle the walls make. What goes wrong, written first: a window stopping short of the corner; + * the two sides out of step (heights, sills); one wall's side placed on the wrong wall or end; a + * square-only answer at a bay's 135° or an acute 60°; a degenerate corner accepted; unwrapping + * deleting a window. + */ + +type Nodes = Record +const LEVEL = 'level_corner' + +/** Wall A runs into the corner at the origin; wall B leaves it at `degrees` from A. */ +function cornerScene(degrees: number, extra: AnyNode[] = []): Nodes { + const rad = (degrees * Math.PI) / 180 + const a = WallNode.parse({ id: 'wall_a', parentId: LEVEL, start: [4, 0], end: [0, 0] }) + const b = WallNode.parse({ + id: 'wall_b', + parentId: LEVEL, + start: [0, 0], + end: [4 * Math.cos(rad), 4 * Math.sin(rad)], + }) + const level = LevelNode.parse({ id: LEVEL, children: [a.id, b.id, ...extra.map((n) => n.id)] }) + return Object.fromEntries([level, a, b, ...extra].map((node) => [node.id, node])) +} + +const apply = (nodes: Nodes, windows: WindowNode[]) => ({ + ...nodes, + ...Object.fromEntries(windows.map((window) => [window.id, window])), +}) + +const refusalOf = (run: () => unknown) => { + try { + run() + } catch (error) { + if (isAgentRefusal(error)) return error.code + throw error + } + return null +} + +for (const degrees of [90, 135, 60]) + describe(`a corner window at ${degrees}°`, () => { + test('one window on each wall, each running to the corner, joined and fused', () => { + const { windows } = planCornerWindow(cornerScene(degrees), { corner: [0, 0], width: 1.2 }) + const [onA, onB] = ['wall_a', 'wall_b'].map((id) => windows.find((w) => w.parentId === id)!) + // Wall A is 4 m long and ends at the corner; wall B starts there. + expect(onA!.position[0]).toBeCloseTo(4 - 0.6, 6) + expect(onB!.position[0]).toBeCloseTo(0.6, 6) + expect(onA!.corner).toEqual({ end: 'end', partnerId: onB!.id, post: 'none' }) + expect(onB!.corner).toEqual({ end: 'start', partnerId: onA!.id, post: 'none' }) + expect([onA!.height, onA!.position[1]]).toEqual([onB!.height, onB!.position[1]]) + const pair = cornerPair(apply(cornerScene(degrees), windows), onA!) + expect(pair?.partner.id).toBe(onB!.id) + expect(pair?.angle).toBeCloseTo(degrees, 6) + }) + + test('each side takes its own width, and a post on request', () => { + const { windows } = planCornerWindow(cornerScene(degrees), { + corner: [0, 0], + width: 1.2, + widths: { wall_b: 1.6 }, + post: 'post', + }) + const onB = windows.find((w) => w.parentId === 'wall_b')! + expect([onB.width, onB.position[0], onB.corner?.post]).toEqual([1.6, 0.8, 'post']) + }) + }) + +// A window with no sill is centred on its wall; the two sides of a corner window must keep one sill +// whatever their walls, so with walls of two heights both are centred on the lower one. +describe('a corner window with no sill given', () => { + const heights = (a: number, b: number) => { + const nodes = cornerScene(90) + nodes.wall_a = WallNode.parse({ ...nodes.wall_a, height: a }) + nodes.wall_b = WallNode.parse({ ...nodes.wall_b, height: b }) + return planCornerWindow(nodes, { corner: [0, 0], width: 1.2 }).windows + } + const sill = (window: WindowNode) => window.position[1] - window.height / 2 + + test('is centred on its walls when they are as tall', () => { + const windows = heights(2.5, 2.5) + expect(windows.map(sill)).toEqual([expect.closeTo(0.5, 6), expect.closeTo(0.5, 6)]) + }) + + test('keeps one sill across walls of two heights, centred on the lower', () => { + const windows = heights(2.5, 3) + expect(windows.map(sill)).toEqual([expect.closeTo(0.5, 6), expect.closeTo(0.5, 6)]) + }) + + test('keeps the sill it is given on both sides', () => { + const nodes = cornerScene(90) + const { windows } = planCornerWindow(nodes, { corner: [0, 0], width: 1.2, sillHeight: 0.9 }) + expect(windows.map(sill)).toEqual([expect.closeTo(0.9, 6), expect.closeTo(0.9, 6)]) + }) +}) + +describe('what a corner window refuses', () => { + test('a point where no two walls meet', () => { + expect(refusalOf(() => planCornerWindow(cornerScene(90), { corner: [2, 0], width: 1 }))).toBe( + 'no_corner', + ) + }) + + test('a degenerate corner, nearly folded back or nearly straight', () => { + for (const degrees of [5, 176]) + expect( + refusalOf(() => planCornerWindow(cornerScene(degrees), { corner: [0, 0], width: 1 })), + ).toBe('corner_angle') + }) + + test('a side wider than its wall', () => { + expect(refusalOf(() => planCornerWindow(cornerScene(90), { corner: [0, 0], width: 4.5 }))).toBe( + 'width_exceeds_wall', + ) + }) + + test('three walls at the point, without saying which two', () => { + const c = WallNode.parse({ id: 'wall_c', parentId: LEVEL, start: [0, 0], end: [0, -3] }) + const nodes = cornerScene(90, [c]) + expect(refusalOf(() => planCornerWindow(nodes, { corner: [0, 0], width: 1 }))).toBe( + 'corner_ambiguous', + ) + expect( + planCornerWindow(nodes, { corner: [0, 0], width: 1, wallIds: ['wall_a', 'wall_c'] }) + .windows.map((w) => w.parentId) + .sort(), + ).toEqual(['wall_a', 'wall_c']) + }) +}) + +describe('by hand: wrap, keep in step, unwrap', () => { + const plain = () => { + const { windows } = planCornerWindow(cornerScene(135), { corner: [0, 0], width: 1.2 }) + const onA = windows.find((w) => w.parentId === 'wall_a')! + const { corner: _corner, ...rest } = onA + return { nodes: apply(cornerScene(135), [rest as WindowNode]), window: rest as WindowNode } + } + + test('a window ending at a corner wraps it: its partner takes its width, height and sill', () => { + const { nodes, window } = plain() + const { create, updates } = wrapCorner(nodes, window.id) + expect(create?.parentId).toBe('wall_b') + expect([create?.width, create?.height, create?.position[1]]).toEqual([ + window.width, + window.height, + window.position[1], + ]) + expect(updates).toEqual({ + [window.id]: { corner: { end: 'end', partnerId: create?.id, post: 'none' } }, + }) + }) + + // The owner (8 October): a window a few cm short of the corner showed no Corner section at all. + const onWall = (id: string, wall: string, x: number, width = 1.2) => + WindowNode.parse({ + id, + parentId: wall, + wallId: wall, + position: [x, 1.25, 0], + width, + height: 1.5, + }) + + test('a window near a corner, not at it, is slid to the corner when it wraps', () => { + // 0.10 m short of the end of wall_a (4 m long): its right edge is at 3.9 m. + const near = onWall('window_near', 'wall_a', 3.3) + const { create, updates } = wrapCorner(apply(cornerScene(90), [near]), near.id) + expect(updates.window_near?.position?.[0]).toBeCloseTo(3.4, 6) + expect(updates.window_near?.corner).toEqual({ + end: 'end', + partnerId: create?.id, + post: 'none', + }) + expect(create?.position[0]).toBeCloseTo(0.6, 6) + }) + + test('a window farther than 0.30 m from a corner has none to wrap', () => { + const far = onWall('window_far', 'wall_a', 3.0) + expect(refusalOf(() => wrapCorner(apply(cornerScene(90), [far]), far.id))).toBe('no_corner') + }) + + test('a window already waiting at the other wall’s corner is adopted, not recreated', () => { + const near = onWall('window_near', 'wall_a', 3.3) + // 0.20 m short of the corner on wall_b, a different height and sill. + const waiting = WindowNode.parse({ + id: 'window_waiting', + parentId: 'wall_b', + wallId: 'wall_b', + position: [0.7, 1.4, 0], + width: 1, + height: 1.2, + }) + const { create, updates } = wrapCorner(apply(cornerScene(90), [near, waiting]), near.id) + expect(create).toBeNull() + expect(Object.keys(updates).sort()).toEqual(['window_near', 'window_waiting']) + expect(updates.window_waiting?.position?.[0]).toBeCloseTo(0.5, 6) + expect(updates.window_waiting?.height).toBe(1.5) + expect(updates.window_waiting?.corner).toEqual({ + end: 'start', + partnerId: 'window_near', + post: 'none', + }) + }) + + test('a window that is already one side of a pair is not adopted', () => { + const near = onWall('window_near', 'wall_a', 3.3) + const taken = WindowNode.parse({ + ...onWall('window_taken', 'wall_b', 0.7, 1), + corner: { end: 'start', partnerId: 'window_elsewhere', post: 'none' }, + }) + const { create } = wrapCorner(apply(cornerScene(90), [near, taken]), near.id) + expect(create?.parentId).toBe('wall_b') + }) + + test('a height or sill changed on one side changes the other', () => { + const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) + const nodes = apply(cornerScene(90), windows) + const onA = windows.find((w) => w.parentId === 'wall_a')! + expect(cornerPartnerUpdates(nodes, onA.id, { height: 1.8 })).toMatchObject({ + height: 1.8, + }) + expect(cornerPartnerUpdates(nodes, onA.id, { width: 2 })).toBeNull() + }) + + test('unwrapping keeps both windows, plain, ending at the corner', () => { + const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) + const nodes = apply(cornerScene(90), windows) + const updates = unwrapCorner(nodes, windows[0]!.id) + expect(Object.keys(updates).sort()).toEqual(windows.map((w) => w.id).sort()) + for (const patch of Object.values(updates)) expect(patch).toEqual({ corner: undefined }) + }) +}) + +describe('verify_scene on corner windows', () => { + test('two plain windows running to the same corner are pointed at add_corner_window', () => { + const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) + const plain = windows.map(({ corner: _corner, ...rest }) => rest as WindowNode) + const issues = cornerWindowIssues(apply(cornerScene(90), plain)) + expect(issues.map((issue) => issue.type)).toEqual(['corner_unjoined']) + expect(issues[0]!.message).toContain('add_corner_window') + }) + + test('a joined pair in step is quiet; drifted apart, it is named', () => { + const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) + expect(cornerWindowIssues(apply(cornerScene(90), windows))).toEqual([]) + const [a, b] = windows as [WindowNode, WindowNode] + const drifted = apply(cornerScene(90), [a, { ...b, height: 1.2 }]) + expect(cornerWindowIssues(drifted).map((issue) => issue.type)).toEqual(['corner_out_of_step']) + }) +}) + +describe('one side wider or narrower', () => { + test('its corner end stays at the corner, the free edge moves', () => { + const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) + const nodes = apply(cornerScene(90), windows) + const onA = windows.find((w) => w.parentId === 'wall_a')! + expect(cornerSideWidth(nodes, onA.id, 2)).toEqual({ + width: 2, + position: [3, onA.position[1], onA.position[2]], + }) + }) + + test('dragged off its corner, a window is no longer one side of the pair', () => { + const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) + const [a, b] = windows as [WindowNode, WindowNode] + const moved = { ...a, position: [2, a.position[1], a.position[2]] as [number, number, number] } + expect(cornerPair(apply(cornerScene(90), [moved, b]), moved)).toBeNull() + }) +}) diff --git a/packages/core/src/building/corner-window.ts b/packages/core/src/building/corner-window.ts new file mode 100644 index 0000000000..3b92e95165 --- /dev/null +++ b/packages/core/src/building/corner-window.ts @@ -0,0 +1,245 @@ +import { refuse } from '../agent-tools/refusal' +import { + CORNER_REACH_M, + cornerAngle, + cornerPair, + DEGENERATE_CORNER_DEG, + wallEndPoint, + wallLengthOf, + wallsEndingAt, + windowEndGap, + windowNearCornerEnd, +} from '../lib/corner-pair' +import type { AnyNode, AnyNodeId, WallNode, WindowNode } from '../schema' +import { mergeWindows } from './merge-windows' +import { + centredWindowSill, + DEFAULT_WINDOW, + planWallOpening, + type WallOpeningInput, +} from './wall-openings' + +export { cornerPair, windowCornerEnd } from '../lib/corner-pair' + +type Nodes = Readonly> +type Point = [number, number] + +export type CornerWindowInput = { + corner: Point + levelId?: string + /** The two walls, when more than two meet at the corner. */ + wallIds?: [string, string] + /** How far the glass runs along each wall from the corner (m), unless `widths` names the wall. */ + width?: number + widths?: Record + height?: number + sillHeight?: number + post?: 'none' | 'post' + windowType?: WallOpeningInput['windowType'] + style?: string +} + +/** + * A corner window (L65): one window on each of the two walls meeting at `corner`, each running to + * it, joined, at any angle the walls make. Each side is placed by add_window's rules, and the two + * keep one sill: with none given, both are centred on the lower of the two walls. + */ +export function planCornerWindow(nodes: Nodes, input: CornerWindowInput) { + const point = input.corner + const meeting = wallsEndingAt(nodes, input.levelId, point).filter( + ({ wall }) => !input.wallIds || input.wallIds.includes(wall.id), + ) + if (meeting.length < 2) + refuse( + 'no_corner', + `No two walls end at (${point.join(', ')}): a corner window goes where two walls meet; get_walls gives their ends.`, + { corner: point }, + ) + if (meeting.length > 2) + refuse( + 'corner_ambiguous', + `${meeting.length} walls end at (${point.join(', ')}): say which two with wallIds (${meeting.map(({ wall }) => wall.id).join(', ')}).`, + { corner: point, wallIds: meeting.map(({ wall }) => wall.id) }, + ) + const [a, b] = meeting as [(typeof meeting)[0], (typeof meeting)[0]] + const angle = cornerAngle(a, b) + if (angle < DEGENERATE_CORNER_DEG || angle > 180 - DEGENERATE_CORNER_DEG) + refuse( + 'corner_angle', + `The walls at (${point.join(', ')}) meet at ${Math.round(angle)}°: too close to ${angle < 90 ? 'folded back' : 'straight'} for a corner window. Along a straight run, one add_window is the window.`, + { angle }, + ) + const sillHeight = + input.sillHeight ?? + centredWindowSill( + [a.wall, b.wall], + input.height ?? DEFAULT_WINDOW.height, + nodes as Readonly>, + ) + const windows = [a, b].map(({ wall, end }) => { + const width = input.widths?.[wall.id] ?? input.width ?? 1.2 + const length = wallLengthOf(wall) + if (width > length) + refuse( + 'width_exceeds_wall', + `Wall ${wall.id} is ${length.toFixed(2)} m long, too short for a ${width.toFixed(2)} m side of the corner window.`, + { wallId: wall.id, width, length }, + ) + const centre = end === 'start' ? width / 2 : length - width / 2 + return { + end, + node: planWallOpening(nodes as never, { + kind: 'window', + wallId: wall.id, + t: centre / length, + width, + ...(input.height === undefined ? {} : { height: input.height }), + sillHeight, + ...(input.windowType ? { windowType: input.windowType } : {}), + ...(input.style ? { style: input.style } : {}), + }).node as WindowNode, + } + }) + const post = input.post ?? 'none' + const [first, second] = windows as [(typeof windows)[0], (typeof windows)[0]] + return { + windows: [ + { ...first.node, corner: { end: first.end, partnerId: second.node.id, post } }, + { ...second.node, corner: { end: second.end, partnerId: first.node.id, post } }, + ] as WindowNode[], + wallIds: [a.wall.id, b.wall.id] as [string, string], + angle, + } +} + +/** What both sides of a corner window share: changed on one, it changes on the other. */ +const SHARED = [ + 'height', + 'windowType', + 'frameThickness', + 'frameDepth', + 'sill', + 'sillDepth', + 'sillThickness', +] as const + +/** The partner's update for a change to one side of a corner window; null when nothing shared changed. */ +export function cornerPartnerUpdates( + nodes: Nodes, + windowId: string, + patch: Partial, +): Partial | null { + const window = nodes[windowId] + if (window?.type !== 'window') return null + const pair = cornerPair(nodes, window as WindowNode) + if (!pair) return null + const update: Record = {} + for (const key of SHARED) if (patch[key] !== undefined) update[key] = patch[key] + if (patch.position) + update.position = [pair.partner.position[0], patch.position[1], pair.partner.position[2]] + if (patch.corner?.post) update.corner = { ...pair.partner.corner!, post: patch.corner.post } + return Object.keys(update).length ? (update as Partial) : null +} + +/** + * The editor's Wrap the corner: a window within `CORNER_REACH_M` of a corner where exactly one other + * wall ends is slid to the corner and joined to the window waiting there on that wall, adopted + * (`mergeWindows`); with none waiting, it gets a partner on that wall, of its width (as far as the + * wall allows), height, sill and type. `create` is the partner to add, if any; `updates` are the + * windows that change, by id. + */ +export function wrapCorner( + nodes: Nodes, + windowId: string, +): { create: WindowNode | null; updates: Record> } { + const window = nodes[windowId] as WindowNode | undefined + const end = window?.type === 'window' ? windowNearCornerEnd(nodes, window) : null + const wall = window ? (nodes[window.parentId ?? ''] as WallNode | undefined) : undefined + if (!window || !end || wall?.type !== 'wall') + refuse( + 'no_corner', + `This window is not within ${CORNER_REACH_M.toFixed(2)} m of a corner of its wall.`, + ) + const others = wallsEndingAt(nodes, wall.parentId ?? undefined, wallEndPoint(wall, end)).filter( + (meeting) => meeting.wall.id !== wall.id, + ) + if (others.length !== 1) + refuse('no_corner', 'A corner window needs exactly one other wall ending at this corner.') + const { wall: other, end: otherEnd } = others[0]! + + // A window already waiting at the other wall's corner is adopted, the nearest first. + const waiting = Object.values(nodes) + .filter( + (node): node is WindowNode => + node.type === 'window' && + node.parentId === other.id && + node.id !== window.id && + !node.corner && + windowNearCornerEnd(nodes, node as WindowNode) === otherEnd, + ) + .sort((a, b) => windowEndGap(other, a, otherEnd) - windowEndGap(other, b, otherEnd))[0] + if (waiting) { + const merge = mergeWindows(nodes, window.id, waiting.id) + return { + create: null, + updates: Object.fromEntries(merge.changes.update.map(({ id, data }) => [id, data])), + } + } + + const width = Math.min(window.width, wallLengthOf(other)) + const centre = otherEnd === 'start' ? width / 2 : wallLengthOf(other) - width / 2 + const sillHeight = window.position[1] - window.height / 2 + const partner = planWallOpening(nodes as never, { + kind: 'window', + wallId: other.id, + t: centre / wallLengthOf(other), + width, + height: window.height, + sillHeight, + windowType: window.windowType, + }).node as WindowNode + const post = 'none' as const + const slidTo = end === 'start' ? window.width / 2 : wallLengthOf(wall) - window.width / 2 + return { + create: { + ...partner, + columnRatios: window.columnRatios, + rowRatios: window.rowRatios, + corner: { end: otherEnd, partnerId: window.id, post }, + } as WindowNode, + updates: { + [window.id]: { + corner: { end, partnerId: partner.id, post }, + ...(Math.abs(windowEndGap(wall, window, end)) > 1e-6 + ? { position: [slidTo, window.position[1], window.position[2]] } + : {}), + } as Partial, + }, + } +} + +/** The editor's Unwrap: both windows stay, plain, ending at the corner. */ +export function unwrapCorner(nodes: Nodes, windowId: string): Record> { + const window = nodes[windowId] as WindowNode | undefined + const pair = window?.type === 'window' ? cornerPair(nodes, window) : null + if (!window || !pair) return {} + return { [window.id]: { corner: undefined }, [pair.partner.id]: { corner: undefined } } +} + +/** + * A new width for one side of a corner window, its corner end kept at the corner (the panel's + * Width and Other side): the free edge moves, as far as the wall allows. + */ +export function cornerSideWidth( + nodes: Nodes, + windowId: string, + width: number, +): Partial | null { + const window = nodes[windowId] as WindowNode | undefined + const pair = window?.type === 'window' ? cornerPair(nodes, window) : null + if (!window || !pair) return null + const length = wallLengthOf(pair.wall) + const clamped = Math.max(0.1, Math.min(width, length)) + const centre = pair.end === 'start' ? clamped / 2 : length - clamped / 2 + return { width: clamped, position: [centre, window.position[1], window.position[2]] } +} diff --git a/packages/core/src/building/index.ts b/packages/core/src/building/index.ts index 7c43c8fb7b..1cde79bb75 100644 --- a/packages/core/src/building/index.ts +++ b/packages/core/src/building/index.ts @@ -1,6 +1,9 @@ +export * from './corner-window' export * from './door-types' export * from './floor-item-fit' export * from './level-duplication' +export * from './merge-windows' export * from './opening-style-presets' +export { type RgbaImage, warpImage } from './photo-elevation' export * from './wall-openings' export * from './window-types' diff --git a/packages/core/src/building/merge-windows.test.ts b/packages/core/src/building/merge-windows.test.ts new file mode 100644 index 0000000000..6e8c8aada3 --- /dev/null +++ b/packages/core/src/building/merge-windows.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LevelNode, WallNode, WindowNode } from '../schema' +import { MERGE_GAP_M, mergeWindows, windowMergeOffer } from './merge-windows' + +// The Merge windows button (the owner, 8 October): offered when exactly two windows are selected, +// disabled with the reason when they cannot join, and silent for any other selection. + +const wall = new Map() +const nodes = (extra: AnyNode[] = []) => { + const a = WallNode.parse({ + id: 'wall_m', + parentId: 'level_m', + start: [0, 0], + end: [8, 0], + height: 2.5, + }) + const level = LevelNode.parse({ id: 'level_m', children: [a.id] }) + return Object.fromEntries([level, a, ...extra].map((node) => [node.id, node])) as Record< + string, + AnyNode + > +} +const window = (id: string, x: number, width = 1) => + WindowNode.parse({ + id, + parentId: 'wall_m', + wallId: 'wall_m', + position: [x, 1.25, 0], + width, + height: 1.5, + }) + +describe('windowMergeOffer', () => { + const scene = nodes([window('window_a', 1), window('window_b', 2.2), window('window_far', 6)]) + + test('is nothing unless exactly two windows are selected', () => { + expect(windowMergeOffer(scene, [])).toBeNull() + expect(windowMergeOffer(scene, ['window_a'])).toBeNull() + expect(windowMergeOffer(scene, ['window_a', 'window_b', 'window_far'])).toBeNull() + expect(windowMergeOffer(scene, ['window_a', 'wall_m'])).toBeNull() + expect(windowMergeOffer(scene, ['window_a', 'missing'])).toBeNull() + }) + + test('is offered with no reason for two windows that can join', () => { + expect(windowMergeOffer(scene, ['window_a', 'window_b'])).toEqual({ reason: null }) + }) + + test('is offered with the reason, in the words of the refusal, for two that cannot', () => { + const offer = windowMergeOffer(scene, ['window_a', 'window_far']) + + expect(offer?.reason).toContain(`${MERGE_GAP_M.toFixed(2)} m`) + expect(offer?.reason).toBe( + (() => { + try { + mergeWindows(scene, 'window_a', 'window_far') + } catch (error) { + return (error as Error).message + } + })(), + ) + }) +}) diff --git a/packages/core/src/building/merge-windows.ts b/packages/core/src/building/merge-windows.ts new file mode 100644 index 0000000000..4b8812e144 --- /dev/null +++ b/packages/core/src/building/merge-windows.ts @@ -0,0 +1,280 @@ +import { refuse } from '../agent-tools/refusal' +import { + CORNER_REACH_M, + cornerAngle, + cornerPair, + DEGENERATE_CORNER_DEG, + type WallEnd, + wallEndPoint, + wallLengthOf, + windowEndGap, +} from '../lib/corner-pair' +import type { AnyNode, AnyNodeId, WallNode, WindowNode } from '../schema' +import { findWallChildOverlap, resolveWallOpeningCeiling } from './wall-openings' + +/** + * Two windows that exist become one (the owner, 8 October): the one operation behind the editor's + * Merge windows button, Wrap the corner when a window already waits at the corner, and the agents' + * `merge_windows`. + * + * On two walls that meet at a corner, each reaching it within `CORNER_REACH_M`, they become one + * corner window: both adopted, each slid to the corner keeping its width, the glass fused, one sill. + * Side by side on one wall with at most `MERGE_GAP_M` between them and nothing between, they + * become one window spanning both outer edges. The first window named is the one whose height, sill + * and type (corner) or type and style (one wall) the result keeps. + */ + +type Nodes = Readonly> + +/** The widest gap between two windows on one wall that still merges them (m). */ +export const MERGE_GAP_M = 0.3 + +/** What two windows share on a corner window: the second takes the first's. */ +const SHARED = [ + 'height', + 'windowType', + 'frameThickness', + 'frameDepth', + 'sill', + 'sillDepth', + 'sillThickness', +] as const + +export type WindowMerge = { + kind: 'corner' | 'same-wall' + /** The windows that stay, in the order named. */ + windowIds: string[] + /** The window that went into the first (a merge on one wall). */ + removedIds: string[] + wallIds: string[] + /** The bottom edge of the result (m). */ + sillHeight: number + height: number + /** The width of the merged window (a merge on one wall). */ + width?: number + /** The angle the walls make (a corner). */ + angle?: number + changes: { update: { id: AnyNodeId; data: Partial }[]; delete: AnyNodeId[] } + message: string +} + +const metres = (value: number) => `${value.toFixed(2)} m` +const round = (value: number) => Math.round(value * 100) / 100 + +function windowOf(nodes: Nodes, id: string): { window: WindowNode; wall: WallNode } { + const node = nodes[id] + if (!node) refuse('window_not_found', `Window not found: ${id}.`, { windowId: id }) + if (node.type !== 'window') + refuse('not_a_window', `Node ${id} is a ${node.type}, not a window.`, { + windowId: id, + type: node.type, + }) + const window = node as WindowNode + const wall = nodes[window.parentId ?? ''] + if (wall?.type !== 'wall') + refuse( + 'window_not_on_wall', + `Window ${id} is not on a straight wall (a roof or dormer window), so it cannot join another.`, + { windowId: id }, + ) + return { window, wall: wall as WallNode } +} + +const sillOf = (window: WindowNode) => window.position[1] - window.height / 2 +const headOf = (window: WindowNode) => window.position[1] + window.height / 2 + +/** The shared point of two walls' ends, with the end of each there; null when they share none. */ +function sharedCorner(a: WallNode, b: WallNode) { + for (const endA of ['start', 'end'] as const) + for (const endB of ['start', 'end'] as const) { + const [p, q] = [wallEndPoint(a, endA), wallEndPoint(b, endB)] + if (Math.hypot(p[0]! - q[0]!, p[1]! - q[1]!) <= 1e-3) return { endA, endB, point: p } + } + return null +} + +/** + * The plan to merge two windows, or a refusal that says why. Pure: `changes` is for the caller to + * apply (the editor's store, an agent operation's `SceneChanges`). + */ +export function mergeWindows(nodes: Nodes, firstId: string, secondId: string): WindowMerge { + if (firstId === secondId) refuse('same_window', 'Pick two different windows to merge.') + const first = windowOf(nodes, firstId) + const second = windowOf(nodes, secondId) + for (const [mine, other] of [ + [first.window, second.window], + [second.window, first.window], + ] as const) { + if (mine.corner && mine.corner.partnerId === other.id && cornerPair(nodes, mine)) + refuse('windows_already_joined', `${mine.id} and ${other.id} already wrap this corner.`, { + windowIds: [mine.id, other.id], + }) + if (mine.corner && mine.corner.partnerId !== other.id && nodes[mine.corner.partnerId]) + refuse( + 'window_already_joined', + `Window ${mine.id} already wraps a corner with ${mine.corner.partnerId}; unwrap it before joining it to ${other.id}.`, + { windowId: mine.id, partnerId: mine.corner.partnerId }, + ) + } + return first.wall.id === second.wall.id + ? mergeOnOneWall(nodes, first, second) + : mergeAtCorner(nodes, first, second) +} + +type Side = { window: WindowNode; wall: WallNode } + +function mergeOnOneWall(nodes: Nodes, first: Side, second: Side): WindowMerge { + const wall = first.wall + const [a, b] = [first.window, second.window] + const [leftOf, rightOf] = a.position[0] <= b.position[0] ? ([a, b] as const) : ([b, a] as const) + const gap = rightOf.position[0] - rightOf.width / 2 - (leftOf.position[0] + leftOf.width / 2) + if (gap > MERGE_GAP_M + 1e-9) + refuse( + 'windows_not_adjacent', + `${a.id} and ${b.id} are ${metres(gap)} apart on wall ${wall.id}; windows join when at most ${metres(MERGE_GAP_M)} separates them.`, + { windowIds: [a.id, b.id], gap, limit: MERGE_GAP_M }, + ) + + const outerLeft = leftOf.position[0] - leftOf.width / 2 + const outerRight = rightOf.position[0] + rightOf.width / 2 + const bottom = Math.min(sillOf(a), sillOf(b)) + const top = Math.max(headOf(a), headOf(b)) + const centre: [number, number] = [(outerLeft + outerRight) / 2, (bottom + top) / 2] + const between = findWallChildOverlap( + wall.id, + nodes, + centre[0], + centre[1], + outerRight - outerLeft, + top - bottom, + [a.id, b.id], + ) + if (between) + refuse( + 'opening_between', + `${between.type === 'item' ? 'An item' : `A ${between.type}`}, ${between.id}, sits between ${a.id} and ${b.id} on wall ${wall.id}, so they cannot become one window.`, + { windowIds: [a.id, b.id], blockingId: between.id }, + ) + + const width = outerRight - outerLeft + return { + kind: 'same-wall', + windowIds: [a.id], + removedIds: [b.id], + wallIds: [wall.id], + sillHeight: bottom, + height: top - bottom, + width, + changes: { + update: [ + { + id: a.id as AnyNodeId, + data: { width, height: top - bottom, position: [centre[0], centre[1], a.position[2]] }, + }, + ], + delete: [b.id as AnyNodeId], + }, + message: `Merged ${b.id} into ${a.id}: one window ${metres(width)} wide on wall ${wall.id}, from a sill of ${metres(bottom)} to ${metres(top)}. Its type and style are ${a.id}'s, the window named first.`, + } +} + +function mergeAtCorner(nodes: Nodes, first: Side, second: Side): WindowMerge { + const [a, b] = [first.window, second.window] + const joint = sharedCorner(first.wall, second.wall) + if (!joint || first.wall.parentId !== second.wall.parentId) + refuse( + 'walls_not_meeting', + `Wall ${first.wall.id} and wall ${second.wall.id} do not meet at a corner, so ${a.id} and ${b.id} cannot wrap one. On one wall, windows side by side merge instead.`, + { wallIds: [first.wall.id, second.wall.id] }, + ) + const angle = cornerAngle( + { wall: first.wall, end: joint.endA }, + { wall: second.wall, end: joint.endB }, + ) + if (angle < DEGENERATE_CORNER_DEG || angle > 180 - DEGENERATE_CORNER_DEG) + refuse( + 'corner_angle', + `The walls meet at ${Math.round(angle)}°: too close to ${angle < 90 ? 'folded back' : 'straight'} for a corner window.`, + { angle }, + ) + + const sides = [ + { ...first, end: joint.endA }, + { ...second, end: joint.endB }, + ] + for (const { window, wall, end } of sides) { + const gap = windowEndGap(wall, window, end) + if (gap > CORNER_REACH_M + 1e-9) + refuse( + 'window_not_at_corner', + `Window ${window.id} is ${metres(gap)} from the corner of wall ${wall.id}; a window joins a corner window when it is within ${metres(CORNER_REACH_M)} of the corner.`, + { windowId: window.id, gap, limit: CORNER_REACH_M }, + ) + } + + const ceiling = resolveWallOpeningCeiling(second.wall, nodes as Record) + if (headOf(a) > ceiling + 1e-6) + refuse( + 'height_exceeds_wall', + `Window ${a.id} is taller than wall ${second.wall.id} (${metres(headOf(a))} against ${metres(ceiling)}), so ${b.id} cannot take its height and sill. Name the other window first, or lower ${a.id}.`, + { windowId: b.id, wallId: second.wall.id }, + ) + + // Each side slides to the corner, keeping its width, if no other opening is in the way. + const centreAt = (window: WindowNode, wall: WallNode, end: WallEnd) => + end === 'start' ? window.width / 2 : wallLengthOf(wall) - window.width / 2 + const placed = sides.map(({ window, wall, end }, index) => { + const x = centreAt(window, wall, end) + const y = a.position[1] + const blocking = findWallChildOverlap(wall.id, nodes, x, y, window.width, a.height, window.id) + if (blocking) + refuse( + 'opening_overlap', + `Sliding ${window.id} to the corner of wall ${wall.id} would overlap ${blocking.type} ${blocking.id}.`, + { windowId: window.id, blockingId: blocking.id }, + ) + const corner = { + end, + partnerId: sides[1 - index]!.window.id, + post: 'none' as const, + } + const aligned: Partial = + index === 0 + ? {} + : Object.fromEntries(SHARED.map((key) => [key, a[key]]).filter(([, v]) => v !== undefined)) + return { + id: window.id as AnyNodeId, + data: { ...aligned, position: [x, y, window.position[2]], corner } as Partial, + } + }) + + return { + kind: 'corner', + windowIds: [a.id, b.id], + removedIds: [], + wallIds: [first.wall.id, second.wall.id], + sillHeight: sillOf(a), + height: a.height, + angle: round(angle), + changes: { update: placed, delete: [] }, + message: `Joined ${a.id} and ${b.id} as one corner window across ${first.wall.id} and ${second.wall.id} (${Math.round(angle)}°), the glass fused at the corner. Both take ${a.id}'s height and sill (${metres(a.height)} tall from ${metres(sillOf(a))}) and type, as it was named first.`, + } +} + +/** + * What the Merge windows button does for a selection: nothing unless exactly two windows are + * selected; otherwise whether they can join, and if not, why, in the refusal's own words. + */ +export function windowMergeOffer( + nodes: Nodes, + selectedIds: readonly string[], +): { reason: string | null } | null { + if (selectedIds.length !== 2) return null + if (selectedIds.some((id) => nodes[id]?.type !== 'window')) return null + try { + mergeWindows(nodes, selectedIds[0]!, selectedIds[1]!) + return { reason: null } + } catch (error) { + return { reason: error instanceof Error ? error.message : 'These windows cannot be merged.' } + } +} diff --git a/packages/core/src/building/photo-elevation.ts b/packages/core/src/building/photo-elevation.ts new file mode 100644 index 0000000000..6dad8f6cc6 --- /dev/null +++ b/packages/core/src/building/photo-elevation.ts @@ -0,0 +1,477 @@ +/** + * Straightening a photo of a flat face: the plane-to-plane perspective transform (a homography) + * between metres on the face and pixels in the photo, and the warp that draws the face head-on. + * Several faces of one photo share its camera: the vanishing points of their lines give its focal + * length and rotation, and each face's homography is K·[along up origin]. + * Pure over numbers and RGBA buffers, so the chat (canvas) and the MCP (any codec) share it. + */ + +type Pt = [number, number] +type Segment = readonly [Pt, Pt] +export type Vec3 = [number, number, number] + +/** + * Row-major 3 × 3. A fitted one has `h[8]` normalised to 1; a camera's keeps its third row as the + * depth in metres, positive in front of the camera. + */ +export type Homography = number[] + +export type RgbaImage = { width: number; height: number; data: Uint8ClampedArray } + +export function applyHomography(h: Homography, [x, y]: Pt): Pt | null { + const w = h[6]! * x + h[7]! * y + h[8]! + if (Math.abs(w) < 1e-12) return null + return [(h[0]! * x + h[1]! * y + h[2]!) / w, (h[3]! * x + h[4]! * y + h[5]!) / w] +} + +export function multiplyHomography(a: Homography, b: Homography): Homography { + const m: number[] = [] + for (let r = 0; r < 3; r++) + for (let c = 0; c < 3; c++) + m.push(a[r * 3]! * b[c]! + a[r * 3 + 1]! * b[3 + c]! + a[r * 3 + 2]! * b[6 + c]!) + return m +} + +export function invertHomography(h: Homography): Homography | null { + const [a, b, c, d, e, f, g, i, j] = h as [number, ...number[]] + const co = [ + e! * j! - f! * i!, + -(b! * j! - c! * i!), + b! * f! - c! * e!, + -(d! * j! - f! * g!), + a * j! - c! * g!, + -(a * f! - c! * d!), + d! * i! - e! * g!, + -(a * i! - b! * g!), + a * e! - b! * d!, + ] + const det = a * co[0]! + b! * co[3]! + c! * co[6]! + if (Math.abs(det) < 1e-12) return null + const inverse = co.map((value) => value / det) + return normalised(inverse) +} + +function normalised(h: Homography): Homography | null { + const scale = h[8]! + if (Math.abs(scale) < 1e-12 || !h.every(Number.isFinite)) return null + return h.map((value) => value / scale) +} + +/** Moves points to their centroid and scales them to an average distance of √2 (Hartley). */ +function conditioning(points: readonly Pt[]): Homography { + const cx = points.reduce((sum, [x]) => sum + x, 0) / points.length + const cy = points.reduce((sum, [, y]) => sum + y, 0) / points.length + const mean = points.reduce((sum, [x, y]) => sum + Math.hypot(x - cx, y - cy), 0) / points.length + const s = mean > 1e-12 ? Math.SQRT2 / mean : 1 + return [s, 0, -s * cx, 0, s, -s * cy, 0, 0, 1] +} + +/** Gaussian elimination with partial pivoting; null when the system is singular. */ +function solveLinear(a: number[][], b: number[]): number[] | null { + const n = b.length + const m = a.map((row, i) => [...row, b[i]!]) + const scale = Math.max(...m.map((row, i) => Math.abs(row[i]!)), 1e-300) + for (let col = 0; col < n; col++) { + let pivot = col + for (let row = col + 1; row < n; row++) + if (Math.abs(m[row]![col]!) > Math.abs(m[pivot]![col]!)) pivot = row + if (Math.abs(m[pivot]![col]!) < 1e-10 * scale) return null + ;[m[col], m[pivot]] = [m[pivot]!, m[col]!] + for (let row = 0; row < n; row++) { + if (row === col) continue + const factor = m[row]![col]! / m[col]![col]! + for (let k = col; k <= n; k++) m[row]![k]! -= factor * m[col]![k]! + } + } + return m.map((row, i) => row[n]! / row[i]!) +} + +/** + * The homography taking each `from` to its `to`, fitted over all pairs by least squares on + * conditioned coordinates, so one point picked a few pixels off moves the fit, not breaks it. + * Null for fewer than four pairs or points that do not span the plane. + */ +export function solveHomography(pairs: readonly { from: Pt; to: Pt }[]): Homography | null { + if (pairs.length < 4) return null + const tFrom = conditioning(pairs.map((pair) => pair.from)) + const tTo = conditioning(pairs.map((pair) => pair.to)) + const ata = Array.from({ length: 8 }, () => new Array(8).fill(0)) + const atb = new Array(8).fill(0) + const accumulate = (row: number[], value: number) => { + for (let i = 0; i < 8; i++) { + atb[i]! += row[i]! * value + for (let j = 0; j < 8; j++) ata[i]![j]! += row[i]! * row[j]! + } + } + for (const { from, to } of pairs) { + const [x, y] = applyHomography(tFrom, from)! + const [u, v] = applyHomography(tTo, to)! + accumulate([x, y, 1, 0, 0, 0, -u * x, -u * y], u) + accumulate([0, 0, 0, x, y, 1, -v * x, -v * y], v) + } + const solved = solveLinear(ata, atb) + if (!solved) return null + const conditioned = [...solved, 1] + const tToInverse = invertHomography(tTo) + if (!tToInverse) return null + return normalised(multiplyHomography(multiplyHomography(tToInverse, conditioned), tFrom)) +} + +/** Where the lines through `a` and through `b` cross; null when they are parallel. */ +export function lineIntersection(a: readonly [Pt, Pt], b: readonly [Pt, Pt]): Pt | null { + const [[x1, y1], [x2, y2]] = a + const [[x3, y3], [x4, y4]] = b + const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) + const size = Math.max(Math.hypot(x2 - x1, y2 - y1) * Math.hypot(x4 - x3, y4 - y3), 1e-300) + if (Math.abs(denominator) < 1e-9 * size) return null + const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denominator + return [x1 + t * (x2 - x1), y1 + t * (y2 - y1)] +} + +const dot3 = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +const cross3 = (a: Vec3, b: Vec3): Vec3 => [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], +] +const scale3 = (a: Vec3, s: number): Vec3 => [a[0] * s, a[1] * s, a[2] * s] +const add3 = (a: Vec3, b: Vec3): Vec3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +function unit3(a: Vec3): Vec3 | null { + const n = Math.hypot(a[0], a[1], a[2]) + return n > 1e-12 && Number.isFinite(n) ? scale3(a, 1 / n) : null +} + +/** Eigenvalues ascending, with their unit eigenvectors, of a symmetric 3 × 3 (cyclic Jacobi). */ +function symmetricEigen(m: number[][]): { value: number; vector: Vec3 }[] { + const a = m.map((row) => [...row]) + const v = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ] + for (let sweep = 0; sweep < 64; sweep++) { + const off = a[0]![1]! ** 2 + a[0]![2]! ** 2 + a[1]![2]! ** 2 + const diagonal = a[0]![0]! ** 2 + a[1]![1]! ** 2 + a[2]![2]! ** 2 + if (off <= 1e-30 * diagonal || off === 0) break + for (const [p, q] of [ + [0, 1], + [0, 2], + [1, 2], + ] as const) { + if (Math.abs(a[p]![q]!) < 1e-300) continue + const theta = (a[q]![q]! - a[p]![p]!) / (2 * a[p]![q]!) + const t = (theta >= 0 ? 1 : -1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1)) + const c = 1 / Math.sqrt(t * t + 1) + const s = t * c + for (let k = 0; k < 3; k++) { + const [kp, kq] = [a[k]![p]!, a[k]![q]!] + a[k]![p] = c * kp - s * kq + a[k]![q] = s * kp + c * kq + } + for (let k = 0; k < 3; k++) { + const [pk, qk] = [a[p]![k]!, a[q]![k]!] + a[p]![k] = c * pk - s * qk + a[q]![k] = s * pk + c * qk + } + for (let k = 0; k < 3; k++) { + const [kp, kq] = [v[k]![p]!, v[k]![q]!] + v[k]![p] = c * kp - s * kq + v[k]![q] = s * kp + c * kq + } + } + } + return [0, 1, 2] + .map((i) => ({ value: a[i]![i]!, vector: [v[0]![i]!, v[1]![i]!, v[2]![i]!] as Vec3 })) + .sort((x, y) => x.value - y.value) +} + +type PhotoSize = { width: number; height: number } + +/** + * Where lines that are parallel on the building meet in the photo: their vanishing point, as a unit + * homogeneous point (x, y, w) about the photo's centre with its longer side as unit, so lines + * parallel in the photo give w = 0 (a point at infinity) instead of a division by zero. Least + * squares over all the lines, each weighted by its length. Null for fewer than two distinct lines. + */ +export function vanishingPoint(segments: readonly Segment[], photo: PhotoSize): Vec3 | null { + const unit = Math.max(photo.width, photo.height) + const at = ([x, y]: Pt): Vec3 => [(x - photo.width / 2) / unit, (y - photo.height / 2) / unit, 1] + const m = [0, 1, 2].map(() => [0, 0, 0]) + for (const [a, b] of segments) { + const line = cross3(at(a), at(b)) + // |line.xy| is the segment's length: dividing by it once leaves the length as the weight. + const length = Math.hypot(line[0], line[1]) + if (length < 1e-12) continue + for (let i = 0; i < 3; i++) + for (let j = 0; j < 3; j++) m[i]![j]! += (line[i]! * line[j]!) / length + } + const [smallest, middle, largest] = symmetricEigen(m) + // Two near-identical lines (the same corner picked twice) cross anywhere: no vanishing point. + if (!(middle!.value > 1e-5 * largest!.value)) return null + const point = smallest!.vector + return point[2] < 0 ? scale3(point, -1) : point +} + +/** Root mean square distance, in photo pixels, of each segment's ends from the line through its middle and `direction`'s vanishing point. */ +export function vanishingResidualPx( + camera: PhotoCamera, + direction: Vec3, + segments: readonly Segment[], +): number { + if (!segments.length) return 0 + const point: Vec3 = [ + camera.focal * direction[0] + camera.centre[0] * direction[2], + camera.focal * direction[1] + camera.centre[1] * direction[2], + direction[2], + ] + let sum = 0 + for (const [a, b] of segments) { + const line = cross3([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, 1], point) + const norm = Math.hypot(line[0], line[1]) || 1 + for (const [x, y] of [a, b]) sum += ((line[0] * x + line[1] * y + line[2]) / norm) ** 2 + } + return Math.sqrt(sum / (segments.length * 2)) +} + +/** + * The photo's camera: square pixels, no skew, no lens distortion (straight lines stay straight). + * The principal point is at the photo's centre across; its height is fitted, since a + * perspective-corrected or shift photo keeps verticals parallel by moving it toward the horizon. + */ +export type PhotoCamera = { + width: number + height: number + /** Focal length, in photo pixels. */ + focal: number + /** Where the optical axis meets the photo, in photo pixels. */ + centre: Pt + /** World up, in camera coordinates: x right, y down, z forward. */ + up: Vec3 +} + +/** A face's axes in camera coordinates: along it left to right as seen, up, and out toward the camera. */ +export type FaceAxes = { along: Vec3; up: Vec3; normal: Vec3 } + +/** A face's plane: its axes, and `normal · P` of its points, negative since it faces the camera. */ +export type CameraFace = FaceAxes & { distance: number } + +/** A focal length below a quarter or above ten times the photo's longer side is no photo's. */ +const FOCAL_RANGE = [0.25, 10] +/** How much the perpendicular pairs must say about the focal length, at least. */ +const MIN_PAIR_WEIGHT = 0.02 +/** The principal point stays within this many longer sides of the centre, up or down. */ +const MAX_SHIFT = 1 + +export type CameraFailure = 'needs_second_direction' | 'at_infinity' | 'unsolved' + +/** + * The focal length and the principal point's height (in units of the photo's longer side, from + * its centre) that make each pair of perpendicular directions perpendicular. With the principal + * point at (0, c), a pair of vanishing points gives (x₁, y₁ − c·w₁, f·w₁)·(x₂, y₂ − c·w₂, f·w₂) = 0, + * linear in c and g = f² + c²: −(y₁w₂ + y₂w₁)·c + w₁w₂·g = −(x₁x₂ + y₁y₂). Least squares over the + * pairs; a vanishing point far out has a small w and says little about f. With the verticals + * parallel (w = 0) their pairs put c on the horizon. Too few or too alike pairs keep c = 0. + */ +function focalAndCentre(pairs: readonly (readonly [Vec3, Vec3])[]) { + const rows = pairs.map(([a, b]) => ({ + c: -(a[1] * b[2] + b[1] * a[2]), + g: a[2] * b[2], + rhs: -(a[0] * b[0] + a[1] * b[1]), + })) + const sum = (term: (row: (typeof rows)[number]) => number) => + rows.reduce((total, row) => total + term(row), 0) + const weight = Math.sqrt(sum((row) => row.g * row.g)) + const [cc, cg, gg] = [sum((r) => r.c * r.c), sum((r) => r.c * r.g), sum((r) => r.g * r.g)] + const [cr, gr] = [sum((r) => r.c * r.rhs), sum((r) => r.g * r.rhs)] + const determinant = cc * gg - cg * cg + let centre = 0 + let squared = gr / gg + if (rows.length > 1 && determinant > 1e-3 * cc * gg) { + const c = (cr * gg - gr * cg) / determinant + const g = (gr * cc - cr * cg) / determinant + if (Math.abs(c) <= MAX_SHIFT && g - c * c > 0) { + centre = c + squared = g - c * c + } + } + // Each well-placed pair's focal length on its own, at that centre: they agree when the picks do. + const alone = rows + .filter((row) => Math.abs(row.g) > 0.05) + .map((row) => (row.rhs - row.c * centre) / row.g - centre * centre) + .filter((value) => value > 0) + .map(Math.sqrt) + return { weight, focal: Math.sqrt(squared), centre, alone } +} + +/** + * The photo's camera from vanishing points (from `vanishingPoint`): the vertical one when the + * verticals were picked, one per face, and which faces meet at a right angle. First the focal + * length and the principal point's height (`focalAndCentre`), then the rotation: up from the + * verticals (or across two faces), each face's direction from its vanishing point, made level, and + * turned so the face looks at the camera (`probe` is a photo pixel on it). `focalSpread` is the + * ratio between the largest and the smallest focal length the well-placed pairs give on their own: + * 1 when they agree. + */ +export function solvePhotoCamera(input: { + photo: PhotoSize + vertical: Vec3 | null + faces: readonly { vanishing: Vec3; probe: Pt }[] + perpendicular: readonly (readonly [number, number])[] +}): { camera: PhotoCamera; faces: FaceAxes[]; focalSpread: number } | { failure: CameraFailure } { + const { photo, vertical, faces } = input + const pairs = [ + ...(vertical ? faces.map((face) => [vertical, face.vanishing] as const) : []), + ...input.perpendicular.map(([i, j]) => [faces[i]!.vanishing, faces[j]!.vanishing] as const), + ] + if (!pairs.length) return { failure: 'needs_second_direction' } + const { weight, focal, centre, alone } = focalAndCentre(pairs) + if (weight < MIN_PAIR_WEIGHT) return { failure: 'at_infinity' } + if (!(focal >= FOCAL_RANGE[0]! && focal <= FOCAL_RANGE[1]!)) return { failure: 'unsolved' } + + const direction = (v: Vec3) => unit3([v[0], v[1] - centre * v[2], focal * v[2]]) + const directions = faces.map((face) => direction(face.vanishing)) + const [i, j] = input.perpendicular[0] ?? [] + let up = + (vertical && direction(vertical)) ?? + (i !== undefined && directions[i] && directions[j!] + ? unit3(cross3(directions[i]!, directions[j!]!)) + : null) + if (!up) return { failure: 'unsolved' } + if (up[1] > 0) up = scale3(up, -1) + + const unitPx = Math.max(photo.width, photo.height) + const camera: PhotoCamera = { + ...photo, + focal: focal * unitPx, + centre: [photo.width / 2, photo.height / 2 + centre * unitPx], + up, + } + const axes: FaceAxes[] = [] + for (const [index, face] of faces.entries()) { + const raw = directions[index] + let along = raw && unit3(add3(raw, scale3(up, -dot3(raw, up)))) + if (!along) return { failure: 'unsolved' } + let normal = cross3(along, up) + if (dot3(normal, photoRay(camera, face.probe)) > 0) { + along = scale3(along, -1) + normal = scale3(normal, -1) + } + axes.push({ along, up, normal }) + } + const focalSpread = alone.length > 1 ? Math.max(...alone) / Math.min(...alone) : 1 + return { camera, faces: axes, focalSpread } +} + +/** The ray from the camera through a photo pixel, in camera coordinates (its depth is 1). */ +export function photoRay(camera: PhotoCamera, [x, y]: Pt): Vec3 { + return [(x - camera.centre[0]) / camera.focal, (y - camera.centre[1]) / camera.focal, 1] +} + +/** Where a photo pixel's ray meets a face's plane, in camera coordinates; null behind the camera. */ +export function pointOnFace(camera: PhotoCamera, face: CameraFace, pixel: Pt): Vec3 | null { + const ray = photoRay(camera, pixel) + const depth = face.distance / dot3(face.normal, ray) + return depth > 0 && Number.isFinite(depth) ? scale3(ray, depth) : null +} + +/** A photo pixel's place on a face, (along, up) in the face's units, from where its normal meets it. */ +export function faceCoordinates(camera: PhotoCamera, face: CameraFace, pixel: Pt): Pt | null { + const point = pointOnFace(camera, face, pixel) + return point && [dot3(face.along, point), dot3(face.up, point)] +} + +/** + * The plane of a face from the camera and its two vertical edges `width` apart (the walls' extent): + * the one distance that puts them so far apart. Null when the edges come out the wrong way round. + */ +export function faceAtWidth( + camera: PhotoCamera, + axes: FaceAxes, + edges: { left: readonly Pt[]; right: readonly Pt[] }, + width: number, +): CameraFace | null { + const across = (points: readonly Pt[]) => + points.reduce((sum, pixel) => { + const ray = photoRay(camera, pixel) + return sum + dot3(axes.along, ray) / dot3(axes.normal, ray) + }, 0) / points.length + const distance = width / (across(edges.right) - across(edges.left)) + return distance < 0 && Number.isFinite(distance) ? { ...axes, distance } : null +} + +/** The plane of a face through the corner it shares with another: `corner` is that edge's pixels. */ +export function faceThroughCorner( + camera: PhotoCamera, + axes: FaceAxes, + other: CameraFace, + corner: readonly Pt[], +): CameraFace | null { + const points = corner.map((pixel) => pointOnFace(camera, other, pixel)) + if (points.some((point) => !point)) return null + const middle = scale3( + points.reduce((sum, point) => add3(sum, point!), [0, 0, 0]), + 1 / points.length, + ) + const distance = dot3(axes.normal, middle) + return distance < 0 ? { ...axes, distance } : null +} + +/** + * Metres on a face, from `origin` (its face coordinates), to photo pixels: K·[along up origin]. + * Its third row is the depth in metres, so a point behind the camera has w < 0. + */ +export function cameraFaceHomography( + camera: PhotoCamera, + face: CameraFace, + origin: Pt, +): Homography { + const offset = add3( + add3(scale3(face.along, origin[0]), scale3(face.up, origin[1])), + scale3(face.normal, face.distance), + ) + const [a, u, t] = [face.along, face.up, offset].map( + (v): Vec3 => [ + camera.focal * v[0] + camera.centre[0] * v[2], + camera.focal * v[1] + camera.centre[1] * v[2], + v[2], + ], + ) + return [a![0], u![0], t![0], a![1], u![1], t![1], a![2], u![2], t![2]] +} + +/** + * An image `width` × `height` whose every pixel samples `src` (bilinear) where `toSource` maps + * its centre. What falls outside the source stays transparent. + */ +export function warpImage( + src: RgbaImage, + toSource: Homography, + width: number, + height: number, +): RgbaImage { + const out = new Uint8ClampedArray(width * height * 4) + const { width: sw, height: sh, data } = src + for (let v = 0; v < height; v++) { + for (let u = 0; u < width; u++) { + const w = toSource[6]! * (u + 0.5) + toSource[7]! * (v + 0.5) + toSource[8]! + // Behind the camera: a face running past the photo's side would wrap round. + if (w < 1e-12) continue + const sx = (toSource[0]! * (u + 0.5) + toSource[1]! * (v + 0.5) + toSource[2]!) / w - 0.5 + const sy = (toSource[3]! * (u + 0.5) + toSource[4]! * (v + 0.5) + toSource[5]!) / w - 0.5 + if (sx < -0.5 || sy < -0.5 || sx > sw - 0.5 || sy > sh - 0.5) continue + const x0 = Math.max(0, Math.min(sw - 1, Math.floor(sx))) + const y0 = Math.max(0, Math.min(sh - 1, Math.floor(sy))) + const x1 = Math.min(sw - 1, x0 + 1) + const y1 = Math.min(sh - 1, y0 + 1) + const fx = Math.max(0, Math.min(1, sx - x0)) + const fy = Math.max(0, Math.min(1, sy - y0)) + const o = (v * width + u) * 4 + for (let c = 0; c < 4; c++) { + const top = data[(y0 * sw + x0) * 4 + c]! * (1 - fx) + data[(y0 * sw + x1) * 4 + c]! * fx + const bottom = data[(y1 * sw + x0) * 4 + c]! * (1 - fx) + data[(y1 * sw + x1) * 4 + c]! * fx + out[o + c] = Math.round(top * (1 - fy) + bottom * fy) + } + } + } + return { width, height, data: out } +} diff --git a/packages/core/src/building/wall-gaps.ts b/packages/core/src/building/wall-gaps.ts new file mode 100644 index 0000000000..790b55f45e --- /dev/null +++ b/packages/core/src/building/wall-gaps.ts @@ -0,0 +1,173 @@ +import type { WallNode } from '../schema' + +type Pt = [number, number] + +/** Wall ends this close are joined. */ +const JOIN = 0.05 +/** The narrowest gap a door or window leaves. */ +const MIN_GAP = 0.6 +/** On the outside: up to a garage door or a stacking slider. */ +const MAX_OUTSIDE = 5 +/** Two gaps closer than this, side by side, are one opening found from two wall ends. */ +const DUPLICATE = 0.5 + +const sub = (a: readonly number[], b: readonly number[]): Pt => [a[0]! - b[0]!, a[1]! - b[1]!] +const dot = (a: Pt, b: Pt) => a[0] * b[0] + a[1] * b[1] +const cross = (a: Pt, b: Pt) => a[0] * b[1] - a[1] * b[0] +const round = (value: number) => Math.round(value * 1e6) / 1e6 +const at = (p: readonly number[], u: Pt, s: number): Pt => [ + round(p[0]! + u[0] * s), + round(p[1]! + u[1] * s), +] +const same = (p: readonly number[], q: readonly number[]) => + Math.hypot(q[0]! - p[0]!, q[1]! - p[1]!) < JOIN + +function unit(wall: WallNode): Pt { + const [dx, dz] = sub(wall.end, wall.start) + const length = Math.hypot(dx, dz) + return [dx / length, dz / length] +} + +/** + * How a wall end meets the others. Closed: a wall carries on in line from it, or it runs into + * another wall's side. Cornered: another wall turns there (a partition ending at an outside wall's + * jamb leaves the gap open: Hawkesbury's laundry door). + */ +function endOf(p: readonly number[], self: WallNode, walls: readonly WallNode[]) { + let cornered = false + for (const other of walls) { + if (other === self) continue + const u = unit(other) + if (same(other.start, p) || same(other.end, p)) { + if (Math.abs(cross(u, unit(self))) < 0.05) return 'closed' + cornered = true + continue + } + const v = sub(p, other.start) + const along = dot(v, u) + const length = Math.hypot(...sub(other.end, other.start)) + if ( + along > JOIN && + along < length - JOIN && + Math.abs(cross(u, v)) <= (other.thickness ?? 0.2) / 2 + JOIN + ) + return 'closed' + } + return cornered ? 'cornered' : 'free' +} + +/** A gap a plan's walls leave: the wall that closes it, from the open end to what it faces. */ +type Candidate = { start: Pt; end: Pt; thickness: number; clear: number } + +/** + * Where a plan's walls leave a gap for an opening: a wall end facing, in line, the end of another + * wall, or a partition stopping short of the wall it runs to. Found once each. + */ +function gapCandidates(walls: readonly WallNode[]) { + const straight = walls.filter( + (wall) => !wall.curveOffset && Math.hypot(...sub(wall.end, wall.start)) > JOIN, + ) + const found = new Map() + for (const wall of straight) { + const u = unit(wall) + for (const [p, out] of [ + [wall.end, u], + [wall.start, [-u[0], -u[1]] as Pt], + ] as const) { + const end = endOf(p, wall, straight) + if (end === 'closed') continue + // The nearest thing ahead: a wall end in line, or a wall across the way. + let nearest: { s: number; end: Pt; to: WallNode; clear: number; atEnd: boolean } | null = null + for (const other of straight) { + if (other === wall) continue + const w = unit(other) + const half = (wall.thickness ?? 0.2) / 2 + if (Math.abs(cross(u, w)) < 0.05) { + for (const q of [other.start, other.end]) { + const v = sub(q, p) + const s = dot(v, out) + if (s > JOIN && Math.abs(cross(out, v)) < Math.max(half, (other.thickness ?? 0.2) / 2)) + if (!nearest || s < nearest.s) + nearest = { s, end: [q[0], q[1]], to: other, clear: s, atEnd: true } + } + continue + } + if (Math.abs(cross(out, w)) < 0.5) continue + const v = sub(other.start, p) + const s = cross(v, w) / cross(out, w) + const t = cross(v, out) / cross(out, w) + const length = Math.hypot(...sub(other.end, other.start)) + // The ray meets the wall's body, which reaches half its thickness past its centreline's end. + const reach = Math.max(JOIN, (other.thickness ?? 0.2) / 2) + if (s <= JOIN || t < -reach || t > length + reach) continue + const clear = s - (other.thickness ?? 0.2) / 2 + if (!nearest || s < nearest.s) + nearest = { + s, + end: at(p, out, s), + to: other, + clear, + atEnd: t < reach || t > length - reach, + } + } + if (!nearest || nearest.clear < MIN_GAP - 1e-9 || nearest.clear > MAX_OUTSIDE + 1e-9) continue + // From a corner, only a gap that ends at a wall end: the outside past a building's corner + // runs to whatever wall lies across it. + if (end === 'cornered' && !nearest.atEnd) continue + const start: Pt = [p[0], p[1]] + const key = [start, nearest.end] + .map((q) => `${Math.round(q[0] * 100)},${Math.round(q[1] * 100)}`) + .sort() + .join('|') + if (found.has(key)) continue + found.set(key, { + start, + end: nearest.end, + // In line, the gap belongs to the thicker of its two walls (an outside wall's last jamb is + // often a thin stub); across, to the wall it starts from. + thickness: + Math.abs(cross(u, unit(nearest.to))) < 0.05 + ? Math.max(wall.thickness ?? 0.2, nearest.to.thickness ?? 0.2) + : (wall.thickness ?? 0.2), + clear: nearest.clear, + }) + } + } + // One gap per opening: two wall ends a few centimetres apart find it twice. + const candidates: Candidate[] = [] + for (const gap of [...found.values()].sort((a, b) => a.clear - b.clear)) { + const centre = mid(gap) + const direction = unitOf(gap) + if ( + !candidates.some( + (kept) => + Math.hypot(...sub(mid(kept), centre)) < DUPLICATE && + Math.abs(cross(unitOf(kept), direction)) < 0.1, + ) + ) + candidates.push(gap) + } + return candidates +} + +/** + * The walls that close every gap a plan's walls leave, whatever opening each takes: a storey's + * outline for its roof, whether or not its doors and windows are in yet. + */ +export function gapBridges(walls: readonly WallNode[]) { + return gapCandidates(walls).map(({ start, end, thickness }) => ({ + start, + end, + thickness, + })) +} + +const mid = (gap: { start: Pt; end: Pt }): Pt => [ + (gap.start[0] + gap.end[0]) / 2, + (gap.start[1] + gap.end[1]) / 2, +] +const unitOf = (gap: { start: Pt; end: Pt }): Pt => { + const [dx, dz] = sub(gap.end, gap.start) + const length = Math.hypot(dx, dz) + return [dx / length, dz / length] +} diff --git a/packages/core/src/building/wall-openings.test.ts b/packages/core/src/building/wall-openings.test.ts index a66a920a26..7e0adcfc5b 100644 --- a/packages/core/src/building/wall-openings.test.ts +++ b/packages/core/src/building/wall-openings.test.ts @@ -33,6 +33,24 @@ describe('planWallOpening', () => { } }) +// Not a shared case: core stands a window taller than its wall on the floor and says it clamped, +// while a host refuses what pokes above the wall when it writes it ("The window does not fit"). +describe('a window taller than its wall, with no sill given', () => { + test('stands on the floor and is reported clamped', () => { + const { nodes } = openingScene() + const planned = planWallOpening(nodes, { + kind: 'window', + wallId: 'wall_main', + t: 0.5, + height: 3, + }) + + expect(planned.node.position[1]).toBeCloseTo(1.5, 6) + expect(planned.sillHeight).toBeCloseTo(0, 6) + expect(planned.clamped).toBe(true) + }) +}) + // An agent wrote a front door's three glass strips off as "not possible": both tools take code, // and their descriptions stopped at outline, type and style. describe('where an opening beyond the fields is made', () => { diff --git a/packages/core/src/building/wall-openings.ts b/packages/core/src/building/wall-openings.ts index 0a3e8823c9..e675cffcc6 100644 --- a/packages/core/src/building/wall-openings.ts +++ b/packages/core/src/building/wall-openings.ts @@ -1,6 +1,11 @@ import { refuseParamsWithoutScript } from '../agent-operations/add-object' import { refuse } from '../agent-tools/refusal' -import { matchScriptSlotsToLibrary, scriptedSize, scriptSource } from '../lib/geometry-script-node' +import { + matchScriptSlotsToLibrary, + requireMeasured, + scriptedSize, + scriptSource, +} from '../lib/geometry-script-node' import { wallSupportForNodes } from '../lib/opening-floor-datum' import { type AnyNode, @@ -15,10 +20,9 @@ import { } from '../schema' import { getCurtainWallConfig } from '../schema/nodes/curtain-wall' import type { DoorType, WindowType } from '../schema/nodes/opening-types' -import { getWallPlaneTop } from '../services/storey' +import { getWallTop } from '../services/storey' import { resolveWallExteriorSide } from '../systems/wall/wall-assembly' import { getWallCurveLength, isCurvedWall } from '../systems/wall/wall-curve' -import { resolveWallTop } from '../systems/wall/wall-top' import { type DoorStyle, doorStyleLook, @@ -49,11 +53,8 @@ export function resolveWallOpeningCeiling( ): number { const support = wallSupportForNodes(wall, nodes) return ( - resolveWallTop( - wall, - getWallPlaneTop(wall, wall.parentId ?? '', nodes as Record), - support.elevation, - ) - support.elevation + getWallTop(wall, wall.parentId ?? '', nodes as Record, support.elevation) - + support.elevation ) } @@ -103,7 +104,7 @@ export function findWallChildOverlap( clampedY: number, width: number, height: number, - ignoreId?: string, + ignoreId?: string | readonly string[], ): AnyNode | null { const wallNode = nodes[wallId] as WallNode | undefined if (!wallNode) return null @@ -113,7 +114,7 @@ export function findWallChildOverlap( const newRight = clampedX + width / 2 for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { - if (childId === ignoreId) continue + if (typeof ignoreId === 'string' ? childId === ignoreId : ignoreId?.includes(childId)) continue const child = nodes[childId] if (!child || child.metadata.isTransient) continue @@ -160,7 +161,7 @@ export function hasWallChildOverlap( clampedY: number, width: number, height: number, - ignoreId?: string, + ignoreId?: string | readonly string[], ): boolean { if (!nodes[wallId]) return true return findWallChildOverlap(wallId, nodes, clampedX, clampedY, width, height, ignoreId) !== null @@ -199,13 +200,29 @@ export type WallOpeningInput = { const equalRatios = (count: number) => Array.from({ length: count }, () => 1 / count) +/** A window's size when none is given. Its sill is not a number: see `centredWindowSill`. */ +export const DEFAULT_WINDOW = { width: 1.5, height: 1.5 } as const + const DEFAULTS = { door: { width: 0.9, height: 2.1 }, - window: { width: 1.5, height: 1.5, sillHeight: 0.9 }, + window: DEFAULT_WINDOW, } as const const metres = (value: number) => `${value.toFixed(2)} m` +/** + * The sill that centres a window of `height` vertically on the lowest of `walls`, or 0 when it is + * taller than they are. What a window with no sill given takes, on every agent surface. + */ +export function centredWindowSill( + walls: readonly WallNode[], + height: number, + nodes: Readonly>, +): number { + const ceiling = Math.min(...walls.map((wall) => resolveWallOpeningCeiling(wall, nodes))) + return Math.max(0, (ceiling - height) / 2) +} + /** * Plan a door or window on a wall with the editor's rules, or refuse with a code: the one * operation behind `add_door` / `add_window` on every agent surface. The caller creates @@ -282,6 +299,7 @@ export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { const { compiled } = input if (compiled && compiled.mount !== 'wall') refuse('wrong_mount', `A ${kind}'s script uses mount 'wall'.`, { mount: compiled.mount }) + if (compiled) requireMeasured(compiled, { label: `A ${kind}`, tool: `add_${kind}` }) const [scriptedWidth, scriptedHeight] = compiled ? scriptedSize(compiled.manifest) : [] const width = scriptedWidth ?? input.width ?? DEFAULTS[kind].width const height = scriptedHeight ?? input.height ?? DEFAULTS[kind].height @@ -294,8 +312,14 @@ export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { ) const rawX = along * wallLength - const sillHeight = input.sillHeight ?? DEFAULTS.window.sillHeight - const rawY = kind === 'door' ? height / 2 : sillHeight + height / 2 + // A window with no sill given is centred on its wall, taller than the wall or not: the clamp + // below stands one that is too tall on the floor and reports it clamped. + const rawY = + kind === 'door' + ? height / 2 + : input.sillHeight === undefined + ? resolveWallOpeningCeiling(wall, nodes as Record) / 2 + : input.sillHeight + height / 2 const { clampedX, clampedY } = kind === 'door' ? clampDoorToWall(wall, rawX, width, height) diff --git a/packages/core/src/commands/structure/create-zone-walled-room.test.ts b/packages/core/src/commands/structure/create-zone-walled-room.test.ts new file mode 100644 index 0000000000..2b56dfda4d --- /dev/null +++ b/packages/core/src/commands/structure/create-zone-walled-room.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'bun:test' +import { reconcileSceneStructure } from '../../lib/structure-reconcile' +import { + type AnyNode, + BuildingNode, + generateId, + LevelNode, + WallNode, + type ZoneNode, +} from '../../schema' +import { createZone } from './create-zone' + +// Walls first, then a room drawn over them (run 6): the editor's own draw (a terrace over a walled +// courtyard) and an agent's create_room both go through createZone. What it does stays what a person +// meets by hand; the agent's refusal over a room a person named lives in create_room, not here. + +const SQUARE: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + +function walled(zoneName?: string) { + const walls = SQUARE.map((start, i) => + WallNode.parse({ + id: `wall_wr${i}`, + parentId: 'level_wr', + start, + end: SQUARE[(i + 1) % 4]!, + height: 2.5, + }), + ) + const level = LevelNode.parse({ + id: 'level_wr', + parentId: 'building_wr', + children: walls.map((w) => w.id), + }) + const building = BuildingNode.parse({ id: 'building_wr', children: [level.id] }) + const base = Object.fromEntries([building, level, ...walls].map((node) => [node.id, node])) + let count = 0 + const nodes = reconcileSceneStructure({ + nodes: base as Record, + mintId: (kind) => (kind === 'zone' ? 'zone_wr' : `${kind}_wr${++count}`), + }).nodes as Record + if (zoneName) nodes.zone_wr = { ...nodes.zone_wr!, name: zoneName } as AnyNode + return nodes +} + +const zonesAfter = (nodes: Record, plan: ReturnType) => { + const created = plan.changes.filter( + (change) => change.op === 'create' && change.node.type === 'zone', + ) + return { created, renamed: plan.renamed, zoneId: plan.zoneId, nodes } +} + +describe('a room drawn over walls that already enclose it', () => { + test('names the room the reconciler numbered, without a second zone', () => { + const nodes = walled() + expect((nodes.zone_wr as ZoneNode).name).toMatch(/^Room \d+$/) + const plan = createZone(nodes, { + levelId: 'level_wr', + polygon: SQUARE, + name: 'Bedroom', + mintId: generateId, + }) + const { created, zoneId } = zonesAfter(nodes, plan) + expect(zoneId).toBe('zone_wr') + expect(created).toHaveLength(0) + }) + + test('a courtyard drawn as outdoor over its four walls is named outdoor, not refused', () => { + const nodes = walled() + const plan = createZone(nodes, { + levelId: 'level_wr', + polygon: SQUARE, + name: 'Courtyard', + enclose: false, + intent: { hasCeiling: false }, + mintId: generateId, + }) + expect(plan.conflicts).toBeUndefined() + expect(plan.zoneId).toBe('zone_wr') + }) + + test('by hand, a room a person named is left as it is: createZone does not name it, and does not refuse', () => { + const nodes = walled('Library') + const plan = createZone(nodes, { + levelId: 'level_wr', + polygon: SQUARE, + name: 'Bedroom', + mintId: generateId, + }) + expect(plan.renamed).toBeUndefined() + expect(plan.zoneId).not.toBe('zone_wr') + expect( + plan.changes.some((change) => change.op === 'create' && change.node.type === 'zone'), + ).toBe(true) + }) + + test("an agent's explicit rename (adoptNamed) names the person's room in place", () => { + const nodes = walled('Library') + const plan = createZone(nodes, { + levelId: 'level_wr', + polygon: SQUARE, + name: 'Bedroom', + adoptNamed: true, + mintId: generateId, + }) + expect(plan.zoneId).toBe('zone_wr') + expect(plan.renamed).toBe('Library') + expect( + plan.changes.some((change) => change.op === 'create' && change.node.type === 'zone'), + ).toBe(false) + }) +}) diff --git a/packages/core/src/commands/structure/create-zone.ts b/packages/core/src/commands/structure/create-zone.ts index e98485eaf1..89d83624e2 100644 --- a/packages/core/src/commands/structure/create-zone.ts +++ b/packages/core/src/commands/structure/create-zone.ts @@ -3,7 +3,9 @@ import { area, intersection } from '../../lib/polygon-boolean' import { polygonInteriorPoint } from '../../lib/polygon-label' import { segmentsIntersect } from '../../lib/polygon-relations' import { extractRooms } from '../../lib/room-graph' -import { type AnyNodeId, SeparatorNode, type WallNode } from '../../schema' +import { isAllocatedRoomName } from '../../lib/room-name' +import { adoptableFace, zoneFaceFits } from '../../lib/room-zone-adoption' +import { type AnyNodeId, SeparatorNode, type WallNode, ZoneNode } from '../../schema' import { getWallCurveFrameAt } from '../../systems/wall/wall-curve' import { planWallInsertion, uncoveredWallSegments } from '../../systems/wall/wall-topology' import { setZoneIntent, type ZoneIntentPatch } from './set-zone-intent' @@ -29,6 +31,8 @@ export type CreateZoneInput = { name?: string intent?: ZoneIntentPatch enclose?: boolean + /** Name the walled room a person already named, instead of stacking a zone on it (an agent's explicit rename). */ + adoptNamed?: boolean wall?: Pick, 'height' | 'thickness' | 'justification'> mintId: StructureMintId } @@ -52,10 +56,66 @@ export function outdoorRoomConflicts(nodes: StructureNodes, levelId: string, pol ) } +/** + * The room walls already make where a room is drawn, to name rather than stack a second zone on + * (run 6: walls first, then create_room, gave every room a "Room N" twin). Only when every edge of + * the polygon runs along a wall, so nothing is built and the rooms stand as they are; the room the + * polygon fits by the reconciler's own adoption rule, and only while it keeps the name the + * reconciler gave it. + */ +export function walledRoomAt( + nodes: StructureNodes, + levelId: string, + polygon: Point[], +): ZoneNode | undefined { + const walls = boundaries(nodes, levelId).filter((n): n is WallNode => n.type === 'wall') + const walled = polygon.every((start, i) => { + const end = polygon[(i + 1) % polygon.length]! + return ( + Math.hypot(end[0] - start[0], end[1] - start[1]) < 1e-8 || + uncoveredWallSegments(start, end, walls).length === 0 + ) + }) + if (!walled) return + const rooms = Object.values(nodes).filter( + (node): node is ZoneNode => + node.type === 'zone' && + node.parentId === levelId && + node.spaceRole === 'room' && + node.autoFromWalls && + node.enclosureStatus !== 'open', + ) + const fit = adoptableFace( + zoneFaceFits( + { id: '', polygon }, + rooms.map((room) => ({ + key: room.id, + polygon: { outer: room.polygon, holes: room.holes ?? [] }, + clear: { outer: [], holes: [] }, + })), + ), + ) + return fit && rooms[fit.face] +} + +/** + * The walled room a drawn polygon names instead of stacking a zone on: one that still has the name + * the reconciler gave it, or any walled room when `adoptNamed` says its person's name may change. + */ +export function walledRoomToName( + nodes: StructureNodes, + levelId: string, + polygon: Point[], + adoptNamed = false, +): ZoneNode | undefined { + const room = walledRoomAt(nodes, levelId, polygon) + return room && (adoptNamed || isAllocatedRoomName(room.name)) ? room : undefined +} + export function createZone( nodes: StructureNodes, input: CreateZoneInput, -): StructurePlan & { zoneId: string } { +): StructurePlan & { zoneId: string; renamed?: string } { if (nodes[input.levelId]?.type !== 'level') throw Error('Select an editable floor.') if ([input.polygon, input.boundaryIds, input.edges].filter(Boolean).length !== 1) throw Error('Supply exactly one polygon, boundary set or edge list.') @@ -120,6 +180,22 @@ export function createZone( ) ) throw Error('A room needs a valid polygon.') + const walledRoom = walledRoomToName(nodes, input.levelId, polygon, input.adoptNamed) + if (walledRoom) { + scratch[walledRoom.id] = { ...walledRoom, name: input.name ?? walledRoom.name } + if (input.intent) + scratch = applyToScratch( + scratch, + structureChangeBatch( + setZoneIntent(scratch, { zoneId: walledRoom.id, patch: input.intent }).changes, + ), + ) + return { + changes: diffStructure(nodes, scratch), + zoneId: walledRoom.id, + renamed: walledRoom.name, + } + } if (input.enclose === false) { const conflicts = outdoorRoomConflicts(nodes, input.levelId, polygon) if (conflicts.length) return { changes: [], conflicts, zoneId: '' } diff --git a/packages/core/src/commands/structure/index.ts b/packages/core/src/commands/structure/index.ts index 643cf6f6b8..e3bb8ff4de 100644 --- a/packages/core/src/commands/structure/index.ts +++ b/packages/core/src/commands/structure/index.ts @@ -6,7 +6,13 @@ export { type SupportHostAssignment, } from './apply-zone-transform' export { type CreateMezzanineInput, createMezzanine } from './create-mezzanine' -export { type CreateZoneInput, createZone, outdoorRoomConflicts } from './create-zone' +export { + type CreateZoneInput, + createZone, + outdoorRoomConflicts, + walledRoomAt, + walledRoomToName, +} from './create-zone' export { type DeleteZonePayload, deleteZone, SHARED_WALLS_DELETE_MESSAGE } from './delete-zone' export { createZoneDivisionContext, diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 3a534b3feb..b8161c5efa 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -80,6 +80,7 @@ const MATERIAL_FIELDS = [ 'material', 'edgeMaterial', 'exteriorMaterial', + 'fasciaMaterial', 'glassMaterial', 'interiorMaterial', 'panelMaterial', @@ -192,6 +193,14 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [ remaps: CLONES, note: 'Roof accessories, and doors/windows/items on a segment wall face.', }), + row({ + kind: 'window', + path: 'corner.partnerId', + ...policy('node', 'connection', 'drop', 'strip'), + targetKinds: ['window'], + remaps: ['clone-scene-graph'], + note: "A corner window's other side (L65). The whole-graph clone remaps it; a level or a selection copied otherwise renders its pair as two plain windows until wrapped again.", + }), row({ kind: 'window', path: 'dormerId', @@ -896,6 +905,11 @@ export const METADATA_REFERENCES: readonly ExistingReference[] = [ ...policy('source', 'content', 'freeze', 'strip'), note: 'Import provenance: the source element ids an importer recorded (the /next converter writes them); find_nodes filters on them.', }), + meta('balcony.id', { + ...policy('node', 'internal', 'drop', 'strip'), + targetKinds: ['slab'], + note: 'The deck a balcony railing or deck belongs to.', + }), ] const described = (reason: string, paths: readonly string[]) => @@ -1006,6 +1020,15 @@ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[ 'title', ]), ...described('An authored object: what it stands in for, as add_object was told.', ['reason']), + ...described('Balcony state: a snapshot, flag or label; names nothing.', [ + 'balcony.options', + 'balcony.role', + 'balcony.source', + 'balcony.guardBoundaryIndices', + 'balcony.hiddenByStyle', + 'balcony.options.depth', + 'balcony.options.railing', + ]), ] const bare = (path: string) => path.replace(/^metadata\./, '').replace(/\[\]/g, '') diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 5cecc7af74..819727958e 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,7 @@ import type { ThreeEvent } from '@react-three/fiber' import mitt from 'mitt' import type { Object3D } from 'three' +import type { PointAskPin, PointAskStatus } from '../agent-operations/point-context' import type { SceneViewInterior } from '../agent-operations/scene-view' import type { BlockNode, @@ -381,6 +382,13 @@ export interface RoomPresetCreateEvent { zoneId: ZoneNode['id'] } +type PointAskEvents = { + /** The chat's word on an ask's turn, for its pin. */ + 'point-ask:status': PointAskStatus + /** The pin tells the chat to light or open the ask's turn. */ + 'point-ask:pin': PointAskPin +} + type RoomPresetEvents = { 'room-preset:create': RoomPresetCreateEvent } @@ -412,6 +420,7 @@ type EditorEvents = GridEvents & ThumbnailEvents & SnapshotEvents & AIChatEvents & + PointAskEvents & RoomPresetEvents & SelectionEvents diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 14e97bf936..50c3a2a02f 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -6,7 +6,7 @@ import { levelBaseElevationAt } from '../../lib/terrain-support' import { floorPlacedCollides, nodeRegistry } from '../../registry' import type { AnyNode, AnyNodeId, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' -import { getWallPlaneTop } from '../../services/storey' +import { getWallTop } from '../../services/storey' import useLiveNodeOverrides, { getEffectiveNode } from '../../store/use-live-node-overrides' import useLiveTerrain from '../../store/use-live-terrain' import useLiveTransforms from '../../store/use-live-transforms' @@ -19,7 +19,6 @@ import { } from '../../systems/slab/slab-support' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import type { WallJustification } from '../../systems/wall/wall-frame' -import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -254,11 +253,7 @@ export class SpatialGridManager { wall.supportOffset, wall.justification, ) - return resolveWallEffectiveHeight( - wall, - getWallPlaneTop(wall, levelId, nodes), - support.elevation, - ) + return getWallTop(wall, levelId, nodes, support.elevation) - support.elevation } private getCeilingGrid(ceilingId: string): SpatialGrid { @@ -1206,8 +1201,7 @@ export function getWallBaseElevationForNodes( /** * Effective (extruded) height of a wall resolved from a nodes record: - * {@link resolveWallEffectiveHeight} over the covering-clamped plane top - * (`getWallPlaneTop`) and the singleton manager's slab election — so the + * {@link getWallTop} minus the singleton manager's slab election — so the * value always agrees with the rendered wall. One shared resolver for the * editor overlays (measurement label, action menu, side handles) that used * to copy this derivation locally. @@ -1228,5 +1222,5 @@ export function getWallEffectiveHeightForNodes( wall.supportOffset, wall.justification, ) - return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), support.elevation) + return getWallTop(wall, levelId, nodes, support.elevation) - support.elevation } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d70dc47f3a..99779a8062 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,13 @@ +export type { + PointAskPin, + PointAskStatus, + PointAskSubmit, + PointAskSubmitResult, + PointCamera, + PointContext, + PointImage, + PointTarget, +} from './agent-operations/point-context' export * from './commands/structure' export type { BlockEvent, @@ -130,6 +140,23 @@ export { computeCeilingSurfaceCells, parseCeilingRegionRole, } from './lib/ceiling-surface' +export { + CORNER_REACH_M, + cornerPair, + windowCornerEnd, + windowNearCornerEnd, +} from './lib/corner-pair' +export { + CORNER_SNAP_ENTER_M, + CORNER_SNAP_EXIT_M, + type CornerBlock, + type CornerBlockReason, + type CornerSnap, + cornerBlock, + cornerSnap, + evaluateCorner, +} from './lib/corner-snap' +export { criticallyDamped } from './lib/critically-damped' export { isCutterName, resolveCutterHost } from './lib/cutter-host' export { clampDoorOperationState, @@ -255,6 +282,13 @@ export { plateSideRuns, roomFinishRole, } from './lib/plate-surface' +export { + hasPointAskHandler, + type PointAskHandler, + registerPointAskHandler, + submitPointAsk, + useHasPointAskHandler, +} from './lib/point-ask' export { area, containsPoint, @@ -346,6 +380,7 @@ export { slotLabelFromId, slotPaintMaterial, } from './lib/slots' +export { createSnapEaser, prefersReducedMotion, SNAP_EASE_MS } from './lib/snap-ease' export { createRoomTopologyIndex, detectSpacesForLevel, @@ -503,6 +538,7 @@ export * from './services' export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export { collectionIdsOf, + getPendingNodeUpdateCount, joinCollections, type NodeDeletionPlan, type NodeDeletionScene, @@ -521,16 +557,20 @@ export { isDerivedNode, } from './store/derived-node-guard' export { + acquireSceneCommitAuthor, acquireSceneHistoryPause, activeSceneCommitNodeIds, beginSceneHistoryPauseSession, getSceneHistoryPauseDepth, isApplyingRemoteSceneChange, + isRestoringSceneHistory, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, + runAsSceneCommitAuthor, runAsSingleSceneHistoryStep, type SceneCommit, + type SceneCommitAuthor, type SceneCommitListener, type SceneCommitOrigin, type SceneHistoryPauseSession, @@ -554,6 +594,20 @@ export { withSceneNodeBuildImages, writeSceneNodeField, } from './store/scene-annotations' +export { + beginScenePreview, + endScenePreview, + isScenePreviewing, + type ScenePreview, + subscribeScenePreview, +} from './store/scene-preview' +export { + CORNER_BLOCK_NOTES, + type CornerHintRow, + type CornerSnapHint, + cornerHintRows, + useCornerSnapHint, +} from './store/use-corner-snap-hint' export { type ControlValue, type DoorAnimationState, @@ -902,6 +956,7 @@ export { MIN_WALL_HEIGHT, resolveWallEffectiveHeight, resolveWallTop, + wallFollowsStorey, } from './systems/wall/wall-top' export { findOpenWallEnds, @@ -915,6 +970,7 @@ export { } from './systems/wall/wall-topology' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' +export { areSemanticValuesEqual } from './utils/semantic-equal' export { isObject } from './utils/types' export { checkOpeningWithinWall, diff --git a/packages/core/src/lib/corner-pair.ts b/packages/core/src/lib/corner-pair.ts new file mode 100644 index 0000000000..29b89367ed --- /dev/null +++ b/packages/core/src/lib/corner-pair.ts @@ -0,0 +1,130 @@ +import type { AnyNode, WallNode, WindowNode } from '../schema' + +/** + * Corner windows (L65): a window joined at its wall's end to a window on the wall that meets it + * there. The lookups the viewer and the editor read; planning one lives in building/corner-window. + */ + +type Nodes = Readonly> +export type WallEnd = 'start' | 'end' + +/** How close a wall's end must be to a point to meet there (m). */ +const AT = 1e-3 +/** How close a window's edge must be to its wall's end to be one side of a corner pair (m). */ +const REACHES = 0.01 +/** + * How near a corner a window counts as being at it, to wrap it or join it to the window on the + * other wall (m); a window this near is slid to the corner when it joins. + */ +export const CORNER_REACH_M = 0.3 +/** A corner this close to folded back or to straight is no corner (degrees). */ +export const DEGENERATE_CORNER_DEG = 10 + +const distance = (a: readonly number[], b: readonly number[]) => + Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!) +export const wallLengthOf = (wall: WallNode) => distance(wall.start, wall.end) +export const wallEndPoint = (wall: WallNode, end: WallEnd) => + end === 'start' ? wall.start : wall.end + +/** The walls of a level ending at a point, with the end that does. */ +export function wallsEndingAt( + nodes: Nodes, + levelId: string | undefined, + point: readonly number[], + includeCurved = false, +) { + return Object.values(nodes).flatMap((node) => { + if ( + node.type !== 'wall' || + (levelId && node.parentId !== levelId) || + (node.curveOffset && !includeCurved) + ) + return [] + const wall = node as WallNode + const end: WallEnd | null = + distance(wall.start, point) <= AT ? 'start' : distance(wall.end, point) <= AT ? 'end' : null + return end ? [{ wall, end }] : [] + }) +} + +/** The angle two walls make at the corner they share, between their directions away from it. */ +export function cornerAngle( + a: { wall: WallNode; end: WallEnd }, + b: { wall: WallNode; end: WallEnd }, +) { + const away = ({ wall, end }: { wall: WallNode; end: WallEnd }) => { + const [from, to] = end === 'start' ? [wall.start, wall.end] : [wall.end, wall.start] + const length = distance(from, to) + return [(to[0] - from[0]) / length, (to[1] - from[1]) / length] + } + const [u, v] = [away(a), away(b)] + const cos = Math.max(-1, Math.min(1, u[0]! * v[0]! + u[1]! * v[1]!)) + return (Math.acos(cos) * 180) / Math.PI +} + +/** The end of its wall a window's edge reaches, if it reaches one. */ +export function windowCornerEnd(nodes: Nodes, window: WindowNode): WallEnd | null { + const wall = nodes[window.parentId ?? ''] + if (wall?.type !== 'wall') return null + const length = wallLengthOf(wall as WallNode) + const [left, right] = [ + window.position[0] - window.width / 2, + window.position[0] + window.width / 2, + ] + return Math.abs(left) <= REACHES ? 'start' : Math.abs(right - length) <= REACHES ? 'end' : null +} + +/** How far a window's edge is from an end of its wall (m); negative past it. */ +export function windowEndGap(wall: WallNode, window: WindowNode, end: WallEnd): number { + return end === 'start' + ? window.position[0] - window.width / 2 + : wallLengthOf(wall) - (window.position[0] + window.width / 2) +} + +/** The end of its wall a window is within `reach` of, the nearer if both; null when neither. */ +export function windowNearCornerEnd( + nodes: Nodes, + window: WindowNode, + reach = CORNER_REACH_M, +): WallEnd | null { + const wall = nodes[window.parentId ?? ''] + if (wall?.type !== 'wall') return null + const gaps = (['start', 'end'] as const) + .map((end) => ({ end, gap: windowEndGap(wall as WallNode, window, end) })) + .filter(({ gap }) => gap <= reach) + .sort((a, b) => a.gap - b.gap) + return gaps[0]?.end ?? null +} + +/** + * A window's corner pair: its wall, the end it runs to, the partner on the other wall and the + * angle between the walls; null when it has none, or its partner does not name it back (a deleted + * or unwrapped partner leaves no pair). + */ +export function cornerPair(nodes: Nodes, window: WindowNode) { + const corner = window.corner + const partner = corner ? nodes[corner.partnerId] : undefined + if (!corner || partner?.type !== 'window') return null + const other = partner as WindowNode + if (other.corner?.partnerId !== window.id) return null + // Moved off its corner (dragged along its wall), a window is no longer one side of the pair. + if ( + windowCornerEnd(nodes, window) !== corner.end || + windowCornerEnd(nodes, other) !== other.corner.end + ) + return null + const [wall, partnerWall] = [nodes[window.parentId ?? ''], nodes[other.parentId ?? '']] + if (wall?.type !== 'wall' || partnerWall?.type !== 'wall') return null + const angle = cornerAngle( + { wall: wall as WallNode, end: corner.end }, + { wall: partnerWall as WallNode, end: other.corner.end }, + ) + return { + wall: wall as WallNode, + end: corner.end, + partner: other, + partnerWall, + angle, + post: corner.post, + } +} diff --git a/packages/core/src/lib/corner-snap.test.ts b/packages/core/src/lib/corner-snap.test.ts new file mode 100644 index 0000000000..14fc012c69 --- /dev/null +++ b/packages/core/src/lib/corner-snap.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LevelNode, WallNode, WindowNode } from '../schema' +import { CORNER_SNAP_EXIT_M, cornerBlock, cornerSnap } from './corner-snap' + +// The owner (8 October): placing or dragging a window near a corner told you nothing about joining +// the window on the other wall. The ghost snaps its edge to the corner inside a zone, the window +// waiting there is found for the highlight, and the zone has hysteresis so the hint does not +// flicker at its edge. Written first: no snap inside 0.30 m; snapping where no corner is; the zone +// flickering at its threshold; the window waiting on the other wall not found, or a pair's taken. + +const LEVEL = 'level_s' + +/** Wall A (4 m) runs into the corner at the origin; wall B (3 m) leaves it at `degrees`. */ +function scene(degrees: number, extra: AnyNode[] = [], extraWalls: AnyNode[] = []) { + const rad = (degrees * Math.PI) / 180 + const a = WallNode.parse({ id: 'wall_a', parentId: LEVEL, start: [4, 0], end: [0, 0] }) + const b = WallNode.parse({ + id: 'wall_b', + parentId: LEVEL, + start: [0, 0], + end: [3 * Math.cos(rad), 3 * Math.sin(rad)], + }) + const level = LevelNode.parse({ id: LEVEL, children: [a.id, b.id] }) + // A wall lists its openings as children, as a scene does. + const withChildren = (wall: typeof a) => ({ + ...wall, + children: extra.filter((node) => node.parentId === wall.id).map((node) => node.id), + }) + return Object.fromEntries( + [level, withChildren(a), withChildren(b), ...extraWalls, ...extra].map((node) => [ + node.id, + node, + ]), + ) +} + +const onWall = (id: string, wall: string, x: number, width = 1.2, extra = {}) => + WindowNode.parse({ + id, + parentId: wall, + wallId: wall, + position: [x, 1.25, 0], + width, + height: 1.5, + ...extra, + }) + +/** The centre that leaves a window of `width` this far from the end of wall_a (4 m). */ +const centreAt = (gap: number, width = 1.2) => 4 - gap - width / 2 + +describe('the corner zone', () => { + test('snaps a window whose edge is within 0.30 m of a corner, to the corner', () => { + const snap = cornerSnap(scene(90), { wallId: 'wall_a', centreX: centreAt(0.25), width: 1.2 }) + + expect(snap?.end).toBe('end') + expect(snap?.centreX).toBeCloseTo(4 - 0.6, 6) + expect(snap?.otherWall.id).toBe('wall_b') + expect(snap?.otherEnd).toBe('start') + }) + + test('does not snap farther out', () => { + expect( + cornerSnap(scene(90), { wallId: 'wall_a', centreX: centreAt(0.31), width: 1.2 }), + ).toBeNull() + }) + + test('snaps at the other end of a wall too, and at the nearer end of a short one', () => { + // wall_b leaves the corner at its start: a window 0.2 m from there. + const snap = cornerSnap(scene(90), { wallId: 'wall_b', centreX: 0.2 + 0.5, width: 1 }) + expect(snap?.end).toBe('start') + expect(snap?.centreX).toBeCloseTo(0.5, 6) + }) + + test('has hysteresis: held, the zone reaches out to its exit edge; not held, it does not', () => { + const gap = (0.3 + CORNER_SNAP_EXIT_M) / 2 + const input = { wallId: 'wall_a', centreX: centreAt(gap), width: 1.2 } + + expect(cornerSnap(scene(90), input)).toBeNull() + expect(cornerSnap(scene(90), { ...input, held: 'end' })?.end).toBe('end') + expect( + cornerSnap(scene(90), { + wallId: 'wall_a', + centreX: centreAt(CORNER_SNAP_EXIT_M + 0.02), + width: 1.2, + held: 'end', + }), + ).toBeNull() + }) + + test('is not a corner where no other wall ends', () => { + // wall_a runs from (4, 0), a free end, to the corner at the origin: 0.1 m from the free end + // there is nothing to join. + expect(cornerSnap(scene(90), { wallId: 'wall_a', centreX: 0.1 + 0.6, width: 1.2 })).toBeNull() + }) + + test('is not a corner where three walls meet, nor one nearly straight, nor a wall too short', () => { + const third = WallNode.parse({ + id: 'wall_c', + parentId: LEVEL, + start: [0, 0], + end: [-2, 0], + }) + const input = { wallId: 'wall_a', centreX: centreAt(0.1), width: 1.2 } + + expect(cornerSnap(scene(90, [], [third]), input)).toBeNull() + expect(cornerSnap(scene(176), input)).toBeNull() + expect(cornerSnap(scene(90), { ...input, width: 4.5 })).toBeNull() + }) +}) + +describe('the window waiting at the other wall’s corner', () => { + const input = { wallId: 'wall_a', centreX: centreAt(0.1), width: 1.2 } + + test('is found, the nearest first', () => { + const nodes = scene(90, [ + onWall('window_far', 'wall_b', 0.6 + 0.25), + onWall('window_near', 'wall_b', 0.6 + 0.05), + ]) + + expect(cornerSnap(nodes, input)?.waitingId).toBe('window_near') + }) + + test('is none when the wall has none at the corner', () => { + // Beyond 0.30 m of the corner, it is not waiting there (and leaves room for the match). + const nodes = scene(90, [onWall('window_beyond', 'wall_b', 0.6 + 1.5)]) + + expect(cornerSnap(nodes, input)?.waitingId).toBeNull() + }) + + test('is not one that is already a side of a corner window', () => { + const taken = onWall('window_taken', 'wall_b', 0.6, 1.2, { + corner: { end: 'start', partnerId: 'window_elsewhere', post: 'none' }, + }) + + // Taken, it is not waiting; with it in the way the match has no room, so no corner is offered. + expect(cornerSnap(scene(90, [taken]), input)).toBeNull() + expect(cornerBlock(scene(90, [taken]), input)?.reason).toBe('no_room') + }) + + test('is never a draft still being placed', () => { + const draft = onWall('window_draft', 'wall_b', 0.6, 1.2, { metadata: { isTransient: true } }) + + expect(cornerSnap(scene(90, [draft]), input)?.waitingId).toBeNull() + }) + + test('is never the window being dragged', () => { + const self = onWall('window_self', 'wall_a', centreAt(0.1)) + // Dragged along its own wall near the corner: nothing on wall_b waits, and it is not its own. + expect( + cornerSnap(scene(90, [self]), { ...input, ignoreId: 'window_self' })?.waitingId, + ).toBeNull() + }) +}) + +// The owner could not find "Wrap the corner" (8 October): a lone wall, a T-junction, a curved or +// curtain wall, an angle out of range, or no room for the match show nothing, so the hint says why +// where a window is placed or dragged within the zone. Written first: a reason where there is none, +// no reason where the corner would join, and the zone's hysteresis leaving the reason flickering. +describe('why a wall end is not a corner', () => { + const input = { wallId: 'wall_a', centreX: centreAt(0.1), width: 1.2 } + + test('says nothing where the corner would join, and away from every wall end', () => { + expect(cornerBlock(scene(90), input)).toBeNull() + expect(cornerBlock(scene(90), { ...input, centreX: 2 })).toBeNull() + }) + + test('a free end: no other wall ends there', () => { + expect(cornerBlock(scene(90), { wallId: 'wall_a', centreX: 0.1 + 0.6, width: 1.2 })).toEqual({ + reason: 'free_end', + end: 'start', + }) + }) + + test('three walls meeting', () => { + const third = WallNode.parse({ id: 'wall_c', parentId: LEVEL, start: [0, 0], end: [-2, 0] }) + + expect(cornerBlock(scene(90, [], [third]), input)?.reason).toBe('several_walls') + }) + + test('an angle too close to straight or folded back', () => { + expect(cornerBlock(scene(176), input)?.reason).toBe('angle') + expect(cornerBlock(scene(4), input)?.reason).toBe('angle') + }) + + test('a curved wall, on either side of the corner', () => { + const curved = (id: string, start: [number, number], end: [number, number]) => + WallNode.parse({ id, parentId: LEVEL, start, end, curveOffset: 0.4 }) + const withCurved = (nodes: Record, wall: AnyNode) => ({ + ...nodes, + [wall.id]: wall, + }) + + expect( + cornerBlock(withCurved(scene(90), curved('wall_a', [4, 0], [0, 0])), input)?.reason, + ).toBe('curved') + expect( + cornerBlock(withCurved(scene(90), curved('wall_b', [0, 0], [0, 3])), input)?.reason, + ).toBe('curved') + }) + + test('a curtain wall, on either side of the corner', () => { + const curtain = (id: string, start: [number, number], end: [number, number]) => + WallNode.parse({ id, parentId: LEVEL, start, end, wallType: 'curtain' }) + + expect( + cornerBlock({ ...scene(90), wall_b: curtain('wall_b', [0, 0], [0, 3]) }, input)?.reason, + ).toBe('curtain') + expect( + cornerBlock({ ...scene(90), wall_a: curtain('wall_a', [4, 0], [0, 0]) }, input)?.reason, + ).toBe('curtain') + }) + + test('no room for the match on the other wall: an opening already stands at its corner end', () => { + const taken = onWall('window_taken', 'wall_b', 0.6, 1.2, { + corner: { end: 'start', partnerId: 'window_elsewhere', post: 'none' }, + }) + + expect(cornerBlock(scene(90, [taken]), input)?.reason).toBe('no_room') + // And the ghost does not snap to a corner it cannot join. + expect(cornerSnap(scene(90, [taken]), input)).toBeNull() + }) + + test('a window waiting there needs no room: it is adopted', () => { + const waiting = onWall('window_waiting', 'wall_b', 0.6) + + expect(cornerBlock(scene(90, [waiting]), input)).toBeNull() + expect(cornerSnap(scene(90, [waiting]), input)?.waitingId).toBe('window_waiting') + }) + + test('keeps its reason across the zone’s edge like the snap does', () => { + const gap = (0.3 + CORNER_SNAP_EXIT_M) / 2 + const edge = { wallId: 'wall_a', centreX: gap + 0.6, width: 1.2 } + + expect(cornerBlock(scene(90), edge)).toBeNull() + expect(cornerBlock(scene(90), { ...edge, held: 'start' })?.reason).toBe('free_end') + }) +}) diff --git a/packages/core/src/lib/corner-snap.ts b/packages/core/src/lib/corner-snap.ts new file mode 100644 index 0000000000..7fdc76d41e --- /dev/null +++ b/packages/core/src/lib/corner-snap.ts @@ -0,0 +1,177 @@ +import { isAgentRefusal } from '../agent-tools/refusal' +import { planWallOpening } from '../building/wall-openings' +import type { AnyNode, WallNode, WindowNode } from '../schema' +import { + CORNER_REACH_M, + cornerAngle, + DEGENERATE_CORNER_DEG, + type WallEnd, + wallEndPoint, + wallLengthOf, + wallsEndingAt, + windowEndGap, + windowNearCornerEnd, +} from './corner-pair' + +/** + * Placing or dragging a window near a corner (the owner, 8 October): inside the corner zone the + * window snaps its edge to the corner and the window waiting on the other wall, if any, is found, so + * the tool can highlight it and say the two will join. Pure: the tools read the answer. + */ + +type Nodes = Readonly> + +/** How near a corner the zone starts (m): a window's edge this close to a wall's end snaps to it. */ +export const CORNER_SNAP_ENTER_M = CORNER_REACH_M +/** How far out the zone reaches for a window already snapped (m), so its edge does not flicker. */ +export const CORNER_SNAP_EXIT_M = CORNER_REACH_M + 0.1 + +export type CornerSnap = { + /** The end of the window's wall the corner is at. */ + end: WallEnd + /** The window's centre along its wall once snapped (m). */ + centreX: number + corner: [number, number] + otherWall: WallNode + otherEnd: WallEnd + /** The window waiting at the other wall's corner, the nearest, if one is. */ + waitingId: string | null +} + +/** + * Why a wall end in the zone is not a corner window's: the owner could not find Wrap the corner + * where a lone wall, a T-junction, a curved or curtain wall, a flat angle or a full wall gave + * nothing at all, so the hint says it. + */ +export type CornerBlockReason = + | 'free_end' + | 'several_walls' + | 'angle' + | 'curved' + | 'curtain' + | 'no_room' + +export type CornerBlock = { reason: CornerBlockReason; end: WallEnd } + +export type CornerInput = { + wallId: string + /** Where the window's centre is along its wall (m). */ + centreX: number + width: number + /** The window's height and the sill it stands at (m), for the match the other wall would take. */ + height?: number + sillHeight?: number + /** The window being dragged: never the one waiting. */ + ignoreId?: string + /** The end snapped to or blocked at the last time, so the zone is wider while the window stays in it. */ + held?: WallEnd | null +} + +type Evaluation = { snap: CornerSnap } | { block: CornerBlock } | null + +const isCurved = (wall: WallNode) => Boolean(wall.curveOffset) +const isCurtain = (wall: WallNode) => wall.wallType === 'curtain' + +function evaluate(nodes: Nodes, input: CornerInput): Evaluation { + const wall = nodes[input.wallId] + if (wall?.type !== 'wall') return null + const length = wallLengthOf(wall as WallNode) + if (input.width > length) return null + + const gaps = (['start', 'end'] as const) + .map((end) => ({ + end, + gap: + end === 'start' + ? input.centreX - input.width / 2 + : length - (input.centreX + input.width / 2), + })) + .filter( + ({ end, gap }) => gap <= (input.held === end ? CORNER_SNAP_EXIT_M : CORNER_SNAP_ENTER_M), + ) + .sort((a, b) => a.gap - b.gap) + const end = gaps[0]?.end + if (!end) return null + const blocked = (reason: CornerBlockReason): Evaluation => ({ block: { reason, end } }) + + if (isCurved(wall as WallNode)) return blocked('curved') + const point = wallEndPoint(wall as WallNode, end) + // Curved walls are counted here only to be named: they never make a corner window. + const others = wallsEndingAt(nodes, wall.parentId ?? undefined, point, true).filter( + (meeting) => meeting.wall.id !== wall.id, + ) + if (others.some((meeting) => isCurved(meeting.wall))) return blocked('curved') + if (others.length === 0) return blocked('free_end') + if (others.length > 1) return blocked('several_walls') + const { wall: otherWall, end: otherEnd } = others[0]! + if (isCurtain(wall as WallNode) || isCurtain(otherWall)) return blocked('curtain') + const angle = cornerAngle({ wall: wall as WallNode, end }, { wall: otherWall, end: otherEnd }) + if (angle < DEGENERATE_CORNER_DEG || angle > 180 - DEGENERATE_CORNER_DEG) return blocked('angle') + + const waiting = Object.values(nodes) + .filter( + (node): node is WindowNode => + node.type === 'window' && + node.parentId === otherWall.id && + node.id !== input.ignoreId && + !node.corner && + !(node.metadata as { isTransient?: boolean } | null)?.isTransient && + windowNearCornerEnd(nodes, node as WindowNode) === otherEnd, + ) + .sort((a, b) => windowEndGap(otherWall, a, otherEnd) - windowEndGap(otherWall, b, otherEnd))[0] + + // With none waiting the click makes the match on the other wall: if that wall cannot take it, + // the corner is not offered. + if (!waiting) { + const otherLength = wallLengthOf(otherWall) + const width = Math.min(input.width, otherLength) + const height = input.height ?? 1.5 + try { + planWallOpening(nodes as never, { + kind: 'window', + wallId: otherWall.id, + t: (otherEnd === 'start' ? width / 2 : otherLength - width / 2) / otherLength, + width, + height, + ...(input.sillHeight === undefined ? {} : { sillHeight: input.sillHeight }), + }) + } catch (error) { + if (isAgentRefusal(error)) return blocked('no_room') + throw error + } + } + + return { + snap: { + end, + centreX: end === 'start' ? input.width / 2 : length - input.width / 2, + corner: [point[0]!, point[1]!], + otherWall, + otherEnd, + waitingId: waiting?.id ?? null, + }, + } +} + +/** The snap and the block for one window position: at most one of them is set. */ +export function evaluateCorner( + nodes: Nodes, + input: CornerInput, +): { snap: CornerSnap | null; block: CornerBlock | null } { + const found = evaluate(nodes, input) + return { + snap: found && 'snap' in found ? found.snap : null, + block: found && 'block' in found ? found.block : null, + } +} + +export function cornerSnap(nodes: Nodes, input: CornerInput): CornerSnap | null { + const found = evaluate(nodes, input) + return found && 'snap' in found ? found.snap : null +} + +/** Why a window within the corner zone gets no corner, or null where it does, or is out of the zone. */ +export function cornerBlock(nodes: Nodes, input: CornerInput): CornerBlock | null { + const found = evaluate(nodes, input) + return found && 'block' in found ? found.block : null +} diff --git a/packages/core/src/lib/critically-damped.test.ts b/packages/core/src/lib/critically-damped.test.ts new file mode 100644 index 0000000000..7588c1600f --- /dev/null +++ b/packages/core/src/lib/critically-damped.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { criticallyDamped } from './critically-damped' + +describe('a critically damped move', () => { + const run = (response: number, from: number, to: number, ms = 3000, dt = 16) => { + const state = { x: from, v: 0 } + const path = [state.x] + for (let elapsed = 0; elapsed < ms; elapsed += dt) { + criticallyDamped(state, to, response, dt / 1000) + path.push(state.x) + } + return { state, path } + } + + test('arrives without overshooting, and stays', () => { + const { state, path } = run(0.5, 0, 1) + for (const x of path) expect(x).toBeLessThanOrEqual(1 + 1e-9) + for (let index = 1; index < path.length; index += 1) + expect(path[index]!).toBeGreaterThanOrEqual(path[index - 1]! - 1e-12) + expect(state.x).toBeCloseTo(1, 4) + expect(Math.abs(state.v)).toBeLessThan(1e-3) + }) + + test('response is how long it takes: most of the way in about that time', () => { + const half = run(0.5, 0, 1, 500).state.x + expect(half).toBeGreaterThan(0.85) + expect(half).toBeLessThan(1) + expect(run(0.25, 0, 1, 500).state.x).toBeGreaterThan(half) + }) + + test('takes the same path however the frames are cut', () => { + const fine = run(0.5, 0, 1, 400, 4).state.x + const coarse = run(0.5, 0, 1, 400, 40).state.x + expect(Math.abs(fine - coarse)).toBeLessThan(1e-3) + }) + + test('turns around from where it is, carrying its speed: no jump when the target flips', () => { + const state = { x: 0, v: 0 } + for (let index = 0; index < 20; index += 1) criticallyDamped(state, 1, 0.5, 0.016) + const at = state.x + const speed = state.v + expect(speed).toBeGreaterThan(0) + criticallyDamped(state, 0, 0.5, 0.001) + expect(Math.abs(state.x - at)).toBeLessThan(0.01) + // The speed is carried through the turn, not reset: it is still moving up a moment later. + expect(state.v).toBeGreaterThan(0) + }) +}) diff --git a/packages/core/src/lib/critically-damped.ts b/packages/core/src/lib/critically-damped.ts new file mode 100644 index 0000000000..a346db150e --- /dev/null +++ b/packages/core/src/lib/critically-damped.ts @@ -0,0 +1,19 @@ +/** + * One step of a critically damped spring (the exact solution, so frames of any length take the same + * path): it settles on `target` in about `response` seconds without overshooting, and a new target + * turns it around from where it is, carrying its speed. The camera that follows a build and the roof + * that lifts out of its way move this way. + */ +export function criticallyDamped( + state: { x: number; v: number }, + target: number, + response: number, + dt: number, +): void { + const omega = (2 * Math.PI) / Math.max(1e-3, response) + const offset = state.x - target + const carry = state.v + omega * offset + const decay = Math.exp(-omega * dt) + state.x = target + (offset + carry * dt) * decay + state.v = (state.v - omega * carry * dt) * decay +} diff --git a/packages/core/src/lib/exterior-wall-loops.ts b/packages/core/src/lib/exterior-wall-loops.ts new file mode 100644 index 0000000000..bcda0c248a --- /dev/null +++ b/packages/core/src/lib/exterior-wall-loops.ts @@ -0,0 +1,393 @@ +import type { WallNode } from '../schema' +import { isCurvedWall } from '../systems/wall/wall-curve' +import { + dedupeSequentialPoints, + type Point2D, + pointFromTuple, + pointToTuple, + polygonArea, + polygonSignature, + type SpaceBoundaryFace, + sampleWallPointsForRoomDetection, +} from './room-graph' +import { distanceToSegment } from './room-topology-index' + +/** + * The outside of a storey's walls: the unbounded faces of the planar wall + * graph, walked the other way round from rooms. Facades fill these loops and + * the wall tool selects them. Kept apart from the room kernel (`room-graph`), + * which only ever wants the bounded faces. + */ + +type WallFace = { + polygon: Point2D[] + boundaryFaces: SpaceBoundaryFace[] +} + +const WALL_JUNCTION_TOLERANCE = 0.08 +// Endpoints closer than this are one graph node, whatever their rounding: a millimetre, as the +// room kernel's keys (a wall posed by hand 0.65 mm short of its neighbour left the loop open). +// A gap past it stays open, as a real opening may; a caller may join wider (`joinTolerance`). +const WALL_ENDPOINT_TOLERANCE = 1e-3 + +function samePointWithinTolerance(a: Point2D, b: Point2D, tolerance = 1e-4) { + return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance +} + +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + if (polygon.length < 3) return false + + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i]?.x ?? 0 + const yi = polygon[i]?.y ?? 0 + const xj = polygon[j]?.x ?? 0 + const yj = polygon[j]?.y ?? 0 + + const intersect = + yi > point.y !== yj > point.y && + point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + 1e-12) + xi + if (intersect) inside = !inside + } + + return inside +} + +function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) { + let minDistance = Number.POSITIVE_INFINITY + for (let index = 0; index < polygon.length; index += 1) { + const start = polygon[index] + const end = polygon[(index + 1) % polygon.length] + if (!(start && end)) continue + minDistance = Math.min( + minDistance, + distanceToSegment(pointToTuple(point), pointToTuple(start), pointToTuple(end)), + ) + } + return minDistance +} + +function segmentProjection(point: Point2D, start: Point2D, end: Point2D) { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + if (lengthSquared < 1e-12) { + return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) } + } + const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared + const clampedT = Math.max(0, Math.min(1, t)) + const projX = start.x + clampedT * dx + const projY = start.y + clampedT * dy + return { t, distance: Math.hypot(point.x - projX, point.y - projY) } +} + +function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Point2D[]) { + const length = Math.hypot(end.x - start.x, end.y - start.y) + if (length < 1e-9) return [start, end] + + const interior: Array<{ point: Point2D; t: number }> = [] + for (const vertex of vertices) { + const { t, distance } = segmentProjection(vertex, start, end) + if (distance > WALL_JUNCTION_TOLERANCE) continue + const along = t * length + if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE) continue + interior.push({ point: vertex, t }) + } + interior.sort((a, b) => a.t - b.t) + + const ordered: Point2D[] = [start] + for (const { point } of interior) { + if (samePointWithinTolerance(point, ordered[ordered.length - 1]!, WALL_ENDPOINT_TOLERANCE)) + continue + ordered.push(point) + } + if (!samePointWithinTolerance(ordered[ordered.length - 1]!, end, WALL_ENDPOINT_TOLERANCE)) + ordered.push(end) + return ordered +} + +function exteriorFaces(walls: WallNode[], joinTolerance: number): WallFace[] { + if (walls.length < 3) return [] + + type HalfEdge = { + id: string + reverseId: string + fromKey: string + toKey: string + angle: number + points: Point2D[] + wallId: WallNode['id'] + face: 'front' | 'back' + } + type Node = { point: Point2D; outgoing: string[] } + + const graph = new Map() + const halfEdges = new Map() + const endpointBuckets = new Map() + + const upsertNode = (point: Point2D) => { + // Rounded coordinate strings can split coincident SVG endpoints on a cell + // boundary. Buckets only narrow the search; distance establishes identity. + const x = Math.floor(point.x / joinTolerance) + const y = Math.floor(point.y / joinTolerance) + let nearest: string | undefined + let nearestDistance = Number.POSITIVE_INFINITY + for (let dx = -1; dx <= 1; dx += 1) { + for (let dy = -1; dy <= 1; dy += 1) { + for (const key of endpointBuckets.get(`${x + dx},${y + dy}`) ?? []) { + const candidate = graph.get(key)!.point + const distance = Math.hypot(point.x - candidate.x, point.y - candidate.y) + if (distance <= joinTolerance && distance < nearestDistance) { + nearest = key + nearestDistance = distance + } + } + } + } + if (nearest !== undefined) return nearest + const key = String(graph.size) + graph.set(key, { point: { ...point }, outgoing: [] }) + const bucketKey = `${x},${y}` + const bucket = endpointBuckets.get(bucketKey) ?? [] + bucket.push(key) + endpointBuckets.set(bucketKey, bucket) + return key + } + + // Planarize first: collect every wall endpoint as a candidate graph vertex so + // straight walls can be split at T-junctions where another wall ends mid-span. + // Without this the touching wall's endpoint is a dangling degree-1 node and the + // enclosed area (e.g. a room added against the middle of an existing wall) + // never forms a cycle. + for (const wall of walls) { + upsertNode(pointFromTuple(wall.start)) + upsertNode(pointFromTuple(wall.end)) + } + const vertices = [...graph.values()].map((node) => node.point) + + for (const wall of walls) { + const start = pointFromTuple(wall.start) + const end = pointFromTuple(wall.end) + if (samePointWithinTolerance(start, end)) continue + + // Curved walls keep their sampled polyline as one edge; straight walls split + // into consecutive sub-edges at their interior junction vertices. + const subPolylines: Point2D[][] = isCurvedWall(wall) + ? [sampleWallPointsForRoomDetection(wall)] + : (() => { + const ordered = splitStraightWallAtVertices(start, end, vertices) + const parts: Point2D[][] = [] + for (let index = 0; index < ordered.length - 1; index += 1) { + parts.push([ordered[index]!, ordered[index + 1]!]) + } + return parts + })() + + subPolylines.forEach((points, subIndex) => { + const from = points[0]! + const to = points[points.length - 1]! + const fromKey = upsertNode(from) + const toKey = upsertNode(to) + if (fromKey === toKey) return + + const reversePoints = [...points].reverse() + const forwardId = `${wall.id}#${subIndex}:f` + const reverseId = `${wall.id}#${subIndex}:r` + + halfEdges.set(forwardId, { + id: forwardId, + reverseId, + fromKey, + toKey, + angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x), + points, + wallId: wall.id, + face: 'front', + }) + halfEdges.set(reverseId, { + id: reverseId, + reverseId: forwardId, + fromKey: toKey, + toKey: fromKey, + angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x), + points: reversePoints, + wallId: wall.id, + face: 'back', + }) + + graph.get(fromKey)?.outgoing.push(forwardId) + graph.get(toKey)?.outgoing.push(reverseId) + }) + } + + const sortedOutgoing = new Map() + for (const [key, node] of graph.entries()) { + const outgoing = [...node.outgoing] + outgoing.sort((a, b) => (halfEdges.get(a)?.angle ?? 0) - (halfEdges.get(b)?.angle ?? 0)) + sortedOutgoing.set(key, outgoing) + } + + const nextEdge = (edgeId: string) => { + const edge = halfEdges.get(edgeId) + if (!edge) return null + + const outgoing = sortedOutgoing.get(edge.toKey) + if (!outgoing || outgoing.length === 0) return null + + const idx = outgoing.indexOf(edge.reverseId) + if (idx === -1) return null + + const nextIdx = (idx - 1 + outgoing.length) % outgoing.length + return outgoing[nextIdx] ?? null + } + + const splitIntoSimpleCycles = (walkEdgeIds: string[]) => { + const cycles: string[][] = [] + const firstEdge = halfEdges.get(walkEdgeIds[0] ?? '') + if (!firstEdge) return cycles + + const pathEdges: string[] = [] + const pathVertices = [firstEdge.fromKey] + const vertexIndex = new Map([[firstEdge.fromKey, 0]]) + + for (const edgeId of walkEdgeIds) { + const edge = halfEdges.get(edgeId) + if (!edge || edge.fromKey !== pathVertices[pathVertices.length - 1]) return [] + + pathEdges.push(edgeId) + const repeatedIndex = vertexIndex.get(edge.toKey) + if (repeatedIndex === undefined) { + pathVertices.push(edge.toKey) + vertexIndex.set(edge.toKey, pathVertices.length - 1) + continue + } + + const cycle = pathEdges.slice(repeatedIndex) + if (cycle.length >= 3) cycles.push(cycle) + + for (let index = repeatedIndex + 1; index < pathVertices.length; index += 1) { + vertexIndex.delete(pathVertices[index]!) + } + pathVertices.length = repeatedIndex + 1 + pathEdges.length = repeatedIndex + } + + return pathEdges.length === 0 && pathVertices.length === 1 ? cycles : [] + } + + const visitedDirected = new Set() + const rooms: WallFace[] = [] + // A face walk cannot revisit a half-edge, so the half-edge count bounds its + // length. It can revisit a vertex when dangling walls or other graph bridges + // are traced out and back; those excursions are removed below. + const maxSteps = Math.min(2000, halfEdges.size + 10) + + for (const edgeId of halfEdges.keys()) { + if (visitedDirected.has(edgeId)) continue + + const cycleEdgeIds: string[] = [] + let currentEdgeId = edgeId + let valid = true + let closed = false + + for (let step = 0; step < maxSteps; step += 1) { + const currentEdge = halfEdges.get(currentEdgeId) + if (!currentEdge) { + valid = false + break + } + + visitedDirected.add(currentEdgeId) + cycleEdgeIds.push(currentEdgeId) + + const next = nextEdge(currentEdgeId) + if (!next) { + valid = false + break + } + + currentEdgeId = next + if (currentEdgeId === edgeId) { + closed = true + break + } + } + + if (!(valid && closed) || cycleEdgeIds.length < 3) continue + + for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) { + const polygon = dedupeSequentialPoints( + simpleCycleEdgeIds.flatMap((id, index) => { + const points = halfEdges.get(id)?.points ?? [] + return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1) + }), + ) + + if (polygon.length < 3) continue + + const signedArea = polygonArea(polygon) + // Rooms wind one way; the outside of the walls is walked the other. + if (signedArea >= -1e-6) continue + + const signature = polygonSignature(polygon) + if (rooms.some((room) => polygonSignature(room.polygon) === signature)) continue + + rooms.push({ + polygon, + boundaryFaces: simpleCycleEdgeIds.flatMap((id) => { + const edge = halfEdges.get(id) + if (!edge) return [] + return [ + { + wallId: edge.wallId, + face: edge.face, + points: edge.points.map(pointToTuple), + }, + ] + }), + }) + } + } + + rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon))) + return rooms +} + +/** + * The outside loops of a storey's walls. Wall ends within `joinTolerance` metres are one corner: a + * millimetre by default; a vectorised plan's near-miss corners may need a few centimetres. + */ +export function exteriorWallLoops( + walls: WallNode[], + joinTolerance = WALL_ENDPOINT_TOLERANCE, +): SpaceBoundaryFace[][] { + return outerFaces(walls, joinTolerance).map((loop) => loop.boundaryFaces) +} + +/** The outlines of a storey's walls: each outside loop as a polygon on the walls' centrelines. */ +export function exteriorWallOutlines( + walls: WallNode[], + joinTolerance = WALL_ENDPOINT_TOLERANCE, +): [number, number][][] { + return outerFaces(walls, joinTolerance).map((loop) => loop.polygon.map(pointToTuple)) +} + +function outerFaces(walls: WallNode[], joinTolerance: number) { + const loops = exteriorFaces(walls, Math.max(WALL_ENDPOINT_TOLERANCE, joinTolerance)) + return loops.filter( + (loop, index) => + !loops.some((other, otherIndex) => { + if ( + otherIndex === index || + Math.abs(polygonArea(other.polygon)) <= Math.abs(polygonArea(loop.polygon)) + ) + return false + // Use boundary points, not the centroid: concave footprints may have a centroid outside. + return loop.polygon.every( + (point) => + pointInPolygon(point, other.polygon) || + pointDistanceToPolygonBoundary(point, other.polygon) < 1e-4, + ) + }), + ) +} diff --git a/packages/core/src/lib/floor-intent-changes.ts b/packages/core/src/lib/floor-intent-changes.ts index 1ed1c397d1..d79e167b68 100644 --- a/packages/core/src/lib/floor-intent-changes.ts +++ b/packages/core/src/lib/floor-intent-changes.ts @@ -14,9 +14,8 @@ import { findLevelBelowId, getLevelElevations, getWallCoveringSlabUnderside, - getWallPlaneTop, + getWallTop, } from '../services/storey' -import { resolveWallTop } from '../systems/wall/wall-top' import { automaticFloorHeight, floorPlateAtGroundContact, @@ -518,11 +517,7 @@ export function floorIntentConflicts( getOpeningFloorTarget(host, opening, graph) + opening.position[1] + opening.height / 2 - - resolveWallTop( - host, - getWallPlaneTop(host, host.parentId!, graph), - wallSupportForNodes(host, graph).elevation, - ) + getWallTop(host, host.parentId!, graph, wallSupportForNodes(host, graph).elevation) ) } const after = excess(draft, node) @@ -551,11 +546,8 @@ export function floorIntentConflicts( ) continue const excess = (graph: Readonly>, wall: typeof node) => - resolveWallTop( - wall, - getWallPlaneTop(wall, wall.parentId!, graph), - wallSupportForNodes(wall, graph).elevation, - ) - (getWallCoveringSlabUnderside(wall, wall.parentId!, graph) ?? Infinity) + getWallTop(wall, wall.parentId!, graph, wallSupportForNodes(wall, graph).elevation) - + (getWallCoveringSlabUnderside(wall, wall.parentId!, graph) ?? Infinity) const after = excess(draft, node) const previous = nodes[node.id] const before = previous?.type === 'wall' ? excess(nodes, previous) : -Infinity diff --git a/packages/core/src/lib/geometry-script-node.ts b/packages/core/src/lib/geometry-script-node.ts index de92ca28e1..cd72d820df 100644 --- a/packages/core/src/lib/geometry-script-node.ts +++ b/packages/core/src/lib/geometry-script-node.ts @@ -1,3 +1,4 @@ +import { refuse } from '../agent-tools/refusal' import { matchPascalMaterial } from '../procedural-items/library-colors' import type { CompiledGeometryScript } from '../schema' import { GeometrySourceMeta } from '../schema/geometry-metadata' @@ -127,6 +128,26 @@ export function scriptedSize( return [max[0] - min[0], max[1] - min[1], max[2] - min[2]] } +/** + * A compile whose bounds are not finite numbers cannot be sized or placed, and what is built from + * it would fail its schema with a raw validation array or carry a non-finite size: refused before + * anything is built, whichever scripted tool it came to. A mesh at a position that is not a number + * leaves NaN bounds (null once they crossed a JSON hop). + */ +export function requireMeasured( + compiled: CompiledGeometryScript, + subject: { label?: string; nodeId?: string; tool: string }, +) { + const { min, max } = compiled.manifest.bounds + if ([...min, ...max].every(Number.isFinite)) return + const label = subject.label ? `"${subject.label}"` : (subject.nodeId ?? 'The object') + refuse( + 'unmeasurable_object', + `${label} cannot be measured: its bounds are not finite numbers (min [${min.map(String).join(', ')}], max [${max.map(String).join(', ')}]) with params ${JSON.stringify(compiled.params)}. A mesh in the script has a position, rotation or scale that is not a number (a helper called one argument short passes the next value in its place). Fix the script and call ${subject.tool} again; nothing was ${subject.nodeId ? 'changed' : 'added'}.`, + { label: subject.label ?? subject.nodeId }, + ) +} + /** * Where the script's origin (the bottom centre of what it built) sits in the * node's own frame: a window or door is placed by its centre. diff --git a/packages/core/src/lib/opening-floor-datum.ts b/packages/core/src/lib/opening-floor-datum.ts index 4d2dbd1f55..737ec5e4d5 100644 --- a/packages/core/src/lib/opening-floor-datum.ts +++ b/packages/core/src/lib/opening-floor-datum.ts @@ -1,5 +1,5 @@ import type { AnyNode, DoorNode, SlabNode, WallNode, WindowNode } from '../schema' -import { getWallPlaneTop } from '../services/storey' +import { getWallTop } from '../services/storey' import { computeWallSlabSupport, type WallSlabSupport, @@ -8,7 +8,6 @@ import { import { getWallCurveFrameAt, getWallCurveLength } from '../systems/wall/wall-curve' import { calculateLevelMiters, getWallPlanFootprint } from '../systems/wall/wall-footprint' import { getWallBodyCenterOffset } from '../systems/wall/wall-frame' -import { resolveWallTop } from '../systems/wall/wall-top' import { isFloorAnchoredOpening, openingAperture, @@ -108,7 +107,7 @@ export function openingFitsAtDatum( nodes: Readonly>, support = wallSupportForNodes(wall, nodes), ): boolean { - const top = resolveWallTop(wall, getWallPlaneTop(wall, wall.parentId!, nodes), support.elevation) + const top = getWallTop(wall, wall.parentId!, nodes, support.elevation) return datum + opening.position[1] + opening.height / 2 <= top + 1e-9 } diff --git a/packages/core/src/lib/own-floor-intent.ts b/packages/core/src/lib/own-floor-intent.ts index 27f2148371..37cf8d81c5 100644 --- a/packages/core/src/lib/own-floor-intent.ts +++ b/packages/core/src/lib/own-floor-intent.ts @@ -1,8 +1,8 @@ import { boundaries, diffStructure, roomFace } from '../commands/structure/shared' import type { AnyNode, AnyNodeId, SlabNode, ZoneNode } from '../schema' import { resolveCeilingHeight } from '../services/level-height' -import { getLevelElevations, getWallPlaneTop } from '../services/storey' -import { MIN_WALL_HEIGHT, resolveWallTop } from '../systems/wall/wall-top' +import { getLevelElevations, getWallTop } from '../services/storey' +import { MIN_WALL_HEIGHT } from '../systems/wall/wall-top' import { reconcileStructureWithStableIds } from '../utils/structure-id' import { mintFloorFootprintKey } from './floor-footprint-key' import { floorPlateHoldsUnderside } from './floor-foundation-datum' @@ -141,17 +141,9 @@ export function ownFloorIntentChanges( for (const old of Object.values(nodes)) { const current = next[old.id] if (old.type !== 'wall' || current?.type !== 'wall' || !old.parentId) continue - const oldTop = resolveWallTop( - old, - getWallPlaneTop(old, old.parentId, nodes), - wallSupportForNodes(old, nodes).elevation, - ) + const oldTop = getWallTop(old, old.parentId, nodes, wallSupportForNodes(old, nodes).elevation) const support = wallSupportForNodes(current, next).elevation - const newTop = resolveWallTop( - current, - getWallPlaneTop(current, current.parentId!, next), - support, - ) + const newTop = getWallTop(current, current.parentId!, next, support) if (Math.abs(oldTop - newTop) <= 1e-6) continue if (oldTop - support < MIN_WALL_HEIGHT) return refuse( diff --git a/packages/core/src/lib/point-ask.test.ts b/packages/core/src/lib/point-ask.test.ts new file mode 100644 index 0000000000..360145ac58 --- /dev/null +++ b/packages/core/src/lib/point-ask.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test' +import type { PointAskSubmit } from '../agent-operations/point-context' +import { hasPointAskHandler, registerPointAskHandler, submitPointAsk } from './point-ask' + +// Point and ask is offered only where a host answers it (the open-source editor has no chat), and +// an ask is never lost silently: no handler or a throwing one comes back as a reason in words. +const submit = { askId: 'ask_1', text: 'Make it wider' } as unknown as PointAskSubmit + +describe('the point-ask handler', () => { + test('is absent until a host registers, and gone when it unregisters', () => { + expect(hasPointAskHandler()).toBe(false) + const off = registerPointAskHandler(async () => ({ ok: true })) + expect(hasPointAskHandler()).toBe(true) + off() + expect(hasPointAskHandler()).toBe(false) + }) + + test('hands the ask to the handler and returns its answer', async () => { + const seen: string[] = [] + const off = registerPointAskHandler(async (s) => { + seen.push(s.askId) + return { ok: true } + }) + + expect(await submitPointAsk(submit)).toEqual({ ok: true }) + expect(seen).toEqual(['ask_1']) + off() + }) + + test('with no handler, says it is unavailable instead of dropping the ask', async () => { + const result = await submitPointAsk(submit) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe('unavailable') + }) + + test('a throwing handler is an error result, not an exception', async () => { + const off = registerPointAskHandler(async () => { + throw new Error('socket closed') + }) + const result = await submitPointAsk(submit) + + expect(result).toEqual({ ok: false, code: 'error', message: 'socket closed' }) + off() + }) + + test('a stale unregister does not remove a newer handler', () => { + const offOld = registerPointAskHandler(async () => ({ ok: true })) + const offNew = registerPointAskHandler(async () => ({ ok: true })) + offOld() + + expect(hasPointAskHandler()).toBe(true) + offNew() + }) +}) diff --git a/packages/core/src/lib/point-ask.ts b/packages/core/src/lib/point-ask.ts new file mode 100644 index 0000000000..9297a989fb --- /dev/null +++ b/packages/core/src/lib/point-ask.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand' +import type { PointAskSubmit, PointAskSubmitResult } from '../agent-operations/point-context' + +// The door between Point and ask's scene side and whoever answers it (the hosted chat). The open +// source editor has no chat, so the mode is offered only while a host has registered a handler: +// `useHasPointAskHandler` drives the toolbar button and the C key. + +export type PointAskHandler = (submit: PointAskSubmit) => Promise + +const useHandlerStore = create<{ handler: PointAskHandler | null }>(() => ({ handler: null })) + +/** The chat registers on mount; the returned function unregisters it (only its own registration). */ +export function registerPointAskHandler(handler: PointAskHandler): () => void { + useHandlerStore.setState({ handler }) + return () => { + if (useHandlerStore.getState().handler === handler) useHandlerStore.setState({ handler: null }) + } +} + +export function useHasPointAskHandler(): boolean { + return useHandlerStore((state) => state.handler !== null) +} + +export function hasPointAskHandler(): boolean { + return useHandlerStore.getState().handler !== null +} + +/** Hands an ask to the registered handler; with none, the person is told it is unavailable. */ +export async function submitPointAsk(submit: PointAskSubmit): Promise { + const handler = useHandlerStore.getState().handler + if (!handler) + return { + ok: false, + code: 'unavailable', + message: 'Point and ask needs the chat, which is not available here.', + } + try { + return await handler(submit) + } catch (error) { + return { + ok: false, + code: 'error', + message: error instanceof Error ? error.message : 'Pascal could not take this ask.', + } + } +} diff --git a/packages/core/src/lib/room-floor-feasibility.ts b/packages/core/src/lib/room-floor-feasibility.ts index aafa460527..ada06f9391 100644 --- a/packages/core/src/lib/room-floor-feasibility.ts +++ b/packages/core/src/lib/room-floor-feasibility.ts @@ -5,9 +5,9 @@ import { MIN_GROUND_FLOOR_THICKNESS, MIN_SLAB_THICKNESS, } from '../schema/nodes/slab' -import { getStoredLevelHeight, getWallPlaneTop } from '../services/storey' +import { getStoredLevelHeight, getWallTop } from '../services/storey' import { getWallCurveFrameAt, getWallCurveLength } from '../systems/wall/wall-curve' -import { MIN_WALL_HEIGHT, resolveWallTop } from '../systems/wall/wall-top' +import { MIN_WALL_HEIGHT } from '../systems/wall/wall-top' import { floorPlateHoldsUnderside, footprintLift } from './floor-foundation-datum' import { isFloorAnchoredOpening } from './floor-opening-footprints' import { floorRoomFaces } from './floor-room-faces' @@ -248,11 +248,7 @@ function computeRoomFloor( ) const support = hasOpenings || wall.height !== undefined ? wallSupportForNodes(wall, candidate) : undefined - const top = resolveWallTop( - wall, - getWallPlaneTop(wall, level.id, nodes), - support?.elevation ?? 0, - ) + const top = getWallTop(wall, level.id, nodes, support?.elevation ?? 0) const openingSpans = hasOpenings && linked ? (roomFace(nodes, zone, floorRoomFaces(boundaries(nodes, zone.parentId!)))?.spans ?? []) diff --git a/packages/core/src/lib/room-name.ts b/packages/core/src/lib/room-name.ts index 2b3aef8d21..f930f75d10 100644 --- a/packages/core/src/lib/room-name.ts +++ b/packages/core/src/lib/room-name.ts @@ -6,6 +6,9 @@ const NUMBERED_ROOM = /^Room (\d+)$/ * deterministic room order, and only when a room zone is created, so an existing * room is never renamed and a name the user cleared stays cleared. */ +/** A name the allocator gave, which no person has changed. */ +export const isAllocatedRoomName = (name: string | undefined) => NUMBERED_ROOM.test(name ?? '') + export function roomNameAllocator(levelZones: Iterable<{ name?: string }>) { const used = new Set() for (const zone of levelZones) { diff --git a/packages/core/src/lib/snap-ease.test.ts b/packages/core/src/lib/snap-ease.test.ts new file mode 100644 index 0000000000..770457f5fc --- /dev/null +++ b/packages/core/src/lib/snap-ease.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test' +import { createSnapEaser, prefersReducedMotion, SNAP_EASE_MS } from './snap-ease' + +// The ghost window snapping to a corner is a short, eased move, never a jump or a bounce: it covers +// the distance in SNAP_EASE_MS, keeps following the cursor while it eases, and does not ease at all +// for a reader who asked for reduced motion. + +describe('createSnapEaser', () => { + test('follows the cursor exactly while nothing snaps', () => { + const easer = createSnapEaser() + + expect(easer.display(1.0, false, 0, false)).toBe(1.0) + expect(easer.display(1.4, false, 16, false)).toBe(1.4) + }) + + test('eases from where it was to the snapped place over SNAP_EASE_MS', () => { + const easer = createSnapEaser() + easer.display(3.0, false, 0, false) + + const start = easer.display(3.4, true, 100, false) + const middle = easer.display(3.4, true, 100 + SNAP_EASE_MS / 2, false) + const end = easer.display(3.4, true, 100 + SNAP_EASE_MS, false) + + expect(start).toBeCloseTo(3.0, 6) + expect(middle).toBeGreaterThan(3.0) + expect(middle).toBeLessThan(3.4) + expect(end).toBe(3.4) + }) + + test('keeps following the cursor while it eases', () => { + const easer = createSnapEaser() + easer.display(3.0, false, 0, false) + easer.display(3.4, true, 0, false) + + // The snapped target moved (a different corner end): the move lands on the new target. + expect(easer.display(3.6, true, SNAP_EASE_MS, false)).toBe(3.6) + }) + + test('eases out of a snap the same way, back to the cursor', () => { + const easer = createSnapEaser() + easer.display(3.0, false, 0, false) + easer.display(3.4, true, 0, false) + easer.display(3.4, true, SNAP_EASE_MS, false) + + const leaving = easer.display(2.8, false, 500, false) + expect(leaving).toBeCloseTo(3.4, 6) + expect(easer.display(2.8, false, 500 + SNAP_EASE_MS, false)).toBe(2.8) + }) + + test('does not ease at all under reduced motion', () => { + const easer = createSnapEaser() + easer.display(3.0, false, 0, true) + + expect(easer.display(3.4, true, 10, true)).toBe(3.4) + expect(easer.active(10)).toBe(false) + }) + + test('says while it is easing, so the tool keeps drawing frames', () => { + const easer = createSnapEaser() + easer.display(3.0, false, 0, false) + easer.display(3.4, true, 100, false) + + expect(easer.active(100 + SNAP_EASE_MS / 2)).toBe(true) + expect(easer.active(100 + SNAP_EASE_MS)).toBe(false) + }) +}) + +describe('prefersReducedMotion', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'window') + const set = (value: unknown) => + Object.defineProperty(globalThis, 'window', { value, configurable: true, writable: true }) + const restore = () => { + if (original) Object.defineProperty(globalThis, 'window', original) + else Reflect.deleteProperty(globalThis, 'window') + } + + test('is false where there is no media query to ask (a test environment, a server)', () => { + set({}) + expect(prefersReducedMotion()).toBe(false) + set(undefined) + expect(prefersReducedMotion()).toBe(false) + restore() + }) + + test("follows the reader's setting where the browser can say", () => { + set({ matchMedia: (query: string) => ({ matches: query.includes('reduce') }) }) + expect(prefersReducedMotion()).toBe(true) + set({ matchMedia: () => ({ matches: false }) }) + expect(prefersReducedMotion()).toBe(false) + restore() + }) +}) diff --git a/packages/core/src/lib/snap-ease.ts b/packages/core/src/lib/snap-ease.ts new file mode 100644 index 0000000000..fd92f42797 --- /dev/null +++ b/packages/core/src/lib/snap-ease.ts @@ -0,0 +1,56 @@ +/** + * The ghost snapping to a corner (the owner, 8 October): a short eased move, never a jump or a + * bounce. Under 200 ms and easing out, so it feels like the window was pulled in; it keeps following + * the cursor while it eases, and does nothing at all under reduced motion. + */ + +/** How long the ghost takes to move into, or out of, a snap (ms). */ +export const SNAP_EASE_MS = 120 + +/** Whether the reader asked for reduced motion; false where the browser cannot say. */ +export function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} + +const easeOutCubic = (t: number) => 1 - (1 - t) ** 3 + +export function createSnapEaser() { + let snapped = false + let last: number | null = null + let from = 0 + let started = Number.NEGATIVE_INFINITY + + return { + /** + * Where to draw the ghost along its wall: `target` is where it belongs (the cursor, or the + * snapped place), `isSnapped` whether it is snapped now. A change of `isSnapped` starts the move. + */ + display(target: number, isSnapped: boolean, now: number, reducedMotion: boolean): number { + if (isSnapped !== snapped) { + snapped = isSnapped + if (reducedMotion || last === null) started = Number.NEGATIVE_INFINITY + else { + from = last + started = now + } + } + const progress = Math.min(1, Math.max(0, (now - started) / SNAP_EASE_MS)) + const shown = progress >= 1 ? target : target + (from - target) * (1 - easeOutCubic(progress)) + last = shown + return shown + }, + /** Whether a move is under way at `now`, so the caller keeps drawing frames. */ + active(now: number): boolean { + return now - started < SNAP_EASE_MS + }, + reset() { + snapped = false + last = null + started = Number.NEGATIVE_INFINITY + }, + } +} diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index 97d0e48054..41f86a68c6 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -1,11 +1,10 @@ import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' import type { AnyNodeId } from '../schema/types' -import { DEFAULT_LEVEL_HEIGHT, resolveCeilingHeight } from '../services/level-height' -import { getWallPlaneTop } from '../services/storey' +import { resolveCeilingHeight } from '../services/level-height' +import { getWallTop } from '../services/storey' import { computeWallSlabSupport } from '../systems/slab/slab-support' import { sampleWallCenterline } from '../systems/wall/wall-curve' import { getWallLocalFaceZ } from '../systems/wall/wall-frame' -import { resolveWallEffectiveHeight } from '../systems/wall/wall-top' import { levelWallCover } from './plate-surface' import { area, @@ -329,8 +328,7 @@ export function deriveZoneQuantityReport( 0, sceneNodes, ) - const planeTop = levelId ? getWallPlaneTop(wall, levelId, sceneNodes) : DEFAULT_LEVEL_HEIGHT - return resolveWallEffectiveHeight(wall, planeTop, support.elevation) + return getWallTop(wall, levelId ?? '', sceneNodes, support.elevation) - support.elevation } const edgeLengths = [zone.polygon, ...(zone.holes ?? [])].flatMap((ring) => ring.map((start, index) => { diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index f126331872..64a34c3d37 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -171,6 +171,10 @@ export type { ReferenceRole, Relations, RendererSource, + RevealClears, + RevealConfig, + RevealPhase, + RevealStyle, RoofAccessoryConfig, RotatableConfig, ScalableConfig, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index ff77da80a1..5b43f29d03 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1719,6 +1719,12 @@ export type Capabilities = { * `BatchableConfig`. */ batchable?: BatchableConfig + /** + * How the node appears when an agent builds it: its construction phase and + * motion. A kind without it appears at once, with its parent. See + * `RevealConfig`. + */ + reveal?: RevealConfig /** * Plan footprint this kind exposes to the alignment-anchor pool when it * isn't `floorPlaced` and isn't a structural primitive the bridge handles @@ -2541,6 +2547,53 @@ export type BatchableConfig = { waitsForWalls?: boolean } +/** + * Construction phases, in the order a reveal plays them: slabs, then walls and + * columns, stairs, doors and windows, roofs and ceilings, furniture. + */ +export type RevealPhase = + | 'foundation' + | 'structure' + | 'circulation' + | 'openings' + | 'roof' + | 'furnishing' + +/** + * How a node moves into place when it is revealed: + * + * - `rise` grows it up from its base (walls, fences); + * - `scale` grows it from its footprint's centre; + * - `settle` lowers it the last few centimetres onto its support (slabs); + * - `drop` lets it fall from `height` like a dropped load and land with a small bounce; + * - `cut` pops it open from its centre (doors, windows); + * - `assemble` holds the node still while its child nodes, its parts, drop in one by one from + * `height` (a roof and its segments). Its renderer reads `isRevealAssembling` and + * `subscribeRevealAssembling` (`@pascal-app/viewer`) to draw them apart. + */ +export type RevealStyle = 'rise' | 'scale' | 'settle' | 'drop' | 'cut' | 'assemble' + +/** + * A staged appearance (`capabilities.reveal`), presentation only: the store + * write is already done. The styles that travel carry their height, in + * metres above the rest pose, so each kind tunes its own fall. + */ +export type RevealConfig = + | { phase: RevealPhase; style: 'rise' | 'scale' | 'cut'; clears?: RevealClears } + | { + phase: RevealPhase + style: 'settle' | 'drop' | 'assemble' + height: number + clears?: RevealClears + } + +/** + * A node that lifts out of the way while a phase of the build plays: a roof rises `height` metres + * and turns ghostly while the furniture drops in, so the rooms show from above, and seats again + * once the phase has landed. Presentation only, like the reveal itself. + */ +export type RevealClears = { for: RevealPhase; height: number } + /** * Plan footprint a kind contributes to the alignment-anchor pool when it is * neither `floorPlaced` (columns / items, whose footprint the bridge already diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index c3defeb1bc..d52f999806 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -145,6 +145,7 @@ export { export { DoorNode, DoorSegment, + FRENCH_DOOR_SEGMENTS, OpeningConstructionType, OpeningDimensionReference, } from './nodes/door' @@ -286,6 +287,7 @@ export { getPitchFromActiveRoofHeight, getRoofSegmentSurfaceY, getRoofSegmentVisibleTopBounds, + getSegmentFasciaMaterial, getSegmentSlopeFrame, hasSegmentMaterialOverride, isBandedShedSegment, diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index fb1144a22b..ff12f55382 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -164,6 +164,8 @@ export const ColumnNode = BaseNode.extend({ braceBottomSpread: z.number().min(0.2).default(1.2), braceTopSpread: z.number().min(0).default(0.12), bracePlateEnabled: z.boolean().default(true), + // A declaration consumed by the selected shared support checker; old columns stay unchanged. + role: z.enum(['ornament']).optional(), material: MaterialSchema.optional(), materialPreset: z.string().optional(), // Unified paint-slot refs (`scene:`/`library:` MaterialRef per slot id), diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index fa487e6c8a..37b4647e5f 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -22,6 +22,26 @@ export const DoorSegment = z.object({ export type DoorSegment = z.infer +/** Glazed leaves over a low panel: the French door (porte-fenêtre) glazing. */ +export const FRENCH_DOOR_SEGMENTS: DoorSegment[] = [ + { + type: 'glass', + heightRatio: 0.76, + columnRatios: [1, 1], + dividerThickness: 0.025, + panelDepth: 0.01, + panelInset: 0.04, + }, + { + type: 'panel', + heightRatio: 0.24, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.012, + panelInset: 0.035, + }, +] + export const DoorCategory = z.enum(['interior', 'garage']) export const OpeningConstructionType = z.enum(['framed', 'masonry']) export const OpeningDimensionReference = z.enum([ diff --git a/packages/core/src/schema/nodes/roof-segment-fascia.test.ts b/packages/core/src/schema/nodes/roof-segment-fascia.test.ts new file mode 100644 index 0000000000..a87256baa1 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-fascia.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'bun:test' +import { + getSegmentFasciaMaterial, + hasSegmentMaterialOverride, + RoofSegmentNode, +} from './roof-segment' + +// Hawkesbury's flat roofs wear a black edge band; a fascia was a fixed 1x8 in the wall finish. +test('a fascia takes a height, a depth and its own finish', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'flat', + fascia: true, + fasciaHeight: 0.3, + fasciaDepth: 0.03, + fasciaMaterialPreset: 'library:preset-black', + }) + expect(segment.fasciaHeight).toBe(0.3) + expect(getSegmentFasciaMaterial(segment)).toEqual({ + material: undefined, + materialPreset: 'library:preset-black', + }) + expect(hasSegmentMaterialOverride(segment)).toBe(true) +}) + +test('without its own finish the fascia takes the wall finish, as before', () => { + const segment = RoofSegmentNode.parse({ roofType: 'flat', fascia: true }) + expect(getSegmentFasciaMaterial(segment)).toBeNull() + expect(hasSegmentMaterialOverride(segment)).toBe(false) +}) diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 9ea6f5b14d..4b77ff33bc 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -180,6 +180,13 @@ export const RoofSegmentNode = BaseNode.extend({ // Shed only: the board on the high edge. Off when that edge meets a wall // (a porch cover on a ledger); a lean-to never has one. fasciaHighEdge: z.boolean().optional(), + // The boards' height and thickness; a 1x8 (0.184 × 0.019 m) when absent. With a height set, a + // flat roof's boards rise to the roof's top: a coping band over the deck and membrane edges. + fasciaHeight: z.number().positive().optional(), + fasciaDepth: z.number().positive().optional(), + // The boards' and rakes' own finish; absent, they take the wall role's, as they always did. + fasciaMaterial: MaterialSchema.optional(), + fasciaMaterialPreset: z.string().optional(), // Shape-specific ratios. Only the pair matching `roofType` is read; the // rest are inert. Defined on every segment so the panel can flip // roofType without losing the previous shape's tuning. @@ -250,6 +257,8 @@ export const RoofSegmentNode = BaseNode.extend({ - shingleThickness: outer shingle layer thickness - fascia: plumb fascia and rake boards on the eaves and rakes (gable, hip, shed, flat) (off when absent) - fasciaHighEdge: shed only — a board on the high edge (off when it meets a wall; on when absent) + - fasciaHeight/fasciaDepth: the boards' size (a 1x8 when absent); with a height, a flat roof's boards reach its top + - fasciaMaterial/fasciaMaterialPreset: the boards' and rakes' finish (the wall role's when absent) - gambrelLowerWidthRatio / gambrelLowerHeightRatio: kink position on gambrel roofs - mansardSteepWidthRatio / mansardSteepHeightRatio: waist position on mansard roofs - dutchHipWidthRatio / dutchHipHeightRatio: hip-to-gable split on dutch roofs @@ -843,6 +852,19 @@ export function getEffectiveSegmentSurfaceMaterial( return parentFallback ?? { material: undefined, materialPreset: undefined } } +/** The fascia and rake boards' own finish, or null when they take the wall role's. */ +export function getSegmentFasciaMaterial( + node: RoofSegmentNode, +): RoofSegmentSurfaceMaterialSpec | null { + if (node.fasciaMaterial === undefined && typeof node.fasciaMaterialPreset !== 'string') + return null + return { + material: node.fasciaMaterial, + materialPreset: + typeof node.fasciaMaterialPreset === 'string' ? node.fasciaMaterialPreset : undefined, + } +} + /** * Returns true when the segment has any segment-level material override — * either the legacy catch-all or any of the three role-specific fields. @@ -859,6 +881,8 @@ export function hasSegmentMaterialOverride(node: RoofSegmentNode): boolean { node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string' || node.wallMaterial !== undefined || - typeof node.wallMaterialPreset === 'string' + typeof node.wallMaterialPreset === 'string' || + node.fasciaMaterial !== undefined || + typeof node.fasciaMaterialPreset === 'string' ) } diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index a3f4650eca..15d71938c7 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -297,6 +297,7 @@ export const WallNode = BaseNode.extend({ // Extend downward from the authored wall base to the terrain while keeping // the wall body height and top unchanged. fillToTerrain: z.boolean().optional(), + cap: z.boolean().optional(), // The finish and the foundation carried below the base — see WallUnderpinning. underpinning: WallUnderpinning.optional(), skirting: WallTrimConfig.optional(), diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 730cec570a..0c45c868f1 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -99,6 +99,16 @@ export const WindowNode = BaseNode.extend({ columnDividerThickness: z.number().default(0.03), rowDividerThickness: z.number().default(0.03), + // A corner window (L65): this window runs to its wall's `end` and is joined there to `partnerId` + // on the other wall, the glass fused at the corner (`none`) or meeting at a thin post. + corner: z + .object({ + end: z.enum(['start', 'end']), + partnerId: z.string(), + post: z.enum(['none', 'post']).default('none'), + }) + .optional(), + // Sill sill: z.boolean().default(true), sillDepth: z.number().default(0.08), @@ -111,6 +121,7 @@ export const WindowNode = BaseNode.extend({ - frameDepth: how deep the frame sits within the wall - columnRatios/rowRatios: pane division ratios - sill: whether to show a window sill + - corner: joined to a window on the other wall at this wall's start or end (a corner window) `) export type WindowNode = z.infer diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 56023b6c13..8a759a3960 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -120,6 +120,7 @@ export { getLevelFloorToFloorHeight, getStoredLevelHeight, getWallPlaneTop, + getWallTop, type LevelElevation, } from './storey' export { diff --git a/packages/core/src/services/level-height.test.ts b/packages/core/src/services/level-height.test.ts index 81698a26e6..dc31bf0aac 100644 --- a/packages/core/src/services/level-height.test.ts +++ b/packages/core/src/services/level-height.test.ts @@ -96,7 +96,7 @@ describe('deriveLegacyLevelHeight', () => { const nodes = createFixture() const cases = [ ['level_no_slab', 2.5], - ['level_standard_slab', 2.5], + ['level_standard_slab', 2.55], ['level_tall_wall', 3.55], ['level_ceiling', 3.4], ['level_negative_slab', 2.8], diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index ef37c5a0b4..b2e99768df 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,6 +1,7 @@ import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' +import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' // Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: // both sides only reference the other inside function bodies. import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' @@ -66,10 +67,12 @@ export function deriveLegacyLevelHeight( 0, nodes, ).elevation - const top = - wall.height == null - ? (level.height ?? DEFAULT_LEVEL_HEIGHT) - : Math.max(0, electedElevation) + wall.height + // Likewise for walls: before levels stored a height, a wall without one + // was drawn DEFAULT_WALL_HEIGHT up from the slab it stands on, and the + // storey above was stacked on that top. Reading the absence as + // plane-bound here would drop every storey above by the slab's height + // and leave this level's walls standing through it. + const top = Math.max(0, electedElevation) + (wall.height ?? DEFAULT_WALL_HEIGHT) if (top > maxTop) maxTop = top } } diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts index 60ce246204..298a53b85b 100644 --- a/packages/core/src/services/storey.ts +++ b/packages/core/src/services/storey.ts @@ -8,6 +8,7 @@ import { pointOnPolygonBoundary, wallOverlapsSlabFootprint, } from '../systems/slab/slab-support' +import { resolveWallTop } from '../systems/wall/wall-top' import { DEFAULT_LEVEL_HEIGHT } from './level-height' /** @@ -385,10 +386,8 @@ export function getCoveringSlabUndersideAt( * edge's orientation (one wall clamped, its neighbor didn't). Boundary * contact counts as covered on every side of the slab. * - * This is THE plane for a plane-bound wall (`height` absent). Explicit-height - * walls ignore the value (`resolveWallTop` returns their stored height), so - * passing it wherever a raw storey height feeds `resolveWallTop` / - * `resolveWallEffectiveHeight` is always safe. Falls back to + * Use {@link getWallTop} to resolve a wall's top with its elected base and + * raw authored storey height, independently of this plane. Falls back to * {@link DEFAULT_LEVEL_HEIGHT} when `levelId` doesn't resolve to a level. */ export function getWallPlaneTop( @@ -399,17 +398,45 @@ export function getWallPlaneTop( return wallPlaneTop(wall, resolveCoveringSlabContext(levelId, nodes)) } +export function getWallTop( + wall: WallNode, + levelId: string, + nodes: Record, + electedBase: number, +): number { + const level = nodes[levelId as LevelNode['id']] + const authoredStoreyHeight = level?.type === 'level' ? (level.height ?? null) : null + return resolveWallTop( + wall, + getWallPlaneTop(wall, levelId, nodes), + electedBase, + authoredStoreyHeight, + ) +} + export function getWallCoveringSlabUnderside( wall: Pick & Partial>, levelId: string, nodes: Record, ): number | null { + const undersides = getWallCoveringSlabs(wall, levelId, nodes).map(({ underside }) => underside) + return undersides.length ? Math.min(...undersides) : null +} + +export function getWallCoveringSlabs( + wall: Pick & Partial>, + levelId: string, + nodes: Record, +): Array<{ slab: SlabNode; underside: number; top: number }> { const context = resolveCoveringSlabContext(levelId, nodes) - if (!context) return null - const undersides = context.slabs + if (!context) return [] + return context.slabs .filter((slab) => wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) - .map((slab) => coveringUndersideY(context.floorToFloorHeight, slab)) - return undersides.length ? Math.min(...undersides) : null + .map((slab) => ({ + slab, + underside: coveringUndersideY(context.floorToFloorHeight, slab), + top: context.floorToFloorHeight + slab.elevation, + })) } export function getWallPlaneTops( diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index ee7d0d90f7..61b83fd954 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1068,6 +1068,11 @@ function refreshDefaultGuttersForRoofIds( let pendingRafId: number | null = null let pendingUpdates: Set = new Set() +/** Authored updates whose deferred dirty intent has not reached render systems yet. */ +export function getPendingNodeUpdateCount(): number { + return pendingUpdates.size +} + function buildWallMergePlans( nodes: Record, idsToDelete: AnyNodeId[], diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts index d426e51247..847c787fd6 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -52,8 +52,17 @@ export type SceneSnapshot = { export type SceneCommitOrigin = 'local' | 'load' | 'host' +/** + * Who made a local commit when it was not the person at the keyboard: `agent` + * is an AI agent's tool write. Presentation reads it (the construction reveal + * stages agent writes); the document and its history are the same either way. + */ +export type SceneCommitAuthor = 'agent' + export type SceneCommit = { origin: SceneCommitOrigin + /** Absent for the person's own edits. See `runAsSceneCommitAuthor`. */ + author?: SceneCommitAuthor before: SceneSnapshot current: SceneSnapshot changedNodeIds?: ReadonlySet @@ -123,6 +132,62 @@ export function runWithSceneCommitNodeIds( } } } +const sceneWriteGuards = new Set<() => void>() + +/** + * Registers something that must let go of the scene before anything writes it: a preview that + * shows another state (`scene-preview.ts`) would otherwise swallow the write or have it land on + * the wrong state. Returns the unregister. + */ +export function registerSceneWriteGuard(guard: () => void): () => void { + sceneWriteGuards.add(guard) + return () => { + sceneWriteGuards.delete(guard) + } +} + +export function runSceneWriteGuards(): void { + for (const guard of [...sceneWriteGuards]) guard() +} + +const sceneCommitAuthorScopes: SceneCommitAuthor[] = [] +const sceneCommitAuthorLeases = new Map() + +export function activeSceneCommitAuthor(): SceneCommitAuthor | undefined { + const scoped = sceneCommitAuthorScopes.at(-1) + if (scoped) return scoped + for (const author of sceneCommitAuthorLeases.values()) return author + return undefined +} + +/** Commits made while `run` runs carry `author`. */ +export function runAsSceneCommitAuthor( + author: SceneCommitAuthor, + run: () => TResult, +): TResult { + runSceneWriteGuards() + sceneCommitAuthorScopes.push(author) + try { + return run() + } finally { + sceneCommitAuthorScopes.pop() + } +} + +/** + * The async form of `runAsSceneCommitAuthor`, for work that awaits between its + * writes: commits carry `author` until the returned release runs. Every commit + * in that window carries it, whoever made it, so hold it only around the + * author's own work. Release is idempotent. + */ +export function acquireSceneCommitAuthor(author: SceneCommitAuthor): () => void { + runSceneWriteGuards() + const lease = Symbol('scene-commit-author') + sceneCommitAuthorLeases.set(lease, author) + return () => { + sceneCommitAuthorLeases.delete(lease) + } +} function areSemanticValuesEqual(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true @@ -179,15 +244,19 @@ function emitSceneCommit(commit: SceneCommit): void { export function notifySceneCommit(commit: SceneCommit): void { if (areSceneSnapshotsEqual(commit.before, commit.current)) return - const contextualCommit = { + const author = commit.author ?? activeSceneCommitAuthor() + const contextualCommit: SceneCommit = { ...commit, + ...(author ? { author } : {}), changedNodeIds: mergedNodeIds(commit.changedNodeIds, activeSceneCommitNodeIds()), } if (sceneCommitTransactionDepth > 0) { if (pendingSceneCommit) { + const pendingAuthor = pendingSceneCommit.author ?? contextualCommit.author pendingSceneCommit = { origin: pendingSceneCommit.origin, + ...(pendingAuthor ? { author: pendingAuthor } : {}), before: pendingSceneCommit.before, current: contextualCommit.current, changedNodeIds: mergedNodeIds( diff --git a/packages/core/src/store/scene-preview.test.ts b/packages/core/src/store/scene-preview.test.ts new file mode 100644 index 0000000000..7327bfee6f --- /dev/null +++ b/packages/core/src/store/scene-preview.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { BuildingNode } from '../schema/nodes/building' +import { ItemNode } from '../schema/nodes/item' +import { LevelNode } from '../schema/nodes/level' +import { WallNode } from '../schema/nodes/wall' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { + acquireSceneCommitAuthor, + runAsSceneCommitAuthor, + type SceneCommit, + type SceneSnapshot, + subscribeSceneCommits, +} from './history-control' +import { + beginScenePreview, + endScenePreview, + isScenePreviewing, + subscribeScenePreview, +} from './scene-preview' +import useLiveNodeOverrides from './use-live-node-overrides' +import useLiveTransforms from './use-live-transforms' +import useScene, { applySceneOperationPatch, clearSceneHistory } from './use-scene' + +type RafFn = (cb: (time: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => { + cb(0) + return 0 +} +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +// A preview is the scene as it was before an agent's turn, held under the person's finger and let go +// of again. Written first: a preview that leaves a trace in history, a commit, the saves or the +// agent's view; one that survives a write it would have swallowed; a release that does not give back +// the very scene that was there. + +const BUILDING_ID = 'building_preview' as AnyNodeId +const LEVEL_ID = 'level_preview' as AnyNodeId +const ITEM_ID = 'item_preview' as AnyNodeId +const WALL_ID = 'wall_preview' as AnyNodeId +const OTHER_ID = 'item_untouched' as AnyNodeId + +const asset = { id: 'chair', name: 'Chair', category: 'seating', thumbnail: '', src: '/chair.glb' } +let stopCommits = () => {} + +function position(id: AnyNodeId): [number, number, number] { + return (useScene.getState().nodes[id] as unknown as { position: [number, number, number] }) + .position +} + +/** An agent turn: one wall built, one chair moved. Returns the scene as it stood before. */ +function runTurn(): SceneSnapshot { + const before = useScene.temporal.getState().pastStates.length + const wall = WallNode.parse({ id: WALL_ID, parentId: LEVEL_ID, start: [0, 0], end: [4, 0] }) + runAsSceneCommitAuthor('agent', () => { + useScene.getState().createNode(wall, LEVEL_ID) + useScene.getState().updateNode(ITEM_ID, { position: [2, 0, 2] } as Partial) + }) + const past = useScene.temporal.getState().pastStates + expect(past.length).toBeGreaterThan(before) + return past[before] as SceneSnapshot +} + +beforeEach(() => { + const level = LevelNode.parse({ id: LEVEL_ID, parentId: BUILDING_ID, children: [], level: 0 }) + const building = BuildingNode.parse({ id: BUILDING_ID, parentId: null, children: [LEVEL_ID] }) + useScene.setState({ + nodes: { [BUILDING_ID]: building, [LEVEL_ID]: level }, + rootNodeIds: [BUILDING_ID], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + readOnly: false, + } as never) + clearSceneHistory() + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + for (const [id, at] of [ + [ITEM_ID, [1, 0, 1]], + [OTHER_ID, [3, 0, 3]], + ] as const) { + useScene + .getState() + .createNode(ItemNode.parse({ id, parentId: LEVEL_ID, position: [...at], asset }), LEVEL_ID) + } + clearSceneHistory() +}) + +afterEach(() => { + endScenePreview() + stopCommits() + stopCommits = () => {} +}) + +describe('beginScenePreview', () => { + test('shows the scene as it was before the turn', () => { + const before = runTurn() + + const preview = beginScenePreview(before) + + expect(preview).not.toBeNull() + expect(useScene.getState().nodes[WALL_ID]).toBeUndefined() + expect(position(ITEM_ID)).toEqual([1, 0, 1]) + expect(useScene.getState().nodes[OTHER_ID]).toBe(before.nodes[OTHER_ID]) + expect(isScenePreviewing()).toBe(true) + }) + + test('writes nothing: no history entry, no commit, the scene is read only', () => { + const before = runTurn() + const commits: SceneCommit[] = [] + stopCommits = subscribeSceneCommits((commit) => commits.push(commit)) + const temporal = useScene.temporal.getState() + const { pastStates, futureStates } = temporal + const pastCopy = [...pastStates] + + const preview = beginScenePreview(before) + + expect(useScene.getState().readOnly).toBe(true) + expect(useScene.temporal.getState().pastStates).toEqual(pastCopy) + expect(useScene.temporal.getState().futureStates).toEqual(futureStates) + useScene.getState().updateNode(ITEM_ID, { position: [9, 0, 9] } as Partial) + expect(position(ITEM_ID)).toEqual([1, 0, 1]) + preview?.end() + expect(commits).toEqual([]) + expect(useScene.temporal.getState().pastStates).toEqual(pastCopy) + expect(useScene.temporal.getState().futureStates).toEqual(futureStates) + }) + + test('refuses while history is held by a gesture, and while one preview is up', () => { + const before = runTurn() + useScene.temporal.getState().pause() + expect(beginScenePreview(before)).toBeNull() + useScene.temporal.getState().resume() + + const preview = beginScenePreview(before) + expect(beginScenePreview(before)).toBeNull() + preview?.end() + }) + + test('marks only what differs, and its parents, for a rebuild', () => { + const before = runTurn() + useScene.setState({ dirtyNodes: new Set() } as never) + + beginScenePreview(before) + + const dirty = useScene.getState().dirtyNodes + expect(dirty.has(ITEM_ID)).toBe(true) + expect(dirty.has(LEVEL_ID)).toBe(true) + expect(dirty.has(OTHER_ID)).toBe(false) + }) + + test('marks the neighbours of a wall that goes, which re-mitre', () => { + const neighbour = WallNode.parse({ + id: 'wall_neighbour', + parentId: LEVEL_ID, + start: [4, 0], + end: [4, 3], + }) + useScene.getState().createNode(neighbour, LEVEL_ID) + const before = runTurn() + useScene.setState({ dirtyNodes: new Set() } as never) + + beginScenePreview(before) + + expect(useScene.getState().dirtyNodes.has(neighbour.id)).toBe(true) + }) +}) + +describe('endScenePreview', () => { + test('gives back the very scene that was there', () => { + const before = runTurn() + const held = useScene.getState() + const nodes = held.nodes + const preview = beginScenePreview(before) + + preview?.end() + + expect(useScene.getState().nodes).toBe(nodes) + expect(useScene.getState().rootNodeIds).toBe(held.rootNodeIds) + expect(useScene.getState().readOnly).toBe(false) + expect(isScenePreviewing()).toBe(false) + expect(position(ITEM_ID)).toEqual([2, 0, 2]) + }) + + test('hands history back working: the next edit is one undoable step', () => { + const before = runTurn() + const steps = useScene.temporal.getState().pastStates.length + beginScenePreview(before)?.end() + + useScene.getState().updateNode(ITEM_ID, { position: [5, 0, 5] } as Partial) + + expect(useScene.temporal.getState().pastStates).toHaveLength(steps + 1) + useScene.temporal.getState().undo() + expect(position(ITEM_ID)).toEqual([2, 0, 2]) + }) + + test('is safe to call twice, and with nothing to end', () => { + const before = runTurn() + const preview = beginScenePreview(before) + preview?.end() + preview?.end() + endScenePreview() + expect(useScene.getState().readOnly).toBe(false) + expect(useScene.temporal.getState().isTracking).toBe(true) + }) + + test('tells whoever watches, once each way', () => { + const seen: boolean[] = [] + const stop = subscribeScenePreview((previewing) => seen.push(previewing)) + const before = runTurn() + + beginScenePreview(before)?.end() + + stop() + expect(seen).toEqual([true, false]) + }) +}) + +describe('a write ends the preview first', () => { + test('an agent writing', () => { + const before = runTurn() + beginScenePreview(before) + + runAsSceneCommitAuthor('agent', () => { + expect(isScenePreviewing()).toBe(false) + useScene.getState().updateNode(ITEM_ID, { position: [7, 0, 7] } as Partial) + }) + + expect(position(ITEM_ID)).toEqual([7, 0, 7]) + expect(useScene.getState().nodes[WALL_ID]).toBeDefined() + }) + + test('an agent about to write across awaits', () => { + const before = runTurn() + beginScenePreview(before) + + const release = acquireSceneCommitAuthor('agent') + + expect(isScenePreviewing()).toBe(false) + release() + }) + + test('a collaborator or the host patching the scene', () => { + const before = runTurn() + beginScenePreview(before) + + const applied = applySceneOperationPatch({ + materialChanges: [], + nodeCreates: [], + nodeDeletes: [], + nodeUpdates: [ + { id: OTHER_ID, data: { position: [3.5, 0, 3.5] } as Partial, removeFields: [] }, + ], + }) + + expect(applied).toBe(true) + expect(isScenePreviewing()).toBe(false) + expect(useScene.getState().nodes[WALL_ID]).toBeDefined() + expect(position(OTHER_ID)).toEqual([3.5, 0, 3.5]) + }) + + test('an undo, which acts on the real history and not on what is shown', () => { + const before = runTurn() + beginScenePreview(before) + + useScene.temporal.getState().undo(2) + + expect(isScenePreviewing()).toBe(false) + expect(useScene.getState().nodes[WALL_ID]).toBeUndefined() + expect(position(ITEM_ID)).toEqual([1, 0, 1]) + expect(useScene.getState().readOnly).toBe(false) + }) + + test('a redo', () => { + const before = runTurn() + useScene.temporal.getState().undo(1) + beginScenePreview(before) + expect(useScene.getState().nodes[WALL_ID]).toBeUndefined() + + useScene.temporal.getState().redo(1) + + expect(isScenePreviewing()).toBe(false) + expect(useScene.getState().nodes[WALL_ID]).toBeDefined() + expect(position(ITEM_ID)).toEqual([2, 0, 2]) + }) +}) diff --git a/packages/core/src/store/scene-preview.ts b/packages/core/src/store/scene-preview.ts new file mode 100644 index 0000000000..16fe69c4ec --- /dev/null +++ b/packages/core/src/store/scene-preview.ts @@ -0,0 +1,186 @@ +import { toSceneMaterialRef } from '../material-library' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { + beginSceneHistoryPauseSession, + getSceneHistoryPauseDepth, + registerSceneWriteGuard, + runAsRemoteSceneChange, + type SceneSnapshot, +} from './history-control' +import { hasSceneHistoryDrafts } from './history-drafts' +import useLiveNodeOverrides from './use-live-node-overrides' +import useLiveTransforms from './use-live-transforms' +import useScene, { acquireSceneReadOnlyLease } from './use-scene' + +// A scene preview shows another state of the document (what it was before an agent's turn) for as +// long as someone holds a control, and gives the live scene back, untouched, when they let go. +// +// It is not an edit. History is held for its whole life, the scene is read only, and no commit is +// announced, so nothing that watches commits (the collaboration journal, the construction reveal, +// the pin) hears of it. Anything that is about to write the scene (an agent, a collaborator, an +// undo) lets go of the preview first, because a write onto a preview would be lost or land on the +// wrong state. Whoever persists the scene reads `isScenePreviewing()` and stays still. + +export type ScenePreview = { + /** Gives the live scene back. Idempotent. */ + end: () => void +} + +type Held = Pick< + SceneSnapshot, + | 'nodes' + | 'rootNodeIds' + | 'collections' + | 'materials' + | 'installedPlugins' + | 'hasExplicitPluginInstallState' +> + +type ActivePreview = { + held: Held + releaseHistory: () => void + releaseReadOnly: () => void +} + +let active: ActivePreview | null = null +const listeners = new Set<(previewing: boolean) => void>() + +export function isScenePreviewing(): boolean { + return active !== null +} + +export function subscribeScenePreview(listener: (previewing: boolean) => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function notify(previewing: boolean): void { + for (const listener of [...listeners]) { + try { + listener(previewing) + } catch (error) { + console.error('[Scene] Scene preview listener failed', error) + } + } +} + +/** The ids whose node differs between two records: created, deleted or changed. Nodes are immutable. */ +function differingNodeIds( + left: Record, + right: Record, +): AnyNodeId[] { + const ids: AnyNodeId[] = [] + for (const id of Object.keys(left) as AnyNodeId[]) if (left[id] !== right[id]) ids.push(id) + for (const id of Object.keys(right) as AnyNodeId[]) if (!(id in left)) ids.push(id) + return ids +} + +const childrenOf = (node: AnyNode | undefined): readonly string[] => + node && 'children' in node && Array.isArray(node.children) ? (node.children as string[]) : [] + +/** + * Puts `target` on screen: the store takes it as a remote change (history and collaboration leave + * it alone), and only what differs is marked for the systems to rebuild, with its parents and the + * siblings of its kind when a node appears or goes, as the host's own patches do for walls. + */ +function showState(target: Held): void { + const before = useScene.getState() + const touched = differingNodeIds(before.nodes, target.nodes) + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + runAsRemoteSceneChange(() => + useScene.setState({ + nodes: target.nodes, + rootNodeIds: target.rootNodeIds, + collections: target.collections, + materials: target.materials, + installedPlugins: target.installedPlugins, + hasExplicitPluginInstallState: target.hasExplicitPluginInstallState ?? false, + }), + ) + const current = useScene.getState() + const dirty = new Set() + for (const id of touched) { + if (current.nodes[id]) dirty.add(id) + else current.clearDirty(id) + for (const parentId of [before.nodes[id]?.parentId, current.nodes[id]?.parentId]) { + if (parentId && current.nodes[parentId as AnyNodeId]) dirty.add(parentId as AnyNodeId) + } + // A wall gone or come back re-mitres its neighbours; a chair has none, so only the same kind. + const node = before.nodes[id] ?? current.nodes[id] + const appeared = !before.nodes[id] || !current.nodes[id] + const parentId = node?.parentId as AnyNodeId | undefined + if (appeared && node && parentId) { + for (const siblingId of childrenOf(current.nodes[parentId])) { + if (current.nodes[siblingId as AnyNodeId]?.type === node.type) { + dirty.add(siblingId as AnyNodeId) + } + } + } + } + if (before.materials !== current.materials) { + const changed = new Set() + for (const id of Object.keys({ ...before.materials, ...current.materials })) { + const key = id as keyof typeof current.materials + if (before.materials[key] !== current.materials[key]) changed.add(toSceneMaterialRef(key)) + } + for (const node of Object.values(current.nodes)) { + const slots = 'slots' in node ? (node.slots as Record | undefined) : undefined + if (slots && Object.values(slots).some((ref) => changed.has(ref))) dirty.add(node.id) + } + } + for (const id of dirty) current.markDirty(id) +} + +/** + * Shows `snapshot` (typically the state from before an agent's turn, read from the history Undo + * would restore) until the returned handle ends. Refused (null) while another preview is up or an + * interaction holds history: a gesture's live writes must not be shown over or swapped out. + */ +export function beginScenePreview(snapshot: SceneSnapshot): ScenePreview | null { + if (active) return null + if ( + !useScene.temporal.getState().isTracking || + getSceneHistoryPauseDepth() > 0 || + hasSceneHistoryDrafts() + ) { + return null + } + const state = useScene.getState() + const held: Held = { + nodes: state.nodes, + rootNodeIds: state.rootNodeIds, + collections: state.collections, + materials: state.materials, + installedPlugins: state.installedPlugins, + hasExplicitPluginInstallState: state.hasExplicitPluginInstallState, + } + const history = beginSceneHistoryPauseSession(useScene) + const preview: ActivePreview = { + held, + releaseHistory: () => history.end(), + releaseReadOnly: acquireSceneReadOnlyLease(), + } + active = preview + notify(true) + showState(snapshot) + return { end: () => endIf(preview) } +} + +function endIf(preview: ActivePreview): void { + if (active !== preview) return + showState(preview.held) + active = null + preview.releaseReadOnly() + preview.releaseHistory() + notify(false) +} + +/** Lets go of whichever preview is up; nothing when none is. */ +export function endScenePreview(): void { + if (active) endIf(active) +} + +registerSceneWriteGuard(endScenePreview) diff --git a/packages/core/src/store/use-corner-snap-hint.test.ts b/packages/core/src/store/use-corner-snap-hint.test.ts new file mode 100644 index 0000000000..34c8bf472f --- /dev/null +++ b/packages/core/src/store/use-corner-snap-hint.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' +import { CORNER_BLOCK_NOTES, cornerHintRows } from './use-corner-snap-hint' + +// The HUD near a wall end (the owner, 8 October): what a click does where a window can join, and +// why not where it cannot, in one wording for the placement HUD and the move HUD. +describe('cornerHintRows', () => { + test('says nothing away from a wall end', () => { + expect(cornerHintRows(null)).toEqual([]) + }) + + test('says what a click does where the window can join, and how to skip it', () => { + const rows = cornerHintRows('join') + + expect(rows[0]?.keys).toEqual(['Left click']) + expect(rows.some((row) => row.keys.includes('Alt'))).toBe(true) + expect(cornerHintRows('new')[0]?.keys).toEqual(['Left click']) + }) + + test('gives each reason its own sentence where the end is no corner', () => { + const reasons = Object.keys(CORNER_BLOCK_NOTES) as (keyof typeof CORNER_BLOCK_NOTES)[] + const labels = reasons.map((reason) => cornerHintRows(reason)[0]?.label) + + expect(labels.every((label) => Boolean(label))).toBe(true) + expect(new Set(labels).size).toBe(reasons.length) + // It offers no click, since a click here places a plain window. + expect(cornerHintRows('free_end').every((row) => !row.keys.includes('Left click'))).toBe(true) + }) +}) diff --git a/packages/core/src/store/use-corner-snap-hint.ts b/packages/core/src/store/use-corner-snap-hint.ts new file mode 100644 index 0000000000..f9143aac7d --- /dev/null +++ b/packages/core/src/store/use-corner-snap-hint.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand' +import type { CornerBlockReason } from '../lib/corner-snap' + +/** + * What the window being placed or dragged near a wall end says (the owner, 8 October): `join` when + * a window already waits on the other wall, `new` when one would be created, the reason when the + * end is not a corner a window can wrap, null away from a wall end. The tools write it; the HUD + * reads it, so the hint is there only inside the zone. + */ +export type CornerSnapHint = 'join' | 'new' | CornerBlockReason | null + +export const useCornerSnapHint = create<{ + hint: CornerSnapHint + setHint: (hint: CornerSnapHint) => void +}>((set) => ({ + hint: null, + setHint: (hint) => set((state) => (state.hint === hint ? state : { hint })), +})) + +/** One line each, in the HUD's voice: what is in the way, not what to do about it. */ +export const CORNER_BLOCK_NOTES: Record = { + free_end: 'No wall meets here', + several_walls: 'Three or more walls meet here', + angle: 'The walls meet at too sharp or too flat an angle', + curved: 'A curved wall takes no corner window', + curtain: 'A curtain wall takes no corner window', + no_room: 'No room for the matching window on the other wall', +} + +export type CornerHintRow = { keys: string[]; label: string } + +/** The HUD rows for a hint, shared by the placement tool and the move HUD. */ +export function cornerHintRows(hint: CornerSnapHint): CornerHintRow[] { + if (!hint) return [] + if (hint === 'join' || hint === 'new') + return [ + { + keys: ['Left click'], + label: hint === 'join' ? 'Join as a corner window' : 'Corner window', + }, + { keys: ['Alt'], label: 'Place without the corner snap' }, + ] + return [{ keys: ['Corner'], label: CORNER_BLOCK_NOTES[hint] }] +} diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index edecc255e6..1a5cc118d6 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -14,9 +14,11 @@ import { SceneMaterial, type SceneMaterialId } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' import { migrateCeilingRoomLinks, migrateRoomZones } from '../utils/room-zone-migration' import { + acquireSceneCommitAuthor, areSceneSnapshotsEqual, pauseSceneHistory, resumeSceneHistory, + runAsSceneCommitAuthor, runAsSingleSceneHistoryStep, type SceneCommit, type SceneSnapshot, @@ -1399,3 +1401,86 @@ describe('scene commit boundary', () => { expect(areSceneSnapshotsEqual(left, right)).toBe(true) }) }) + +describe('commit author', () => { + beforeEach(() => { + unsubscribe() + resetScene() + }) + + afterEach(() => { + unsubscribe() + unsubscribe = () => {} + }) + + function wall(id: string) { + return WallNode.parse({ id, parentId: LEVEL_ID, start: [0, 0], end: [4, 0] }) + } + + test("an agent scope tags its commits; the person's edits stay untagged", () => { + const commits: SceneCommit[] = [] + unsubscribe = subscribeSceneCommits((commit) => commits.push(commit)) + + runAsSceneCommitAuthor('agent', () => + useScene.getState().createNode(wall('wall_agent'), LEVEL_ID), + ) + useScene.getState().createNode(wall('wall_person'), LEVEL_ID) + + expect(commits.map((commit) => commit.author)).toEqual(['agent', undefined]) + expect(commits[1]).not.toHaveProperty('author') + }) + + test('one undo step written by an agent is one tagged commit', () => { + const commits: SceneCommit[] = [] + unsubscribe = subscribeSceneCommits((commit) => commits.push(commit)) + + runAsSceneCommitAuthor('agent', () => + runAsSingleSceneHistoryStep(useScene, () => { + useScene.getState().createNode(wall('wall_a'), LEVEL_ID) + useScene.getState().createNode(wall('wall_b'), LEVEL_ID) + }), + ) + + expect(commits).toHaveLength(1) + expect(commits[0]?.author).toBe('agent') + expect(commits[0]?.origin).toBe('local') + }) + + test('a held author tags every commit until released', () => { + const commits: SceneCommit[] = [] + unsubscribe = subscribeSceneCommits((commit) => commits.push(commit)) + + const release = acquireSceneCommitAuthor('agent') + useScene.getState().createNode(wall('wall_held_a'), LEVEL_ID) + useScene.getState().createNode(wall('wall_held_b'), LEVEL_ID) + release() + release() + useScene.getState().createNode(wall('wall_after'), LEVEL_ID) + + expect(commits.map((commit) => commit.author)).toEqual(['agent', 'agent', undefined]) + }) + + test('a load keeps its origin under an agent scope', () => { + const commits: SceneCommit[] = [] + const snapshot = currentSnapshot() + const loaded = wall('wall_loaded') + unsubscribe = subscribeSceneCommits((commit) => commits.push(commit)) + + runAsSceneCommitAuthor('agent', () => + applySceneSnapshot( + { + ...snapshot, + nodes: { + ...snapshot.nodes, + [loaded.id]: loaded, + [LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], children: [loaded.id] } as AnyNode, + }, + }, + { origin: 'load' }, + ), + ) + + expect(commits).toHaveLength(1) + expect(commits[0]?.origin).toBe('load') + }) +}) diff --git a/packages/core/src/store/use-scene-vertical-migration.test.ts b/packages/core/src/store/use-scene-vertical-migration.test.ts index e3493760f6..02d4bb9d3b 100644 --- a/packages/core/src/store/use-scene-vertical-migration.test.ts +++ b/packages/core/src/store/use-scene-vertical-migration.test.ts @@ -1,5 +1,12 @@ import { beforeEach, describe, expect, test } from 'bun:test' -import type { AnyNode } from '../schema' +import { + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, + spatialGridManager, +} from '../hooks/spatial-grid/spatial-grid-manager' +import { initSpatialGridSync } from '../hooks/spatial-grid/spatial-grid-sync' +import type { AnyNode, AnyNodeId } from '../schema' +import { getLevelElevations } from '../services/storey' import useScene from './use-scene' type RawNode = Record @@ -81,6 +88,7 @@ type WallResult = Extract type StairResult = Extract type SlabResult = Extract type CeilingResult = Extract +type RoofResult = Extract describe('scene vertical model migration', () => { beforeEach(() => { @@ -93,7 +101,7 @@ describe('scene vertical model migration', () => { useScene.temporal.getState().clear() }) - test('default legacy storey derives height 2.5 and keeps walls plane-bound', () => { + test('default legacy storey derives height 2.55 and keeps walls plane-bound', () => { const nodes = loadScene({ site_test: site(['building_a']), building_a: building('building_a', ['level_a']), @@ -103,11 +111,101 @@ describe('scene vertical model migration', () => { wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4]), }) - expect((nodes.level_a as LevelResult).height).toBe(2.5) + // The editor drew a wall without a height 2.5 m up from its slab. + expect((nodes.level_a as LevelResult).height).toBe(0.05 + 2.5) expect('height' in (nodes.wall_a as WallResult)).toBe(false) expect('height' in (nodes.wall_b as WallResult)).toBe(false) }) + test('an upper storey on a raised floor keeps its walls under the roof', () => { + // The landing page's house, reduced: the first floor stands on a 0.30 + // floor slab, its walls have no stored height, and a roof sits on the + // storey above. The editor used to draw those walls 2.5 m up from the + // slab (to 2.55 + 0.30 + 2.5 = 5.35) and stack the roof storey on top of + // them, so the roof sat exactly on the wall tops. + const BOX: Array<[number, number]> = [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ] + const ring = (prefix: string, levelId: string) => + Object.fromEntries( + BOX.map((start, index) => [ + `${prefix}_${index}`, + wall(`${prefix}_${index}`, levelId, start, BOX[(index + 1) % BOX.length]!), + ]), + ) + const groundWalls = ring('wall_ground', 'level_ground') + const upperWalls = ring('wall_upper', 'level_upper') + const stopSync = initSpatialGridSync() + try { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_ground', 'level_upper', 'level_roof']), + level_ground: level('level_ground', 'building_a', 0, [ + 'slab_ground', + ...Object.keys(groundWalls), + ]), + slab_ground: slab('slab_ground', 'level_ground', BOX), + ...groundWalls, + level_upper: level('level_upper', 'building_a', 1, [ + 'slab_upper', + 'wall_railing', + ...Object.keys(upperWalls), + ]), + slab_upper: slab('slab_upper', 'level_upper', BOX, 0.3), + ...upperWalls, + wall_railing: wall('wall_railing', 'level_upper', [1, 2], [5, 2], 1.2), + level_roof: level('level_roof', 'building_a', 2, ['roof_a']), + roof_a: baseNode('roof_a', 'roof', 'level_roof', { + position: [3, 0, 2], + rotation: 0, + children: ['roof_segment_a'], + }), + roof_segment_a: baseNode('roof_segment_a', 'roof-segment', 'roof_a', { + position: [0, 0, 0], + rotation: 0, + width: 6, + depth: 4, + overhang: 0.3, + roofType: 'gable', + roofHeight: 1.5, + wallHeight: 0, + }), + }) + + const elevations = getLevelElevations(nodes as Record) + const topOf = (id: string) => { + const wallNode = nodes[id] as WallResult + return ( + elevations.get(wallNode.parentId!)!.baseY + + getWallBaseElevationForNodes(wallNode, nodes) + + getWallEffectiveHeightForNodes(wallNode, nodes) + ) + } + const roofSeat = + elevations.get('level_roof')!.baseY + (nodes.roof_a as RoofResult).position[1] + + // No wall stands above the roof: each upper wall top meets the seat. + for (const id of Object.keys(upperWalls)) { + expect(topOf(id) - roofSeat).toBeCloseTo(0, 3) + } + // And both stay where the editor drew them, to the millimetre. + expect(roofSeat).toBeCloseTo(5.35, 3) + for (const id of Object.keys(upperWalls)) { + expect('height' in (nodes[id] as WallResult)).toBe(false) + expect(topOf(id)).toBeCloseTo(5.35, 3) + } + expect(topOf('wall_railing')).toBeCloseTo(2.55 + 0.3 + 1.2, 3) + expect((nodes.level_ground as LevelResult).height).toBeCloseTo(2.55, 9) + expect((nodes.level_upper as LevelResult).height).toBeCloseTo(2.8, 9) + } finally { + stopSync() + spatialGridManager.clear() + } + }) + test('materializes a finite zero base elevation for legacy levels', () => { const nodes = loadScene({ site_test: site(['building_a']), diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 94a3493603..ba97963e7f 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -64,6 +64,7 @@ import { resumeSceneHistory, runAsRemoteSceneChange, runAsSceneHistoryRestore, + runSceneWriteGuards, runWithSceneCommitNodeIds, type SceneCommitOrigin, type SceneSnapshot, @@ -1494,6 +1495,7 @@ const useScene: UseSceneStore = createSceneStore( setReadOnly: (readOnly: boolean) => set({ readOnly }), unloadScene: () => { + runSceneWriteGuards() invalidatePendingHydration() set({ hydrationToken: null, @@ -1517,6 +1519,7 @@ const useScene: UseSceneStore = createSceneStore( }, setScene: (nodes, rootNodeIds, extra) => { + runSceneWriteGuards() // Apply backward compatibility migrations const { nodes: patchedNodes, mintedMaterials } = migrateNodes(nodes) // Scene materials minted by the legacy→slots migrations join the @@ -1794,14 +1797,18 @@ const useScene: UseSceneStore = createSceneStore( const state = config(set, get, store) return { ...state, - undo: (steps = 1) => - runTemporalJump(get().pastStates.slice().splice(-steps, steps)[0], () => + undo: (steps = 1) => { + runSceneWriteGuards() + return runTemporalJump(get().pastStates.slice().splice(-steps, steps)[0], () => state.undo(steps), - ), - redo: (steps = 1) => - runTemporalJump(get().futureStates.slice().splice(-steps, steps)[0], () => + ) + }, + redo: (steps = 1) => { + runSceneWriteGuards() + return runTemporalJump(get().futureStates.slice().splice(-steps, steps)[0], () => state.redo(steps), - ), + ) + }, } }, limit: 50, // Limit to last 50 actions @@ -2267,6 +2274,7 @@ export function applySceneOperationPatch( changes: SceneOperationPatch, options: ApplySceneOperationPatchOptions = {}, ): boolean { + runSceneWriteGuards() const beforeState = useScene.getState() if ( changes.nodeUpdates.length === 0 && @@ -2412,6 +2420,7 @@ export function applySceneSnapshot( snapshot: SceneSnapshot, options: ApplySceneSnapshotOptions, ): boolean { + runSceneWriteGuards() const before = sceneHistorySnapshotFromState(useScene.getState()) const temporalState = useScene.temporal.getState() if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0 || hasSceneHistoryDrafts()) { diff --git a/packages/core/src/systems/roof/roof-elevation-system.test.ts b/packages/core/src/systems/roof/roof-elevation-system.test.ts index 0617edb6e4..cf7f9f2f74 100644 --- a/packages/core/src/systems/roof/roof-elevation-system.test.ts +++ b/packages/core/src/systems/roof/roof-elevation-system.test.ts @@ -123,7 +123,7 @@ describe('RoofElevationSystem', () => { }) test('waits for the slab grid listener and tracks subsequent slab elevation edits', async () => { - const { level, roof } = setup() + const { level, wall, roof } = setup() const slab = SlabNode.parse({ elevation: 0.5, polygon: [ @@ -135,10 +135,13 @@ describe('RoofElevationSystem', () => { }) useScene.getState().createNode(slab, level.id) await Promise.resolve() - expect(currentRoof(roof.id).position[1]).toBe(0.5) + expect(currentRoof(roof.id).position[1]).toBe(0) + useScene.getState().updateNode(wall.id, { height: 3.2 }) + await Promise.resolve() + expect(currentRoof(roof.id).position[1]).toBeCloseTo(0.7) useScene.getState().updateNode(slab.id, { elevation: 0.9 }) await Promise.resolve() - expect(currentRoof(roof.id).position[1]).toBeCloseTo(0.9) + expect(currentRoof(roof.id).position[1]).toBeCloseTo(1.1) }) test('keeps a custom Y edit after follow mode is disabled', async () => { diff --git a/packages/core/src/systems/slab/slab-support.test.ts b/packages/core/src/systems/slab/slab-support.test.ts index 758d31ab62..34ff3706ac 100644 --- a/packages/core/src/systems/slab/slab-support.test.ts +++ b/packages/core/src/systems/slab/slab-support.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'bun:test' -import { SlabNode, WallNode } from '../../schema' -import { MIN_WALL_HEIGHT } from '../wall/wall-top' import { clampSlabElevationForWalls, computeWallSlabSupport, getSlabElevationUpperBound, -} from './slab-support' + MIN_WALL_HEIGHT, + SlabNode, + WallNode, +} from '../../index' // 4×3 room slab drawn on the wall centerlines, like an auto-slab. const SQUARE: Array<[number, number]> = [ @@ -40,6 +41,24 @@ describe('clampSlabElevationForWalls', () => { expect(result.elevation).toBeCloseTo(BOUND) }) + it('bounds authored storey-height walls but not taller or ground-hosted walls', () => { + const slab = roomSlab(0.05) + for (const { height, supportSlabId, follows } of [ + { height: STOREY_HEIGHT, follows: true }, + { height: STOREY_HEIGHT + 0.3, follows: false }, + { height: STOREY_HEIGHT, supportSlabId: 'ground' as const, follows: false }, + ]) { + const walls = roomWalls(height).map((wall) => ({ ...wall, supportSlabId })) + const result = clampSlabElevationForWalls(2.5, slab, walls, [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(follows) + expect(result.elevation).toBeCloseTo(follows ? BOUND : 2.5) + expect(getSlabElevationUpperBound(slab, walls, [slab], STOREY_HEIGHT)).toBe( + follows ? BOUND : Number.POSITIVE_INFINITY, + ) + } + }) + it('leaves proposals at or below the bound untouched', () => { const slab = roomSlab(0.05) const result = clampSlabElevationForWalls(BOUND, slab, roomWalls(), [slab], STOREY_HEIGHT) diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts index 0e0c92601c..1529334df6 100644 --- a/packages/core/src/systems/slab/slab-support.ts +++ b/packages/core/src/systems/slab/slab-support.ts @@ -11,7 +11,7 @@ import type { AnyNode, SeparatorNode, SlabNode, WallNode, ZoneNode } from '../.. import { getWallCurveFrameAt, isCurvedWall } from '../wall/wall-curve' import { DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint' import { getWallFaceOffsets, type WallJustification } from '../wall/wall-frame' -import { MIN_WALL_HEIGHT } from '../wall/wall-top' +import { MIN_WALL_HEIGHT, wallFollowsStorey } from '../wall/wall-top' export type SlabElevationClamp = { elevation: number @@ -20,11 +20,12 @@ export type SlabElevationClamp = { /** * Clamp-never-ask upper bound for a slab's elevation. A plane-bound wall - * (no stored `height`) keeps its top at the storey plane, so a slab that + * (no stored `height`, or height equal to the authored storey) keeps its + * top at the storey plane, so a slab that * rises past `storeyHeight - MIN_WALL_HEIGHT` while electing as that * wall's base would squeeze the wall body below its minimum (and at the - * plane, to nothing). Walls with explicit heights don't constrain — their - * top is an explicit level-local height rather than the storey plane. Negative proposals (the + * plane, to nothing). Walls with other explicit heights don't constrain — + * their body height stays fixed above the elected base. Negative proposals (the * drag-through-zero path that commits the `recessed` intent) pass * through untouched: this is a purely numeric upper bound. * @@ -52,7 +53,7 @@ export function clampSlabElevationForWalls( : [...levelSlabs, { ...slab, elevation: proposedElevation }] for (const wall of levelWalls) { - if (wall.height != null) continue + if (!wallFollowsStorey(wall, storeyHeight)) continue const wallLike: WallOverlapInput = { start: wall.start, end: wall.end, diff --git a/packages/core/src/systems/wall/wall-finish.test.ts b/packages/core/src/systems/wall/wall-finish.test.ts index cbd0b051a1..b0e6bc021d 100644 --- a/packages/core/src/systems/wall/wall-finish.test.ts +++ b/packages/core/src/systems/wall/wall-finish.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from 'bun:test' import { extractRooms } from '../../lib/room-graph' +import { type AnyNode, LevelNode } from '../../schema' import { WallNode } from '../../schema/nodes/wall' import { ZoneNode } from '../../schema/nodes/zone' import { buildWallFinishLayout, + getWallLevelZones, getWallZoneSpans, resolveWallFaceChain, resolveWallFinish, @@ -45,7 +47,7 @@ function tJunction(host: Partial = {}) { ...extra, }) } - return { host: walls[0]!, zone } + return { host: walls[0]!, partition: walls[4]!, zone } } const describeHit = (hit: WallFinishHit) => @@ -76,6 +78,47 @@ describe('wall face spans from zones', () => { }) describe('wall finish resolution order', () => { + test('shared and reversed walls keep each room local finish on its geometric face', () => { + const { host, partition, zone } = tJunction() + const zones = [ + zone('A', { + wallMaterial: 'library:room-a', + wallOverrides: [{ wallId: partition.id, face: 'a', finish: 'library:local-a' }], + }), + zone('B', { + wallMaterial: 'library:room-b', + wallOverrides: [{ wallId: host.id, face: 'b', finish: 'library:local-b' }], + }), + ] + const shared = buildWallFinishLayout(partition, zones) + expect(describeHit(resolveWallFinish(shared, 'a', 2, 1))).toBe('override:library:local-a') + expect(describeHit(resolveWallFinish(shared, 'b', 2, 1))).toBe('zone:library:room-b') + const reversed = buildWallFinishLayout({ ...host, start: host.end, end: host.start }, zones) + expect(describeHit(resolveWallFinish(reversed, 'b', 2, 1))).toBe('override:library:local-b') + expect(describeHit(resolveWallFinish(reversed, 'b', 6, 1))).toBe('zone:library:room-a') + expect(describeHit(resolveWallFinish(reversed, 'a', 2, 1))).toBe('slot') + }) + + test('coincident rooms on another storey cannot supply a wall finish', () => { + const { host, zone } = tJunction() + const ground = zone('A', { wallMaterial: 'library:ground' }) + const upper = zone('B', { + id: 'zone_upper', + parentId: 'level_upper', + wallMaterial: 'library:upper', + }) + const level = LevelNode.parse({ id: 'level_t', level: 0, children: [host.id, ground.id] }) + const upstairs = LevelNode.parse({ id: 'level_upper', level: 1, children: [upper.id] }) + const nodes: Record = Object.fromEntries( + [level, upstairs, host, ground, upper].map((node) => [node.id, node]), + ) + const zones = getWallLevelZones(host, nodes) + expect(zones.map((entry) => entry.id)).toEqual([ground.id]) + const layout = buildWallFinishLayout(host, zones) + expect(describeHit(resolveWallFinish(layout, 'a', 2, 1))).toBe('zone:library:ground') + expect(describeHit(resolveWallFinish(layout, 'a', 6, 1))).toBe('slot') + }) + // region > zone override for this face > zone wallMaterial > wall face slot > kind default test('each level wins over the ones below it, per span of the T-junction', () => { const { host, zone } = tJunction({ diff --git a/packages/core/src/systems/wall/wall-top.test.ts b/packages/core/src/systems/wall/wall-top.test.ts index e27feb10d0..c568f9c950 100644 --- a/packages/core/src/systems/wall/wall-top.test.ts +++ b/packages/core/src/systems/wall/wall-top.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from 'bun:test' -import { resolveWallEffectiveHeight, resolveWallTop } from './wall-top' +import { + BuildingNode, + computeWallSlabSupport, + getWallTop, + LevelNode, + resolveWallEffectiveHeight, + resolveWallTop, + SlabNode, + WallNode, +} from '../../index' describe('resolveWallTop', () => { test('explicit height on zero base keeps the stored top', () => { @@ -26,6 +35,16 @@ describe('resolveWallTop', () => { expect(resolveWallTop({}, 3, 0.6)).toBe(3) expect(resolveWallTop({}, 3, -0.4)).toBe(3) }) + + test('an authored storey-height wall ends at the covering underside, not its support plus height', () => { + expect(resolveWallTop({ height: 3.6 }, 3.6, 0.05)).toBeCloseTo(3.6) + expect(resolveWallEffectiveHeight({ height: 3.6 }, 3.6, 0.05)).toBeCloseTo(3.55) + expect(resolveWallTop({ height: 3.6 }, 3.4, 0.05, 3.6)).toBeCloseTo(3.4) + expect(resolveWallTop({ height: 3.6 }, 4.1, 0.55, 3.6)).toBeCloseTo(4.1) + expect(resolveWallTop({ height: 4.2 }, 3.6, 0.05, 3.6)).toBeCloseTo(4.25) + expect(resolveWallTop({ height: 3.6 }, 3.6, 0.05, null)).toBeCloseTo(3.65) + expect(resolveWallTop({ height: 3.6, supportSlabId: 'ground' }, 3.4, 0.2, 3.6)).toBeCloseTo(3.8) + }) }) describe('resolveWallEffectiveHeight', () => { @@ -49,4 +68,73 @@ describe('resolveWallEffectiveHeight', () => { test('plane-bound on sunken base fills down while the top stays at the plane', () => { expect(resolveWallEffectiveHeight({}, 3, -0.4)).toBeCloseTo(3.4) }) + + test('saved storey-height walls meet the upper slab underside without changing authored data', () => { + const polygon: [number, number][] = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] + const building = BuildingNode.parse({ + id: 'building_datum', + children: ['level_ground', 'level_upper'], + }) + const ground = LevelNode.parse({ + id: 'level_ground', + parentId: building.id, + level: 0, + height: 3.6, + children: ['slab_ground', 'wall_datum'], + }) + const upper = LevelNode.parse({ + id: 'level_upper', + parentId: building.id, + level: 1, + height: 3.3, + children: ['slab_upper'], + }) + const floor = SlabNode.parse({ + id: 'slab_ground', + parentId: ground.id, + polygon, + elevation: 0.05, + thickness: 0.05, + }) + const cover = SlabNode.parse({ + id: 'slab_upper', + parentId: upper.id, + polygon, + elevation: 0.05, + thickness: 0.05, + }) + const wall = WallNode.parse({ + id: 'wall_datum', + parentId: ground.id, + start: [0, 0], + end: [4, 0], + height: 3.6, + }) + const nodes = Object.fromEntries( + [building, ground, upper, floor, cover, wall].map((node) => [node.id, node]), + ) + const base = computeWallSlabSupport( + wall, + [floor], + [wall], + undefined, + undefined, + 0, + nodes, + ).elevation + expect(base).toBeCloseTo(0.05) + expect(getWallTop(wall, ground.id, nodes, base)).toBeCloseTo( + ground.height! + cover.elevation - cover.thickness, + ) + const thicker = { ...nodes, [cover.id]: { ...cover, thickness: 0.25 } } + expect(getWallTop(wall, ground.id, thicker, base)).toBeCloseTo(3.4) + const taller = { ...wall, height: 4.2 } + expect(getWallTop(taller, ground.id, { ...nodes, [wall.id]: taller }, base)).toBeCloseTo(4.25) + expect(wall.height).toBe(3.6) + }) }) diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts index 461af204d0..793875fbd5 100644 --- a/packages/core/src/systems/wall/wall-top.ts +++ b/packages/core/src/systems/wall/wall-top.ts @@ -9,13 +9,27 @@ import type { WallNode } from '../../schema/nodes/wall' */ export const MIN_WALL_HEIGHT = 0.5 +export function wallFollowsStorey( + wall: Pick, + authoredStoreyHeight: number | null, +): boolean { + return ( + wall.height == null || + (authoredStoreyHeight !== null && + wall.supportSlabId !== 'ground' && + Math.abs(wall.height - authoredStoreyHeight) <= 1e-6) + ) +} + /** * Wall-top inversion (vertical building model): a wall with no stored * `height` is plane-bound — its top sits at the storey plane (level-local * Y = the level's stored height), so a slab lifting the wall's base makes * the wall shorter, never taller, and no gap can open at the top of a - * level. Explicit walls stand on their elected support; room floor datums do not - * change their tops. Ground-hosted walls retain their authored body height. + * level. An explicit height equal to the authored storey height expresses the + * same storey-top intent, even when the resolved plane is clamped or lifted. + * Other explicit walls stand on their elected support; ground-hosted walls + * always retain their authored body height. * * Returns the top in level-local Y (same frame as `electedBase`). */ @@ -23,9 +37,10 @@ export function resolveWallTop( wall: Pick, storeyHeight: number, electedBase: number, + authoredStoreyHeight: number | null = storeyHeight, ): number { - if (wall.height == null) return storeyHeight - return electedBase + wall.height + if (wallFollowsStorey(wall, authoredStoreyHeight)) return storeyHeight + return electedBase + wall.height! } /** @@ -46,6 +61,7 @@ export function resolveWallEffectiveHeight( wall: Pick, storeyHeight: number, electedBase: number, + authoredStoreyHeight: number | null = storeyHeight, ): number { - return resolveWallTop(wall, storeyHeight, electedBase) - electedBase + return resolveWallTop(wall, storeyHeight, electedBase, authoredStoreyHeight) - electedBase } diff --git a/packages/core/src/systems/wall/wall-topology.ts b/packages/core/src/systems/wall/wall-topology.ts index 2688583a10..a15ff16c6a 100644 --- a/packages/core/src/systems/wall/wall-topology.ts +++ b/packages/core/src/systems/wall/wall-topology.ts @@ -424,7 +424,7 @@ function remapAttachment( } as Partial } -function splitWall( +export function splitWall( wall: WallNode, splitParameters: number[], nodes: Record, diff --git a/packages/core/src/utils/floor-plate-migration.ts b/packages/core/src/utils/floor-plate-migration.ts index 29e615d4e0..36e0d50b2c 100644 --- a/packages/core/src/utils/floor-plate-migration.ts +++ b/packages/core/src/utils/floor-plate-migration.ts @@ -31,7 +31,6 @@ import { getStoredLevelHeight } from '../services/storey' import { wallOverlapsSlabFootprint } from '../systems/slab/slab-support' import { getWallCurveLength } from '../systems/wall/wall-curve' import { calculateLevelMiters, getWallPlanFootprint } from '../systems/wall/wall-footprint' -import { resolveWallTop } from '../systems/wall/wall-top' import { type FloorPieceAdoption, floorPieceFinish, @@ -1464,7 +1463,9 @@ function runFloorPlateMigration( ? getStoredLevelHeight(level) : (wall.supportSlabId === 'ground' ? old.elevation : Math.max(0, old.elevation)) + wall.height - const newTop = resolveWallTop(wall, getStoredLevelHeight(level), next.elevation) + // Preserve legacy body heights rather than reclassify them against the storey. + const newTop = + wall.height === undefined ? getStoredLevelHeight(level) : next.elevation + wall.height if (wall.height !== undefined && Math.abs(oldTop - newTop) > 0.001) nodes[wall.id] = { ...wall, height: oldTop - next.elevation } for (const id of wall.children ?? []) { diff --git a/packages/core/src/utils/legacy-wall-datums.ts b/packages/core/src/utils/legacy-wall-datums.ts index d8f1b3509a..96b441c53e 100644 --- a/packages/core/src/utils/legacy-wall-datums.ts +++ b/packages/core/src/utils/legacy-wall-datums.ts @@ -9,7 +9,6 @@ import { levelBaseElevationAt } from '../lib/terrain-support-query' import type { AnyNode, SlabNode, WallNode } from '../schema' import { findLevelBelowId, getAuthoredLevelElevations, getWallPlaneTops } from '../services/storey' import { computeWallSlabSupport, type WallSlabSupport } from '../systems/slab/slab-support' -import { resolveWallTop } from '../systems/wall/wall-top' const legacySupports = new Map>() @@ -197,7 +196,9 @@ export function preserveLegacyWallDatums( ? oldPlanes.get(wall.id)! : (wall.supportSlabId === 'ground' ? old.elevation : Math.max(0, old.elevation)) + wall.height - const newTop = resolveWallTop(current, newPlanes.get(current.id) ?? 0, next.elevation) + // Explicit legacy heights remain body-relative; only absent heights have a computed plane. + const newTop = + current.height === undefined ? newPlanes.get(current.id)! : next.elevation + current.height const patch: Partial = {} if (Math.abs(oldTop - newTop) > 1e-6) patch.height = oldTop - next.elevation const delta = old.elevation - next.elevation diff --git a/packages/core/src/utils/semantic-equal.ts b/packages/core/src/utils/semantic-equal.ts new file mode 100644 index 0000000000..36e2ecb1a2 --- /dev/null +++ b/packages/core/src/utils/semantic-equal.ts @@ -0,0 +1,25 @@ +/** Structural equality for serialized scene values; object key order is irrelevant. */ +export function areSemanticValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true + if (typeof left !== typeof right || left === null || right === null) return false + + if (Array.isArray(left) || Array.isArray(right)) { + if (!(Array.isArray(left) && Array.isArray(right)) || left.length !== right.length) return false + return left.every((value, index) => areSemanticValuesEqual(value, right[index])) + } + + if (typeof left !== 'object' || typeof right !== 'object') return false + + const leftRecord = left as Record + const rightRecord = right as Record + const leftKeys = Object.keys(leftRecord) + const rightKeys = Object.keys(rightRecord) + if (leftKeys.length !== rightKeys.length) return false + + for (const key of leftKeys) { + if (!(key in rightRecord) || !areSemanticValuesEqual(leftRecord[key], rightRecord[key])) { + return false + } + } + return true +} diff --git a/packages/core/src/utils/vertical-scene-migration.test.ts b/packages/core/src/utils/vertical-scene-migration.test.ts index 7b523112cc..56273d4734 100644 --- a/packages/core/src/utils/vertical-scene-migration.test.ts +++ b/packages/core/src/utils/vertical-scene-migration.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from 'bun:test' +import { readdirSync, readFileSync } from 'node:fs' +import { computeWallSlabSupport } from '../systems/slab/slab-support' +import { resolveWallTop } from '../systems/wall/wall-top' +import { healSceneNodes, normalizeLegacyStructure } from './scene-migrations' import { migrateVerticalSceneNodes } from './vertical-scene-migration' type RawNode = Record @@ -98,3 +102,85 @@ describe('ground-pin heal', () => { expect(second.changed).toBe(false) }) }) + +// Scenes saved before levels stored a height, from the plate corpus. +const PLATE_CORPUS = new URL('../lib/__fixtures__/plate-corpus/', import.meta.url) +const legacyCorpus = ['legacy-load', 'frozen-gate', 'review', 'migrations'] + .flatMap((folder) => + readdirSync(new URL(`${folder}/`, PLATE_CORPUS)) + .filter((file) => file.endsWith('.json')) + .sort() + .map((file) => { + const fixture = JSON.parse(readFileSync(new URL(`${folder}/${file}`, PLATE_CORPUS), 'utf8')) + const stored = (fixture.nodes ?? fixture) as Record + return { + scene: `${folder}/${file}`, + nodes: healSceneNodes(normalizeLegacyStructure(stored)).nodes, + } + }), + ) + .filter(({ nodes }) => + Object.values(nodes).some( + (node) => (node as RawNode).type === 'level' && !('height' in (node as RawNode)), + ), + ) + +type LegacyNode = Record + +/** + * How the editor drew a level before levels stored a height: a wall stood on + * its slab when the slab was above the floor and rose by its height, 2.5 m + * when none was stored; the storey was as tall as its tallest wall top or + * ceiling (2.5 m when the ceiling stored none). + */ +function legacyStorey(levelId: string, nodes: Record) { + const children = ((nodes[levelId]!.children ?? []) as string[]) + .map((id) => nodes[id]) + .filter((child): child is LegacyNode => child !== undefined) + const slabs = children.filter((child) => child.type === 'slab') + const walls = children.filter((child) => child.type === 'wall') + const wallTops = new Map() + for (const wall of walls) { + const base = computeWallSlabSupport(wall as never, slabs as never, walls as never).elevation + wallTops.set(wall.id, Math.max(0, base) + (wall.height ?? 2.5)) + } + const ceilings = children.filter((child) => child.type === 'ceiling').map((c) => c.height ?? 2.5) + const tallest = Math.max(0, ...wallTops.values(), ...ceilings) + return { height: tallest > 0 ? tallest : 2.5, wallTops } +} + +describe('legacy corpus loads as the editor drew it', () => { + test('the corpus holds scenes saved before levels stored a height', () => { + expect(legacyCorpus.length).toBeGreaterThan(0) + }) + + test.each(legacyCorpus)('$scene: storeys keep their height and no wall rises above its storey', ({ + nodes, + }) => { + const migrated = migrateVerticalSceneNodes(nodes).nodes as Record + for (const [levelId, level] of Object.entries(nodes as Record)) { + if (level?.type !== 'level' || 'height' in level) continue + const before = legacyStorey(levelId, nodes as Record) + const plane = migrated[levelId]!.height as number + const rounded = (value: number) => Math.round(value * 1e6) / 1e6 + expect({ levelId, height: rounded(plane) }).toEqual({ + levelId, + height: rounded(before.height), + }) + const slabs = (migrated[levelId]!.children as string[]) + .map((id) => migrated[id]) + .filter((child): child is LegacyNode => child?.type === 'slab') + const walls = (migrated[levelId]!.children as string[]) + .map((id) => migrated[id]) + .filter((child): child is LegacyNode => child?.type === 'wall') + for (const wall of walls) { + const base = computeWallSlabSupport(wall as never, slabs as never, walls as never).elevation + const top = resolveWallTop(wall as never, plane, base) + // Never above the storey it belongs to (it would cut the floor or + // roof above), and moved only by the snap onto the storey plane. + expect(top).toBeLessThanOrEqual(plane + 1e-9) + expect(Math.abs(top - before.wallTops.get(wall.id)!)).toBeLessThan(0.2) + } + } + }) +}) diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index f3f36eab40..28c7d11d90 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -25,6 +25,7 @@ import { clientToPlan } from '../../lib/floorplan/plan-coords' import { isHistoryShortcut } from '../../lib/history' import { formatLinearMeasurement } from '../../lib/measurements' import { sfxEmitter } from '../../lib/sfx-bus' +import { isTypingTarget } from '../../lib/typing-target' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { isAlignmentGuideActive, @@ -368,6 +369,7 @@ export function startFloorplanGroupMove( // Escape arm (registered earlier on window in the bubble phase), which // would otherwise clear the multi-selection mid-cancel. const onKeyDown = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return const key = e.key.toLowerCase() if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { // Armed but still under the drag threshold: engage first (exactly what @@ -569,6 +571,7 @@ export function startFloorplanGroupRotate(event: { // Capture phase so Escape wins over the global `use-keyboard` arm. const onKeyDown = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return if (e.key === 'Delete' || e.key === 'Backspace') { // Deleting mid-rotate: revert first, then let the global Delete arm run. cancel() diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 340154b37b..ef1de406d7 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -39,6 +39,7 @@ import { import { movementSfxStepKey } from '../../lib/sfx/movement-tick' import { sfxEmitter } from '../../lib/sfx-bus' import { surfaceAttachmentId } from '../../lib/surface-attachment' +import { isTypingTarget } from '../../lib/typing-target' import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { @@ -497,6 +498,7 @@ export function FloorplanRegistryMoveOverlay() { } const onKey = (event: KeyboardEvent) => { + if (isTypingTarget(event.target)) return // R flips a directional kind's facing mid-placement (door / window: // front ↔ back). The session records the flip and re-runs its last // apply so the 2D symbol updates immediately; kinds without a facing @@ -516,10 +518,6 @@ export function FloorplanRegistryMoveOverlay() { if (event.metaKey || event.ctrlKey) return if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return if (event.repeat) return - const t = event.target as HTMLElement | null - if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) { - return - } event.preventDefault() event.stopImmediatePropagation() ownWrite(() => session.flipSide?.()) @@ -964,6 +962,7 @@ export function FloorplanRegistryMoveOverlay() { } const onKey = (event: KeyboardEvent) => { + if (isTypingTarget(event.target)) return if (event.key !== 'Escape' && !isHistoryShortcut(event)) return if (isHistoryShortcut(event)) { // ⌘Z mid-move cancels like Escape — keep it from reaching the diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 8395e24099..4ceda9a676 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -29,7 +29,14 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { beginPerfAction, cancelPerfAction, commitPerfAction, useViewer } from '@pascal-app/viewer' +import { + beginPerfAction, + cancelPerfAction, + commitPerfAction, + useRevealPlanActive, + useRevealWaiting, + useViewer, +} from '@pascal-app/viewer' import { type ComponentProps, memo, @@ -151,6 +158,7 @@ import { resolveFloorplanLabelAngle, updateSvgFloorplanLabelOrientations, } from './floorplan-label-angle' +import { useFloorplanReveal } from './floorplan-reveal' /** * Registry-driven floor-plan layer. @@ -2027,6 +2035,10 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ wallDimensionReference, visibilityRootId, }: FloorplanRegistryEntryProps): React.ReactElement | null { + // A node an agent just built is drawn on its construction-reveal turn, as in 3D. + const revealWaiting = useRevealWaiting(nodeId) + const revealActive = useRevealPlanActive(nodeId) + const entryRef = useRef(null) const selected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const highlighted = useViewer((state) => state.previewSelectedIds.includes(nodeId)) const multiSelected = useViewer( @@ -2215,7 +2227,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ visibleGeometry && suppressHandles && pass !== 'base' ? stripHandleChrome(visibleGeometry) : visibleGeometry - if (!geometry) return null + useFloorplanReveal(nodeId, entryRef, cacheEntry, revealWaiting, revealActive) + if (revealWaiting || !geometry) return null const entryClick = isMarqueeSelectionActive ? undefined : onClickStop const entryPointerDown = isMarqueeSelectionActive ? undefined : handlePointerDown @@ -2224,6 +2237,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ FloorplanGeometry, +): { schema: typeof BaseNode; definition: AnyNodeDefinition } { + const schema = BaseNode.extend({ + id: objectId(prefix), + type: nodeType(kind), + children: LevelNode.shape.children, + }) + const definition = { + kind, + schemaVersion: 1, + schema, + category: 'structure', + defaults: () => ({ ...base, children: [] }), + capabilities: { reveal }, + floorplan, + } + return { + schema: schema as unknown as typeof BaseNode, + definition: definition as unknown as AnyNodeDefinition, + } +} + +const WALL_LINE = { x1: 0, y1: 0, x2: 4, y2: 0 } +const Wall = planKind( + 'fixture:plan-wall', + 'fxpwall', + { phase: 'structure', style: 'rise' }, + () => ({ + kind: 'group', + children: [ + { + kind: 'polygon', + points: [ + [0, -0.1], + [4, -0.1], + [4, 0.1], + [0, 0.1], + ], + fill: '#222', + }, + { kind: 'hit-line', ...WALL_LINE, strokeWidthPx: 18 }, + ], + }), +) +const Crate = planKind( + 'fixture:plan-crate', + 'fxpcrate', + { phase: 'furnishing', style: 'drop', height: 0.3 }, + () => ({ kind: 'rect', x: 1, y: 1, width: 0.5, height: 0.5, fill: '#888' }), +) + +const make = (kind: { schema: typeof BaseNode }) => kind.schema.parse({}) as unknown as AnyNode + +let driver: ConstructionRevealDriver | null = null +let restoreRegistry: () => void = () => {} +const savedScene = useScene.getState() +const savedViewer = useViewer.getState() +const savedEditor = useEditor.getState() + +function plan(): string { + // Server rendering reads Zustand's startup state; render the current scene instead. + const useSyncExternalStore = React.useSyncExternalStore + const serverSnapshot = spyOn(React, 'useSyncExternalStore').mockImplementation( + (subscribe, getSnapshot) => useSyncExternalStore(subscribe, getSnapshot, getSnapshot), + ) + try { + return renderToStaticMarkup( + + + , + ) + } finally { + serverSnapshot.mockRestore() + } +} + +function openLevel(): AnyNodeId { + const level = LevelNode.parse({ level: 0, children: [] }) + useScene.getState().setScene({ [level.id]: level as AnyNode }, [level.id as AnyNodeId], { + installedPlugins: [PLUGIN_ID], + hasExplicitPluginInstallState: true, + }) + clearSceneHistory() + useViewer.setState({ + selection: { ...useViewer.getState().selection, levelId: level.id, selectedIds: [] }, + } as never) + useEditor.setState({ viewMode: '2d' }) + driver = startConstructionReveal({ + reveals: (commit) => commit.author === 'agent', + prefersReducedMotion: () => false, + }) + return level.id as AnyNodeId +} + +beforeEach(async () => { + restoreRegistry = nodeRegistry._snapshot() + nodeRegistry._reset() + await loadPlugin({ id: PLUGIN_ID, apiVersion: 1, nodes: [Wall.definition, Crate.definition] }) +}) + +afterEach(() => { + driver?.stop() + driver = null + restoreRegistry() + useScene.setState(savedScene) + useViewer.setState(savedViewer) + useEditor.setState(savedEditor) +}) + +describe('the plan during a construction reveal', () => { + test('a node waiting for its turn is not drawn; it is once its turn comes', () => { + const levelId = openLevel() + const wall = make(Wall) + const crate = make(Crate) + runAsSceneCommitAuthor('agent', () => + useScene.getState().createNodes([ + { node: wall, parentId: levelId }, + { node: crate, parentId: levelId }, + ]), + ) + let markup = plan() + expect(markup).not.toContain(`data-node-id="${wall.id}"`) + expect(markup).not.toContain(`data-node-id="${crate.id}"`) + + driver!.tick(0) + expect(getRevealPlanState(wall.id)).not.toBeNull() + // Nothing draws in a test with no canvas, so the build does not wait for a first draw to settle. + let now = 0 + while (getRevealPlanState(wall.id)?.progress === 0 && now < 3000) { + now += 20 + driver!.tick(now) + } + markup = plan() + expect(markup).toContain(`data-node-id="${wall.id}"`) + expect(markup).not.toContain(`data-node-id="${crate.id}"`) + + while (getRevealPlanState(crate.id)?.progress === 0 && now < 6000) { + now += 20 + driver!.tick(now) + } + expect(plan()).toContain(`data-node-id="${crate.id}"`) + }) + + test("a person's own edit is drawn at once", () => { + const levelId = openLevel() + const wall = make(Wall) + useScene.getState().createNode(wall, levelId) + expect(plan()).toContain(`data-node-id="${wall.id}"`) + }) +}) + +describe('how a revealing entry draws', () => { + test('a wall grows along its centreline from its start', () => { + const axis = floorplanRevealAxis([Wall.definition.floorplan!(make(Wall), {} as never)]) + expect(axis).toEqual(WALL_LINE) + + const start = floorplanRevealLook({ style: 'rise', progress: 0 }, axis) + const middle = floorplanRevealLook({ style: 'rise', progress: 0.5 }, axis) + const end = floorplanRevealLook({ style: 'rise', progress: 1 }, axis) + expect(start.opacity).toBe(1) + expect(start.clip!.width).toBeLessThan(middle.clip!.width) + expect(middle.clip!.width).toBeLessThan(end.clip!.width) + // Grown, it covers the whole wall and its mitred corners. + expect(start.clip!.x + end.clip!.width).toBeGreaterThan(WALL_LINE.x2) + expect(start.clip!.x).toBeLessThan(WALL_LINE.x1) + expect(end.clip!.rotateDeg).toBe(0) + expect( + floorplanRevealLook({ style: 'rise', progress: 0.5 }, { x1: 0, y1: 0, x2: 0, y2: 3 }).clip! + .rotateDeg, + ).toBe(90) + }) + + test("an undo's reverse play is the same look read backwards: a wall's clip shrinks, the rest fades out", () => { + const axis = floorplanRevealAxis([Wall.definition.floorplan!(make(Wall), {} as never)]) + const whole = floorplanRevealLook({ style: 'rise', progress: 1 }, axis) + const half = floorplanRevealLook({ style: 'rise', progress: 0.5 }, axis) + const gone = floorplanRevealLook({ style: 'rise', progress: 0 }, axis) + expect(whole.clip!.width).toBeGreaterThan(half.clip!.width) + expect(half.clip!.width).toBeGreaterThan(gone.clip!.width) + // Gone, the clip covers nothing of the wall (only the pad before its start). + expect(gone.clip!.width).toBeLessThanOrEqual(1) + expect(floorplanRevealLook({ style: 'drop', progress: 1 }, null).opacity).toBe(1) + expect(floorplanRevealLook({ style: 'drop', progress: 0.4 }, null).opacity).toBeLessThan(1) + expect(floorplanRevealLook({ style: 'drop', progress: 0 }, null).opacity).toBe(0) + }) + + test('everything else fades in', () => { + for (const style of ['drop', 'settle', 'cut', 'scale', 'assemble'] as const) { + const faint = floorplanRevealLook({ style, progress: 0.1 }, null) + const strong = floorplanRevealLook({ style, progress: 0.8 }, null) + expect(faint.clip).toBeNull() + expect(faint.opacity).toBeLessThan(strong.opacity) + expect(floorplanRevealLook({ style, progress: 1 }, null).opacity).toBe(1) + } + // A rising kind with no centreline fades too. + expect(floorplanRevealLook({ style: 'rise', progress: 0.1 }, null).clip).toBeNull() + }) + + test('only a centreline outside any transformed group is an axis', () => { + expect(floorplanRevealAxis([{ kind: 'rect', x: 0, y: 0, width: 1, height: 1 }])).toBeNull() + expect( + floorplanRevealAxis([ + { + kind: 'group', + transform: { rotate: 1 }, + children: [{ kind: 'hit-line', ...WALL_LINE, strokeWidthPx: 18 }], + }, + ]), + ).toBeNull() + expect( + floorplanRevealAxis([{ kind: 'hit-line', x1: 1, y1: 1, x2: 1, y2: 1, strokeWidthPx: 18 }]), + ).toBeNull() + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-reveal.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-reveal.ts new file mode 100644 index 0000000000..9528033b72 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-reveal.ts @@ -0,0 +1,141 @@ +'use client' + +import type { FloorplanGeometry } from '@pascal-app/core' +import { getRevealPlanState, type RevealPlanState, subscribeRevealTicks } from '@pascal-app/viewer' +import { type RefObject, useEffect } from 'react' + +/** A node's plan centreline, from its start: what a growing reveal follows. */ +export type FloorplanRevealAxis = { x1: number; y1: number; x2: number; y2: number } + +/** An entry's plan geometry, by pass. */ +export type FloorplanRevealEntry = { + base: FloorplanGeometry | null + overlay: FloorplanGeometry | null + handles: FloorplanGeometry | null +} + +/** How a revealing entry draws: its opacity, and for a growing one the clip that uncovers it. */ +export type FloorplanRevealLook = { + opacity: number + clip: { x: number; y: number; width: number; height: number; rotateDeg: number } | null +} + +/** The clip reaches this far before the start and past the end, so mitred corners show whole. */ +const CLIP_PAD_M = 1 +/** And this far to either side of the centreline: the wall, its layers and its labels. */ +const CLIP_HALF_WIDTH_M = 50 + +const easeOutCubic = (t: number) => 1 - (1 - t) ** 3 + +/** + * The centreline a linear kind (a wall, a fence) draws as its `hit-line`, if + * its plan geometry has one outside any transformed group. + */ +export function floorplanRevealAxis( + geometries: readonly (FloorplanGeometry | null | undefined)[], +): FloorplanRevealAxis | null { + const visit = (geometry: FloorplanGeometry): FloorplanRevealAxis | null => { + if (geometry.kind === 'hit-line') { + const { x1, y1, x2, y2 } = geometry + return Math.hypot(x2 - x1, y2 - y1) > 1e-6 ? { x1, y1, x2, y2 } : null + } + if (geometry.kind !== 'group' || geometry.transform) return null + for (const child of geometry.children) { + const axis = visit(child) + if (axis) return axis + } + return null + } + for (const geometry of geometries) { + const axis = geometry ? visit(geometry) : null + if (axis) return axis + } + return null +} + +/** + * The plan's twin of a 3D reveal: a node that rises in 3D and has a + * centreline grows along it from its start; everything else fades in. An undo's + * reverse play reads the same look backwards: a wall's clip shrinks toward its + * start, everything else fades out. + */ +export function floorplanRevealLook( + state: RevealPlanState, + axis: FloorplanRevealAxis | null, +): FloorplanRevealLook { + const eased = easeOutCubic(Math.min(1, Math.max(0, state.progress))) + if (state.style !== 'rise' || !axis) return { opacity: eased, clip: null } + const length = Math.hypot(axis.x2 - axis.x1, axis.y2 - axis.y1) + return { + opacity: 1, + clip: { + x: axis.x1 - CLIP_PAD_M, + y: axis.y1 - CLIP_HALF_WIDTH_M, + width: CLIP_PAD_M + eased * (length + CLIP_PAD_M), + height: CLIP_HALF_WIDTH_M * 2, + rotateDeg: (Math.atan2(axis.y2 - axis.y1, axis.x2 - axis.x1) * 180) / Math.PI, + }, + } +} + +const SVG_NS = 'http://www.w3.org/2000/svg' +let clipIds = 0 + +/** + * Plays a node's construction reveal on its plan entry: while the reveal + * runs, each tick sets the entry group's opacity or its growing clip, then + * clears them when the node is done. Imperative, so a reveal re-renders no + * React tree; an entry with nothing revealing subscribes to nothing. + */ +export function useFloorplanReveal( + nodeId: string, + groupRef: RefObject, + entry: FloorplanRevealEntry | null, + waiting: boolean, + /** The reveal has something to play for the node (it moves in, or an undo takes it back): the effect runs again. */ + active = false, +) { + useEffect(() => { + const group = groupRef.current + if (waiting || !group || !getRevealPlanState(nodeId)) return + const axis = entry ? floorplanRevealAxis([entry.base, entry.overlay, entry.handles]) : null + let clip: SVGClipPathElement | null = null + let rect: SVGRectElement | null = null + let stop = () => {} + const clear = () => { + stop() + group.removeAttribute('opacity') + group.removeAttribute('clip-path') + clip?.remove() + clip = null + } + const apply = () => { + const state = getRevealPlanState(nodeId) + if (!state) return clear() + const look = floorplanRevealLook(state, axis) + group.setAttribute('opacity', String(look.opacity)) + if (!look.clip) return + if (!(clip && rect)) { + clipIds += 1 + const id = `floorplan-reveal-${clipIds}` + clip = document.createElementNS(SVG_NS, 'clipPath') + clip.setAttribute('id', id) + clip.setAttribute('clipPathUnits', 'userSpaceOnUse') + rect = document.createElementNS(SVG_NS, 'rect') + clip.appendChild(rect) + group.appendChild(clip) + group.setAttribute('clip-path', `url(#${id})`) + } + const { x, y, width, height, rotateDeg } = look.clip + rect.setAttribute('x', String(x)) + rect.setAttribute('y', String(y)) + rect.setAttribute('width', String(width)) + rect.setAttribute('height', String(height)) + rect.setAttribute('transform', `rotate(${rotateDeg} ${axis?.x1 ?? 0} ${axis?.y1 ?? 0})`) + } + stop = subscribeRevealTicks(apply) + apply() + return clear + // biome-ignore lint/correctness/useExhaustiveDependencies: `active` only says the reveal's state may have changed + }, [nodeId, groupRef, entry, waiting, active]) +} diff --git a/packages/editor/src/components/editor/construction-reveal-audio.test.tsx b/packages/editor/src/components/editor/construction-reveal-audio.test.tsx new file mode 100644 index 0000000000..35d3c3b0a9 --- /dev/null +++ b/packages/editor/src/components/editor/construction-reveal-audio.test.tsx @@ -0,0 +1,150 @@ +import { expect, spyOn, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + BaseNode, + LevelNode, + nodeRegistry, + objectId, + registerNode, + runAsSceneCommitAuthor, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + type RevealGroupEvent, + startConstructionReveal, + subscribeRevealEvents, + useViewer, +} from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import { BoxGeometry, Mesh, MeshBasicMaterial } from 'three' +import { sfxEmitter } from '../../lib/sfx-bus' +import { ConstructionRevealAudio } from './construction-reveal-audio' + +test('real reveal landings respect audio controls and the shared cue clock', async () => { + const restore = nodeRegistry._snapshot() + const savedScene = useScene.getState() + const savedHistory = useScene.temporal.getState() + const savedViewer = useViewer.getState() + const savedObjects = new Map(sceneRegistry.nodes) + const actDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT') + const rafDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'requestAnimationFrame') + const cancelDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cancelAnimationFrame') + Object.defineProperties(globalThis, { + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + requestAnimationFrame: { configurable: true, value: () => 0 }, + cancelAnimationFrame: { configurable: true, value: () => {} }, + }) + let audioClock = 0 + const clock = spyOn(performance, 'now').mockImplementation(() => audioClock) + const cues: string[] = [] + const observe = (name: string) => cues.push(name) + sfxEmitter.on('*', observe) + const events: RevealGroupEvent[] = [] + const stopObserve = subscribeRevealEvents((event) => { + if (event.type === 'land') events.push(event) + }) + let instant = false + let time = 0 + let index = 0 + const meshes: Mesh[] = [] + const schema = BaseNode.extend({ id: objectId('audio-smoke') }) + for (const [kind, phase] of [ + ['audio:structure', 'structure'], + ['audio:furnishing', 'furnishing'], + ] as const) { + registerNode({ + kind, + schemaVersion: 1, + schema, + category: 'utility', + defaults: () => ({}), + capabilities: { reveal: { phase, style: 'scale' } }, + } as unknown as AnyNodeDefinition) + } + const level = LevelNode.parse({ id: 'level_audio_smoke' }) + useScene.setState({ nodes: { [level.id]: level }, rootNodeIds: [level.id], readOnly: false }) + useViewer.setState({ renderContext: 'editor', isExporting: false }) + useViewer + .getState() + .setSelection({ levelId: level.id, buildingId: null, selectedIds: [], zoneId: null }) + const driver = startConstructionReveal({ + reveals: (commit) => commit.author === 'agent', + level: () => 'full', + prefersReducedMotion: () => instant, + }) + const renderer = await create() + async function land(kind = 'audio:structure') { + const id = `audio-smoke_${++index}` + const node = { ...schema.parse({ id }), type: kind, parentId: level.id } as unknown as AnyNode + const mesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial()) + meshes.push(mesh) + sceneRegistry.nodes.set(node.id, mesh) + const before = events.length + await act(async () => + runAsSceneCommitAuthor('agent', () => useScene.getState().createNode(node, level.id)), + ) + // A real CPU mesh supplies the renderer's ready handshake, not drawn pixels. + useScene.getState().clearDirty(node.id) + for (let frame = 0; frame < 150; frame++) { + time += 20 + await act(async () => driver.tick(time, 20)) + } + expect(events.length).toBeGreaterThan(before) + expect(Boolean(events.at(-1)?.instant)).toBe(instant) + } + try { + await land() + expect(cues).toEqual([]) + await renderer.update() + await land() + expect(cues).toEqual([]) + await renderer.update() + await land() + expect(cues).toEqual([]) + await renderer.update() + await land() + expect(cues).toEqual(['sfx:structure-build']) + await renderer.update() + await renderer.update() + audioClock = 249 + await land() + expect(cues).toEqual(['sfx:structure-build']) + audioClock = 250 + await land('audio:furnishing') + expect(cues).toEqual(['sfx:structure-build', 'sfx:item-place']) + audioClock = 1000 + instant = true + await land() + expect(cues).toHaveLength(2) + await renderer.unmount() + instant = false + await land() + expect(cues).toHaveLength(2) + } finally { + driver.stop() + await renderer.unmount() + stopObserve() + sfxEmitter.off('*', observe) + clock.mockRestore() + restore() + useScene.setState(savedScene, true) + useScene.temporal.setState(savedHistory, true) + useViewer.setState(savedViewer, true) + sceneRegistry.nodes.clear() + for (const [id, mesh] of savedObjects) sceneRegistry.nodes.set(id, mesh) + for (const mesh of meshes) { + mesh.geometry.dispose() + ;(mesh.material as MeshBasicMaterial).dispose() + } + for (const [name, descriptor] of [ + ['IS_REACT_ACT_ENVIRONMENT', actDescriptor], + ['requestAnimationFrame', rafDescriptor], + ['cancelAnimationFrame', cancelDescriptor], + ] as const) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else Reflect.deleteProperty(globalThis, name) + } + } +}) diff --git a/packages/editor/src/components/editor/construction-reveal-audio.tsx b/packages/editor/src/components/editor/construction-reveal-audio.tsx new file mode 100644 index 0000000000..525adefd3c --- /dev/null +++ b/packages/editor/src/components/editor/construction-reveal-audio.tsx @@ -0,0 +1,40 @@ +'use client' + +import type { RevealPhase } from '@pascal-app/core' +import { type ConstructionRevealLevel, subscribeRevealEvents } from '@pascal-app/viewer' +import { useEffect, useRef } from 'react' +import { triggerSFX } from '../../lib/sfx-bus' + +const REVEAL_PHASE_CUES = { + foundation: 'sfx:structure-build', + structure: 'sfx:structure-build', + circulation: 'sfx:structure-build', + openings: 'sfx:structure-build', + roof: 'sfx:structure-build', + furnishing: 'sfx:item-place', +} as const satisfies Record + +/** Mount once alongside ConstructionReveal; its driver owns the commit policy. */ +export function ConstructionRevealAudio({ + enabled, + level, +}: { + enabled: boolean + level: ConstructionRevealLevel +}) { + // Prop changes and Strict Effects must not reset the shared phase/level gate. + const lastPlayedMs = useRef(Number.NEGATIVE_INFINITY) + + useEffect(() => { + if (!enabled || (level !== 'full' && level !== 'framing')) return + return subscribeRevealEvents((event) => { + if (event.type !== 'land' || event.instant) return + const nowMs = performance.now() + if (nowMs - lastPlayedMs.current < 250) return + lastPlayedMs.current = nowMs + triggerSFX(REVEAL_PHASE_CUES[event.phase]) + }) + }, [enabled, level]) + + return null +} diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 29c87378a3..0813b6fa38 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -97,7 +97,13 @@ import { type FirstPersonColliderWorld, type FirstPersonSpawn, } from './first-person/build-collider-world' -import { mechanismHudInteract, mechanismTargetIds } from './first-person/mechanism-targets' +import { + hasNativeLeaf, + mechanismHudInteract, + mechanismTargetIds, + nativeOpeningIds, + watchScriptedDoors, +} from './first-person/mechanism-targets' const CAMERA_EYE_OFFSET = 0.45 const LOOK_SENSITIVITY = 0.002 @@ -838,6 +844,7 @@ export const FirstPersonControls = () => { const node = nodes[doorId as AnyNodeId] if (node?.type !== 'door') continue if (node.openingKind === 'opening') continue + if (!hasNativeLeaf(node)) continue if (node.segments.every((segment) => segment.type === 'empty')) continue const object = sceneRegistry.nodes.get(doorId) @@ -936,6 +943,7 @@ export const FirstPersonControls = () => { const node = nodes[windowId as AnyNodeId] if (node?.type !== 'window') continue if (node.openingKind === 'opening') continue + if (!hasNativeLeaf(node)) continue if (!isOperableWindowType(node.windowType)) continue const object = sceneRegistry.nodes.get(windowId) @@ -1082,9 +1090,9 @@ export const FirstPersonControls = () => { closestDistance = hit.distance } } - // Doors and windows have their own walkthrough targets (resolveInteractable*Id). - for (const rawId of sceneRegistry.byType.door ?? []) covered.add(rawId) - for (const rawId of sceneRegistry.byType.window ?? []) covered.add(rawId) + // Doors and windows have their own walkthrough targets (resolveInteractable*Id), except those + // built from a script: their clips run through the mechanism target below. + for (const id of nativeOpeningIds()) covered.add(id) for (const id of mechanismTargetIds(covered)) { const object = sceneRegistry.nodes.get(id) const hit = object && proceduralInteractionRaycaster.intersectObject(object, true)[0] @@ -1298,9 +1306,12 @@ export const FirstPersonControls = () => { if (isDroneMode) return emitter.on('door:animation-completed', rebuildColliderWorld) emitter.on('window:animation-completed', rebuildColliderWorld) + // A door built from a script opens by its model's clip, which reports no animation end. + const stopWatching = watchScriptedDoors(rebuildColliderWorld) return () => { emitter.off('door:animation-completed', rebuildColliderWorld) emitter.off('window:animation-completed', rebuildColliderWorld) + stopWatching() } }, [isDroneMode, rebuildColliderWorld]) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index 82e4deaffc..20fb7f5433 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -7,9 +7,11 @@ import { CeilingNode, ColumnNode, createTerrainField, + DoorNode, ElevatorNode, encodeTerrainField, flattenPatch, + GeometryArtifactManifest, LevelNode, nodeRegistry, registerNode, @@ -17,6 +19,7 @@ import { SiteNode, SlabNode, sceneRegistry, + useInteractive, useScene, } from '@pascal-app/core' import { hideFromScene, STAND_CLEARANCE, showInScene } from '@pascal-app/viewer' @@ -325,3 +328,109 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { world.dispose() }) }) + +// A door built from a script is drawn by its model: the leaves are the model's own and its `open` +// clip moves them. What the player is stopped by is a stand-in leaf box at the door's frame, and +// that box has to follow the door's own open state, not the native `operationState` the clip +// never touches. +describe('a door built from a script', () => { + const source = { + kind: 'script' as const, + script: 'b'.repeat(64), + artifact: 'a'.repeat(64), + manifest: GeometryArtifactManifest.parse({ + bounds: { min: [-0.6, 0, -0.11], max: [0.6, 2.55, 0.11] }, + triangles: 12, + animations: [{ name: 'open', duration: 1.3 }], + }), + } + // The door mechanism's open state for a script: the first control toggle of the item. + const mechanism = { + verb: 'open', + icon: 'door', + has: (node: AnyNode) => Boolean((node as { source?: unknown }).source), + isOn: (node: AnyNode, state: ReturnType) => + Boolean(state.items[node.id as never]?.controlValues[0]), + set: () => {}, + } + + function mountScriptedDoor(language: 'three' | 'bpy' = 'three') { + registerNode({ + kind: 'door', + schema: DoorNode, + schemaVersion: 1, + category: 'structure', + capabilities: { mechanism }, + } as unknown as AnyNodeDefinition) + registerColliderDefinition('column', ColumnNode, 'structure') + const door = DoorNode.parse({ + id: 'door_shoji', + doorType: 'sliding', + width: 1.2, + height: 2.55, + source: { ...source, language }, + }) + const column = ColumnNode.parse({ id: 'column_far' }) + setSceneNodes([door, column]) + mountNode(column, [1, 2, 1], [10, 1, 0]) + // The door's registered group, as its renderer builds it: the compiled model under it, with + // the agent's 1 m deep `cutout` box and two leaves. + const group = new Group() + group.position.set(0, 1.275, 0) + const box = (name: string, size: [number, number, number], x: number, z: number) => { + const mesh = new Mesh(new BoxGeometry(...size), new MeshBasicMaterial()) + mesh.name = name + mesh.position.set(x, 0, z) + group.add(mesh) + } + box('cutout', [1.2, 2.55, 1], 0, 0) + box('leaf_0', [0.5, 2.4, 0.02], -0.255, 0.025) + box('leaf_1', [0.5, 2.4, 0.02], 0.255, -0.025) + group.updateMatrixWorld(true) + sceneRegistry.nodes.set(door.id, group) + sceneRegistry.byType.door!.add(door.id) + return door + } + + /** Walk straight at the doorway from the front, at hip height: where the first thing is hit. */ + function firstHitAcrossDoorway() { + const world = buildFirstPersonColliderWorldFromRegistry() + if (!world) return null + const raycaster = new Raycaster(new Vector3(0, 1, -3), new Vector3(0, 0, 1)) + const hit = raycaster.intersectObject(world.mesh, false)[0] + world.dispose() + return hit ? { z: hit.point.z } : null + } + + afterEach(() => { + useInteractive.setState({ items: {} } as never) + sceneRegistry.clear() + nodeRegistry._reset() + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) + }) + + test('a closed one stops the player at its leaf, not at the model’s cutout box', () => { + mountScriptedDoor() + useInteractive.setState({ items: { door_shoji: { controlValues: [false] } } } as never) + + const hit = firstHitAcrossDoorway() + expect(hit).not.toBeNull() + // the stand-in leaf is 0.06 m deep at the frame; the cutout would stop the player at z = -0.5 + expect(hit!.z).toBeCloseTo(-0.03, 3) + }) + + test('an open one can be walked through, and its cutout never collides', () => { + mountScriptedDoor() + useInteractive.setState({ items: { door_shoji: { controlValues: [true] } } } as never) + + expect(firstHitAcrossDoorway()).toBeNull() + }) + test('a Blender-built door follows its mechanism the same way: the language makes no difference', () => { + mountScriptedDoor('bpy') + useInteractive.setState({ items: { door_shoji: { controlValues: [false] } } } as never) + expect(firstHitAcrossDoorway()?.z).toBeCloseTo(-0.03, 3) + + useInteractive.setState({ items: { door_shoji: { controlValues: [true] } } } as never) + expect(firstHitAcrossDoorway()).toBeNull() + }) +}) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 01c50eec8e..d339904aa4 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -5,6 +5,7 @@ import { getGarageVisibleOpeningRatio, getLevelElevations, isOperationDoorType, + nodeMechanism, nodeRegistry, sceneRegistry, terrainFieldOf, @@ -281,6 +282,10 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty') if (!hasLeafContent) return null + // A door built from a script opens by its model's `open` clip, which the mechanism runs through + // its own control: the native `operationState` below never moves for it, so open is read there. + if (node.source && nodeMechanism(node)?.isOn(node, useInteractive.getState())) return null + const leafW = node.width - 2 * node.frameThickness const leafH = node.height - node.frameThickness if (leafW <= 0 || leafH <= 0) return null diff --git a/packages/editor/src/components/editor/first-person/mechanism-targets.test.ts b/packages/editor/src/components/editor/first-person/mechanism-targets.test.ts index 829d92a275..657eafb253 100644 --- a/packages/editor/src/components/editor/first-person/mechanism-targets.test.ts +++ b/packages/editor/src/components/editor/first-person/mechanism-targets.test.ts @@ -1,14 +1,21 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, + nodeRegistry, registerNode, sceneRegistry, useInteractive, useScene, } from '@pascal-app/core' import { Object3D } from 'three' -import { mechanismHudInteract, mechanismTargetIds } from './mechanism-targets' +import { + hasNativeLeaf, + mechanismHudInteract, + mechanismTargetIds, + nativeOpeningIds, + watchScriptedDoors, +} from './mechanism-targets' const switchMechanism = { has: (node: AnyNode) => (node as unknown as { joints: number }).joints > 0, @@ -73,3 +80,83 @@ describe('walkthrough mechanism targets', () => { expect(mechanismHudInteract(mount(registerKind({}), 'plain_hud'))).toBeNull() }) }) + +// A door or window built from a script has no native leaf or sash: the walkthrough's own door and +// window targets only start a native swing on it, which draws nothing. Its `open` clip runs +// through its mechanism, as an authored object's does. +describe('walkthrough doors and windows', () => { + const script = { source: { kind: 'script', script: 'b'.repeat(64), artifact: 'a'.repeat(64) } } + + test('the doors and windows it swings itself are the ones with a native leaf', () => { + const native = mount('door', 'door_native') + const scripted = mount('door', 'door_scripted', script) + mount('window', 'window_native') + mount('window', 'window_scripted', script) + + expect(nativeOpeningIds().sort()).toEqual(['door_native', 'window_native'] as AnyNodeId[]) + expect(hasNativeLeaf(native)).toBe(true) + expect(hasNativeLeaf(scripted)).toBe(false) + expect(hasNativeLeaf(undefined)).toBe(true) + }) +}) + +// The walkthrough's collider world is rebuilt when a native door's tween ends. A door built from a +// script opens by a clip that no tween system reports: its collider follows its mechanism, so +// the world has to be rebuilt when that flips. +describe('walkthrough scripted doors', () => { + const mechanism = { + verb: 'open', + has: (node: AnyNode) => Boolean((node as unknown as { source?: unknown }).source), + isOn: (node: AnyNode, state: ReturnType) => + Boolean(state.items[node.id as AnyNodeId]?.controlValues[0]), + set: () => {}, + } + let restoreRegistry = () => {} + + beforeEach(() => { + restoreRegistry = nodeRegistry._snapshot() + registerNode({ + kind: 'door', + schemaVersion: 1, + category: 'structure', + defaults: () => ({}), + capabilities: { mechanism }, + } as never) + }) + + afterEach(() => { + useInteractive.setState({ items: {} } as never) + restoreRegistry() + }) + + test('a rebuild is asked for when a scripted door opens or closes, and for nothing else', () => { + mount('door', 'door_scripted_watch', { source: { kind: 'script' } }) + mount('door', 'door_native_watch') + let rebuilds = 0 + const stop = watchScriptedDoors(() => rebuilds++) + const setControls = (scripted: boolean, native: boolean) => + useInteractive.setState({ + items: { + door_scripted_watch: { controlValues: [scripted] }, + door_native_watch: { controlValues: [native] }, + }, + } as never) + try { + // its model initialising the item closed is not a change + setControls(false, false) + expect(rebuilds).toBe(0) + setControls(true, false) + expect(rebuilds).toBe(1) + // the same state again, and a native door's control, are not its business + setControls(true, false) + setControls(true, true) + expect(rebuilds).toBe(1) + setControls(false, true) + expect(rebuilds).toBe(2) + } finally { + stop() + } + setControls(true, true) + expect(rebuilds).toBe(2) + }) +}) diff --git a/packages/editor/src/components/editor/first-person/mechanism-targets.ts b/packages/editor/src/components/editor/first-person/mechanism-targets.ts index 1da7dfa1ea..e2785917d1 100644 --- a/packages/editor/src/components/editor/first-person/mechanism-targets.ts +++ b/packages/editor/src/components/editor/first-person/mechanism-targets.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + isScriptedNode, nodeMechanism, nodeRegistry, sceneRegistry, @@ -9,6 +10,27 @@ import { } from '@pascal-app/core' import type { WalkthroughInteract } from '../../../store/use-first-person-hud' +/** + * Whether the walkthrough swings this door or window itself, through the native `operationState` + * its door and window systems draw. One built from a script has no native leaf or sash: a swing + * would draw nothing, so its clips run through its mechanism, as an authored object's do. + */ +export function hasNativeLeaf(node: AnyNode | undefined): boolean { + return !isScriptedNode(node) +} + +/** Mounted doors and windows the walkthrough's own door and window targets cover. */ +export function nativeOpeningIds(): AnyNodeId[] { + const nodes = useScene.getState().nodes + const ids: AnyNodeId[] = [] + for (const kind of ['door', 'window'] as const) { + for (const id of sceneRegistry.byType[kind] ?? []) { + if (hasNativeLeaf(nodes[id as AnyNodeId])) ids.push(id as AnyNodeId) + } + } + return ids +} + /** * Mounted nodes the walkthrough runs through `capabilities.mechanism`, any kind * included. `covered` holds the nodes a finer target already tested (procedural @@ -34,3 +56,33 @@ export function mechanismHudInteract(node: AnyNode): WalkthroughInteract { const verb = mechanism.verb === 'open' ? (on ? 'close' : 'open') : on ? 'turn off' : 'turn on' return { label: node.name ?? 'item', verb } } + +/** Ids of the mounted doors built from a script that are open, by their mechanism. */ +function openScriptedDoors(): string { + const nodes = useScene.getState().nodes + const state = useInteractive.getState() + const open: string[] = [] + for (const id of sceneRegistry.byType.door ?? []) { + const node = nodes[id as AnyNodeId] + if (isScriptedNode(node) && nodeMechanism(node)?.isOn(node, state)) open.push(id) + } + return open.sort().join(',') +} + +/** + * Calls `onChange` when a mounted door built from a script opens or closes. The walkthrough's + * collider follows the door's mechanism, and its model's clip is moved by no tween system that + * would report the end, as a native door's does: without this the world keeps the leaf that was + * there when it was last built. It fires at the toggle: the doorway is passable as the leaves + * start to move, and solid again as they start to close. + */ +export function watchScriptedDoors(onChange: () => void): () => void { + let known = openScriptedDoors() + return useInteractive.subscribe((state, previous) => { + if (state.items === previous.items) return + const next = openScriptedDoors() + if (next === known) return + known = next + onChange() + }) +} diff --git a/packages/editor/src/components/editor/follow-pascal.test.tsx b/packages/editor/src/components/editor/follow-pascal.test.tsx new file mode 100644 index 0000000000..c732433e18 --- /dev/null +++ b/packages/editor/src/components/editor/follow-pascal.test.tsx @@ -0,0 +1,367 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { + type AnyNodeDefinition, + BuildingNode, + type CameraPose, + emitter, + ItemNode, + LevelNode, + loadPlugin, + nodeRegistry, + runAsSceneCommitAuthor, + useScene, +} from '@pascal-app/core' +import { startConstructionReveal, useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import { BoxGeometry, DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import useEditor from '../../store/use-editor' +import useFollowPascal from '../../store/use-follow-pascal' +import { usePointAsk } from '../../store/use-point-ask' +import { FollowPascal } from './follow-pascal' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true +globalThis.requestAnimationFrame ??= () => 0 +globalThis.cancelAnimationFrame ??= () => {} + +let renderer: Awaited> | null = null +let driver: ReturnType | null = null +let restoreRegistry = () => {} +const previousScene = useScene.getState() +const previousViewer = useViewer.getState() +const previousEditor = useEditor.getState() +const previousFollow = useFollowPascal.getState() +const previousAsk = usePointAsk.getState() +let shell: Mesh | null = null +let poses: CameraPose[] = [] +const recordPose = (pose: CameraPose) => poses.push(pose) + +beforeEach(async () => { + poses = [] + emitter.on('camera-controls:apply-pose', recordPose) + restoreRegistry = nodeRegistry._snapshot() + nodeRegistry._reset() + await loadPlugin({ + id: 'fixture:follow-camera', + apiVersion: 1, + nodes: [ + { + kind: 'item', + schemaVersion: 1, + schema: ItemNode, + category: 'furnish', + defaults: () => ({}), + capabilities: { reveal: { phase: 'furnishing', style: 'drop', height: 0.3 } }, + geometry: () => null, + } as unknown as AnyNodeDefinition, + ], + }) + useFollowPascal.setState({ on: true, paused: false, building: false }) + usePointAsk.setState({ busy: false }) + useEditor.setState({ isFirstPersonMode: false }) + useViewer.getState().setCameraMode('perspective') +}) + +afterEach(async () => { + emitter.off('camera-controls:apply-pose', recordPose) + driver?.stop() + driver = null + await renderer?.unmount() + renderer = null + restoreRegistry() + useScene.setState(previousScene) + useViewer.setState(previousViewer) + useEditor.setState(previousEditor) + useFollowPascal.setState(previousFollow) + usePointAsk.setState(previousAsk) + if (shell) { + shell.geometry.dispose() + ;(shell.material as MeshBasicMaterial).dispose() + shell = null + } +}) + +/** The agent's turn is over and the reveal has played its last word. */ +function turnOver() { + usePointAsk.getState().setBusy(false) + useFollowPascal.getState().endBuild() +} + +type FollowSetup = { + /** Walls all round the target, so no eye sees it clear and Follow falls back to cutaway. */ + enclosed?: boolean + hideAbove?: boolean +} + +async function followItem( + fromUpper = false, + mode: 'stacked' | 'solo' = 'stacked', + setup: FollowSetup = {}, +) { + const building = BuildingNode.parse({ id: 'building_follow' }) + const ground = LevelNode.parse({ id: 'level_ground', parentId: building.id, level: 0 }) + const upper = LevelNode.parse({ id: 'level_upper', parentId: building.id, level: 1 }) + const roof = LevelNode.parse({ id: 'level_roof', parentId: building.id, level: 2 }) + building.children = [ground.id, upper.id, roof.id] + useScene + .getState() + .setScene(Object.fromEntries([building, ground, upper, roof].map((node) => [node.id, node])), [ + building.id, + ]) + const target = fromUpper ? ground : upper + useViewer.getState().setSelection({ + buildingId: building.id, + levelId: fromUpper ? upper.id : ground.id, + }) + useViewer.setState({ levelMode: mode, hideLevelsAboveSelection: setup.hideAbove ?? true }) + renderer = await create( + <> + + + , + ) + if (setup.enclosed) { + shell = new Mesh(new BoxGeometry(6, 6, 6), new MeshBasicMaterial({ side: DoubleSide })) + shell.position.set(2, 0.5, 2) + renderer.scene.instance.getObjectByName('scene-renderer')!.add(shell) + shell.updateWorldMatrix(true, true) + } + driver = startConstructionReveal({ reveals: (commit) => commit.author === 'agent' }) + await act(async () => { + runAsSceneCommitAuthor('agent', () => + useScene.getState().createNode( + ItemNode.parse({ + id: 'item_follow', + position: [2, 0, 2], + asset: { + id: 'chair', + category: 'furniture', + name: 'Chair', + thumbnail: '', + src: '/chair.glb', + dimensions: [1, 1, 1], + }, + }), + target.id, + ), + ) + driver!.tick(20) + await renderer!.advanceFrames(10, 0.1) + }) + return { building, target } +} + +test.each([ + { fromUpper: false, mode: 'stacked' as const }, + { fromUpper: true, mode: 'solo' as const }, +])('following a build on another floor selects it from $mode (fromUpper: $fromUpper)', async ({ + fromUpper, + mode, +}) => { + const { building, target } = await followItem(fromUpper, mode) + expect(useViewer.getState().selection).toMatchObject({ + buildingId: building.id, + levelId: target.id, + }) + expect(useViewer.getState().levelMode).toBe('stacked') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) + // The build is over and no turn is running: the person has the floor they had. + useFollowPascal.getState().endBuild() + expect(useViewer.getState().selection).toMatchObject({ + buildingId: building.id, + levelId: fromUpper ? 'level_upper' : 'level_ground', + }) +}) + +test('an obstacle appearing after arrival causes one calm flight to a clear view', async () => { + await followItem() + await renderer!.advanceFrames(120, 0.05) + const before = poses.at(-1)! + const obstacle = new Mesh(new BoxGeometry(8, 5, 0.5), new MeshBasicMaterial({ side: DoubleSide })) + obstacle.position.set(before.target[0], before.target[1] + 2.5, before.target[2] + 2) + renderer!.scene.instance.getObjectByName('scene-renderer')!.add(obstacle) + obstacle.updateWorldMatrix(true, true) + const ray = new Raycaster() + const sees = (pose: CameraPose) => { + const eye = new Vector3(...pose.position) + const direction = new Vector3(...pose.target).sub(eye) + ray.set(eye, direction.clone().normalize()) + ray.far = direction.length() - 0.02 + return ray.intersectObject(obstacle).length === 0 + } + expect(sees(before)).toBe(false) + await renderer!.advanceFrames(160, 0.05) + const after = poses.at(-1)! + expect(sees(after)).toBe(true) + expect(after.target[0]).toBeCloseTo(before.target[0], 2) + expect(after.target[2]).toBeCloseTo(before.target[2], 2) + const count = poses.length + await renderer!.advanceFrames(40, 0.05) + expect(poses.length).toBe(count) + obstacle.geometry.dispose() + ;(obstacle.material as MeshBasicMaterial).dispose() +}) + +test.each([ + 'camera-controls:interaction-start', + 'camera-controls:orbit-cw', +] as const)('$0 yields the camera, puts the floor back and does not select it again', async (event) => { + await followItem() + expect(poses.length).toBeGreaterThan(0) + expect(useViewer.getState().selection.levelId).toBe('level_upper') + emitter.emit(event) + const count = poses.length + expect(useFollowPascal.getState().paused).toBe(true) + expect(useViewer.getState().selection.levelId).toBe('level_ground') + await act(async () => { + runAsSceneCommitAuthor('agent', () => + useScene + .getState() + .createNode( + ItemNode.parse({ ...useScene.getState().nodes.item_follow, id: 'item_next' }), + 'level_roof', + ), + ) + driver!.tick(40) + await renderer!.advanceFrames(100, 0.05) + }) + expect(useFollowPascal.getState().paused).toBe(true) + expect(poses.length).toBe(count) + expect(useViewer.getState().selection.levelId).toBe('level_ground') +}) + +// The owner, 12 October, on 3114: after Follow Pascal stopped the walls looked invisible. It had +// fallen back to cutaway when no eye saw its target, selected the floor being built and hidden the +// floors above it, and nothing put them back. +const ENDS: { name: string; end: () => unknown }[] = [ + { name: 'it is switched off', end: () => useFollowPascal.getState().setOn(false) }, + { + name: 'the person takes the camera', + end: () => emitter.emit('camera-controls:interaction-start'), + }, + { + // The turn ended, and the reveal's last words are over (what it still plays is still followed). + name: 'the turn is over and the build is over', + end: turnOver, + }, +] + +// The person is on the upper floor in the solo display, with the floors above not hidden; the build +// is on the ground floor, which is not shown, so the reveal speaks at once. +test.each(ENDS)('when $name, the cutaway, the floor and the hidden floors are put back', async ({ + end, +}) => { + usePointAsk.getState().setBusy(true) + const { building } = await followItem(true, 'solo', { enclosed: true, hideAbove: false }) + expect(useViewer.getState().wallMode).toBe('cutaway') + expect(useViewer.getState().levelMode).toBe('stacked') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) + expect(useViewer.getState().selection.levelId).toBe('level_ground') + + await act(async () => end()) + + expect(useViewer.getState().wallMode).toBe('up') + expect(useViewer.getState().levelMode).toBe('solo') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(false) + expect(useViewer.getState().selection).toMatchObject({ + buildingId: building.id, + levelId: 'level_upper', + }) +}) + +test('leaving the editor puts them back', async () => { + await followItem(false, 'stacked', { enclosed: true }) + expect(useViewer.getState().wallMode).toBe('cutaway') + await renderer!.unmount() + renderer = null + expect(useViewer.getState().wallMode).toBe('up') + expect(useViewer.getState().selection.levelId).toBe('level_ground') +}) + +test('a wall mode the person picks while Follow is on is theirs, and Follow does not take it back', async () => { + usePointAsk.getState().setBusy(true) + await followItem(false, 'stacked', { enclosed: true }) + expect(useViewer.getState().wallMode).toBe('cutaway') + useViewer.getState().setWallMode('translucent') + await act(async () => { + await renderer!.advanceFrames(40, 0.05) + }) + expect(useViewer.getState().wallMode).toBe('translucent') + turnOver() + expect(useViewer.getState().wallMode).toBe('translucent') + // The floor was Follow's: it goes back all the same. + expect(useViewer.getState().selection.levelId).toBe('level_ground') +}) + +test('a floor the person picks while Follow is on is theirs, and Follow does not select over it', async () => { + usePointAsk.getState().setBusy(true) + await followItem() + expect(useViewer.getState().selection.levelId).toBe('level_upper') + const count = poses.length + useViewer.getState().setSelection({ levelId: 'level_ground' }) + // The next piece goes on the roof level: above the person's floor, so the reveal speaks at once. + await act(async () => { + runAsSceneCommitAuthor('agent', () => + useScene + .getState() + .createNode( + ItemNode.parse({ ...useScene.getState().nodes.item_follow, id: 'item_roof' }), + 'level_roof', + ), + ) + driver!.tick(40) + await renderer!.advanceFrames(100, 0.05) + }) + // Follow went on to the new piece, and the floor stayed the person's. + expect(poses.length).toBeGreaterThan(count) + expect(useViewer.getState().selection.levelId).toBe('level_ground') + turnOver() + expect(useViewer.getState().selection.levelId).toBe('level_ground') +}) + +test('what Follow did not change is left as it was when it ends', async () => { + useViewer.getState().setWallMode('cutaway') + usePointAsk.getState().setBusy(true) + const { building } = await followItem(false, 'stacked', { enclosed: true }) + expect(useViewer.getState().selection.levelId).toBe('level_upper') + turnOver() + expect(useViewer.getState().wallMode).toBe('cutaway') + expect(useViewer.getState().hideLevelsAboveSelection).toBe(true) + expect(useViewer.getState().selection).toMatchObject({ + buildingId: building.id, + levelId: 'level_ground', + }) +}) + +// A turn runs several programs, with the agent thinking between them: the build is over for a few +// seconds and then goes on. The view holds steady until the turn is over. +test('a build that is over keeps the view while the turn goes on', async () => { + usePointAsk.getState().setBusy(true) + await followItem(false, 'stacked', { enclosed: true }) + useFollowPascal.getState().endBuild() + expect(useViewer.getState().wallMode).toBe('cutaway') + expect(useViewer.getState().selection.levelId).toBe('level_upper') + usePointAsk.getState().setBusy(false) + expect(useViewer.getState().wallMode).toBe('up') + expect(useViewer.getState().selection.levelId).toBe('level_ground') +}) + +// The reveal can play on after the agent's turn: it is still followed, and ends with the build. +test('a turn that ends while the reveal is still playing ends it with the reveal', async () => { + usePointAsk.getState().setBusy(true) + await followItem(false, 'stacked', { enclosed: true }) + usePointAsk.getState().setBusy(false) + expect(useFollowPascal.getState().building).toBe(true) + expect(useViewer.getState().wallMode).toBe('cutaway') + useFollowPascal.getState().endBuild() + expect(useViewer.getState().wallMode).toBe('up') + expect(useViewer.getState().selection.levelId).toBe('level_ground') +}) + +// A host that does not say when a turn runs (a standalone editor, a build driven over MCP). +test('without a host that says a turn is running, the build being over ends it', async () => { + await followItem(false, 'stacked', { enclosed: true }) + expect(useViewer.getState().wallMode).toBe('cutaway') + useFollowPascal.getState().endBuild() + expect(useViewer.getState().wallMode).toBe('up') + expect(useViewer.getState().selection.levelId).toBe('level_ground') +}) diff --git a/packages/editor/src/components/editor/follow-pascal.tsx b/packages/editor/src/components/editor/follow-pascal.tsx new file mode 100644 index 0000000000..84d3100866 --- /dev/null +++ b/packages/editor/src/components/editor/follow-pascal.tsx @@ -0,0 +1,306 @@ +'use client' + +import { + type AnyNodeId, + type BaseNode, + type BuildingNode, + type CameraPose, + emitter, + type LevelNode, + resolveBuildingForLevel, + useScene, + type ZoneNode, +} from '@pascal-app/core' +import { type RevealEvent, subscribeRevealEvents, useViewer } from '@pascal-app/viewer' +import { useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef } from 'react' +import { Vector3 } from 'three' +import { + clearFrame, + Follower, + type FollowWant, + frameOf, + OrbitSpring, + type OrbitValue, +} from '../../lib/follow-pascal' +import { FollowPresentation, type PresentationPort } from '../../lib/follow-pascal-presentation' +import { FollowSight } from '../../lib/follow-pascal-sight' +import { wantFromEvent } from '../../lib/follow-pascal-targets' +import useEditor from '../../store/use-editor' +import useFollowPascal from '../../store/use-follow-pascal' +import { usePointAsk } from '../../store/use-point-ask' + +/** + * Follow Pascal: while a build plays, the camera eases to what the current step is working on, framed + * at the distance its scale needs. It listens to the reveal's own words (`subscribeRevealEvents`), so + * a step and the camera share one rhythm, and moves like a critically damped spring on an orbit about + * what it looks at, so the horizon stays level. Touching the camera, or choosing a view, pauses it; the + * person resumes it from the pill, or the next build takes it again. Under reduced motion it cuts. + * + * To see a build it changes the presentation: it cuts the walls away when no eye sees the target, + * selects the floor being built and hides the floors above it. When Follow ends (switched off, the + * person takes the camera, the build is over and no turn is running) it puts back what it changed, + * and leaves what the person changed since (`FollowPresentation`). + */ + +/** A build is over this long after its last word; the pill goes with it. */ +const BUILD_OVER_MS = 4000 +/** Close enough to its goal that the flight is over. */ +const ARRIVED = { position: 0.01, angle: 0.002 } + +/** Every way a person takes the camera. */ +const TAKEN = [ + 'camera-controls:interaction-start', + 'camera-controls:view', + 'camera-controls:focus', + 'camera-controls:top-view', + 'camera-controls:orbit-cw', + 'camera-controls:orbit-ccw', + 'camera-controls:fit-scene', +] as const + +/** The viewer and the editor as `FollowPresentation` reads and writes them. */ +const viewPort: PresentationPort = { + read: () => { + const viewer = useViewer.getState() + const { selection } = viewer + return { + wall: viewer.wallMode, + levels: viewer.levelMode, + hideAbove: viewer.hideLevelsAboveSelection, + floor: { + buildingId: selection.buildingId, + levelId: selection.levelId, + zoneId: selection.zoneId, + selectedIds: selection.selectedIds, + room: useEditor.getState().room, + }, + } + }, + setWall: (mode) => useViewer.getState().setWallMode(mode), + setLevels: (mode) => useViewer.getState().setLevelMode(mode), + setHideAbove: (hide) => useViewer.setState({ hideLevelsAboveSelection: hide }), + setFloor: ({ buildingId, levelId }) => { + useViewer.getState().setSelection({ + ...(buildingId ? { buildingId: buildingId as BuildingNode['id'] } : {}), + levelId: levelId as LevelNode['id'], + }) + useEditor.getState().clearRoom() + }, + putFloor: (floor) => { + const nodes = useScene.getState().nodes + const exists = (id: string | null) => id !== null && !!nodes[id as AnyNodeId] + useViewer.getState().setSelection({ + buildingId: exists(floor.buildingId) ? (floor.buildingId as BuildingNode['id']) : null, + levelId: exists(floor.levelId) ? (floor.levelId as LevelNode['id']) : null, + zoneId: exists(floor.zoneId) ? (floor.zoneId as ZoneNode['id']) : null, + selectedIds: floor.selectedIds.filter(exists) as BaseNode['id'][], + }) + const { room } = floor + useEditor.setState({ + room: room && exists(room.zoneId) && exists(room.levelId) ? room : null, + hoveredRoom: null, + }) + }, +} + +const prefersReducedMotion = () => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + +/** The canvas dips and comes back, so a cut reads as a cross-fade rather than a jump. */ +function dipCanvas() { + const canvas = document.querySelector('[data-pascal-viewer-3d]') + canvas?.animate([{ opacity: 0.35 }, { opacity: 1 }], { duration: 240, easing: 'ease-out' }) +} + +const arrived = (value: OrbitValue, goal: OrbitValue) => + Math.abs(value.x - goal.x) < ARRIVED.position && + Math.abs(value.y - goal.y) < ARRIVED.position && + Math.abs(value.z - goal.z) < ARRIVED.position && + Math.abs(value.r - goal.r) < ARRIVED.position && + Math.abs(value.az - goal.az) < ARRIVED.angle && + Math.abs(value.el - goal.el) < ARRIVED.angle + +export function FollowPascal() { + const camera = useThree((state) => state.camera) + const scene = useThree((state) => state.scene) + const sight = useMemo(() => new FollowSight(scene), [scene]) + const presentation = useMemo(() => new FollowPresentation(viewPort), []) + const controls = useThree((state) => state.controls) as { + getTarget?: (out: Vector3) => Vector3 + } | null + const size = useThree((state) => state.size) + const follower = useRef(new Follower()) + const spring = useRef(null) + const clock = useRef(0) + const flying = useRef(false) + const synced = useRef(false) + const last = useRef(null) + const resuming = useRef(false) + const over = useRef | null>(null) + const scratch = useRef(new Vector3()) + const nextSightCheck = useRef(0) + + /** The camera as it is now, so a flight starts from the view the person has. */ + const currentPose = (): CameraPose => { + const target = controls?.getTarget?.(scratch.current) ?? scratch.current.set(0, 0, 0) + return { + position: [camera.position.x, camera.position.y, camera.position.z], + target: [target.x, target.y, target.z], + projection: 'perspective', + } + } + + const unobstructed = (want: FollowWant, goal: OrbitValue): OrbitValue => { + sight.refresh(want) + const clear = clearFrame(goal, (pose) => sight.clear(pose)) + if (clear) return clear + presentation.wall('cutaway') + return goal + } + + const moveTo = (goal: OrbitValue) => { + const orbit = spring.current! + if (prefersReducedMotion()) { + orbit.snap(goal) + emitter.emit('camera-controls:apply-pose', orbit.pose()) + dipCanvas() + flying.current = false + return + } + orbit.to(goal) + flying.current = true + } + + const fly = (want: FollowWant) => { + last.current = want + const nodes = useScene.getState().nodes + const level = want.levelId ? nodes[want.levelId as AnyNodeId] : undefined + if (level?.type === 'level') { + const buildingId = resolveBuildingForLevel(level.id as AnyNodeId, nodes) + const building = buildingId ? nodes[buildingId] : undefined + presentation.floor({ + buildingId: building?.type === 'building' ? building.id : null, + levelId: level.id, + }) + presentation.levels('stacked') + presentation.hideAbove(true) + } + if (!spring.current) { + spring.current = new OrbitSpring({ x: 0, y: 1, z: 0, r: 20, az: 0.6, el: 0.5 }) + synced.current = false + } + if (!synced.current) { + spring.current.fromPose(currentPose()) + synced.current = true + } + const perspective = camera as { fov?: number } + const goal = frameOf(want, { + fov: perspective.fov ?? 45, + aspect: size.width / Math.max(1, size.height), + azimuth: spring.current.value().az, + }) + moveTo(unobstructed(want, goal)) + nextSightCheck.current = clock.current + 0.5 + } + + // What the build says it is doing: where to look, and when a build begins and ends. + useEffect(() => { + const hear = (event: RevealEvent) => { + const store = useFollowPascal.getState() + if (over.current) clearTimeout(over.current) + if (!store.building) { + store.beginBuild() + follower.current.reset() + synced.current = false + } + over.current = setTimeout(() => useFollowPascal.getState().endBuild(), BUILD_OVER_MS) + if (event.type === 'complete') return + const want = wantFromEvent(event, useScene.getState().nodes, clock.current) + if (want) follower.current.want(want) + } + const stop = subscribeRevealEvents(hear) + return () => { + stop() + if (over.current) clearTimeout(over.current) + } + }, []) + + // The person takes the camera: it stays theirs, and the next flight starts from where they leave it. + useEffect(() => { + const take = () => { + flying.current = false + synced.current = false + useFollowPascal.getState().pause() + } + for (const name of TAKEN) emitter.on(name, take) + return () => { + for (const name of TAKEN) emitter.off(name, take) + } + }, []) + + // Resuming flies back to what it was last looking at; switching it off ends a flight. + // Follow ends when it is switched off and when the person takes the camera: what it changed to see + // the build goes back. A build being over ends it only when no turn is running: a turn runs several + // programs, and the view holds steady between them (the host's busy flag, below). + useEffect( + () => + useFollowPascal.subscribe((state, before) => { + if (before.paused && !state.paused && state.on) resuming.current = true + if (before.on && !state.on) flying.current = false + if ((before.on && !state.on) || (!before.paused && state.paused)) presentation.restore() + // The build is over: that ends it when no turn is running to build more. + if (before.building && !state.building && !usePointAsk.getState().busy) + presentation.restore() + }), + [presentation], + ) + + // The turn ends, or the person stops it: the host's busy flag is the editor's word on it. What the + // reveal still plays after that is still followed, so it ends with the build, not with the turn. + useEffect( + () => + usePointAsk.subscribe((state, before) => { + if (before.busy && !state.busy && !useFollowPascal.getState().building) + presentation.restore() + }), + [presentation], + ) + + // Leaving the editor ends it too. + useEffect(() => () => presentation.restore(), [presentation]) + + useFrame((_, delta) => { + clock.current += Math.min(delta, 0.1) + const { on, paused, building } = useFollowPascal.getState() + if (!on || paused) return + if (useViewer.getState().cameraMode !== 'perspective' || useEditor.getState().isFirstPersonMode) + return + if (resuming.current) { + resuming.current = false + if (last.current) fly(last.current) + } + const due = follower.current.due(clock.current) + if (due) fly(due) + const orbit = spring.current + // Recheck as geometry lands, even after arrival; retain a clear orbit instead of hunting each frame. + if ( + orbit && + last.current && + (building || flying.current) && + clock.current >= nextSightCheck.current + ) { + nextSightCheck.current = clock.current + 0.5 + const goal = unobstructed(last.current, orbit.destination()) + if (!arrived(goal, orbit.destination())) moveTo(goal) + } + if (!(flying.current && orbit)) return + orbit.step(Math.min(delta, 0.05)) + emitter.emit('camera-controls:apply-pose', orbit.pose()) + if (arrived(orbit.value(), orbit.destination())) flying.current = false + }) + + return null +} diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index 345199df7d..a14125a858 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -28,6 +28,7 @@ import { pasteSystemEditorClipboardToLevel, } from '../../lib/scene-clipboard' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' +import { isTypingTarget } from '../../lib/typing-target' import useAlignmentGuides from '../../store/use-alignment-guides' import useDeleteConfirmation from '../../store/use-delete-confirmation' import useEditor, { @@ -370,6 +371,7 @@ export function startGroupPickUp( } const onKeyDown = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return const key = e.key.toLowerCase() if ((e.metaKey || e.ctrlKey) && (key === 'c' || key === 'v' || key === 'x')) { // A clipboard chord replaces the current carry. Let the global keyboard diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts index 53f6bf0e57..74cc625d31 100644 --- a/packages/editor/src/components/editor/group-move-3d.ts +++ b/packages/editor/src/components/editor/group-move-3d.ts @@ -17,6 +17,7 @@ import { type Camera, Plane, type Raycaster, Vector2, Vector3 } from 'three' import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help' import { isHistoryShortcut } from '../../lib/history' import { sfxEmitter } from '../../lib/sfx-bus' +import { isTypingTarget } from '../../lib/typing-target' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { isAlignmentGuideActive, @@ -380,6 +381,7 @@ export function armGroupMove3d(args: { // `use-keyboard` arms, which would otherwise act on stale store state // (or clear the multi-selection) mid-session. const onKeyDown = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return const key = e.key.toLowerCase() if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { // Armed but still under the drag threshold: engage first (exactly what diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index bf6b8606da..0d2e55046a 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -19,6 +19,7 @@ import { isHistoryShortcut } from '../../lib/history' import { sfxEmitter } from '../../lib/sfx-bus' import { intersectSpatialDragPlane } from '../../lib/spatial-drag-plane' import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input' +import { isTypingTarget } from '../../lib/typing-target' import useEditor, { isAngleSnapActive } from '../../store/use-editor' import useInteractionScope, { useActiveHandleDrag, @@ -377,6 +378,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: // Escape / ⌘Z abort the rotate — capture phase so they win over the global // use-keyboard arms (⌘Z must never history-jump under a live pointer). const onKeyDown = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return if (e.key === 'Alt') { altKey = true return diff --git a/packages/editor/src/components/editor/group-transform-shared.test.ts b/packages/editor/src/components/editor/group-transform-shared.test.ts index 1cac3a7c53..346124a633 100644 --- a/packages/editor/src/components/editor/group-transform-shared.test.ts +++ b/packages/editor/src/components/editor/group-transform-shared.test.ts @@ -4,16 +4,26 @@ import { type AnyNodeDefinition, type AnyNodeId, CeilingNode, + clearSceneHistory, + getSceneHistoryPauseDepth, LevelNode, nodeRegistry, registerNode, SlabNode, sceneRegistry, + useLiveNodeOverrides, + useLiveTransforms, useScene, WallNode, } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { BoxGeometry, Group, Mesh } from 'three' import { z } from 'zod' +import { getEditorClipboardSnapshot } from '../../lib/scene-clipboard' +import useEditor from '../../store/use-editor' +import useInteractionScope from '../../store/use-interaction-scope' +import { startFloorplanGroupRotate, useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move' +import { startGroupPickUp } from './group-actions' import { classifyParticipant, collectParticipants, @@ -597,3 +607,453 @@ describe('group plan bounds', () => { }) }) }) + +// Ownership comes from the editable-focus contract, not the rotation helper. +// Exercise the actual carry's registered listener and real scene/preview stores. +// These CPU targets model ownership only, not native focus, propagation or undo. +describe('group keyboard editable ownership', () => { + function withCarry( + run: (fixture: { + press: ( + key: string, + target?: object, + modifiers?: { ctrlKey?: boolean; metaKey?: boolean }, + ) => Event + snapshot: () => unknown + cancellations: () => number + }) => void, + ) { + const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window') + const documentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document') + const rafDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'requestAnimationFrame') + const cancelRafDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cancelAnimationFrame') + const frames = new Map() + let frameId = 0 + const savedScene = useScene.getState() + const savedViewer = useViewer.getState() + const savedEditor = useEditor.getState() + const savedScope = useInteractionScope.getState() + const savedOverrides = useLiveNodeOverrides.getState() + const savedTransforms = useLiveTransforms.getState() + const history = useScene.temporal.getState() + const savedHistory = { + pastStates: history.pastStates, + futureStates: history.futureStates, + isTracking: history.isTracking, + } + const host = Object.assign(new EventTarget(), { location: { search: '' } }) + Object.defineProperty(globalThis, 'window', { configurable: true, value: host }) + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { body: { style: { cursor: '' } }, querySelector: () => null }, + }) + Object.defineProperty(globalThis, 'requestAnimationFrame', { + configurable: true, + value: (callback: FrameRequestCallback) => { + frames.set(++frameId, callback) + return frameId + }, + }) + Object.defineProperty(globalThis, 'cancelAnimationFrame', { + configurable: true, + value: (id: number) => frames.delete(id), + }) + const canvasTarget = { isContentEditable: false, closest: () => null } + let cancellations = 0 + const press = ( + key: string, + target: object = canvasTarget, + modifiers: { ctrlKey?: boolean; metaKey?: boolean } = {}, + ) => { + const event = Object.assign(new Event('keydown', { bubbles: true, cancelable: true }), { + key, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + ...modifiers, + }) + Object.defineProperty(event, 'target', { value: target }) + host.dispatchEvent(event) + return event + } + const snapshot = () => + structuredClone({ + nodes: useScene.getState().nodes, + selection: useViewer.getState().selection, + scope: useInteractionScope.getState().scope, + overrides: [...useLiveNodeOverrides.getState().overrides], + transforms: [...useLiveTransforms.getState().transforms], + clipboard: getEditorClipboardSnapshot(), + history: { + past: useScene.temporal.getState().pastStates.length, + future: useScene.temporal.getState().futureStates.length, + tracking: useScene.temporal.getState().isTracking, + }, + }) + try { + useScene.temporal.getState().pause() + useScene.setState({ + nodes: placedNodes() as Record, + rootNodeIds: ['building_test' as AnyNodeId], + dirtyNodes: new Set(), + readOnly: false, + }) + useScene.temporal.getState().resume() + clearSceneHistory() + useScene.getState().updateNode('item_chair' as AnyNodeId, { name: 'undo sentinel' }) + expect(useScene.temporal.getState().pastStates.length).toBeGreaterThan(0) + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + useInteractionScope.getState().end() + useEditor.getState().armToolMode({ mode: 'select' }) + useViewer.getState().setSelection({ + buildingId: 'building_test', + levelId: 'level_test', + selectedIds: ['item_chair', 'column_post'], + }) + expect( + startGroupPickUp({ + onCancel: () => { + cancellations += 1 + }, + }), + ).toBe(true) + run({ press, snapshot, cancellations: () => cancellations }) + } finally { + // Use the controller's own teardown before restoring shared globals/state. + press('Escape') + useScene.temporal.getState().pause() + useScene.setState(savedScene) + useScene.temporal.setState({ + pastStates: savedHistory.pastStates, + futureStates: savedHistory.futureStates, + }) + if (savedHistory.isTracking) useScene.temporal.getState().resume() + useViewer.setState(savedViewer) + useEditor.setState(savedEditor) + useInteractionScope.setState(savedScope) + useLiveNodeOverrides.setState(savedOverrides) + useLiveTransforms.setState(savedTransforms) + frames.clear() + for (const [name, descriptor] of [ + ['window', windowDescriptor], + ['document', documentDescriptor], + ['requestAnimationFrame', rafDescriptor], + ['cancelAnimationFrame', cancelRafDescriptor], + ] as const) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else Reflect.deleteProperty(globalThis, name) + } + } + } + + for (const kind of ['input', 'textarea', 'select', 'contenteditable descendant']) { + test(`${kind} leaves an actual carried preview and its history untouched`, () => { + withCarry(({ press, snapshot, cancellations }) => { + expect(press('r').defaultPrevented).toBe(true) + expect(useLiveNodeOverrides.getState().overrides.size).toBeGreaterThan(0) + const before = snapshot() + const owner = { kind } + const target = { + isContentEditable: kind === 'contenteditable descendant', + closest: () => owner, + } + for (const key of ['r', 't', 'Escape', 'Backspace', 'Delete']) { + expect(press(key, target).defaultPrevented).toBe(false) + expect(snapshot()).toEqual(before) + expect(cancellations()).toBe(0) + } + for (const modifier of [{ ctrlKey: true }, { metaKey: true }]) { + for (const key of ['c', 'x', 'v', 'z']) { + expect(press(key, target, modifier).defaultPrevented).toBe(false) + expect(snapshot()).toEqual(before) + expect(cancellations()).toBe(0) + } + } + }) + }) + } + + test('outside R/T still rotate the real preview and Escape restores the carry', () => { + withCarry(({ press, cancellations }) => { + const before = structuredClone(useScene.getState().nodes) + const initialYaw = 0.5 + expect(press('r').defaultPrevented).toBe(true) + const rotated = useLiveNodeOverrides.getState().get('item_chair') + expect(rotated).toBeDefined() + expect((rotated!.rotation as number[])[1]).not.toBeCloseTo(initialYaw) + expect(useScene.getState().nodes).toEqual(before) + expect(press('t').defaultPrevented).toBe(true) + const restored = useLiveNodeOverrides.getState().get('item_chair') + expect((restored!.rotation as number[])[1]).toBeCloseTo(initialYaw) + expect(press('Escape').defaultPrevented).toBe(true) + expect(cancellations()).toBe(1) + expect(useInteractionScope.getState().scope.kind).toBe('idle') + expect(useLiveNodeOverrides.getState().overrides.size).toBe(0) + expect(useLiveTransforms.getState().transforms.size).toBe(0) + expect(useScene.getState().nodes).toEqual(before) + expect(useScene.temporal.getState().isTracking).toBe(true) + expect(useViewer.getState().selection.selectedIds).toEqual(['item_chair', 'column_post']) + }) + }) +}) + +// The editable-focus contract owns these expectations: a live rotation is a +// preview, not a scene/history edit, until an outside release commits it. +// Real package stores/controller and CPU geometry; the DOM below supplies only +// identity screen coordinates and event dispatch, not browser focus/native undo. +describe('floorplan group rotation keyboard ownership', () => { + function snapshot() { + const history = useScene.temporal.getState() + return structuredClone({ + nodes: useScene.getState().nodes, + selection: useViewer.getState().selection, + scope: useInteractionScope.getState().scope, + overrides: [...useLiveNodeOverrides.getState().overrides], + transforms: [...useLiveTransforms.getState().transforms], + rotation: useFloorplanGroupDrag.getState().rotation, + inputDragging: useViewer.getState().inputDragging, + history: { + past: history.pastStates, + future: history.futureStates, + tracking: history.isTracking, + pauseDepth: getSceneHistoryPauseDepth(), + }, + }) + } + + function withRotation( + run: (fixture: { + press: (key: string, target?: object, modifiers?: object) => Event + pointer: (type: string) => void + snapshot: () => ReturnType + }) => void, + ) { + const names = [ + 'window', + 'document', + 'setTimeout', + 'clearTimeout', + 'queueMicrotask', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ] as const + const descriptors = names.map( + (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, + ) + const savedStores = [ + [useScene, useScene.getState()], + [useViewer, useViewer.getState()], + [useEditor, useEditor.getState()], + [useInteractionScope, useInteractionScope.getState()], + [useLiveNodeOverrides, useLiveNodeOverrides.getState()], + [useLiveTransforms, useLiveTransforms.getState()], + [useFloorplanGroupDrag, useFloorplanGroupDrag.getState()], + ] as const + const savedHistory = useScene.temporal.getState() + const pauseDepth = getSceneHistoryPauseDepth() + const savedRegistry = new Map(sceneRegistry.nodes) + const host = Object.assign(new EventTarget(), { location: { search: '' } }) + const timers = new Map void>() + const microtasks: (() => void)[] = [] + let timerId = 0 + const sceneElement = { + getScreenCTM: () => ({ inverse: () => ({}) }), + ownerSVGElement: { + createSVGPoint: () => ({ + x: 0, + y: 0, + matrixTransform() { + return { x: this.x, y: this.y } + }, + }), + }, + } + const globals = { + window: host, + document: { + body: { style: { cursor: '' } }, + querySelector: (selector: string) => + selector === 'g[data-floorplan-scene]' ? sceneElement : null, + }, + setTimeout: (callback: () => void) => { + timers.set(++timerId, callback) + return timerId + }, + clearTimeout: (id: number) => { + timers.delete(id) + }, + queueMicrotask: (callback: () => void) => { + microtasks.push(callback) + }, + requestAnimationFrame: (callback: FrameRequestCallback) => { + timers.set(++timerId, () => callback(0)) + return timerId + }, + cancelAnimationFrame: (id: number) => { + timers.delete(id) + }, + } + const canvas = { isContentEditable: false, closest: () => null } + const press = (key: string, target: object = canvas, modifiers: object = {}) => { + const event = Object.assign(new Event('keydown', { cancelable: true }), { + key, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + ...modifiers, + }) + Object.defineProperty(event, 'target', { value: target }) + host.dispatchEvent(event) + return event + } + const pointer = (type: string) => { + host.dispatchEvent( + Object.assign(new Event(type), { + pointerId: 7, + clientX: 2.5, + clientY: 4, + }), + ) + } + const meshes: Mesh[] = [] + try { + // No shared gesture may already own a history pause in this isolated test. + expect(pauseDepth).toBe(0) + for (const name of names) { + Object.defineProperty(globalThis, name, { configurable: true, value: globals[name] }) + } + useScene.temporal.getState().pause() + useScene.setState({ + nodes: placedNodes() as Record, + rootNodeIds: ['building_test' as AnyNodeId], + dirtyNodes: new Set(), + readOnly: false, + }) + useScene.temporal.getState().resume() + // Clear only this temporal stack, never the process-wide draft registry. + useScene.temporal.getState().clear() + useScene.getState().updateNode('item_chair' as AnyNodeId, { name: 'rotation undo sentinel' }) + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + useFloorplanGroupDrag.setState({ delta: null, rotation: null }) + useInteractionScope.getState().end() + useEditor.getState().armToolMode({ mode: 'select' }) + useViewer.getState().setSelection({ + buildingId: 'building_test', + levelId: 'level_test', + selectedIds: ['item_chair', 'column_post'], + }) + sceneRegistry.nodes.clear() + for (const id of ['item_chair', 'column_post'] as const) { + const mesh = new Mesh(new BoxGeometry(1, 1, 1)) + mesh.position.fromArray( + (useScene.getState().nodes[id as AnyNodeId] as unknown as { position: number[] }) + .position, + ) + mesh.updateMatrixWorld(true) + meshes.push(mesh) + sceneRegistry.nodes.set(id as AnyNodeId, mesh) + } + expect(startFloorplanGroupRotate({ clientX: 5.5, clientY: 1, pointerId: 7 })).toBe(true) + pointer('pointermove') + expect(useFloorplanGroupDrag.getState().rotation?.angle).toBeCloseTo(Math.PI / 2) + expect(useLiveNodeOverrides.getState().overrides.size).toBe(2) + expect(useInteractionScope.getState().scope.kind).toBe('handle-drag') + expect(useScene.temporal.getState().pastStates.length).toBeGreaterThan(0) + expect(useScene.temporal.getState().isTracking).toBe(false) + run({ press, pointer, snapshot }) + } finally { + // Controller teardown balances its own history pause even after an assertion + // fails. Drain its deferred input/click cleanup before restoring shared state. + pointer('pointercancel') + while (microtasks.length) microtasks.shift()!() + for (const callback of timers.values()) { + callback() + while (microtasks.length) microtasks.shift()!() + } + timers.clear() + useScene.temporal.getState().pause() + for (const [store, state] of savedStores) + (store.setState as (snapshot: unknown) => void)(state) + useScene.temporal.setState(savedHistory) + sceneRegistry.nodes.clear() + for (const [id, object] of savedRegistry) sceneRegistry.nodes.set(id, object) + for (const mesh of meshes) { + mesh.geometry.dispose() + if (Array.isArray(mesh.material)) + mesh.material.forEach((material) => { + material.dispose() + }) + else mesh.material.dispose() + } + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else Reflect.deleteProperty(globalThis, name) + } + expect(getSceneHistoryPauseDepth()).toBe(pauseDepth) + } + } + + test('editable undo and cancel keys preserve the actual active rotation', () => { + withRotation(({ press, snapshot }) => { + const before = snapshot() + const targets = [ + { isContentEditable: false, closest: () => ({ tagName: 'TEXTAREA' }) }, + { isContentEditable: true, closest: () => null }, + ] + for (const target of targets) { + for (const modifiers of [{ ctrlKey: true }, { metaKey: true }]) { + expect(press('z', target, modifiers).defaultPrevented).toBe(false) + expect(snapshot()).toEqual(before) + } + for (const key of ['Backspace', 'Delete', 'Escape']) { + expect(press(key, target).defaultPrevented).toBe(false) + expect(snapshot()).toEqual(before) + } + } + }) + }) + + test('outside history and Escape still cancel without consuming a scene undo', () => { + for (const [key, modifiers] of [ + ['Escape', {}], + ['z', { ctrlKey: true }], + ['z', { metaKey: true }], + ] as const) { + withRotation(({ press, snapshot }) => { + const before = snapshot() + expect(press(key, undefined, modifiers).defaultPrevented).toBe(true) + const after = snapshot() + expect(after.nodes).toEqual(before.nodes) + expect(after.history.past).toEqual(before.history.past) + expect(after.history.future).toEqual(before.history.future) + expect(after.history.tracking).toBe(true) + expect(after.history.pauseDepth).toBe(0) + expect(after.scope.kind).toBe('idle') + expect(after.overrides).toEqual([]) + expect(after.transforms).toEqual([]) + expect(after.rotation).toBeNull() + }) + } + }) + + test('outside pointer release commits one undoable rotation', () => { + withRotation(({ pointer, snapshot }) => { + const before = snapshot() + pointer('pointerup') + const after = snapshot() + expect(after.nodes).not.toEqual(before.nodes) + expect(after.history.past).toHaveLength(before.history.past.length + 1) + expect(after.history.tracking).toBe(true) + expect(after.scope.kind).toBe('idle') + expect(after.overrides).toEqual([]) + expect(after.transforms).toEqual([]) + expect(after.rotation).toBeNull() + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(before.nodes) + }) + }) +}) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 770a705707..e39ff0afb9 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -64,6 +64,7 @@ import useEditor from '../../store/use-editor' import useFloorplanMode from '../../store/use-floorplan-mode' import useSessionGroups from '../../store/use-session-groups' import { paneOfView, VIEW_2D, VIEW_3D, type ViewPaneIndex } from '../../store/view-layout' +import { PointAskLayer } from '../point-ask/point-ask-layer' import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system' @@ -105,6 +106,7 @@ import { FloatingActionMenu } from './floating-action-menu' import { FloatingBuildingActionMenu } from './floating-building-action-menu' import { FloorplanModeCoordinator } from './floorplan-mode-coordinator' import { FloorplanPanel } from './floorplan-panel' +import { FollowPascal } from './follow-pascal' import { FootprintHeightHandle } from './footprint-height-handle' import { Grid } from './grid' import { GroupFloatingActionMenu } from './group-floating-action-menu' @@ -156,6 +158,8 @@ const EDITOR_HOVER_STYLES: HoverStyles = { 'paint-ready': { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true }, // Erasing paints nothing: a neutral outline, no paint colour. 'erase-ready': { visibleColor: 0xe4_e4_e7, hiddenColor: 0xa1_a1_aa, strength: 5, pulse: false }, + // Point and ask: the agent accent, steady (nothing animates a hover), the occluded parts faint. + point: { visibleColor: 0xa9_9b_ff, hiddenColor: 0x5b_4f_c9, strength: 5, pulse: false }, 'paint-disabled': { visibleColor: 0x94_a3_b8, hiddenColor: 0x47_55_69, @@ -877,6 +881,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {isFirstPersonMode && } {isCaptureMode && !isXRMode && } {!isXRMode && } + {!isXRMode && } {!isXRMode && } {!isXRMode && } {!(isFirstPersonMode || isXRMode) && } @@ -1231,6 +1236,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ containerRef={viewer3dRef} isVersionPreviewMode={isVersionPreviewMode} /> + {!isVersionPreviewMode && } {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( ) : null} diff --git a/packages/editor/src/components/editor/room-labels.test.ts b/packages/editor/src/components/editor/room-labels.test.ts index ff7bcd6975..c6c7436378 100644 --- a/packages/editor/src/components/editor/room-labels.test.ts +++ b/packages/editor/src/components/editor/room-labels.test.ts @@ -330,6 +330,34 @@ describe('applying placements', () => { expect(has(again.elements.get('far'), 'roomLabelHidden')).toBe(false) }) + // A browser's setTimeout and clearTimeout throw "Illegal invocation" when called as methods of + // another object, which is what a default stored on the placer and called as `this.schedule()` did. + test('the default timers are called as functions, not as methods of the placer', () => { + const real = { set: globalThis.setTimeout, clear: globalThis.clearTimeout } + const window = (name: string, call: (...args: never[]) => unknown) => + function (this: unknown, ...args: never[]) { + if (this !== undefined && this !== globalThis) { + throw new TypeError(`Failed to execute '${name}' on 'Window': Illegal invocation`) + } + return call(...args) + } + globalThis.setTimeout = window('setTimeout', real.set as never) as unknown as typeof setTimeout + globalThis.clearTimeout = window( + 'clearTimeout', + real.clear as never, + ) as unknown as typeof clearTimeout + try { + const element = { dataset: {}, style: { translate: '' } } as unknown as HTMLElement + const placer = new PillPlacer(new Map([['far', element]]), () => {}) + placer.apply(new Map([['far', { mode: 'hidden', dy: 0 }]]), 180) + placer.apply(new Map([['far', full()]]), 180) + expect('roomLabelHidden' in element.dataset).toBe(false) + } finally { + globalThis.setTimeout = real.set + globalThis.clearTimeout = real.clear + } + }) + test('hidden pills can refresh their text measurements without becoming visible', () => { const { elements, timers, placer } = setup() placer.apply(new Map([['far', { mode: 'hidden', dy: 0 }]]), 180) diff --git a/packages/editor/src/components/editor/room-labels.tsx b/packages/editor/src/components/editor/room-labels.tsx index 293b0f840a..793be20a3d 100644 --- a/packages/editor/src/components/editor/room-labels.tsx +++ b/packages/editor/src/components/editor/room-labels.tsx @@ -321,8 +321,9 @@ export class PillPlacer { constructor( private readonly elements: ReadonlyMap, private readonly onGone: (gone: ReadonlySet) => void, - private readonly schedule: (run: () => void, ms: number) => Timer = setTimeout, - private readonly cancel: (timer: Timer) => void = clearTimeout, + private readonly schedule: (run: () => void, ms: number) => Timer = (run, ms) => + setTimeout(run, ms), + private readonly cancel: (timer: Timer) => void = (timer) => clearTimeout(timer), ) {} apply(placements: ReadonlyMap, fadeMs = pillFadeMs()) { diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 2efca9c08e..438d8b252f 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -355,7 +355,7 @@ const wallPaintWorldPoint = new Vector3() const roomHitPoint = new Vector3() const roomHitXZ: [number, number] = [0, 0] -function roomForEvent(event: NodeEvent) { +export function roomForEvent(event: NodeEvent) { if (event.node.type !== 'wall' && event.node.type !== 'slab' && event.node.type !== 'ceiling') return null // The floor edge band and the foundation belong to the footprint, not the @@ -400,7 +400,7 @@ function resolveWallPaintHit(event: NodeEvent): WallPaintHit | undefined { } } -function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null { +export function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null { const roof = event.node if (roof.type !== 'roof') return null diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index af880d8c65..1c9a8c55de 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -5,11 +5,10 @@ import { type AnyNodeId, getWallCurveFrameAt, getWallCurveLength, - getWallPlaneTop, getWallThickness, + getWallTop, isCurvedWall, resolveLevelId, - resolveWallTop, sceneRegistry, spatialGridManager, useScene, @@ -159,8 +158,10 @@ function getWallTopY(wall: WallNode, nodes: Readonly>) { undefined, wall.justification, ) - const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) - return resolveWallTop(wall, planeTop, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT + return ( + getWallTop(wall, levelId, nodes as Record, support.elevation) + + WALL_TOP_HIGHLIGHT_LIFT + ) } function buildHighlightSegment(start: [number, number], end: [number, number]) { diff --git a/packages/editor/src/components/point-ask/bubble.tsx b/packages/editor/src/components/point-ask/bubble.tsx new file mode 100644 index 0000000000..a9be858dc1 --- /dev/null +++ b/packages/editor/src/components/point-ask/bubble.tsx @@ -0,0 +1,538 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + BUBBLE_CLOSE_MS, + BUBBLE_OPEN_BLUR_PX, + BUBBLE_OPEN_SCALE, + EASE_OUT, + ENTER_MS, + SPRING, + SUGGESTION_DELAY_MS, + SUGGESTION_STAGGER_MS, +} from '../../lib/point-ask/choreography' +import { type Dictation, isDictationSupported, startDictation } from '../../lib/point-ask/dictation' +import { subscribeFrame } from '../../lib/point-ask/frame-loop' +import { type PlacementState, placeBubble } from '../../lib/point-ask/placement' +import { freeViewport, projectBox, projectWorld, unionRect } from '../../lib/point-ask/projector' +import { regionLabel } from '../../lib/point-ask/region' +import { closeBubble, sendBubble } from '../../lib/point-ask/session' +import { SpringPair } from '../../lib/point-ask/spring' +import { springKeyframes } from '../../lib/point-ask/spring-keyframes' +import { + followUpSuggestionsFor, + kindForTargets, + placeholderFor, + suggestionsFor, +} from '../../lib/point-ask/suggestions' +import { pointRoomHasContents } from '../../lib/point-ask/target-scope' +import { asksWait, type PointBubble, usePointAsk } from '../../store/use-point-ask' +import { finishOptionalAnimations, pointMotionCalm, usePointMotionCalm } from './optional-motion' + +const ARROW = ( + + + +) +const CROSS = ( + + + +) +const MIC = ( + + + + +) + +/** What the bubble's own outline covers: its targets, or the elements inside its region. */ +const bubbleIds = (bubble: PointBubble) => + (bubble.area ? bubble.area.ids : bubble.targets.map((t) => t.id)) as never + +/** The last words sent, for ↑ in an empty field. */ +let lastAsk = '' + +/** Keeps the last value mounted for `ms` after it goes, so an exit can play. */ +function useExitPresence(value: T | null, ms: number): { shown: T | null; exiting: boolean } { + const [shown, setShown] = useState(value) + const [exiting, setExiting] = useState(false) + useEffect(() => { + if (value) { + setShown(value) + setExiting(false) + return + } + if (!shown) return + setExiting(true) + const timer = setTimeout(() => { + setShown(null) + setExiting(false) + }, ms) + return () => clearTimeout(timer) + }, [value, ms, shown]) + return { shown, exiting } +} + +/** + * The anchored bubble: it grows from the exact click point, sits beside the target (never over it) + * and follows the camera 1:1; on send it folds into the pin at the same spot. See the spec's 2.4 + * and the motion choreography. + */ +export function PointAskBubble() { + const bubble = usePointAsk((s) => s.bubble) + const closeKind = usePointAsk((s) => s.closeKind) + const { shown, exiting } = useExitPresence(bubble, 190) + if (!shown) return null + return ( + + ) +} + +function BubbleView({ + bubble, + exiting, + fold, +}: { + bubble: PointBubble + exiting: boolean + fold: boolean +}) { + const wrap = useRef(null) + const bub = useRef(null) + const tail = useRef(null) + const input = useRef(null) + const place = useRef<{ + state: PlacementState + pair: SpringPair + glide: boolean + first: boolean + offset: { x: number; y: number } + }>({ + state: { side: null, badMs: 0 }, + pair: new SpringPair(0, 0, SPRING), + glide: false, + first: true, + offset: { x: 0, y: 0 }, + }) + const bubbleRef = useRef(bubble) + bubbleRef.current = bubble + const busy = usePointAsk(asksWait) + const [listening, setListening] = useState(false) + const dictation = useRef(null) + const dictationBase = useRef('') + const optionalEntrance = useRef([]) + const optionalExit = useRef(null) + const calm = usePointMotionCalm() + + // Position: beside the target, tracked 1:1 each frame, a side switch gliding on the default spring. + useEffect(() => { + return subscribeFrame((dt) => { + const current = bubbleRef.current + const wrapEl = wrap.current + const bubEl = bub.current + const viewport = freeViewport() + if (!(wrapEl && bubEl && viewport)) return + const area = current.area + const click = area + ? { x: area.click.x, y: area.click.y, ok: true } + : projectWorld(current.anchor) + const boxes = current.targets + .flatMap((t) => (t.box ? [projectBox(t.box)] : [])) + .flatMap((r) => (r ? [r] : [])) + const box = area?.rect ?? + unionRect(boxes) ?? { x0: click.x, y0: click.y, x1: click.x, y1: click.y } + const p = place.current + const result = placeBubble( + { + box, + click: click.ok ? { x: click.x, y: click.y } : null, + size: { w: bubEl.offsetWidth || 320, h: bubEl.offsetHeight || 150 }, + viewport, + offset: p.offset, + first: p.first, + }, + p.state, + dt * 1000, + ) + p.state = result.state + if (result.glide) p.glide = true + if (p.first || !p.glide || pointMotionCalm()) { + p.pair.snap(result.pos.x, result.pos.y) + p.glide = false + } else { + p.pair.to(result.pos.x, result.pos.y).step(dt) + if (p.pair.rest) p.glide = false + } + const { x, y } = p.pair.value + wrapEl.style.transform = `translate3d(${x}px,${y}px,0)` + const tailEl = tail.current + if (tailEl) { + // The tail follows the glide: its anchor is the click, measured from the bubble as drawn. + const lx = (click.ok ? click.x : result.pos.x) - x + const ly = (click.ok ? click.y : result.pos.y) - y + const w = bubEl.offsetWidth || 320 + const h = bubEl.offsetHeight || 150 + let tx: number | null = null + let ty: number | null = null + if (click.ok) { + if (lx < 0) [tx, ty] = [0, Math.min(Math.max(ly, 16), h - 16)] + else if (lx > w) [tx, ty] = [w, Math.min(Math.max(ly, 16), h - 16)] + else if (ly < 0) [tx, ty] = [Math.min(Math.max(lx, 16), w - 16), 0] + else if (ly > h) [tx, ty] = [Math.min(Math.max(lx, 16), w - 16), h] + } + tailEl.style.visibility = tx === null ? 'hidden' : '' + if (tx !== null && ty !== null) + tailEl.style.transform = `translate(${tx - 6}px,${ty - 6}px) rotate(45deg)` + } + if (p.first) { + bubEl.style.transformOrigin = `${result.origin.x}px ${result.origin.y}px` + p.first = false + } + }) + }, []) + + // Opens on pointer-up: grows from the click, the field focused on the same frame so a key typed + // during the animation lands; the suggestions follow at a 30 ms stagger. + useLayoutEffect(() => { + input.current?.focus({ preventScroll: true }) + }, []) + useEffect(() => { + const el = bub.current + if (!el) return + if (pointMotionCalm()) { + const fade = el.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: ENTER_MS, + easing: EASE_OUT, + }) + return () => fade.cancel() + } + optionalEntrance.current.push( + el.animate( + springKeyframes( + { scale: BUBBLE_OPEN_SCALE, opacity: 0, blur: BUBBLE_OPEN_BLUR_PX }, + { scale: 1, opacity: 1, blur: 0 }, + 420, + { + damping: SPRING.damping, + response: SPRING.response, + }, + ), + { duration: 420, easing: 'linear', fill: 'backwards' }, + ), + ) + el.querySelectorAll('.pa-sugs .pa-btn').forEach((chip, index) => { + optionalEntrance.current.push( + chip.animate( + [ + { opacity: 0, transform: 'translateY(4px)' }, + { opacity: 1, transform: 'none' }, + ], + { + duration: ENTER_MS, + delay: SUGGESTION_DELAY_MS + index * SUGGESTION_STAGGER_MS, + easing: EASE_OUT, + fill: 'backwards', + }, + ), + ) + }) + return () => { + for (const animation of optionalEntrance.current) animation.cancel() + optionalEntrance.current = [] + } + }, []) + + // Closes back into the tail, faster than it opened; sending folds it toward the pin. + useEffect(() => { + if (!exiting) return + const el = bub.current + if (!el) return + // A bubble on its way out must not keep the keyboard: the next key belongs to the editor, not to its field. + if (el.contains(document.activeElement)) (document.activeElement as HTMLElement).blur() + for (const animation of el.getAnimations()) animation.cancel() + const now = getComputedStyle(el) + const from = { + transform: now.transform === 'none' ? 'none' : now.transform, + opacity: 1, + filter: 'blur(0px)', + } + const quiet = pointMotionCalm() + const keys: Keyframe[] = quiet + ? [{ opacity: 1 }, { opacity: 0 }] + : [ + from, + { + transform: `scale(${fold ? 0.9 : BUBBLE_OPEN_SCALE})`, + opacity: 0, + filter: `blur(${BUBBLE_OPEN_BLUR_PX}px)`, + }, + ] + const animation = el.animate(keys, { + duration: fold ? ENTER_MS : BUBBLE_CLOSE_MS, + easing: EASE_OUT, + fill: 'forwards', + }) + optionalExit.current = quiet ? null : animation + if (wrap.current) wrap.current.style.pointerEvents = 'none' + return () => { + animation.cancel() + optionalExit.current = null + } + }, [exiting, fold]) + + useEffect(() => { + if (!calm) return + finishOptionalAnimations(optionalEntrance.current) + if (optionalExit.current && bub.current) { + optionalExit.current.cancel() + optionalExit.current = null + // Keep the existing presence deadline and closed state, not a second exit or a visible reset. + bub.current.style.opacity = '0' + bub.current.style.transform = 'none' + bub.current.style.filter = 'none' + } + }, [calm]) + + useEffect(() => { + return () => dictation.current?.stop() + }, []) + + const patch = usePointAsk((s) => s.patchBubble) + const grow = useCallback(() => { + const el = input.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, 6 * 20)}px` + }, []) + // biome-ignore lint/correctness/useExhaustiveDependencies: the field grows whenever its text changes + useLayoutEffect(grow, [bubble.draft, grow]) + + const send = useCallback((words?: string) => { + const text = (words ?? input.current?.value ?? '').trim() + if (!text) return + lastAsk = text + dictation.current?.stop() + const rect = input.current?.getBoundingClientRect() + void sendBubble(text, { + x: rect?.left ?? 0, + y: rect?.top ?? 0, + width: rect?.width ?? 0, + height: rect?.height ?? 0, + }) + }, []) + + const toggleDictation = () => { + if (dictation.current) { + dictation.current.stop() + return + } + dictationBase.current = input.current?.value ?? '' + const base = dictationBase.current + dictation.current = startDictation({ + onText: (heard) => + patch({ draft: `${base}${base && !base.endsWith(' ') ? ' ' : ''}${heard}` }), + onEnd: () => { + dictation.current = null + setListening(false) + }, + }) + setListening(dictation.current !== null) + } + + const targets = bubble.targets + const kind = kindForTargets(targets, bubble.gesture === 'region') + const roomId = kind === 'room' && !bubble.changesAsk ? targets[0]?.id : undefined + const roomEmpty = useScene((s) => (roomId ? !pointRoomHasContents(roomId, s.nodes) : false)) + // Asking again on what a turn just changed offers the next things to try, not the first ones. + const suggestions = bubble.changesAsk + ? followUpSuggestionsFor(kind, bubble.changesAsk.after) + : suggestionsFor(kind, { empty: roomEmpty }) + const empty = bubble.draft.trim() === '' + + return ( +
+
+ +
{ + if ((event.target as HTMLElement).closest('button')) return + event.preventDefault() + const p = place.current + const sx = event.clientX - p.offset.x + const sy = event.clientY - p.offset.y + const move = (m: PointerEvent) => { + p.offset = { x: m.clientX - sx, y: m.clientY - sy } + } + const up = () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + }} + title="Drag to move it aside" + > + {bubble.area ? ( + useViewer.getState().setPointedIds(bubble.area?.ids as never)} + onPointerLeave={() => useViewer.getState().setPointedIds(bubbleIds(bubble))} + > + + {regionLabel(bubble.area.ids.length + bubble.area.more)} + + + ) : null} + {targets.map((t) => ( + useViewer.getState().setPointedIds([t.id as never])} + onPointerLeave={() => useViewer.getState().setPointedIds(bubbleIds(bubble))} + > + + + {t.name} + {t.size ? ` · ${t.size}` : ''} + + + + ))} + {bubble.crop.state === 'ready' && bubble.crop.dataUrl ? ( + // biome-ignore lint/performance/noImgElement: a data URL the capture just made + What Pascal will see + ) : bubble.crop.state === 'failed' ? ( + No picture + ) : null} +
+
{ + event.preventDefault() + send() + }} + > +