diff --git a/packages/core/src/agent-operations/__fixtures__/add-object-cases.ts b/packages/core/src/agent-operations/__fixtures__/add-object-cases.ts new file mode 100644 index 0000000000..2718f0df47 --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/add-object-cases.ts @@ -0,0 +1,251 @@ +import { artifactUrl } from '../../lib/artifact-store' +import { scriptSource } from '../../lib/geometry-script-node' +import { + BuildingNode, + type CompiledGeometryScript, + GeometryArtifactManifest, + type GeometryScriptMount, + ItemNode, + LevelNode, + WallNode, +} from '../../schema' +import type { AgentSurface, SceneGraph } from './cases' + +/** + * `add_object`, the one way to author an object Pascal has no type for. Each new object says what + * it stands in for (its reason, listed by verify_scene), so every one names a gap in Pascal; one + * on a floor shaped like a wall or a floor plate is refused with the tool to use; one named + * after something Pascal builds gets a hint, not a refusal (a word gate once refused "entry door + * pull handle (brass)", and the agent relabelled it until the label named nothing). + * + * The surfaces compile `code` before the operation runs; a case carries what that compile + * produced, and each runner hands it over in place of its own compile. + */ +export type AddObjectCase = { + name: string + scene: () => SceneGraph + input: Record + compiled: CompiledGeometryScript + context?: { activeLevelId?: string | null } + surfaces?: AgentSurface[] + expect: + | { + refusal: string + mentions?: string[] + /** Refused before the script runs: the compile is never called. */ + beforeCompile?: true + } + | { + result: Record + /** Fields the object built or edited (the result's nodeId) has afterwards. */ + node?: Record + /** Text the result must include. */ + mentions?: string[] + } +} + +/** + * A compile of a solid of this size, bottom-centre at the origin, as the compiler reports it: a + * plain box (12 triangles) unless it has more detail. + */ +export function compiledSolid( + [width, height, depth]: [number, number, number], + mount: GeometryScriptMount = 'floor', + triangles = 12, +): CompiledGeometryScript { + return { + sha256: 'a'.repeat(64), + script: 'b'.repeat(64), + mount, + params: {}, + manifest: GeometryArtifactManifest.parse({ + bounds: { min: [-width / 2, 0, -depth / 2], max: [width / 2, height, depth / 2] }, + triangles, + }), + } +} + +/** An object add_object built earlier, with the reason it was given. */ +export function authoredItem( + id: string, + parentId: string, + fields: { name: string; category: string; reason?: string; size: [number, number, number] }, +) { + const compiled = compiledSolid(fields.size) + return ItemNode.parse({ + id, + parentId, + name: fields.name, + source: scriptSource(compiled), + asset: { + id: `script_${compiled.sha256.slice(0, 16)}`, + category: fields.category, + name: fields.name, + thumbnail: '', + source: 'mine', + src: artifactUrl(compiled.sha256), + dimensions: fields.size, + }, + ...(fields.reason ? { metadata: { reason: fields.reason } } : {}), + }) +} + +/** One floor with a 6 m wall, and a screen built earlier with add_object. */ +function floorScene(): SceneGraph { + const wall = WallNode.parse({ + id: 'wall_south', + parentId: 'level_0', + start: [0, 0], + end: [6, 0], + height: 3, + }) + const screen = authoredItem('item_screen', 'level_0', { + name: 'Garden screen', + category: 'screen', + reason: 'No screen type.', + size: [1.2, 1.8, 0.2], + }) + const level = LevelNode.parse({ + id: 'level_0', + parentId: 'building_main', + level: 0, + height: 3, + children: [wall.id, screen.id], + }) + const building = BuildingNode.parse({ id: 'building_main', children: [level.id] }) + return { + nodes: Object.fromEntries([building, level, wall, screen].map((node) => [node.id, node])), + rootNodeIds: [building.id], + } +} + +const CODE = 'export default function build({ THREE }) { return new THREE.Group() }' + +const create = ( + name: string, + input: Record, + size: [number, number, number], + expect: AddObjectCase['expect'], + mount: GeometryScriptMount = 'floor', + triangles?: number, +): AddObjectCase => ({ + name, + scene: floorScene, + input: { code: CODE, reason: 'Pascal has no type for it.', ...input }, + compiled: compiledSolid(size, mount, triangles), + expect, +}) + +export const ADD_OBJECT_CASES: AddObjectCase[] = [ + create( + 'a new object keeps what it stands in for', + { + name: 'Cornice', + category: 'trim', + reason: 'Pascal has no cornice type.', + position: [3, 2.4, 1], + }, + [6, 0.3, 0.4], + { + result: { mount: 'floor' }, + node: { + name: 'Cornice', + parentId: 'level_0', + asset: { category: 'trim' }, + metadata: { reason: 'Pascal has no cornice type.' }, + }, + }, + ), + // Live over the MCP (2026-10-05): the script ran and its artifacts were stored before the missing + // reason was refused. + create( + 'a new object without a reason is refused before its script runs', + { name: 'Planter', reason: undefined }, + [1.2, 0.5, 0.4], + { refusal: 'reason_required', mentions: ['reason'], beforeCompile: true }, + ), + // 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', + { name: 'Panel', category: 'panel' }, + [4, 2.7, 0.2], + { refusal: 'use_walls', mentions: ['add_wall'] }, + ), + // A bookcase and a breeze-block screen (about 1.4 m wide and the tall box's height) + // have a wall's box too, with shelves or holes in it: built, the wall tool named in case. + create( + 'a detailed object with a wall’s size is built, with a hint naming the wall tool', + { name: 'Breeze-block screen', category: 'screen' }, + [1.4, 5.6, 0.19], + { result: { mount: 'floor' }, mentions: ['add_wall'] }, + 'floor', + 1680, + ), + create( + 'a bookcase is built: furniture may have a wall’s box', + { name: 'Bookcase', category: 'furniture' }, + [1.2, 2.1, 0.35], + { result: { mount: 'floor' }, node: { name: 'Bookcase' } }, + 'floor', + 96, + ), + create( + 'a floor plate is refused, measured as drawn in x and z: slabs exist', + { name: 'Terrace', category: 'deck' }, + [8.05, 0.15, 13.52], + { refusal: 'use_slab', mentions: ['slab', '8.05 × 13.52'] }, + ), + create( + 'a turned floor plate is measured as it stands', + { name: 'Terrace', category: 'deck', rotation: 90 }, + [8.05, 0.15, 13.52], + { refusal: 'use_slab', mentions: ['13.52 × 8.05'] }, + ), + create( + 'a plate held off the floor is built: a canopy is no slab', + { name: 'Canopy', category: 'canopy', position: [3, 2.6, 1] }, + [4, 0.2, 3], + { result: { mount: 'floor' }, node: { name: 'Canopy' } }, + ), + create( + 'a wall-side object shaped like a wall is built: it dresses the wall', + { name: 'Wainscot', category: 'panel', parentId: 'wall_south', position: [3, 0, 0] }, + [4, 2.6, 0.05], + { result: { mount: 'wall-side' }, node: { parentId: 'wall_south' } }, + 'wall-side', + ), + create( + 'a name naming a Pascal type is built, with a hint naming the tool for it', + { name: 'Spiral staircase', category: 'stair' }, + [1.6, 2.8, 1.6], + { result: { mount: 'floor' }, mentions: ['is something Pascal builds: create_stair'] }, + ), + create( + 'a type word that only qualifies the name is built', + { name: 'entry door pull handle (brass)', category: 'hardware', position: [1, 0.9, 1] }, + [0.04, 0.6, 0.06], + { result: { mount: 'floor' }, node: { name: 'entry door pull handle (brass)' } }, + ), + // The editor's inspector and resize handles rebuild an object through the same operation: a + // person may make it any shape. + { + name: 'an edit is not judged by its shape, and keeps the reason', + scene: floorScene, + input: { nodeId: 'item_screen', code: CODE }, + compiled: compiledSolid([4, 2.6, 0.2]), + expect: { + result: { nodeId: 'item_screen', size: [4, 2.6, 0.2] }, + node: { metadata: { reason: 'No screen type.' } }, + }, + }, + { + name: 'an edit takes a new reason', + scene: floorScene, + input: { nodeId: 'item_screen', code: CODE, reason: 'No breeze-block type.' }, + compiled: compiledSolid([1.2, 1.8, 0.2]), + expect: { + result: { nodeId: 'item_screen' }, + node: { metadata: { reason: 'No breeze-block type.' } }, + }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts b/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts new file mode 100644 index 0000000000..2cc7a77f11 --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts @@ -0,0 +1,697 @@ +import { + type AnyNode, + type AssetInput, + BuildingNode, + DoorNode, + ItemNode, + LevelNode, + WallNode, + WindowNode, + ZoneNode, +} from '../../schema' +import { findBlockedDoors } from '../door-clearance' +import { findItemItemCollisions } from '../layout-clearance' +import type { AgentToolCase, SceneGraph } from './cases' + +/** + * `create_room` and `furnish_room`: one tool each where the MCP and the chat had two. create_room + * is the core's room command (the MCP's: walls only where no wall runs, terraces) with the chat's + * doors and windows declared by polygon edge, attributed by position among the walls of the room's + * own level and placed with add_door's and add_window's rules. furnish_room is the MCP's placement + * (door clear zones, existing items, nudges, every skip said) with the chat's door detection, over + * the host's catalog. + * + * Rooms: a ground floor with a 4 m wall carrying a door and a window and a kitchen off to the side, + * the same 4 m wall on the floor above (collinear with the one below), and a roof level. + */ + +type Pt = [number, number] +type Nodes = Readonly> + +const graph = (...nodes: { id: string }[]): SceneGraph => ({ + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: nodes + .filter((node) => (node as { type?: string }).type === 'building') + .map((node) => node.id), +}) + +const KITCHEN: Pt[] = [ + [10, 0], + [14, 0], + [14, 3], + [10, 3], +] +/** A 4 × 3 m room clear of everything on either floor. */ +const CLEAR: Pt[] = [ + [0, 5], + [4, 5], + [4, 8], + [0, 8], +] + +function roomsScene(): SceneGraph { + const door = DoorNode.parse({ + id: 'door_g', + parentId: 'wall_g', + wallId: 'wall_g', + position: [1, 1.05, 0], + }) + const window = WindowNode.parse({ + id: 'window_g', + parentId: 'wall_g', + wallId: 'wall_g', + width: 1, + height: 1.2, + position: [3, 1.5, 0], + }) + const wallG = WallNode.parse({ + id: 'wall_g', + parentId: 'level_g', + start: [0, 0], + end: [4, 0], + height: 2.5, + children: [door.id, window.id], + }) + const kitchen = ZoneNode.parse({ + id: 'zone_kitchen', + parentId: 'level_g', + name: 'Kitchen', + polygon: KITCHEN, + spaceRole: 'room', + }) + const wallU = WallNode.parse({ + id: 'wall_u', + parentId: 'level_u', + start: [0, 0], + end: [4, 0], + height: 2.5, + }) + const ground = LevelNode.parse({ + id: 'level_g', + parentId: 'building_b', + level: 0, + name: 'Ground', + height: 2.8, + children: [wallG.id, kitchen.id], + }) + const upper = LevelNode.parse({ + id: 'level_u', + parentId: 'building_b', + level: 1, + name: 'Upper', + height: 2.8, + children: [wallU.id], + }) + const roof = LevelNode.parse({ + id: 'level_r', + parentId: 'building_b', + level: 2, + name: 'Roof', + metadata: { role: 'roof' }, + }) + const building = BuildingNode.parse({ + id: 'building_b', + children: [ground.id, upper.id, roof.id], + }) + return graph(building, ground, upper, roof, wallG, door, window, kitchen, wallU) +} + +// ─── Checks the table cannot state: minted ids and derived construction ─────────────────────── + +const problems = (...entries: [boolean, string][]) => + entries.flatMap(([ok, problem]) => (ok ? [] : [problem])) + +const wallsOn = (nodes: Nodes, levelId: string) => + Object.values(nodes).filter((node) => node.type === 'wall' && node.parentId === levelId) + +/** Where an opening stands on the plan: along its wall from the wall's start. */ +function openingPlanPoint(nodes: Nodes, id: string): Pt | null { + const opening = nodes[id] as (AnyNode & { position?: number[] }) | undefined + const wall = opening?.parentId ? nodes[opening.parentId] : undefined + if (!(opening?.position && wall?.type === 'wall')) return null + const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + const t = opening.position[0]! / length + return [ + wall.start[0] + t * (wall.end[0] - wall.start[0]), + wall.start[1] + t * (wall.end[1] - wall.start[1]), + ] +} + +const near = (a: number, b: number) => Math.abs(a - b) < 1e-6 + +/** The room the result names: its zone, its walls in edge order, its derived floor and ceiling. */ +function roomBuilt(levelId: string, name: string, edges: number) { + return (result: Record, nodes: Nodes) => { + const zone = nodes[result.zoneId as string] + const wallIds = result.wallIds as (string | null)[] + const slab = nodes[result.slabId as string] + const ceiling = nodes[result.ceilingId as string] + return problems( + [ + zone?.type === 'zone' && + zone.parentId === levelId && + zone.name === name && + zone.spaceRole === 'room', + `zone ${String(result.zoneId)} is not the room "${name}" on ${levelId}`, + ], + // The reconciler adopted the room the call wrote rather than minting one of its own. + [ + zone?.type === 'zone' && zone.autoFromWalls === true, + `zone ${String(result.zoneId)} was not adopted by the reconciler`, + ], + [ + wallIds.length === edges && new Set(wallIds).size === edges, + `wallIds ${JSON.stringify(wallIds)} is not one wall per edge`, + ], + [ + wallIds.every((id) => id && nodes[id]?.type === 'wall' && nodes[id]?.parentId === levelId), + `wallIds ${JSON.stringify(wallIds)} are not walls of ${levelId}`, + ], + [ + slab?.type === 'slab' && + slab.boundary === 'auto' && + !!slab.zoneIds?.includes(result.zoneId as string), + `slabId ${String(result.slabId)} is not the room's derived floor plate`, + ], + [ + ceiling?.type === 'ceiling' && + ceiling.boundary === 'auto' && + ceiling.zoneId === result.zoneId, + `ceilingId ${String(result.ceilingId)} is not the room's derived ceiling`, + ], + ) + } +} + +const roomOn = (levelId: string) => (result: Record, nodes: Nodes) => + problems([ + nodes[result.zoneId as string]?.parentId === levelId, + `the room is on ${String(nodes[result.zoneId as string]?.parentId)}, not ${levelId}`, + ]) + +const room = (input: Record) => ({ name: 'Bedroom', polygon: CLEAR, ...input }) + +export const CREATE_ROOM_CASES: AgentToolCase[] = [ + { + name: 'a room is a wall per edge and a zone; its floor plate and ceiling are derived', + tool: 'create_room', + scene: roomsScene, + input: room({ levelId: 'level_g' }), + expect: { + result: { ok: true, reusedWalls: 0, areaSqMeters: 12, doorIds: [], windowIds: [] }, + check: roomBuilt('level_g', 'Bedroom', 4), + }, + }, + { + name: 'an edge a wall already runs along reuses that wall, its door and window kept', + tool: 'create_room', + scene: roomsScene, + input: { + levelId: 'level_g', + name: 'Hall', + polygon: [ + [0, 0], + [4, 0], + [4, -3], + [0, -3], + ], + }, + expect: { + result: { ok: true, reusedWalls: 1 }, + after: { wall_g: { children: ['door_g', 'window_g'] } }, + check: (result, nodes) => [ + ...roomBuilt('level_g', 'Hall', 4)(result, nodes), + ...problems([ + (result.wallIds as string[])[0] === 'wall_g', + `edge 0 is ${(result.wallIds as string[])[0]}, not the wall already there`, + ]), + ], + }, + }, + // The 2026-09-03 defect: the chat bound an upper room's openings to the collinear wall below. + { + name: "openings go on the walls of the room's own level, never the collinear wall below", + tool: 'create_room', + scene: roomsScene, + input: { + levelId: 'level_u', + name: 'Study', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + doors: [{ wallIndex: 0, t: 0.25 }], + windows: [{ wallIndex: 2 }], + }, + expect: { + result: { ok: true, reusedWalls: 1 }, + after: { wall_g: { children: ['door_g', 'window_g'] } }, + check: (result, nodes) => { + const [doorId] = result.doorIds as string[] + const [windowId] = result.windowIds as string[] + const windowWall = nodes[nodes[windowId!]?.parentId ?? ''] + return problems( + [nodes[doorId!]?.parentId === 'wall_u', `the door hangs off ${nodes[doorId!]?.parentId}`], + [ + windowWall?.type === 'wall' && windowWall.parentId === 'level_u', + `the window hangs off ${windowWall?.id} on ${windowWall?.parentId}`, + ], + [ + near(openingPlanPoint(nodes, doorId!)?.[0] ?? -1, 1), + `the door is not a quarter along edge 0: ${openingPlanPoint(nodes, doorId!)}`, + ], + ) + }, + }, + }, + { + name: 'a room across a wall splits it; the door and window stay where they stood', + tool: 'create_room', + scene: roomsScene, + input: { + levelId: 'level_g', + name: 'Den', + polygon: [ + [2, 0], + [6, 0], + [6, 3], + [2, 3], + ], + }, + expect: { + result: { ok: true }, + present: ['door_g', 'window_g'], + check: (result, nodes) => + problems( + ...(['door_g', 'window_g'] as const).map((id): [boolean, string] => { + const wall = nodes[nodes[id]?.parentId ?? ''] + return [ + wall?.type === 'wall' && wall.parentId === 'level_g', + `${id} hangs off ${wall?.id}, not a wall of level_g`, + ] + }), + [near(openingPlanPoint(nodes, 'door_g')?.[0] ?? -1, 1), 'door_g moved'], + [near(openingPlanPoint(nodes, 'window_g')?.[0] ?? -1, 3), 'window_g moved'], + [ + !!nodes[result.zoneId as string] && !!nodes[result.ceilingId as string], + 'the room has no zone or no derived ceiling', + ], + ), + }, + }, + { + name: 'two declared doors that overlap: the second is skipped with its code, the room stands', + tool: 'create_room', + scene: roomsScene, + input: room({ + levelId: 'level_g', + doors: [ + { wallIndex: 0, t: 0.5 }, + { wallIndex: 0, t: 0.55 }, + ], + }), + expect: { + result: { ok: true }, + contains: { skippedOpenings: [{ kind: 'door', index: 1, code: 'opening_overlap' }] }, + check: (result) => + problems([(result.doorIds as string[]).length === 1, 'not exactly one door built']), + }, + }, + { + name: 'a door wider than its edge is skipped: wall_too_short', + tool: 'create_room', + scene: roomsScene, + input: room({ + levelId: 'level_g', + name: 'Closet', + polygon: [ + [0, 5], + [0.8, 5], + [0.8, 8], + [0, 8], + ], + doors: [{ wallIndex: 0 }], + }), + expect: { + result: { ok: true, doorIds: [] }, + contains: { skippedOpenings: [{ kind: 'door', index: 0, code: 'wall_too_short' }] }, + }, + }, + { + name: 'an edge the polygon does not have is skipped: edge_out_of_range', + tool: 'create_room', + scene: roomsScene, + input: room({ levelId: 'level_g', windows: [{ wallIndex: 7 }] }), + expect: { + result: { ok: true, windowIds: [] }, + contains: { skippedOpenings: [{ kind: 'window', index: 0, code: 'edge_out_of_range' }] }, + }, + }, + { + name: "a window sits at add_window's sill height; a door takes its size, hinge, swing and style", + tool: 'create_room', + scene: roomsScene, + input: room({ + levelId: 'level_g', + windows: [{ wallIndex: 2 }], + doors: [ + { + wallIndex: 0, + width: '36 in', + hingesSide: 'right', + swingDirection: 'outward', + style: 'glass', + }, + ], + }), + expect: { + result: { ok: true }, + check: (result, nodes) => { + const window = nodes[(result.windowIds as string[])[0]!] + const door = nodes[(result.doorIds as string[])[0]!] + return problems( + [ + window?.type === 'window' && + near(window.position[1] - window.height / 2, 0.9) && + window.width === 1.5, + `window ${JSON.stringify(window)} is not 1.5 m wide on a 0.9 m sill`, + ], + [ + door?.type === 'door' && + Math.abs(door.width - 0.9144) < 1e-4 && + door.hingesSide === 'right' && + door.swingDirection === 'outward', + `door ${JSON.stringify(door)} is not 36 in, hinged right, opening outward`, + ], + ) + }, + }, + }, + { + name: 'level is the same field as levelId', + tool: 'create_room', + scene: roomsScene, + input: room({ level: 'level_u' }), + expect: { result: { ok: true }, check: roomOn('level_u') }, + }, + { + name: 'without a level, the floor the person is viewing', + tool: 'create_room', + scene: roomsScene, + input: room({}), + context: { activeLevelId: 'level_u' }, + surfaces: ['core', 'chat'], + expect: { result: { ok: true }, check: roomOn('level_u') }, + }, + { + name: 'without a level or a viewed floor, the lowest storey', + tool: 'create_room', + scene: roomsScene, + input: room({}), + expect: { result: { ok: true }, check: roomOn('level_g') }, + }, + { + name: 'outdoor: a terrace closed by separators, no walls of its own and no ceiling', + tool: 'create_room', + scene: roomsScene, + input: { + levelId: 'level_g', + name: 'Terrace', + outdoor: true, + polygon: [ + [0, 10], + [4, 10], + [4, 13], + [0, 13], + ], + }, + expect: { + result: { ok: true, wallIds: [null, null, null, null], ceilingId: null }, + check: (result, nodes) => { + const zone = nodes[result.zoneId as string] + const separators = Object.values(nodes).filter( + (node) => node.type === 'separator' && node.parentId === 'level_g', + ) + return problems( + [ + zone?.type === 'zone' && zone.name === 'Terrace' && zone.hasCeiling === false, + 'no terrace zone without a ceiling', + ], + [separators.length === 4, `${separators.length} separators, not 4`], + [wallsOn(nodes, 'level_g').length === 1, 'the terrace built walls'], + [nodes[result.slabId as string]?.type === 'slab', 'the terrace has no derived floor'], + ) + }, + }, + }, + { + name: 'outdoor over a room is refused and names it', + tool: 'create_room', + scene: roomsScene, + input: { + levelId: 'level_g', + name: 'Terrace', + outdoor: true, + polygon: [ + [11, 1], + [13, 1], + [13, 2], + [11, 2], + ], + }, + expect: { refusal: 'outdoor_room_overlap', mentions: ['Kitchen'] }, + }, + { + name: 'a roof level takes no room', + tool: 'create_room', + scene: roomsScene, + input: room({ levelId: 'level_r' }), + expect: { refusal: 'roof_level' }, + }, + { + name: 'a made-up level id is refused', + tool: 'create_room', + scene: roomsScene, + input: room({ levelId: 'level_1' }), + expect: { refusal: 'level_not_found' }, + }, + { + name: 'a polygon that crosses itself is refused', + tool: 'create_room', + scene: roomsScene, + input: room({ + levelId: 'level_g', + polygon: [ + [0, 5], + [4, 8], + [4, 5], + [0, 8], + ], + }), + expect: { refusal: 'invalid_polygon' }, + }, +] + +// ─── furnish_room ───────────────────────────────────────────────────────────────────────────── + +const item = (id: string, dimensions: [number, number, number]): AssetInput => ({ + id, + name: id, + category: 'furniture', + thumbnail: `/items/${id}/thumbnail.webp`, + src: `/items/${id}/model.glb`, + dimensions, +}) + +const CATALOG: AssetInput[] = [ + item('double-bed', [1.6, 0.5, 2.1]), + item('single-bed', [1, 0.5, 2]), + item('bedside-table', [0.5, 0.5, 0.4]), + item('dresser', [1.2, 0.8, 0.5]), + item('closet', [1.5, 2, 0.6]), +] + +/** A 5 × 4 m bedroom: walls south (edge 0), east, north (edge 2), west; a door where asked. */ +const BEDROOM: Pt[] = [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], +] +const BEDROOM_WALLS = ['wall_s', 'wall_e', 'wall_n', 'wall_w'] as const + +function bedroomScene({ doorOn, desk }: { doorOn?: 'wall_s' | 'wall_n'; desk?: boolean } = {}) { + return (): SceneGraph => { + const door = doorOn + ? DoorNode.parse({ id: 'door_bed', parentId: doorOn, wallId: doorOn, position: [2.5, 1.05, 0] }) + : null + const walls = BEDROOM.map((start, i) => + WallNode.parse({ + id: BEDROOM_WALLS[i], + parentId: 'level_f', + start, + end: BEDROOM[(i + 1) % BEDROOM.length]!, + children: door && door.parentId === BEDROOM_WALLS[i] ? [door.id] : [], + }), + ) + const zone = ZoneNode.parse({ + id: 'zone_bed', + parentId: 'level_f', + name: 'Bedroom', + polygon: BEDROOM, + spaceRole: 'room', + }) + // Where the bed would stand against the north wall. + const deskItem = desk + ? ItemNode.parse({ + id: 'item_desk', + parentId: 'level_f', + position: [2.5, 0, 3.5], + asset: item('desk', [1.6, 0.8, 0.8]), + }) + : null + const level = LevelNode.parse({ + id: 'level_f', + parentId: 'building_f', + level: 0, + height: 2.8, + children: [...walls.map((wall) => wall.id), zone.id, ...(deskItem ? [deskItem.id] : [])], + }) + const building = BuildingNode.parse({ id: 'building_f', children: [level.id] }) + return graph(building, level, ...walls, zone, ...(door ? [door] : []), ...(deskItem ? [deskItem] : [])) + } +} + +const furnished = (result: Record, nodes: Nodes) => { + const items = Object.values(nodes) + const itemIds = result.itemIds as string[] + return problems( + [itemIds.length > 0, 'nothing placed'], + [ + itemIds.every((id) => nodes[id]?.type === 'item' && nodes[id]?.parentId === 'level_f'), + 'an item is not on the room level', + ], + [findBlockedDoors({ nodes: items }).length === 0, 'an item blocks a door'], + [findItemItemCollisions({ nodes: items }).length === 0, 'two items overlap'], + ) +} + +const bedPosition = (nodes: Nodes) => { + const bed = Object.values(nodes).find( + (node) => node.type === 'item' && node.asset.id === 'double-bed', + ) + return bed?.type === 'item' ? bed.position : null +} + +export const FURNISH_ROOM_CASES: AgentToolCase[] = [ + { + name: 'a room named by its zone: its level and outline; the bed faces the door from the far wall', + tool: 'furnish_room', + scene: bedroomScene({ doorOn: 'wall_s' }), + input: { zoneId: 'zone_bed', roomType: 'bedroom' }, + context: { catalog: CATALOG }, + expect: { + result: { ok: true, doorWallIndex: 0, doorsDetected: 1 }, + check: (result, nodes) => [ + ...furnished(result, nodes), + ...problems([(bedPosition(nodes)?.[2] ?? 0) > 2, 'the bed is not against the north wall']), + ], + }, + }, + { + name: 'the door it finds sets the furniture wall, whichever edge it is on', + tool: 'furnish_room', + scene: bedroomScene({ doorOn: 'wall_n' }), + input: { zoneId: 'zone_bed', roomType: 'bedroom' }, + context: { catalog: CATALOG }, + expect: { + result: { ok: true, doorWallIndex: 2, doorsDetected: 1 }, + check: (result, nodes) => [ + ...furnished(result, nodes), + ...problems([(bedPosition(nodes)?.[2] ?? 4) < 2, 'the bed is not against the south wall']), + ], + }, + }, + { + name: 'doorWallIndex names the door wall of a room with no door yet, and keeps it clear', + tool: 'furnish_room', + scene: bedroomScene(), + input: { zoneId: 'zone_bed', roomType: 'bedroom', doorWallIndex: 1 }, + context: { catalog: CATALOG }, + expect: { + result: { ok: true, doorWallIndex: 1, doorsDetected: 0 }, + check: (result, nodes) => [ + ...furnished(result, nodes), + ...problems([(bedPosition(nodes)?.[0] ?? 5) < 2.5, 'the bed is not against the west wall']), + ], + }, + }, + { + name: 'a polygon instead of a zone, on the lowest storey when no level is named', + tool: 'furnish_room', + scene: bedroomScene({ doorOn: 'wall_s' }), + input: { polygon: BEDROOM, roomType: 'bedroom' }, + context: { catalog: CATALOG }, + expect: { result: { ok: true, doorWallIndex: 0 }, check: furnished }, + }, + { + name: 'an item already in the room is never overlapped', + tool: 'furnish_room', + scene: bedroomScene({ doorOn: 'wall_s', desk: true }), + input: { zoneId: 'zone_bed', roomType: 'bedroom' }, + context: { catalog: CATALOG }, + expect: { result: { ok: true }, check: furnished }, + }, + { + name: 'a piece the catalog lacks is skipped and named', + tool: 'furnish_room', + scene: bedroomScene({ doorOn: 'wall_s' }), + input: { zoneId: 'zone_bed', roomType: 'bedroom' }, + context: { catalog: CATALOG.filter((entry) => entry.id !== 'bedside-table') }, + expect: { result: { ok: true }, mentions: ['bedside-table: not in the catalog'] }, + }, + { + name: 'no room named: room_required', + tool: 'furnish_room', + scene: bedroomScene(), + input: { roomType: 'bedroom' }, + context: { catalog: CATALOG }, + expect: { refusal: 'room_required' }, + }, + { + name: 'a wall id for the room: not_a_zone', + tool: 'furnish_room', + scene: bedroomScene(), + input: { zoneId: 'wall_s', roomType: 'bedroom' }, + context: { catalog: CATALOG }, + expect: { refusal: 'not_a_zone', mentions: ['wall_s'] }, + }, + { + name: 'a zone id that does not exist: zone_not_found', + tool: 'furnish_room', + scene: bedroomScene(), + input: { zoneId: 'zone_nope', roomType: 'bedroom' }, + context: { catalog: CATALOG }, + expect: { refusal: 'zone_not_found' }, + }, + { + name: 'a roof level takes no furniture', + tool: 'furnish_room', + scene: roomsScene, + input: { levelId: 'level_r', polygon: CLEAR, roomType: 'bedroom' }, + context: { catalog: CATALOG }, + expect: { refusal: 'roof_level' }, + }, + // Every host has a catalog (the MCP its built-in list at least); an operation run without one + // says so rather than placing nothing. + { + name: 'a host without a catalog: no_catalog', + tool: 'furnish_room', + scene: bedroomScene(), + input: { zoneId: 'zone_bed', roomType: 'bedroom' }, + surfaces: ['core', 'chat'], + expect: { refusal: 'no_catalog' }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/cases.ts b/packages/core/src/agent-operations/__fixtures__/cases.ts index cba0622f28..7202b7c902 100644 --- a/packages/core/src/agent-operations/__fixtures__/cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/cases.ts @@ -1,4 +1,6 @@ import { + type AnyNode, + type AssetInput, BuildingNode, CeilingNode, DoorNode, @@ -15,6 +17,12 @@ import { WindowNode, ZoneNode, } from '../../schema' +import { CREATE_ROOM_CASES, FURNISH_ROOM_CASES } from './build-room-cases' +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 { ADD_LEVEL_CASES, ADD_WALL_CASES, CREATE_STAIR_CASES } from './structure-cases' import { VERIFY_SCENE_CASES } from './verify-scene-cases' /** @@ -33,7 +41,8 @@ export type AgentToolCase = { tool: string scene: () => SceneGraph input: Record - context?: { activeLevelId?: string | null } + /** `catalog`: the item library the host hands search_assets. */ + context?: { activeLevelId?: string | null; catalog?: AssetInput[] } surfaces?: AgentSurface[] expect: | { refusal: string; mentions?: string[] } @@ -47,6 +56,8 @@ export type AgentToolCase = { lacks?: Record[]> /** Text the result must include. */ mentions?: string[] + /** What the table cannot state (minted ids, derived construction): the problems, if any. */ + check?: (result: Record, nodes: Readonly>) => string[] } } @@ -605,6 +616,25 @@ export const DUPLICATE_LEVEL_CASES: AgentToolCase[] = [ after: { level_ground: { level: 0 }, level_upper: { level: 2 }, level_roof: { level: 3 } }, }, }, + // The build guide deletes a family's plan-only floors, then copies its first floor up. Each copy pushed every floor above, gap included, and floor 8 went from index 7 to 10. + { + name: 'a copy fills a free floor above and moves nothing past the gap', + tool: 'duplicate_level', + scene: () => { + const graph = houseScene() + const roof = graph.nodes.level_roof as { level: number } + graph.nodes.level_roof = { ...roof, level: 4 } + return graph + }, + input: { levelId: 'level_upper' }, + expect: { + result: { floorIndex: 2, shiftedLevelIds: [] }, + after: { level_upper: { level: 1 }, level_roof: { level: 4 } }, + }, + // The live stores (chat and MCP) close level gaps when a scene loads; the gap lives inside a + // batch, as in run 10 (duplicate-level.test.ts runs that sequence). + surfaces: ['core'], + }, { name: 'a name names the copy', tool: 'duplicate_level', @@ -674,6 +704,15 @@ export const DELETE_NODE_CASES: AgentToolCase[] = [ present: ['level_ground', 'item_lamp'], }, }, + // The surfaces add what the scene really lost (classcad-ai's delta check). + { + name: 'the result says what the scene lost', + tool: 'delete_node', + scene: houseScene, + input: { id: 'wall_ground' }, + surfaces: ['mcp', 'chat'], + expect: { result: { achieved: { deleted: { wall: 1, door: 1, window: 1 } } } }, + }, { name: 'a wall goes with its doors and windows', tool: 'delete_node', @@ -731,4 +770,13 @@ export const AGENT_TOOL_CASES: readonly AgentToolCase[] = [ ...DUPLICATE_LEVEL_CASES, ...VERIFY_SCENE_CASES, ...DELETE_NODE_CASES, + ...ROOM_CASES, + ...CREATE_ROOM_CASES, + ...FURNISH_ROOM_CASES, + ...SEARCH_ASSETS_CASES, + ...ADD_WALL_CASES, + ...ADD_LEVEL_CASES, + ...CREATE_STAIR_CASES, + ...PLACE_ITEMS_CASES, + ...FIND_BY_TYPE_CASES, ] diff --git a/packages/core/src/agent-operations/__fixtures__/find-by-type-cases.ts b/packages/core/src/agent-operations/__fixtures__/find-by-type-cases.ts new file mode 100644 index 0000000000..2c431bcb02 --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/find-by-type-cases.ts @@ -0,0 +1,23 @@ +import type { AgentToolCase } from './cases' +import { storeysScene } from './structure-cases' + +/** + * find_by_type: an unknown level answered "nothing found" on both surfaces, where get_zones + * refuses it; the agent could not tell a typo from an empty floor. + */ +export const FIND_BY_TYPE_CASES: AgentToolCase[] = [ + { + name: 'the walls of a level', + tool: 'find_by_type', + scene: storeysScene, + input: { type: 'wall', levelId: 'level_ground' }, + expect: { result: {}, contains: { results: [{ id: 'wall_ground' }] } }, + }, + { + name: 'a level that is not there is refused with its id', + tool: 'find_by_type', + scene: storeysScene, + input: { type: 'wall', levelId: 'level_missing' }, + expect: { refusal: 'level_not_found', mentions: ['level_missing'] }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/place-items-cases.ts b/packages/core/src/agent-operations/__fixtures__/place-items-cases.ts new file mode 100644 index 0000000000..4c3014c5b9 --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/place-items-cases.ts @@ -0,0 +1,542 @@ +import { artifactUrl } from '../../lib/artifact-store' +import { scriptSource } from '../../lib/geometry-script-node' +import { + type AnyNode, + type AssetInput, + BuildingNode, + CeilingNode, + DoorNode, + GeometryArtifactManifest, + ItemNode, + LevelNode, + WallNode, + ZoneNode, +} from '../../schema' +import { compiledSolid } from './add-object-cases' +import type { AgentToolCase, SceneGraph } from './cases' +import { storeysScene } from './structure-cases' + +/** + * `place_items`: the chat's batch of floor items and the MCP's place_item, one tool. The chat's + * rules win where they differed: an id the library lacks is refused, not placed as a 0.5 m + * placeholder, and an indoor item outside every room is refused (a model once put a bed on the + * lawn). As place_item did, an item may name its host (targetNodeId): a wall, a ceiling or an + * item, its position in level coordinates as main took it. What goes wrong, written first: art + * hung inside the wall or facing it; an item taller than its wall hung anyway; a lamp set on a + * turned nightstand off its top; a bench on an add_object porch floating at the porch's full + * height, or a pendant at a flat ceiling height under a sloped vault; a door taken for a host. + * + * The ground floor holds a 6 × 5 m hall; the upper floor has no room. + */ + +const item = (id: string, name: string, category: string, tags: string[] = []): AssetInput => ({ + id, + name, + category, + tags, + thumbnail: `/items/${id}/thumbnail.webp`, + src: `/items/${id}/model.glb`, + dimensions: [1, 1, 1], +}) + +const CATALOG = [ + item('sofa', 'Sofa', 'furniture', ['seating']), + item('floor-lamp', 'Floor Lamp', 'lighting'), + item('palm', 'Palm', 'outdoor', ['tree', 'garden']), +] +const library = { activeLevelId: null, catalog: CATALOG } + +const hosted = ( + id: string, + dimensions: [number, number, number], + attachTo?: 'wall' | 'wall-side' | 'ceiling', +): AssetInput => ({ ...item(id, id, 'decor'), dimensions, ...(attachTo ? { attachTo } : {}) }) +const HOSTED_CATALOG = [ + hosted('art', [0.8, 0.6, 0.04], 'wall'), + hosted('sconce', [0.2, 0.3, 0.15], 'wall-side'), + hosted('pendant', [0.4, 0.5, 0.4], 'ceiling'), + hosted('table-lamp', [0.3, 0.45, 0.3]), + hosted('bench', [1.2, 0.45, 0.4]), + hosted('wardrobe', [1, 2.8, 0.6]), +] +const hosts = { activeLevelId: null, catalog: HOSTED_CATALOG } + +/** + * A 6 × 5 m room: a wall along its south side, (0, 0) to (6, 0), 2.6 m high and 0.2 m thick, the + * room on its left (front); its ceiling; a nightstand 0.55 m high at (1, 4), turned 90°, a vase + * on it; a porch built with add_object at (4, 3), 2 × 2 m: a landing 0.3 m up, and a vault over it + * sloping from 2.5 m (west) to 2.7 m (east); a door in the wall. + */ +export function hostScene(): SceneGraph { + const room: [number, number][] = [ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ] + const wall = WallNode.parse({ + id: 'wall_south', + parentId: 'level_h', + start: [0, 0], + end: [6, 0], + height: 2.6, + thickness: 0.2, + children: ['door_h'], + }) + const door = DoorNode.parse({ id: 'door_h', parentId: wall.id, wallId: wall.id }) + const zone = ZoneNode.parse({ id: 'zone_room', parentId: 'level_h', name: 'Room', polygon: room }) + const ceiling = CeilingNode.parse({ id: 'ceiling_room', parentId: 'level_h', polygon: room }) + const nightstand = ItemNode.parse({ + id: 'item_nightstand', + parentId: 'level_h', + name: 'Nightstand', + position: [1, 0, 4], + rotation: [0, Math.PI / 2, 0], + asset: { ...hosted('nightstand', [0.5, 0.55, 0.4]), src: '/items/nightstand/model.glb' }, + children: ['item_vase'], + }) + const vase = ItemNode.parse({ + id: 'item_vase', + parentId: nightstand.id, + name: 'Vase', + position: [0, 0.55, 0], + asset: { ...hosted('vase', [0.15, 0.3, 0.15]), src: '/items/vase/model.glb' }, + }) + const square: [number, number][] = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ] + const solid = compiledSolid([2, 3, 2]) + const compiled = { + ...solid, + manifest: GeometryArtifactManifest.parse({ + ...solid.manifest, + parts: [ + { id: 'landing', type: 'slab' }, + { id: 'vault', type: 'ceiling' }, + ], + surfaces: [{ part: 'landing', y: 0.3, polygon: square }], + // y = 2.6 + 0.1 x: −0.1 x + y − 2.6 = 0. + undersides: [{ part: 'vault', polygon: square, plane: [-0.1, 1, 0, -2.6] }], + }), + } + const porch = ItemNode.parse({ + id: 'item_porch', + parentId: 'level_h', + name: 'Porch', + position: [4, 0, 3], + source: scriptSource(compiled), + asset: { + id: `script_${compiled.sha256.slice(0, 16)}`, + category: 'porch', + name: 'Porch', + thumbnail: '', + source: 'mine', + src: artifactUrl(compiled.sha256), + dimensions: [2, 3, 2], + }, + }) + const level = LevelNode.parse({ + id: 'level_h', + parentId: 'building_h', + level: 0, + height: 2.8, + children: [wall.id, zone.id, ceiling.id, nightstand.id, porch.id], + }) + const building = BuildingNode.parse({ id: 'building_h', children: [level.id] }) + const nodes: AnyNode[] = [building, level, wall, door, zone, ceiling, nightstand, vase, porch] + return { nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), rootNodeIds: [building.id] } +} + +/** + * A bathroom, 2.2 × 1.9 m unless sized: its door, 0.8 m wide, in the middle of its south wall; the + * catalog's 2.34 m bathtub (its only one), a 1.6 m bath and a vanity. + */ +export function bathScene([width, depth]: [number, number] = [2.2, 1.9]): SceneGraph { + const room: [number, number][] = [ + [0, 0], + [width, 0], + [width, depth], + [0, depth], + ] + const wall = WallNode.parse({ + id: 'wall_bath', + parentId: 'level_b', + start: [0, 0], + end: [width, 0], + thickness: 0.1, + children: ['door_bath'], + }) + const door = DoorNode.parse({ + id: 'door_bath', + parentId: wall.id, + wallId: wall.id, + position: [width / 2, 1.05, 0], + width: 0.8, + }) + const zone = ZoneNode.parse({ id: 'zone_bath', parentId: 'level_b', name: 'Bath', polygon: room }) + const level = LevelNode.parse({ + id: 'level_b', + parentId: 'building_b', + level: 0, + children: [wall.id, zone.id], + }) + const building = BuildingNode.parse({ id: 'building_b', children: [level.id] }) + const nodes: AnyNode[] = [building, level, wall, door, zone] + return { nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), rootNodeIds: [building.id] } +} +export const BATH_CATALOG = [ + { ...item('bathtub', 'Bathtub', 'bathtubs'), dimensions: [2.34, 0.79, 1.11] }, + { ...item('bath-1600', 'Bath 1600', 'bathtubs'), dimensions: [1.6, 0.6, 0.75] }, + { ...item('vanity', 'Vanity', 'sinks'), dimensions: [0.6, 0.85, 0.45] }, +] as AssetInput[] +const bath = { activeLevelId: null, catalog: BATH_CATALOG } + +type Placed = AnyNode & { + parentId: string + position: number[] + rotation: number[] + wallId?: string + wallT?: number + side?: string +} +/** The item placed for an asset, with its fields. */ +const placed = (nodes: Readonly>, assetId: string) => + Object.values(nodes).find( + (node) => node.type === 'item' && (node as { asset: { id: string } }).asset.id === assetId, + ) as Placed | undefined +const near = (a: number | undefined, b: number) => a !== undefined && Math.abs(a - b) < 1e-3 +const at = (node: Placed | undefined, position: number[], label: string) => + node && position.every((value, i) => near(node.position[i], value)) + ? [] + : [`${label} at ${node?.position}`] + +export const PLACE_ITEMS_CASES: AgentToolCase[] = [ + { + name: 'items stand on the floor of the level named, turned in degrees', + tool: 'place_items', + scene: storeysScene, + input: { + levelId: 'level_ground', + items: [ + { assetId: 'sofa', x: 2, z: 2, rotation: '90°' }, + { assetId: 'floor-lamp', x: 1, z: 1 }, + ], + }, + context: library, + expect: { + result: { ok: true, levelId: 'level_ground' }, + contains: { + items: [ + { ok: true, assetId: 'sofa', name: 'Sofa', x: 2, z: 2 }, + { ok: true, assetId: 'floor-lamp', name: 'Floor Lamp', x: 1, z: 1 }, + ], + }, + }, + }, + { + name: 'an id the library lacks is refused on its own; the others are placed', + tool: 'place_items', + scene: storeysScene, + input: { + level: 'level_ground', + items: [ + { assetId: 'sofa', x: 2, z: 2 }, + { assetId: 'unicorn', x: 3, z: 3 }, + ], + }, + context: library, + expect: { + result: { ok: false }, + contains: { + items: [ + { ok: true, assetId: 'sofa' }, + { ok: false, assetId: 'unicorn', code: 'asset_not_found' }, + ], + }, + mentions: ['search_assets', '1 of 2'], + }, + }, + { + name: 'an indoor item outside every room of a level with rooms is refused', + tool: 'place_items', + scene: storeysScene, + input: { levelId: 'level_ground', items: [{ assetId: 'sofa', x: 10, z: 10 }] }, + context: library, + expect: { + result: { ok: false }, + contains: { items: [{ ok: false, assetId: 'sofa', code: 'outside_rooms' }] }, + }, + }, + { + name: 'a garden item may stand outside the rooms', + tool: 'place_items', + scene: storeysScene, + input: { levelId: 'level_ground', items: [{ assetId: 'palm', x: 10, z: 10 }] }, + context: library, + expect: { result: { ok: true }, contains: { items: [{ ok: true, assetId: 'palm' }] } }, + }, + { + name: 'a level without rooms takes items anywhere', + tool: 'place_items', + scene: storeysScene, + input: { levelId: 'level_upper', items: [{ assetId: 'sofa', x: 10, z: 10 }] }, + context: library, + expect: { result: { ok: true, levelId: 'level_upper' } }, + }, + { + name: 'without a level, the floor the person is viewing', + tool: 'place_items', + scene: storeysScene, + input: { items: [{ assetId: 'sofa', x: 10, z: 10 }] }, + context: { ...library, activeLevelId: 'level_upper' }, + surfaces: ['core', 'chat'], + expect: { result: { levelId: 'level_upper' } }, + }, + { + name: 'an unknown level is refused with the id', + tool: 'place_items', + scene: storeysScene, + input: { levelId: 'level_missing', items: [{ assetId: 'sofa', x: 1, z: 1 }] }, + context: library, + expect: { refusal: 'level_not_found', mentions: ['level_missing'] }, + }, + { + name: 'art hangs on a wall at the height given, on the side of the point given', + tool: 'place_items', + scene: hostScene, + input: { items: [{ assetId: 'art', targetNodeId: 'wall_south', x: 3, z: 0.4, y: 1.2 }] }, + context: hosts, + expect: { + result: { ok: true }, + contains: { items: [{ ok: true, assetId: 'art', hostId: 'wall_south', side: 'front' }] }, + check: (_result, nodes) => { + const art = placed(nodes, 'art') + return [ + ...at(art, [3, 1.2, 0], 'art'), + ...(art?.parentId === 'wall_south' && art.wallId === 'wall_south' && near(art.wallT, 0.5) + ? [] + : [`art on ${art?.parentId}, wallT ${art?.wallT}`]), + ...(art?.side === 'front' && near(art.rotation[1], 0) ? [] : [`art faces ${art?.side}`]), + ] + }, + }, + }, + { + name: "a point behind the wall hangs it on the wall's back, turned round", + tool: 'place_items', + scene: hostScene, + input: { items: [{ assetId: 'art', targetNodeId: 'wall_south', x: 2, z: -0.4, y: 1.2 }] }, + context: hosts, + expect: { + result: { ok: true }, + contains: { items: [{ ok: true, side: 'back' }] }, + check: (_result, nodes) => { + const art = placed(nodes, 'art') + return art?.side === 'back' && near(Math.abs(art.rotation[1]!), Math.PI) + ? [] + : [`art on the ${art?.side}, turned ${art?.rotation[1]}`] + }, + }, + }, + { + name: 'a wall-side fixture mounts on the face, not inside the wall', + tool: 'place_items', + scene: hostScene, + input: { items: [{ assetId: 'sconce', targetNodeId: 'wall_south', x: 1, z: 0.4, y: 1.5 }] }, + context: hosts, + expect: { + result: { ok: true }, + check: (_result, nodes) => at(placed(nodes, 'sconce'), [1, 1.5, 0.1], 'sconce'), + }, + }, + { + name: 'a wall item needs its height; one taller than the wall is refused', + tool: 'place_items', + scene: hostScene, + input: { + items: [ + { assetId: 'art', targetNodeId: 'wall_south', x: 3, z: 0.4 }, + { assetId: 'wardrobe', targetNodeId: 'wall_south', x: 3, z: 0.4, y: 0 }, + ], + }, + context: hosts, + expect: { + result: { ok: false }, + contains: { + items: [ + { ok: false, assetId: 'art', code: 'height_required' }, + { ok: false, assetId: 'wardrobe', code: 'item_too_tall' }, + ], + }, + }, + }, + { + name: 'a pendant hangs flush under the ceiling, where it is given', + tool: 'place_items', + scene: hostScene, + input: { items: [{ assetId: 'pendant', targetNodeId: 'ceiling_room', x: 2, z: 2 }] }, + context: hosts, + expect: { + result: { ok: true }, + contains: { items: [{ ok: true, hostId: 'ceiling_room' }] }, + check: (_result, nodes) => { + const pendant = placed(nodes, 'pendant') + return [ + ...at(pendant, [2, -0.5, 2], 'pendant'), + ...(pendant?.parentId === 'ceiling_room' ? [] : [`pendant on ${pendant?.parentId}`]), + ] + }, + }, + }, + { + name: "a lamp stands on a turned nightstand's top, in the nightstand's frame", + tool: 'place_items', + scene: hostScene, + input: { + items: [ + { assetId: 'table-lamp', targetNodeId: 'item_nightstand', x: 1, z: 4.1, rotation: 90 }, + ], + }, + context: hosts, + expect: { + result: { ok: true }, + check: (_result, nodes) => { + const lamp = placed(nodes, 'table-lamp') + return [ + // 0.1 m south of its centre, which the 90° turn makes local −x. + ...at(lamp, [-0.1, 0.55, 0], 'lamp'), + ...(lamp?.parentId === 'item_nightstand' && near(lamp.rotation[1], 0) + ? [] + : [`lamp on ${lamp?.parentId}, turned ${lamp?.rotation[1]}`]), + ] + }, + }, + }, + { + name: "on an add_object porch a bench rests on its landing, and the result names it", + tool: 'place_items', + scene: hostScene, + input: { items: [{ assetId: 'bench', targetNodeId: 'item_porch', x: 4.2, z: 3 }] }, + context: hosts, + expect: { + result: { ok: true }, + contains: { items: [{ ok: true, assetId: 'bench', restingOn: 'landing' }] }, + check: (_result, nodes) => at(placed(nodes, 'bench'), [0.2, 0.3, 0], 'bench'), + }, + }, + { + name: "a pendant hangs from the porch's sloped vault at the height above it", + tool: 'place_items', + scene: hostScene, + input: { items: [{ assetId: 'pendant', targetNodeId: 'item_porch', x: 4.5, z: 3 }] }, + context: hosts, + expect: { + result: { ok: true }, + contains: { items: [{ ok: true, restingOn: 'vault' }] }, + // The vault is 2.65 m up 0.5 m east of the porch's centre; the pendant hangs its 0.5 m. + check: (_result, nodes) => at(placed(nodes, 'pendant'), [0.5, 2.15, 0], 'pendant'), + }, + }, + { + name: 'a room or a slab as the target means its floor', + tool: 'place_items', + scene: hostScene, + input: { items: [{ assetId: 'bench', targetNodeId: 'zone_room', x: 3, z: 2 }] }, + context: hosts, + expect: { + result: { ok: true }, + check: (_result, nodes) => { + const bench = placed(nodes, 'bench') + return [ + ...at(bench, [3, 0, 2], 'bench'), + ...(bench?.parentId === 'level_h' ? [] : [`bench on ${bench?.parentId}`]), + ] + }, + }, + }, + { + name: 'a door is no host, a missing host is named, and an item on an item hosts nothing more', + tool: 'place_items', + scene: hostScene, + input: { + items: [ + { assetId: 'table-lamp', targetNodeId: 'door_h', x: 3, z: 0.4 }, + { assetId: 'table-lamp', targetNodeId: 'item_gone', x: 3, z: 0.4 }, + { assetId: 'table-lamp', targetNodeId: 'item_vase', x: 1, z: 4 }, + ], + }, + context: hosts, + expect: { + result: { ok: false }, + contains: { + items: [ + { ok: false, code: 'unsupported_host' }, + { ok: false, code: 'host_not_found' }, + { ok: false, code: 'host_not_on_level' }, + ], + }, + }, + }, + { + // furnish_room skipped the tub as blocking the bath's door; an agent then put it there itself + // with place_items, which checked only that its centre was in a room. + name: 'an item in front of a door is refused, naming the door, with a spot that fits', + tool: 'place_items', + scene: bathScene, + input: { items: [{ assetId: 'bath-1600', x: 1.1, z: 0.45 }] }, + context: bath, + expect: { + result: { ok: false }, + contains: { items: [{ ok: false, assetId: 'bath-1600', code: 'blocks_door' }] }, + mentions: ['door_bath', 'A spot that fits'], + }, + }, + { + name: 'an item larger than its room is refused with both sizes, pointing to a smaller one', + tool: 'place_items', + scene: bathScene, + input: { items: [{ assetId: 'bathtub', x: 1.1, z: 1.3 }] }, + context: bath, + expect: { + result: { ok: false }, + contains: { items: [{ ok: false, assetId: 'bathtub', code: 'too_large_for_room' }] }, + mentions: ['2.34', '2.2 × 1.9', 'add_object'], + }, + }, + { + name: 'furnish_room names what it skips with its size', + tool: 'furnish_room', + // A bath of 3.1 × 2.2 m, large enough to be given a tub. + scene: () => bathScene([3.1, 2.2]), + input: { zoneId: 'zone_bath', roomType: 'bathroom' }, + context: bath, + expect: { result: { ok: true }, mentions: ['bathtub (2.34 × 1.11 m)'] }, + }, + { + name: 'an item clear of the door, inside its room, is placed', + tool: 'place_items', + scene: bathScene, + input: { + items: [ + { assetId: 'vanity', x: 1.8, z: 1.6 }, + { assetId: 'bath-1600', x: 1.1, z: 1.45 }, + ], + }, + context: bath, + expect: { + result: { ok: true }, + contains: { items: [{ ok: true, assetId: 'vanity' }, { ok: true, assetId: 'bath-1600' }] }, + }, + }, + { + name: 'a host without a library is refused rather than guessing', + tool: 'place_items', + scene: storeysScene, + input: { levelId: 'level_ground', items: [{ assetId: 'sofa', x: 1, z: 1 }] }, + surfaces: ['core', 'chat'], + expect: { refusal: 'no_catalog' }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/room-cases.ts b/packages/core/src/agent-operations/__fixtures__/room-cases.ts new file mode 100644 index 0000000000..2096da91d7 --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/room-cases.ts @@ -0,0 +1,363 @@ +import { createZone, cutFloorOpening, divideZone, structureChangeBatch } from '../../commands/structure' +import { reconcileSceneStructure } from '../../lib/structure-reconcile' +import { type AnyNode, BuildingNode, ItemNode, LevelNode } from '../../schema' +import { applySceneChanges } from '../apply-changes' +import type { AgentToolCase, SceneGraph } from './cases' + +/** + * The room transforms and floor-construction tools (divide_zone … create_mezzanine) on an 8 × 4 m + * kitchen with its own walls, built and reconciled by the editor's own commands so its ceiling and + * floor plate are the derived ones. The divided kitchen has a 5 × 1 m pantry split off along its + * south-east corner by two separators. + */ + +type Build = { divided?: boolean; hatch?: boolean; lamp?: boolean } +type Nodes = Readonly> + +function kitchenScene({ divided, hatch, lamp }: Build = {}): SceneGraph { + const names: Record = { + wall: ['wall_south', 'wall_east', 'wall_north', 'wall_west'], + zone: ['zone_kitchen', 'zone_pantry'], + ceiling: ['ceiling_kitchen', 'ceiling_pantry'], + separator: ['separator_a', 'separator_b'], + 'floor-opening': ['floor-opening_hatch'], + } + const mintId = (kind: string) => names[kind]?.shift() ?? `${kind}_fixture` + const building = BuildingNode.parse({ id: 'building_rooms', children: ['level_rooms'] }) + const level = LevelNode.parse({ id: 'level_rooms', parentId: building.id, level: 0, height: 3 }) + let nodes: Record = { [building.id]: building, [level.id]: level } + const apply = (plan: { changes: Parameters[0] }) => { + nodes = applySceneChanges(nodes, structureChangeBatch(plan.changes)) + nodes = { ...reconcileSceneStructure({ nodes, mintId }).nodes } as Record + } + apply( + createZone(nodes, { + levelId: level.id, + name: 'Kitchen', + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ], + enclose: true, + mintId, + }), + ) + if (divided) + apply( + divideZone(nodes, { + zoneId: 'zone_kitchen', + path: [ + [3, 0], + [3, 1], + [8, 1], + ], + mintId, + }), + ) + if (hatch) + apply( + cutFloorOpening(nodes, { + zoneId: 'zone_kitchen', + rect: { x: 1, z: 2, width: 1, depth: 1 }, + mintId, + }), + ) + if (lamp) { + const item = ItemNode.parse({ + id: 'item_lamp', + parentId: 'ceiling_kitchen', + position: [4, -0.1, 2], + asset: { + id: 'lamp', + category: 'lighting', + name: 'Lamp', + thumbnail: '/items/lamp/thumbnail.webp', + src: '/items/lamp/model.glb', + dimensions: [1, 1, 1], + attachTo: 'ceiling', + }, + }) + nodes = applySceneChanges(nodes, { create: [{ node: item, parentId: 'ceiling_kitchen' }] }) + } + return { nodes, rootNodeIds: [building.id] } +} + +/** 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', +)!.id + +/** The scene holds this many nodes of each type: rooms and separators the call minted. */ +const holds = + (expected: Record) => (_result: Record, nodes: Nodes) => + Object.entries(expected).flatMap(([type, count]) => { + const found = Object.values(nodes).filter((node) => node.type === type).length + return found === count ? [] : [`${count} ${type} expected, ${found} found`] + }) + +export const ROOM_CASES: AgentToolCase[] = [ + { + name: 'divides an open plan with a path; the seed side keeps its id', + tool: 'divide_zone', + scene: kitchenScene, + input: { + zoneId: 'zone_kitchen', + path: [ + [3, 0], + [3, 1], + [8, 1], + ], + }, + expect: { + result: { changes: 2 }, + after: { + zone_kitchen: { + polygon: [ + [0, 0], + [3, 0], + [3, 1], + [8, 1], + [8, 4], + [0, 4], + ], + }, + }, + check: (result, nodes) => [ + ...holds({ zone: 2, separator: 2, ceiling: 2 })(result, nodes), + ...((result.zoneIds as string[] | undefined)?.length === 2 && + (result.zoneIds as string[]).includes('zone_kitchen') + ? [] + : ['zoneIds lists both rooms']), + ...((result.separatorIds as string[] | undefined)?.length === 2 + ? [] + : ['separatorIds lists both separators']), + ], + }, + }, + { + name: 'an island too small to be a room is a conflict, and nothing changes', + tool: 'divide_zone', + scene: kitchenScene, + input: { + zoneId: 'zone_kitchen', + closed: true, + path: [ + [3, 1], + [3.2, 1], + [3.2, 1.2], + [3, 1.2], + ], + }, + expect: { + result: { changes: 0, separatorIds: [] }, + contains: { conflicts: [{ code: 'small-island' }] }, + check: holds({ zone: 1, separator: 0 }), + }, + }, + { + name: 'a room that does not exist is refused', + tool: 'divide_zone', + scene: kitchenScene, + input: { + zoneId: 'zone_missing', + path: [ + [3, 0], + [3, 4], + ], + }, + expect: { refusal: 'structure_refused', mentions: ['zone_missing'] }, + }, + { + name: 'merges two rooms by removing the separators between them', + tool: 'merge_zones', + scene: () => kitchenScene({ divided: true }), + input: { zoneIds: ['zone_kitchen', 'zone_pantry'] }, + expect: { + result: { changes: 2 }, + absent: ['separator_a', 'separator_b'], + check: holds({ zone: 1, ceiling: 1 }), + }, + }, + { + name: 'deletes a room with the walls only it uses', + tool: 'delete_zone', + scene: kitchenScene, + input: { zoneId: 'zone_kitchen', contents: 'keep' }, + expect: { + result: { payload: { zoneId: 'zone_kitchen', mode: 'delete', contents: 'keep' } }, + absent: ['zone_kitchen', 'wall_south', 'wall_east', 'wall_north', 'wall_west'], + check: holds({ zone: 0, ceiling: 0 }), + }, + }, + { + name: 'a room Divide made merges back into its neighbour', + tool: 'delete_zone', + scene: () => kitchenScene({ divided: true }), + input: { zoneId: 'zone_pantry', contents: 'delete' }, + expect: { + result: { + payload: { zoneId: 'zone_pantry', mode: 'merge', mergedIntoZoneId: 'zone_kitchen' }, + }, + present: ['zone_kitchen', 'wall_south'], + absent: ['zone_pantry', 'separator_a', 'separator_b'], + check: holds({ zone: 1 }), + }, + }, + { + name: 'renames a room and sets its floor finish', + tool: 'set_zone_intent', + scene: kitchenScene, + input: { zoneId: 'zone_kitchen', patch: { name: 'Studio', floor: { finish: 'wood' } } }, + expect: { + result: { changes: 1 }, + after: { zone_kitchen: { name: 'Studio', floor: { finish: 'wood' } } }, + }, + }, + { + name: 'a floor key no room has is a conflict that says what to choose', + tool: 'set_zone_intent', + scene: kitchenScene, + input: { zoneId: 'zone_kitchen', patch: { floor: { footprint: 'missing-floor' } } }, + expect: { + result: { changes: 0 }, + contains: { conflicts: [{ code: 'room-floor-footprint' }] }, + mentions: ['Choose an existing floor key'], + after: { zone_kitchen: { name: 'Kitchen' } }, + }, + }, + { + name: 'moves a room with its walls', + tool: 'move_zone', + scene: kitchenScene, + input: { zoneId: 'zone_kitchen', translate: [10, 0] }, + expect: { + result: { zoneId: 'zone_kitchen', idMap: { zone_kitchen: ['zone_kitchen'] } }, + after: { zone_kitchen: { seed: [14, 2] }, wall_south: { start: [10, 0], end: [18, 0] } }, + check: holds({ zone: 1, wall: 4 }), + }, + }, + { + name: 'duplicates a room, its ceiling lamp hung on the copy’s ceiling', + tool: 'duplicate_zone', + scene: () => kitchenScene({ lamp: true }), + input: { zoneId: 'zone_kitchen', translate: [10, 0] }, + expect: { + result: {}, + after: { zone_kitchen: { seed: [4, 2] }, item_lamp: { parentId: 'ceiling_kitchen' } }, + check: (result, nodes) => { + const copy = nodes[(result.idMap as Record).item_lamp?.[0] ?? ''] + const ceiling = copy?.parentId ? nodes[copy.parentId] : undefined + return [ + ...holds({ zone: 2, wall: 8, ceiling: 2, item: 2 })(result, nodes), + ...(ceiling?.type === 'ceiling' && ceiling.zoneId === result.zoneId + ? [] + : ['the copied lamp hangs on the copy’s ceiling']), + ] + }, + }, + }, + { + name: 'turns a room a quarter about its centre, snapped to the grid', + tool: 'rotate_zone', + scene: kitchenScene, + input: { zoneId: 'zone_kitchen', quarterTurns: 1 }, + expect: { + result: { zoneId: 'zone_kitchen' }, + after: { wall_south: { start: [2, 6], end: [2, -2] } }, + check: holds({ zone: 1, wall: 4 }), + }, + }, + { + name: 'puts the outside faces of a level on their reference lines', + tool: 'lock_outside_faces', + scene: kitchenScene, + input: { levelId: 'level_rooms' }, + expect: { + result: {}, + after: { + wall_south: { justification: 'a' }, + wall_east: { justification: 'a' }, + wall_north: { justification: 'a' }, + wall_west: { justification: 'a' }, + }, + }, + }, + { + name: 'a level and rooms together are refused', + tool: 'lock_outside_faces', + scene: kitchenScene, + input: { levelId: 'level_rooms', zoneIds: ['zone_kitchen'] }, + expect: { refusal: 'target_required', mentions: ['levelId or zoneIds'] }, + }, + { + name: 'cuts a floor opening in a room', + tool: 'cut_floor_opening', + scene: kitchenScene, + input: { zoneId: 'zone_kitchen', rect: { x: 2, z: 2, width: 1, depth: 1 } }, + expect: { + result: { hints: [] }, + check: (result, nodes) => [ + ...holds({ 'floor-opening': 1 })(result, nodes), + ...(nodes[(result.openingIds as string[] | undefined)?.[0] ?? '']?.type === 'floor-opening' + ? [] + : ['openingIds names the opening']), + ], + }, + }, + { + name: 'removes a floor opening', + tool: 'remove_floor_opening', + scene: () => kitchenScene({ hatch: true }), + input: { id: 'floor-opening_hatch' }, + expect: { + result: { openingId: 'floor-opening_hatch' }, + absent: ['floor-opening_hatch'], + check: holds({ 'floor-opening': 0 }), + }, + }, + { + name: 'raises a ground floor on a foundation', + tool: 'set_floor_foundation', + scene: kitchenScene, + input: { slabId: plateId, patch: { foundationHeight: 0.5 } }, + expect: { result: {}, after: { [plateId]: { foundation: { type: 'solid' } } } }, + }, + { + name: 'a plate that does not exist is refused', + tool: 'set_floor_foundation', + scene: kitchenScene, + input: { slabId: 'slab_missing', patch: { foundationHeight: 0.5 } }, + expect: { refusal: 'structure_refused', mentions: ['slab_missing'] }, + }, + { + name: 'thickens the slab a room stands on', + tool: 'set_room_floor_construction', + scene: kitchenScene, + input: { zoneId: 'zone_kitchen', patch: { thickness: 0.3 } }, + expect: { result: {}, after: { [plateId]: { thickness: 0.3 } } }, + }, + { + name: 'sets a footprint reference datum', + tool: 'rebase_floor_reference', + scene: kitchenScene, + input: { slabId: plateId, referenceFloorElevation: 0.2 }, + expect: { result: {}, after: { [plateId]: { referenceFloorElevation: 0.2 } } }, + }, + { + name: 'adds a mezzanine over part of a room', + tool: 'create_mezzanine', + scene: kitchenScene, + input: { + hostZoneId: 'zone_kitchen', + polygon: [ + [0, 0], + [3, 0], + [3, 4], + [0, 4], + ], + }, + expect: { result: {}, check: holds({ zone: 2 }) }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/search-assets-cases.ts b/packages/core/src/agent-operations/__fixtures__/search-assets-cases.ts new file mode 100644 index 0000000000..6e0990ca5f --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/search-assets-cases.ts @@ -0,0 +1,151 @@ +import type { AssetInput } from '../../schema' +import type { AgentToolCase, SceneGraph } from './cases' + +/** + * `search_assets`: the host's item library, several queries in one call. The chat searched its + * library in batches; the MCP searched one query, on id and tags too, with a category filter, and + * mixed in design presets placed with place_design, a tool the chat lacks. One search now: batched, + * id and tags matched, category per query, library items only, and a miss points to add_object. + */ + +const item = ( + id: string, + name: string, + category: string, + tags: string[], + dimensions?: [number, number, number], + attachTo?: AssetInput['attachTo'], +): AssetInput => ({ + id, + name, + category, + tags, + thumbnail: `/items/${id}/thumbnail.webp`, + src: `/items/${id}/model.glb`, + ...(dimensions ? { dimensions } : {}), + ...(attachTo ? { attachTo } : {}), +}) + +export const LIBRARY: AssetInput[] = [ + item('sofa', 'Sofa', 'furniture', ['seating', 'living'], [2.5, 0.8, 1.5]), + item('dining-chair', 'Dining Chair', 'furniture', ['seating', 'dining'], [0.5, 1, 0.5]), + item('garden-chair', 'Garden Chair', 'outdoor', ['seating', 'garden'], [0.6, 0.9, 0.6]), + item('palm', 'Palm', 'outdoor', ['tree', 'plant', 'garden'], [1.5, 3, 1.5]), + item('picture', 'Picture', 'decor', ['art'], undefined, 'wall-side'), +] + +const noScene = (): SceneGraph => ({ nodes: {}, rootNodeIds: [] }) +const library = { activeLevelId: null, catalog: LIBRARY } + +export const SEARCH_ASSETS_CASES: AgentToolCase[] = [ + { + name: 'one group per query, in order, on the name, category or tags', + tool: 'search_assets', + scene: noScene, + input: { queries: [{ query: 'sofa' }, { query: 'plant' }] }, + context: library, + expect: { + result: { + total: 2, + groups: [ + { + query: 'sofa', + total: 1, + results: [ + { + id: 'sofa', + name: 'Sofa', + category: 'furniture', + dimensions: [2.5, 0.8, 1.5], + attachTo: null, + }, + ], + }, + { query: 'plant', total: 1, results: [{ id: 'palm', category: 'outdoor' }] }, + ], + }, + }, + }, + { + name: 'every word of a query must match', + tool: 'search_assets', + scene: noScene, + input: { queries: [{ query: 'dining chair' }, { query: 'Garden SEATING' }] }, + context: library, + expect: { + result: { + groups: [ + { total: 1, results: [{ id: 'dining-chair' }] }, + { total: 1, results: [{ id: 'garden-chair' }] }, + ], + }, + }, + }, + { + name: 'an id finds its item', + tool: 'search_assets', + scene: noScene, + input: { queries: [{ query: 'dining-chair' }] }, + context: library, + expect: { result: { groups: [{ total: 1, results: [{ id: 'dining-chair' }] }] } }, + }, + { + name: 'a category narrows a query', + tool: 'search_assets', + scene: noScene, + input: { queries: [{ query: 'chair', category: 'outdoor' }] }, + context: library, + expect: { result: { total: 1, groups: [{ total: 1, results: [{ id: 'garden-chair' }] }] } }, + }, + { + name: 'a wall item says where it mounts, and an item without dimensions reads 1 m a side', + tool: 'search_assets', + scene: noScene, + input: { queries: [{ query: 'art' }] }, + context: library, + expect: { + result: { + groups: [ + { results: [{ id: 'picture', attachTo: 'wall-side', dimensions: [1, 1, 1] }] }, + ], + }, + }, + }, + { + name: 'a fixture the library lacks points to add_object, not to a design of its own', + tool: 'search_assets', + scene: noScene, + input: { queries: [{ query: 'wall light' }] }, + context: library, + expect: { + result: { + total: 0, + groups: [{ query: 'wall light', total: 0, results: [] }], + hint: 'Nothing in the library matches "wall light". Build what it lacks with add_object.', + }, + }, + }, + { + name: 'the hint names only the queries that found nothing', + tool: 'search_assets', + scene: noScene, + input: { queries: [{ query: 'sofa' }, { query: 'gazebo' }, { query: 'sconce' }] }, + context: library, + expect: { + result: { + total: 1, + groups: [{ total: 1 }, { query: 'gazebo', total: 0 }, { query: 'sconce', total: 0 }], + hint: 'Nothing in the library matches "gazebo", "sconce". Build what it lacks with add_object.', + }, + }, + }, + { + // The MCP always has one (its built-in list when the host passes none). + name: 'a host without a catalog is refused rather than answered with nothing', + tool: 'search_assets', + scene: noScene, + input: { queries: [{ query: 'sofa' }] }, + surfaces: ['core', 'chat'], + expect: { refusal: 'no_catalog' }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/structure-cases.ts b/packages/core/src/agent-operations/__fixtures__/structure-cases.ts new file mode 100644 index 0000000000..b37c921f0a --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/structure-cases.ts @@ -0,0 +1,617 @@ +import { + type AnyNode, + BuildingNode, + CeilingNode, + LevelNode, + SiteNode, + SlabNode, + WallNode, + ZoneNode, +} from '../../schema' +import type { AgentToolCase, SceneGraph } from './cases' + +/** + * `add_wall`, `add_level` and `create_stair`: one tool each where the MCP and the chat had two + * (create_wall, create_level, create_stair_between_levels), each with its own rules. Where the two + * disagreed the editor decides: a stair owns its floor openings and gets a storey made for it, + * a level goes above the highest or below the lowest. The MCP's roof refusals are kept. + * + * A house of two 2.8 m storeys: the ground floor has a 6 × 5 m hall and one wall, the upper floor + * a slab over the whole of it. Next to it a 3 m storey under a declared roof level. + */ + +type Pt = [number, number] +const HALL: Pt[] = [ + [0, 0], + [6, 0], + [6, 5], + [0, 5], +] + +const graph = (...nodes: { id: string }[]): SceneGraph => ({ + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: nodes + .filter((node) => (node as { type?: string }).type === 'building') + .map((node) => node.id), +}) + +export function storeysScene(): SceneGraph { + const wall = WallNode.parse({ + id: 'wall_ground', + parentId: 'level_ground', + name: 'Wall 1', + start: [0, 0], + end: [6, 0], + }) + const hall = ZoneNode.parse({ + id: 'zone_hall', + parentId: 'level_ground', + name: 'Hall', + polygon: HALL, + }) + const groundSlab = SlabNode.parse({ id: 'slab_ground', parentId: 'level_ground', polygon: HALL }) + const upperSlab = SlabNode.parse({ id: 'slab_upper', parentId: 'level_upper', polygon: HALL }) + const ground = LevelNode.parse({ + id: 'level_ground', + parentId: 'building_house', + level: 0, + name: 'Ground', + height: 2.8, + children: [wall.id, hall.id, groundSlab.id], + }) + const upper = LevelNode.parse({ + id: 'level_upper', + parentId: 'building_house', + level: 1, + name: 'Upper', + height: 2.8, + children: [upperSlab.id], + }) + const house = BuildingNode.parse({ id: 'building_house', children: [ground.id, upper.id] }) + const storey = LevelNode.parse({ + id: 'level_storey', + parentId: 'building_roofed', + level: 0, + name: 'Storey', + height: 3, + }) + const roof = LevelNode.parse({ + id: 'level_roof', + parentId: 'building_roofed', + level: 1, + name: 'Roof', + height: 3, + metadata: { role: 'roof' }, + }) + const roofed = BuildingNode.parse({ id: 'building_roofed', children: [storey.id, roof.id] }) + return graph(house, ground, upper, wall, hall, groundSlab, upperSlab, roofed, storey, roof) +} + +/** One building, one storey, nothing on it. */ +function soloScene(): SceneGraph { + const level = LevelNode.parse({ + id: 'level_solo', + parentId: 'building_solo', + level: 0, + height: 2.5, + }) + return graph(BuildingNode.parse({ id: 'building_solo', children: [level.id] }), level) +} + +const bareBuildingScene = (): SceneGraph => graph(BuildingNode.parse({ id: 'building_bare' })) + +const emptyScene = (): SceneGraph => ({ nodes: {}, rootNodeIds: [] }) + +export const ADD_WALL_CASES: AgentToolCase[] = [ + { + name: 'a wall on the level named, numbered as the editor numbers walls', + tool: 'add_wall', + scene: storeysScene, + input: { levelId: 'level_upper', start: [0, 0], end: [3, 4] }, + expect: { result: { ok: true, levelId: 'level_upper', length: 5 }, mentions: ['Wall 2'] }, + }, + { + name: 'level is the same field as levelId', + tool: 'add_wall', + scene: storeysScene, + input: { level: 'level_upper', start: [0, 0], end: [4, 0] }, + expect: { result: { levelId: 'level_upper', length: 4 } }, + }, + { + name: 'without a level, the floor the person is viewing', + tool: 'add_wall', + scene: storeysScene, + input: { start: [0, 0], end: [4, 0] }, + context: { activeLevelId: 'level_upper' }, + surfaces: ['core', 'chat'], + expect: { result: { levelId: 'level_upper' } }, + }, + { + name: 'without a level or a viewed floor, the lowest storey', + tool: 'add_wall', + scene: storeysScene, + input: { start: [0, 0], end: [4, 0] }, + expect: { result: { levelId: 'level_ground' } }, + }, + { + name: 'a bend past half the chord is clamped to a half circle', + tool: 'add_wall', + scene: storeysScene, + input: { levelId: 'level_ground', start: [0, 3], end: [4, 3], curveOffset: '3 m' }, + expect: { result: { curveOffset: 2 } }, + }, + { + name: 'a declared roof level is not a storey and takes no walls', + tool: 'add_wall', + scene: storeysScene, + input: { levelId: 'level_roof', start: [0, 0], end: [4, 0] }, + expect: { refusal: 'roof_level', mentions: ['level_roof'] }, + }, + { + name: 'an unknown level is refused with the id', + tool: 'add_wall', + scene: storeysScene, + input: { levelId: 'level_missing', start: [0, 0], end: [4, 0] }, + expect: { refusal: 'level_not_found', mentions: ['level_missing'] }, + }, + { + name: 'a node that is not a level is refused', + tool: 'add_wall', + scene: storeysScene, + input: { levelId: 'wall_ground', start: [0, 0], end: [4, 0] }, + expect: { refusal: 'not_a_level' }, + }, + { + name: 'a wall shorter than a centimetre is refused, as the editor does not draw it', + tool: 'add_wall', + scene: storeysScene, + input: { levelId: 'level_ground', start: [1, 1], end: [1, 1.005] }, + expect: { refusal: 'wall_too_short' }, + }, +] + +export const ADD_LEVEL_CASES: AgentToolCase[] = [ + { + name: 'above the highest level of the viewed building, at the default storey height', + tool: 'add_level', + scene: storeysScene, + input: { name: 'Attic' }, + context: { activeLevelId: 'level_ground' }, + surfaces: ['core', 'chat'], + expect: { + result: { ok: true, buildingId: 'building_house', floorIndex: 2, name: 'Attic', height: 2.5 }, + }, + }, + { + name: 'below the lowest level for a basement, at the height asked', + tool: 'add_level', + scene: storeysScene, + input: { buildingId: 'building_house', position: 'below', height: '3 m' }, + expect: { result: { buildingId: 'building_house', floorIndex: -1, height: 3 } }, + }, + { + name: 'the scene’s only building needs no id', + tool: 'add_level', + scene: soloScene, + input: {}, + expect: { result: { buildingId: 'building_solo', floorIndex: 1 } }, + }, + { + name: 'a building with no level yet gets its ground floor', + tool: 'add_level', + scene: bareBuildingScene, + input: {}, + expect: { result: { buildingId: 'building_bare', floorIndex: 0 } }, + }, + { + name: 'with several buildings and none viewed, the building is asked for', + tool: 'add_level', + scene: storeysScene, + input: {}, + expect: { refusal: 'building_required', mentions: ['building_house', 'building_roofed'] }, + }, + { + name: 'an unknown building is refused with the id', + tool: 'add_level', + scene: storeysScene, + input: { buildingId: 'building_missing' }, + expect: { refusal: 'building_not_found', mentions: ['building_missing'] }, + }, + { + name: 'a node that is not a building is refused', + tool: 'add_level', + scene: storeysScene, + input: { buildingId: 'level_ground' }, + expect: { refusal: 'not_a_building' }, + }, + // The fresh start: an agent that cleared the scene to restart could not begin again with + // the tools (add_level answered no_building, add_wall no_levels). An empty scene gets the + // editor's own empty scene: a site, its building, the ground level. + { + name: 'a scene with no building starts as the editor starts: site, building, ground level', + tool: 'add_level', + scene: emptyScene, + input: {}, + expect: { + result: { ok: true, floorIndex: 0 }, + check: (result, nodes) => { + const level = nodes[result.levelId as string] + const building = nodes[result.buildingId as string] + const site = nodes[result.siteId as string] + return [ + ...(level?.type === 'level' && level.parentId === building?.id ? [] : ['no ground level']), + ...(building?.type === 'building' && building.parentId === site?.id ? [] : ['no building']), + ...(site?.type === 'site' && site.parentId == null ? [] : ['no site at the root']), + ] + }, + }, + }, + { + name: 'a site with no building gets its building and ground level', + tool: 'add_level', + scene: () => ({ + nodes: { site_lot: SiteNode.parse({ id: 'site_lot', children: [] }) }, + rootNodeIds: ['site_lot'], + }), + input: {}, + expect: { + result: { ok: true, floorIndex: 0 }, + check: (result, nodes) => { + const building = nodes[result.buildingId as string] + return [ + ...('siteId' in result ? ['made a second site'] : []), + ...(building?.parentId === 'site_lot' ? [] : ['the building is not on the site']), + ] + }, + }, + }, +] + +/** The house with the hall's ceiling on the ground floor, the surface a flight cuts below. */ +function ceiledScene(): SceneGraph { + const scene = storeysScene() + const ceiling = CeilingNode.parse({ id: 'ceiling_hall', parentId: 'level_ground', polygon: HALL }) + const ground = scene.nodes.level_ground as LevelNode + scene.nodes.ceiling_hall = ceiling + scene.nodes.level_ground = { ...ground, children: [...ground.children, ceiling.id] } + return scene +} + +/** + * The house with its hall split in two rooms, west and east, on both floors: a slab per room + * upstairs (or only the west one) and a ceiling per room below, the west first on each level. + */ +function splitScene(eastSlab = true): SceneGraph { + const scene = storeysScene() + const west: Pt[] = [ + [0, 0], + [3, 0], + [3, 5], + [0, 5], + ] + const east: Pt[] = [ + [3, 0], + [6, 0], + [6, 5], + [3, 5], + ] + const slabs = [ + SlabNode.parse({ id: 'slab_upper_west', parentId: 'level_upper', polygon: west }), + ...(eastSlab + ? [SlabNode.parse({ id: 'slab_upper_east', parentId: 'level_upper', polygon: east })] + : []), + ] + const ceilings = [ + CeilingNode.parse({ id: 'ceiling_west', parentId: 'level_ground', polygon: west }), + CeilingNode.parse({ id: 'ceiling_east', parentId: 'level_ground', polygon: east }), + ] + delete scene.nodes.slab_upper + for (const node of [...slabs, ...ceilings]) scene.nodes[node.id] = node + const upper = scene.nodes.level_upper as LevelNode + const ground = scene.nodes.level_ground as LevelNode + scene.nodes.level_upper = { ...upper, children: slabs.map((slab) => slab.id) } + scene.nodes.level_ground = { + ...ground, + children: [...ground.children, ...ceilings.map((ceiling) => ceiling.id)], + } + return scene +} + +type Stair = AnyNode & { + name?: string + railingMode?: string + materialPreset?: string + slabOpeningMode?: string + openingOffset?: number + children: string[] +} +type Opening = AnyNode & { + parentId: string + polygon: Pt[] + drawnOn?: string + ownerId?: string + source?: string +} +const stairOf = (nodes: Readonly>) => + Object.values(nodes).find((node) => node.type === 'stair') as Stair | undefined +const openingsOf = (nodes: Readonly>) => + Object.values(nodes).filter((node) => node.type === 'floor-opening') as Opening[] +/** An outline's extent: [minX, minZ, maxX, maxZ]. */ +const extent = (polygon: Pt[]) => [ + Math.min(...polygon.map((p) => p[0])), + Math.min(...polygon.map((p) => p[1])), + Math.max(...polygon.map((p) => p[0])), + Math.max(...polygon.map((p) => p[1])), +] +const spans = (polygon: Pt[] | undefined, expected: number[]) => + !!polygon && extent(polygon).every((value, i) => Math.abs(value - expected[i]!) < 1e-3) + +export const CREATE_STAIR_CASES: AgentToolCase[] = [ + { + name: 'a flight rises to the floor above and owns the opening it cuts there', + tool: 'create_stair', + scene: storeysScene, + input: { levelId: 'level_ground', x: 3, z: 1 }, + expect: { + result: { + ok: true, + fromLevelId: 'level_ground', + upperLevelId: 'level_upper', + createdUpperLevel: false, + // A 2.8 m storey in ~18 cm risers. + stepCount: 16, + slabHoleCut: true, + }, + }, + }, + { + name: 'from the viewed floor, turned in degrees, with the rise and steps asked', + tool: 'create_stair', + scene: storeysScene, + input: { x: 3, z: 4, rotation: '180°', height: 2.8, steps: 14, width: '90 cm' }, + context: { activeLevelId: 'level_ground' }, + surfaces: ['core', 'chat'], + expect: { + result: { + fromLevelId: 'level_ground', + upperLevelId: 'level_upper', + stepCount: 14, + rotation: 180, + width: 0.9, + }, + }, + }, + { + name: 'from the top storey, a blank level is made above for it to arrive on', + tool: 'create_stair', + scene: soloScene, + input: { x: 1, z: 1 }, + expect: { result: { fromLevelId: 'level_solo', createdUpperLevel: true, slabHoleCut: false } }, + }, + { + name: 'a flight onto a declared roof level is refused', + tool: 'create_stair', + scene: storeysScene, + input: { levelId: 'level_storey', x: 1, z: 1 }, + expect: { refusal: 'roof_level', mentions: ['level_roof'] }, + }, + { + name: 'a flight from a declared roof level is refused', + tool: 'create_stair', + scene: storeysScene, + input: { levelId: 'level_roof', x: 1, z: 1 }, + expect: { refusal: 'roof_level', mentions: ['level_roof'] }, + }, + { + name: 'a level it should arrive on that is not above is refused', + tool: 'create_stair', + scene: storeysScene, + input: { levelId: 'level_upper', toLevelId: 'level_ground', x: 1, z: 1 }, + expect: { refusal: 'not_above', mentions: ['level_ground'] }, + }, + { + // create_stair_between_levels' options (S1 parity with main): railings, a finish, a name. + name: 'railings on one side, a finish and a name, as asked', + tool: 'create_stair', + scene: storeysScene, + input: { + levelId: 'level_ground', + x: 3, + z: 1, + railingMode: 'left', + materialPreset: 'library:wood-woodfine1', + name: 'Main stair', + }, + expect: { + result: { ok: true, railingMode: 'left' }, + check: (_result, nodes) => { + const stair = stairOf(nodes) + const segment = stair && (nodes[stair.children[0]!] as Stair | undefined) + return stair?.railingMode === 'left' && + stair.name === 'Main stair' && + stair.materialPreset === 'library:wood-woodfine1' && + segment?.materialPreset === 'library:wood-woodfine1' + ? [] + : [`stair ${JSON.stringify({ ...stair, children: undefined })}`] + }, + }, + }, + { + name: 'a finish the library lacks is refused, naming the nearest, and nothing is built', + tool: 'create_stair', + scene: storeysScene, + input: { levelId: 'level_ground', x: 3, z: 1, materialPreset: 'library:oak-treads' }, + expect: { + refusal: 'unknown_material', + mentions: ['library:oak-treads', 'flooring', 'library:preset-'], + }, + }, + { + name: 'the margin round the opening, alone, widens the opening the stair owns', + tool: 'create_stair', + scene: storeysScene, + input: { levelId: 'level_ground', x: 3, z: 1, openingOffset: 0.2 }, + expect: { + result: { ok: true, slabHoleCut: true }, + check: (_result, nodes) => { + const stair = stairOf(nodes) + return stair?.slabOpeningMode === 'destination' && stair.openingOffset === 0.2 + ? [] + : [`mode ${stair?.slabOpeningMode}, offset ${stair?.openingOffset}`] + }, + }, + }, + { + name: 'with both cuts off, no opening is cut', + tool: 'create_stair', + scene: ceiledScene, + input: { + levelId: 'level_ground', + x: 3, + z: 1, + createDestinationSlabOpening: false, + createSourceCeilingOpening: false, + }, + expect: { + result: { ok: true, slabHoleCut: false }, + check: (_result, nodes) => [ + ...(openingsOf(nodes).length ? [`${openingsOf(nodes).length} openings`] : []), + ...(stairOf(nodes)?.slabOpeningMode === 'none' ? [] : ['the stair still cuts']), + ], + }, + }, + { + name: 'an opening of the size, margin, centre and turn given, owned by the stair', + tool: 'create_stair', + scene: ceiledScene, + input: { + levelId: 'level_ground', + x: 3, + z: 1, + openingWidth: 1.6, + openingLength: 3.4, + openingOffset: 0.1, + openingCenter: [3, 2.5], + openingRotation: 0, + createSourceCeilingOpening: false, + }, + expect: { + result: { ok: true, slabHoleCut: true, destinationSlabId: 'slab_upper' }, + check: (_result, nodes) => { + const openings = openingsOf(nodes) + const [opening] = openings + return [ + ...(openings.length === 1 ? [] : [`${openings.length} openings`]), + ...(opening?.parentId === 'level_upper' && + opening.source === 'stair' && + opening.ownerId === stairOf(nodes)?.id + ? [] + : [`opening ${JSON.stringify(opening)}`]), + // 1.6 + 2 × 0.1 across, 3.4 + 2 × 0.1 along, round (3, 2.5). + ...(spans(opening?.polygon, [2.1, 0.7, 3.9, 4.3]) + ? [] + : [`spans ${opening && extent(opening.polygon)}`]), + ...(stairOf(nodes)?.slabOpeningMode === 'none' ? [] : ['the stair cuts its own too']), + ] + }, + }, + }, + { + name: 'the ceiling below alone, when the floor above is not to be cut', + tool: 'create_stair', + scene: ceiledScene, + input: { + levelId: 'level_ground', + x: 3, + z: 1, + sourceCeilingId: 'ceiling_hall', + createDestinationSlabOpening: false, + }, + expect: { + // A hole in the ceiling below is not one in the slab above: the floor upstairs stays closed. + result: { ok: true, slabHoleCut: false, sourceCeilingId: 'ceiling_hall' }, + check: (_result, nodes) => { + const openings = openingsOf(nodes) + return openings.length === 1 && + openings[0]!.parentId === 'level_ground' && + openings[0]!.drawnOn === 'ceiling' + ? [] + : [`openings ${JSON.stringify(openings.map((o) => [o.parentId, o.drawnOn]))}`] + }, + }, + }, + { + // Main centred the opening at z + length / 2 whatever the turn: off the flight once turned. + name: 'an opening of a turned flight follows the climb', + tool: 'create_stair', + scene: ceiledScene, + input: { + levelId: 'level_ground', + x: 2, + z: 2, + rotation: '90°', + length: 3, + openingWidth: 1.2, + createSourceCeilingOpening: false, + }, + expect: { + result: { ok: true }, + // Climbing toward +X from (2, 2): 3 m along x, 1.2 m across z, and the 0.08 m margin. + check: (_result, nodes) => { + const [opening] = openingsOf(nodes) + return spans(opening?.polygon, [1.92, 1.32, 5.08, 2.68]) + ? [] + : [`spans ${opening && extent(opening.polygon)}`] + }, + }, + }, + { + // On a floor of several rooms, the surfaces a flight cuts are the ones over and under it, not + // the first on each storey. + name: 'the slab above and the ceiling below are the ones the opening falls in', + tool: 'create_stair', + scene: () => splitScene(), + input: { levelId: 'level_ground', x: 4.5, z: 1, openingWidth: 1 }, + expect: { + result: { + ok: true, + slabHoleCut: true, + destinationSlabId: 'slab_upper_east', + sourceCeilingId: 'ceiling_east', + }, + }, + }, + { + name: 'an opening under no slab is not reported as cut through one', + tool: 'create_stair', + scene: () => splitScene(false), + input: { + levelId: 'level_ground', + x: 4.5, + z: 1, + openingWidth: 1, + createSourceCeilingOpening: false, + }, + expect: { + result: { ok: true, slabHoleCut: false }, + check: (result, nodes) => [ + ...('destinationSlabId' in result ? [`reported ${result.destinationSlabId}`] : []), + ...(openingsOf(nodes).length ? [`${openingsOf(nodes).length} openings`] : []), + ], + }, + }, + { + name: 'a slab id that names no slab, or a ceiling id no ceiling, is refused', + tool: 'create_stair', + scene: ceiledScene, + input: { levelId: 'level_ground', x: 3, z: 1, destinationSlabId: 'zone_hall' }, + expect: { refusal: 'slab_not_found', mentions: ['zone_hall'] }, + }, + { + name: 'an unknown level is refused with the id', + tool: 'create_stair', + scene: storeysScene, + input: { levelId: 'level_missing', x: 1, z: 1 }, + expect: { refusal: 'level_not_found', mentions: ['level_missing'] }, + }, +] 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 18c1693ef8..b0dc9ed01e 100644 --- a/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts @@ -3,6 +3,7 @@ import { BuildingNode, CeilingNode, DoorNode, + FloorOpeningNode, ItemNode, LevelNode, RoofNode, @@ -13,6 +14,7 @@ import { WindowNode, ZoneNode, } from '../../schema' +import { authoredItem } from './add-object-cases' import type { AgentToolCase, SceneGraph } from './cases' /** @@ -80,6 +82,13 @@ function room(levelId: string, tag: string): AnyNode[] { ] } +const STAIR_HOLE: [number, number][] = [ + [1.5, 0.2], + [2.5, 0.2], + [2.5, 2.2], + [1.5, 2.2], +] + const stairOn = (levelId: string, extra: Record = {}) => { const flight = StairSegmentNode.parse({ id: `sseg_${levelId}`, @@ -383,6 +392,72 @@ export const VERIFY_SCENE_CASES: AgentToolCase[] = [ // Only core: a live store cuts the opening itself when the scene loads, as the editor does. { surfaces: ['core'] }, ), + // Since owned floor openings (#976) a stair's opening is a floor-opening node on the floor + // above, owned by the stair; the slab hole it cuts carries the opening, not the stair. + verify( + "a stair whose owned floor opening sits on the floor above has its opening", + () => + twoStoreys( + ...stairOn('level_0', { + fromLevelId: 'level_0', + toLevelId: 'level_1', + slabOpeningMode: 'destination', + }), + FloorOpeningNode.parse({ + id: 'floor-opening_stair', + parentId: 'level_1', + polygon: STAIR_HOLE, + source: 'stair', + ownerId: 'stair_level_0', + surfaceId: 'slab_upper', + drawnOn: 'floor', + }), + ), + { lacks: ['stair_no_opening'] }, + { surfaces: ['core'] }, + ), + verify( + 'a stair whose only owned opening is drawn on its own ceiling still misses the floor above', + () => + twoStoreys( + ...stairOn('level_0', { + fromLevelId: 'level_0', + toLevelId: 'level_1', + slabOpeningMode: 'destination', + }), + FloorOpeningNode.parse({ + id: 'floor-opening_ceiling', + parentId: 'level_0', + polygon: STAIR_HOLE, + source: 'stair', + ownerId: 'stair_level_0', + drawnOn: 'ceiling', + }), + ), + { contains: ['stair_no_opening'] }, + { surfaces: ['core'] }, + ), + verify( + 'a stair whose only owned opening is drawn on its own ceiling still misses the floor above', + () => + twoStoreys( + ...stairOn('level_0', { + fromLevelId: 'level_0', + toLevelId: 'level_1', + slabOpeningMode: 'destination', + }), + FloorOpeningNode.parse({ + id: 'floor-opening_ceiling', + parentId: 'level_0', + polygon: STAIR_HOLE, + source: 'stair', + ownerId: 'stair_level_0', + drawnOn: 'ceiling', + }), + ), + { contains: ['stair_no_opening'] }, + { surfaces: ['core'] }, + ), verify( "a stair's floor opening is checked in its own building, not in the house next door", () => @@ -469,4 +544,44 @@ export const VERIFY_SCENE_CASES: AgentToolCase[] = [ // Only core: a live store and a bridge would not hold a node that fails its schema. { surfaces: ['core'] }, ), + // Each authored object stands in for something Pascal has no type for: the list names the gaps. + verify( + 'authored objects are listed with what they stand in for', + () => + scene( + building(), + level('level_0', 0), + ...room('level_0', 'ground'), + authoredItem('item_lantern', 'level_0', { + name: 'Porch lantern', + category: 'light', + size: [0.2, 0.4, 0.2], + }), + authoredItem('item_cornice', 'level_0', { + name: 'Cornice', + category: 'trim', + reason: 'Pascal has no cornice type.', + size: [4, 0.3, 0.4], + }), + ItemNode.parse({ + id: 'item_sofa', + parentId: 'level_0', + position: [2, 0, 2], + asset: asset('sofa', [2, 0.8, 0.9]), + }), + ), + { + result: { + authoredObjects: [ + { + id: 'item_cornice', + name: 'Cornice', + category: 'trim', + reason: 'Pascal has no cornice type.', + }, + { id: 'item_lantern', name: 'Porch lantern', category: 'light', reason: null }, + ], + }, + }, + ), ] diff --git a/packages/core/src/agent-operations/achieved.test.ts b/packages/core/src/agent-operations/achieved.test.ts new file mode 100644 index 0000000000..b4911049db --- /dev/null +++ b/packages/core/src/agent-operations/achieved.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, BuildingNode, DoorNode, LevelNode, WallNode } from '../schema' +import { achievedChanges } from './achieved' + +// classcad-ai's delta check, on a scene graph: a tool says what it asked for; the scene says what +// happened. A facade was once reported "applied" on 432 walls that placed no window. +function scene(): Record { + const building = BuildingNode.parse({ id: 'building_main' }) + const level = LevelNode.parse({ id: 'level_0', parentId: building.id, level: 0 }) + const door = DoorNode.parse({ id: 'door_a', parentId: 'wall_a', wallId: 'wall_a' }) + const wall = WallNode.parse({ + id: 'wall_a', + parentId: level.id, + start: [0, 0], + end: [4, 0], + children: [door.id], + }) + const nodes = [ + { ...building, children: [level.id] }, + { ...level, children: [wall.id] }, + wall, + door, + ] + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +describe('what a call achieved', () => { + test('counts what was created, by type', () => { + const before = scene() + const wall = WallNode.parse({ id: 'wall_b', parentId: 'level_0', start: [4, 0], end: [4, 3] }) + expect(achievedChanges(before, { create: [{ node: wall, parentId: 'level_0' }] })).toEqual({ + created: { wall: 1 }, + updated: 0, + deleted: {}, + }) + }) + + test('counts what a delete took with it', () => { + expect(achievedChanges(scene(), { delete: ['wall_a'] })).toEqual({ + created: {}, + updated: 0, + deleted: { wall: 1, door: 1 }, + }) + }) + + test('says when a call changed nothing', () => { + expect(achievedChanges(scene(), { update: [{ id: 'wall_missing', data: {} }] })).toEqual({ + created: {}, + updated: 0, + deleted: {}, + unchanged: true, + }) + }) +}) diff --git a/packages/core/src/agent-operations/achieved.ts b/packages/core/src/agent-operations/achieved.ts new file mode 100644 index 0000000000..ec93d46097 --- /dev/null +++ b/packages/core/src/agent-operations/achieved.ts @@ -0,0 +1,40 @@ +import { applySceneChanges } from './apply-changes' +import type { SceneChanges, SceneNodes } from './types' + +export type Achieved = { + created: Record + updated: number + deleted: Record + /** The call left the scene as it was: what it reports was not built. */ + unchanged?: true +} + +const countByType = (ids: Iterable, nodes: SceneNodes) => { + const counts: Record = {} + for (const id of ids) { + const type = nodes[id]?.type + if (type) counts[type] = (counts[type] ?? 0) + 1 + } + return counts +} + +/** + * What a mutating call really changed, read from the scene before and after its changes, so a + * result never claims what the scene does not hold (a facade was once reported "applied" on 432 + * walls that placed no window). Cheap: one pass over the ids; a scene check per call is not (2.7 s + * on a scene of 4,362 nodes). + */ +export function achievedChanges(before: SceneNodes, changes: SceneChanges): Achieved { + const after = applySceneChanges(before as never, changes) as SceneNodes + const created = Object.keys(after).filter((id) => !(id in before)) + const deleted = Object.keys(before).filter((id) => !(id in after)) + const updated = (changes.update ?? []).filter( + ({ id }) => id in before && id in after && 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-level.ts b/packages/core/src/agent-operations/add-level.ts new file mode 100644 index 0000000000..eaa6c5f5c7 --- /dev/null +++ b/packages/core/src/agent-operations/add-level.ts @@ -0,0 +1,111 @@ +import { refuse } from '../agent-tools/refusal' +import { BuildingNode, LevelNode, SiteNode } from '../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +import { levelsOf } from './scene-queries' +import type { AgentContext, AgentOperation, SceneNodes } from './types' + +type AddLevelInput = { + buildingId?: string + position?: 'above' | 'below' + name?: string + height?: number +} + +/** The building named, else the one holding the viewed floor, else the scene's only building. */ +export function targetBuilding( + nodes: SceneNodes, + buildingId: string | undefined, + context: AgentContext, +): BuildingNode { + if (buildingId) { + const node = nodes[buildingId] + if (!node) refuse('building_not_found', `Building not found: ${buildingId}.`, { buildingId }) + if (node.type !== 'building') + refuse('not_a_building', `Node ${buildingId} is a ${node.type}, not a building.`, { + buildingId, + type: node.type, + }) + return node + } + const viewed = context.activeLevelId ? nodes[context.activeLevelId] : undefined + const holder = viewed?.parentId ? nodes[viewed.parentId] : undefined + if (holder?.type === 'building') return holder + const buildings = Object.values(nodes).filter( + (node): node is BuildingNode => node.type === 'building', + ) + if (buildings.length === 1) return buildings[0]! + if (!buildings.length) refuse('no_building', 'The scene has no building to add a level to.') + return refuse( + 'building_required', + `The scene has ${buildings.length} buildings; say which: ${buildings.map((building) => building.id).join(', ')}.`, + { buildingIds: buildings.map((building) => building.id) }, + ) +} + +/** + * On a scene with no building, the editor's own empty scene: a site (unless one stands), its + * building, the ground level. An agent that cleared the scene to restart begins again here. + */ +function freshStart(nodes: SceneNodes, input: AddLevelInput) { + const standing = Object.values(nodes).find((node) => node.type === 'site') + const site = standing ? null : SiteNode.parse({ children: [] }) + const siteId = standing?.id ?? site!.id + const building = BuildingNode.parse({ parentId: siteId, children: [] }) + const level = LevelNode.parse({ + parentId: building.id, + level: 0, + height: input.height ?? DEFAULT_LEVEL_HEIGHT, + children: [], + ...(input.name ? { name: input.name } : {}), + }) + return { + result: { + ok: true, + levelId: level.id, + buildingId: building.id, + ...(site ? { siteId: site.id } : {}), + floorIndex: 0, + ...(level.name ? { name: level.name } : {}), + height: level.height, + message: `The scene had no building: made ${site ? 'a site, ' : ''}a building and its ground level.`, + }, + changes: { + create: [ + ...(site ? [{ node: site }] : []), + { node: building, parentId: siteId }, + { node: level, parentId: building.id }, + ], + }, + } +} + +/** `add_level`: an empty level over the building's highest, or under its lowest — the editor's +. */ +export const addLevel: AgentOperation = (nodes, input, context) => { + if (!(input.buildingId || Object.values(nodes).some((node) => node.type === 'building'))) + return freshStart(nodes, input) + const building = targetBuilding(nodes, input.buildingId, context) + const floors = levelsOf(nodes) + .filter((level) => level.parentId === building.id || building.children.includes(level.id)) + .map((level) => level.level) + const below = input.position === 'below' + const floorIndex = !floors.length ? 0 : below ? Math.min(...floors) - 1 : Math.max(...floors) + 1 + const level = LevelNode.parse({ + parentId: building.id, + level: floorIndex, + height: input.height ?? DEFAULT_LEVEL_HEIGHT, + children: [], + ...(input.name ? { name: input.name } : {}), + }) + return { + result: { + ok: true, + levelId: level.id, + buildingId: building.id, + floorIndex, + ...(level.name ? { name: level.name } : {}), + height: level.height, + message: `Added ${level.name ?? `level ${floorIndex}`} ${below ? 'below' : 'above'} the existing levels of ${building.name ?? building.id}.`, + }, + changes: { create: [{ node: level, parentId: building.id }] }, + } +} diff --git a/packages/core/src/agent-operations/add-object.test.ts b/packages/core/src/agent-operations/add-object.test.ts new file mode 100644 index 0000000000..4cbd7bc2d5 --- /dev/null +++ b/packages/core/src/agent-operations/add-object.test.ts @@ -0,0 +1,37 @@ +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' + +// Layer 1 of 3: the core operation, handed the case's compile in place of a surface's. +describe('add_object', () => { + for (const c of ADD_OBJECT_CASES) { + if (c.surfaces && !c.surfaces.includes('core')) continue + test(c.name, () => { + const nodes = c.scene().nodes as Record + const input = { ...z.object(addObjectTool.input).parse(c.input), compiled: c.compiled } + const context = { activeLevelId: c.context?.activeLevelId ?? null } + if ('refusal' in c.expect) { + let error: unknown + try { + addObject(nodes, input, context) + } catch (thrown) { + error = thrown + } + expect(isAgentRefusal(error)).toBe(true) + expect((error as AgentRefusal).code).toBe(c.expect.refusal) + for (const text of c.expect.mentions ?? []) expect((error as Error).message).toContain(text) + return + } + const outcome = addObject(nodes, input, context) + expect(outcome.result).toMatchObject(c.expect.result) + const after = applySceneChanges(nodes, outcome.changes) + if (c.expect.node) expect(after[outcome.result.nodeId]).toMatchObject(c.expect.node) + for (const text of c.expect.mentions ?? []) + expect(JSON.stringify(outcome.result)).toContain(text) + }) + } +}) diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index a1d7b506b0..5c0c27bb1e 100644 --- a/packages/core/src/agent-operations/add-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -33,6 +33,8 @@ export type AddObjectInput = { side?: 'front' | 'back' name?: string category?: string + /** What the object stands in for; kept in `metadata.reason`, listed by verify_scene. */ + reason?: string /** What the surface's compile produced from `code` (compiled before the operation runs). */ compiled: CompiledGeometryScript } @@ -75,6 +77,86 @@ function scriptAsset( } } +/** + * Words that name what Pascal already builds, with the tool that builds it. An object named so is + * built with a hint naming that tool: authored objects are for what has no type. + */ +export const PASCAL_TYPES: [RegExp, string][] = [ + [/\bwalls?\b/i, 'walls: add_wall'], + [ + /\b(slabs?|floor plates?|floors?)\b/i, + 'a floor: rooms make their floor plates (create_room), shaped by set_room_floor_construction or set_floor_foundation', + ], + [ + /\b(doors?|windows?|sills?|glazing)\b/i, + 'add_door / add_window (with code for a design their fields cannot express)', + ], + [/\b(stairs?|staircases?)\b/i, 'create_stair'], + [/\broofs?\b/i, 'create_roof'], + [/\b(rooms?|zones?|apartments?)\b/i, 'rooms: create_room'], +] + +/** + * What a label names: the last word of its main phrase. "entry door pull handle (brass)" is a + * handle; "door" only says which one (it was once refused as a door). + */ +function headWord(label: string) { + const phrase = label.split(/\(|,|\s[-–—]\s|\sof\s/i)[0]!.trim() + return phrase.split(/\s+/).at(-1) ?? phrase +} + +/** + * The tool for an object named after something Pascal builds: an invitation, not a gate. A word + * gate taught evasion: refused "entry door pull handle (brass)", the agent relabelled it "brass + * pull bars", and the label stopped naming the gap. + */ +function typeHint(input: AddObjectInput): string | undefined { + for (const label of [input.name, input.category]) { + const named = label && PASCAL_TYPES.find(([word]) => word.test(headWord(label))) + if (named) return `"${label}" is something Pascal builds: ${named[1]}.` + } + return undefined +} + +/** A wall's box: thin, a metre long or more, two metres high or more. */ +function wallSized(compiled: CompiledGeometryScript) { + const [width, height, depth] = scriptedSize(compiled.manifest) + return Math.min(width, depth) <= 0.45 && Math.max(width, depth) >= 1 && height >= 2 +} + +/** A plain box: one cuboid, as a wall is. A bookcase or a screen has shelves or holes. */ +const PLAIN_BOX = 12 + +/** + * A floor object that is a plain box with a wall's size, or a floor plate, is refused: Pascal builds + * those, and rooms, openings and facades only work with its own. Production saw whole houses of + * plain custom solids, nothing editable as walls, rooms or doors. + */ +function refuseWallOrSlabShape(compiled: CompiledGeometryScript, input: AddObjectInput) { + const [width, height, depth] = scriptedSize(compiled.manifest) + const long = Math.max(width, depth) + const short = Math.min(width, depth) + const label = input.name ?? input.category ?? 'The object' + if (wallSized(compiled) && compiled.manifest.triangles <= PLAIN_BOX) + refuse( + 'use_walls', + `"${label}" (${long.toFixed(2)} × ${short.toFixed(2)} m, ${height.toFixed(2)} m high) is a plain box with a wall's size: build it with add_wall so rooms, openings and facades work with it.`, + { label }, + ) + const base = input.position?.[1] ?? 0 + if (height <= 0.35 && short >= 2 && base <= 0.05) { + // Its extent along x and z as it stands, so the numbers match the outline the agent drew. + const turn = ((input.rotation ?? 0) * Math.PI) / 180 + const [c, s] = [Math.abs(Math.cos(turn)), Math.abs(Math.sin(turn))] + const [x, z] = [width * c + depth * s, width * s + depth * c] + refuse( + 'use_slab', + `"${label}" (${x.toFixed(2)} × ${z.toFixed(2)} m in x and z, ${height.toFixed(2)} m thick, on the floor) is a floor plate: build it as a slab.`, + { label }, + ) + } +} + const round = (value: number) => Math.round(value * 1000) / 1000 function summary(node: { id: string }, compiled: CompiledGeometryScript, orphanedSlots: string[]) { @@ -104,8 +186,21 @@ function summary(node: { id: string }, compiled: CompiledGeometryScript, orphane * AGENT_OPERATIONS: each surface compiles `code` first (the chat in its * worker, the MCP on the server) and passes the result as `compiled`. * The artifact is referenced by hash and its bounds become the item's dimensions; editing - * keeps the item's identity, placement, children and paint. + * keeps the item's identity, placement, children, paint and reason. Only a new object is judged + * by its shape and must give a reason: the editor's inspector rebuilds through the edit path. */ +/** + * A new object says what it stands in for. Hosts check it before running the script: a missing + * reason refused after the compile has run it and stored its artifacts for nothing. + */ +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.', + ) +} + export const addObject: AgentOperation = (nodes, input, context) => { const { compiled } = input const rotation: Vec3 | undefined = @@ -134,6 +229,7 @@ export const addObject: AgentOperation = (nodes, input, context) side: input.side ?? previous.side, source: scriptSource(compiled), asset: scriptAsset(compiled, input, previous.asset), + ...(input.reason ? { metadata: { ...previous.metadata, reason: input.reason } } : {}), }) // Children resting on or hanging from the object follow its new geometry. const resettled: { id: string; position: Vec3 }[] = [] @@ -169,6 +265,8 @@ export const addObject: AgentOperation = (nodes, input, context) { mount: compiled.mount, parentType: parent.type }, ) } + if (compiled.mount === 'floor' && parent.type === 'level') refuseWallOrSlabShape(compiled, input) + requireAddObjectReason(input) const asset = scriptAsset(compiled, input, undefined) const node = ItemNode.parse({ object: 'node', @@ -181,9 +279,18 @@ export const addObject: AgentOperation = (nodes, input, context) rotation: rotation ?? [0, 0, 0], source: scriptSource(compiled), asset, + metadata: { reason: input.reason }, }) + const hint = [ + typeHint(input), + compiled.mount === 'floor' && + wallSized(compiled) && + `It has a wall's size: if it is a wall, add_wall builds it so rooms, openings and facades work with it.`, + ] + .filter(Boolean) + .join(' ') return { - result: summary(node, compiled, []), + result: { ...summary(node, compiled, []), ...(hint ? { hint } : {}) }, changes: { create: [{ node, parentId: parent.id }] }, } } diff --git a/packages/core/src/agent-operations/add-wall.ts b/packages/core/src/agent-operations/add-wall.ts new file mode 100644 index 0000000000..4a95d9a71c --- /dev/null +++ b/packages/core/src/agent-operations/add-wall.ts @@ -0,0 +1,67 @@ +import { refuse } from '../agent-tools/refusal' +import { WallNode } from '../schema' +import { normalizeWallCurveOffset } from '../systems/wall/wall-curve' +import { WALL_MIN_LENGTH } from '../systems/wall/wall-topology' +import { type LevelTargetInput, targetLevel } from './level-target' +import { levelRole } from './scene-queries' +import type { AgentOperation, SceneNodes } from './types' + +type Pt = [number, number] +type AddWallInput = LevelTargetInput & { + start: number[] + end: number[] + thickness?: number + height?: number + curveOffset?: number +} + +/** A level declared as the roof holds the roof, not rooms: walls and stairs stay off it. */ +export function refuseRoofLevel(nodes: SceneNodes, levelId: string, what: string) { + const level = nodes[levelId] + if (level && levelRole(nodes, level).metadataRole === 'roof') + refuse( + 'roof_level', + `${levelId} is a roof level, not a storey: ${what} belongs on an occupied storey. Add one with add_level if the building needs it.`, + { levelId }, + ) +} + +/** `add_wall`: one wall between two points, named as the editor names walls. */ +export const addWall: AgentOperation = (nodes, input, context) => { + const level = targetLevel(nodes, input, context) + refuseRoofLevel(nodes, level.id, 'a wall') + const start = input.start as Pt + const end = input.end as Pt + const length = Math.hypot(end[0] - start[0], end[1] - start[1]) + if (length < WALL_MIN_LENGTH) + refuse( + 'wall_too_short', + `A wall from [${start}] to [${end}] is ${length.toFixed(3)} m long; the editor draws none shorter than ${WALL_MIN_LENGTH} m.`, + { length }, + ) + const curveOffset = input.curveOffset + ? normalizeWallCurveOffset({ start, end }, input.curveOffset) + : 0 + const walls = Object.values(nodes).filter((node) => node.type === 'wall').length + const wall = WallNode.parse({ + name: `Wall ${walls + 1}`, + parentId: level.id, + start, + end, + ...(input.thickness === undefined ? {} : { thickness: input.thickness }), + ...(input.height === undefined ? {} : { height: input.height }), + ...(curveOffset ? { curveOffset } : {}), + }) + return { + result: { + ok: true, + wallId: wall.id, + name: wall.name, + levelId: level.id, + length: Math.round(length * 1000) / 1000, + ...(curveOffset ? { curveOffset } : {}), + message: `Added wall "${wall.name}" on ${level.name ?? level.id}`, + }, + changes: { create: [{ node: wall, parentId: level.id }] }, + } +} diff --git a/packages/core/src/agent-operations/apply-changes.ts b/packages/core/src/agent-operations/apply-changes.ts index c1e53d05ae..edea0941d6 100644 --- a/packages/core/src/agent-operations/apply-changes.ts +++ b/packages/core/src/agent-operations/apply-changes.ts @@ -1,21 +1,94 @@ -import type { AnyNode } from '../schema' +import type { AnyNode, Collection } from '../schema' import type { SceneChanges, SceneNodes } from './types' -/** The scene after an operation's changes, without a store: for checks and previews. */ +const childrenOf = (node: AnyNode | undefined): string[] | null => + node && 'children' in node && Array.isArray(node.children) ? (node.children as string[]) : null + +/** + * 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 + * being replaced is no longer among that wall's children when it goes. + */ export function applySceneChanges( nodes: SceneNodes, changes: SceneChanges | undefined, ): Record { const next: Record = { ...nodes } + const setChildren = (parentId: string | null | undefined, edit: (ids: string[]) => string[]) => { + const parent = parentId ? next[parentId] : undefined + const children = childrenOf(parent) + if (parent && children) next[parent.id] = { ...parent, children: edit(children) } as AnyNode + } + const detach = (parentId: string | null | undefined, id: string) => + setChildren(parentId, (ids) => ids.filter((child) => child !== id)) + const attach = (parentId: string | null | undefined, id: string) => + setChildren(parentId, (ids) => (ids.includes(id) ? ids : [...ids, id])) + for (const { id, data } of changes?.update ?? []) { + const current = next[id] + if (!current) continue + if (data.parentId !== undefined && data.parentId !== current.parentId) { + detach(current.parentId, id) + attach(data.parentId, id) + } + next[id] = { ...current, ...data } as AnyNode + } + for (const { node, parentId } of changes?.create ?? []) { + next[node.id] = parentId ? ({ ...node, parentId } as AnyNode) : node + attach(parentId ?? node.parentId, node.id) + } const remove = (id: string) => { const node = next[id] delete next[id] - if (node && 'children' in node && Array.isArray(node.children)) - for (const child of node.children as string[]) remove(child) + for (const child of childrenOf(node) ?? []) remove(child) + } + for (const id of changes?.delete ?? []) { + detach(next[id]?.parentId, id) + remove(id) } - for (const id of changes?.delete ?? []) remove(id) - for (const { id, data } of changes?.update ?? []) - if (next[id]) next[id] = { ...next[id], ...data } as AnyNode - for (const { node } of changes?.create ?? []) next[node.id] = node return next } + +/** + * Several operations' changes, in order, as one set that lands the scene where applying them one + * by one does — whether a surface applies creates, updates or deletes first: a batch is one undo + * step. Updates to a node the batch created are folded into its creation, and a node created then + * deleted never appears; updates only reach nodes that existed before, deletes come last. + */ +export function mergeSceneChanges(sets: readonly SceneChanges[]): Required { + const created = new Map() + const updated = new Map>() + const deleted = new Set() + const collections: Record = {} + const dropCreated = (id: string) => { + created.delete(id) + for (const [childId, entry] of [...created]) + if ((entry.parentId ?? entry.node.parentId) === id) dropCreated(childId) + } + for (const set of sets) { + Object.assign(collections, set.collections) + for (const { id, data } of set.update ?? []) { + const entry = created.get(id) + if (entry) + created.set(id, { + node: { ...entry.node, ...data } as AnyNode, + parentId: (data.parentId as string | undefined) ?? entry.parentId, + }) + else updated.set(id, { ...updated.get(id), ...data } as Partial) + } + for (const entry of set.create ?? []) created.set(entry.node.id, entry) + for (const id of set.delete ?? []) { + if (created.has(id)) { + dropCreated(id) + continue + } + updated.delete(id) + deleted.add(id) + } + } + return { + create: [...created.values()], + update: [...updated].map(([id, data]) => ({ id, data })), + delete: [...deleted], + collections, + } +} diff --git a/packages/core/src/agent-operations/apply-outcome.ts b/packages/core/src/agent-operations/apply-outcome.ts new file mode 100644 index 0000000000..d9d8656d7a --- /dev/null +++ b/packages/core/src/agent-operations/apply-outcome.ts @@ -0,0 +1,29 @@ +import type { AgentOperationOutcome, SceneChanges, SceneNodes } from './types' + +/** What a surface lends an operation's outcome: its scene, its writes and its reconciler. */ +export type AgentHostRuntime = { + getNodes: () => SceneNodes + applyChanges: (changes: SceneChanges) => void + /** Derive construction from the scene as it now is: rooms, auto ceilings, floor plates. */ + reconcile: () => void +} + +/** + * Applies an operation's outcome through a host and returns the answer: its changes, then for an + * outcome that reads derived construction, the reconcile, its follow-up changes and a second + * reconcile. The host frames the call as one undo step. + */ +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) + runtime.reconcile() + } + return settled.result +} diff --git a/packages/core/src/agent-operations/create-room.ts b/packages/core/src/agent-operations/create-room.ts new file mode 100644 index 0000000000..ae279a9cb7 --- /dev/null +++ b/packages/core/src/agent-operations/create-room.ts @@ -0,0 +1,219 @@ +import { isAgentRefusal, refuse } from '../agent-tools/refusal' +import { planWallOpening } from '../building/wall-openings' +import { createZone } from '../commands/structure/create-zone' +import { structureChangeBatch } from '../commands/structure/shared' +import { type AnyNode, generateId, type WallNode } from '../schema' +import { applySceneChanges } from './apply-changes' +import { type LevelTargetInput, targetLevel } from './level-target' +import { polygonArea, type Vec2 } from './plan-geometry' +import { levelRole } from './scene-queries' +import type { AgentOperation, SceneNodes } from './types' + +type OpeningSpec = { + wallIndex: number + t?: number + width?: number + height?: number + sillHeight?: number + hingesSide?: 'left' | 'right' + swingDirection?: 'inward' | 'outward' + style?: string +} + +type CreateRoomInput = LevelTargetInput & { + name: string + polygon: number[][] + color?: string + wallHeight?: number + wallThickness?: number + outdoor?: boolean + doors?: OpeningSpec[] + windows?: OpeningSpec[] +} + +type SkippedOpening = { kind: 'door' | 'window'; index: number; code: string; message: string } + +// A wall counts as running along a polygon edge when both its ends sit this close to the edge's line. +const EDGE_TOLERANCE = 0.2 + +const lerp = (a: Vec2, b: Vec2, t: number): Vec2 => [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, +] + +/** Distance from `point` to the line through a and b, and where it projects along a → b (0..1). */ +export function edgeProjection(a: Vec2, b: Vec2, point: readonly number[]) { + const dx = b[0] - a[0] + const dz = b[1] - a[1] + const lengthSq = dx * dx + dz * dz + if (lengthSq < 1e-12) return { distance: Math.hypot(point[0]! - a[0], point[1]! - a[1]), t: 0 } + const rx = point[0]! - a[0] + const rz = point[1]! - a[1] + return { + distance: Math.abs(rx * dz - rz * dx) / Math.sqrt(lengthSq), + t: (rx * dx + rz * dz) / lengthSq, + } +} + +const wallsOn = (nodes: SceneNodes, levelId: string) => + Object.values(nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === levelId, + ) + +/** The walls of the level that run along the edge a → b, both ends on its line. */ +function wallsAlong(nodes: SceneNodes, levelId: string, a: Vec2, b: Vec2) { + return wallsOn(nodes, levelId).filter((wall) => { + const start = edgeProjection(a, b, wall.start) + const end = edgeProjection(a, b, wall.end) + return ( + start.distance < EDGE_TOLERANCE && + end.distance < EDGE_TOLERANCE && + Math.min(1, Math.max(start.t, end.t)) - Math.max(0, Math.min(start.t, end.t)) > 1e-6 + ) + }) +} + +/** + * Where on which wall a point of a polygon edge falls: by position among the walls of the room's + * own level, never by the id a wall had — walls split and join, and the floor below has walls on + * the same line. + */ +function wallAt(nodes: SceneNodes, levelId: string, a: Vec2, b: Vec2, point: Vec2) { + for (const wall of wallsAlong(nodes, levelId, a, b)) { + const { t } = edgeProjection(wall.start, wall.end, point) + if (t >= -1e-6 && t <= 1 + 1e-6) return { wallId: wall.id, t: Math.min(1, Math.max(0, t)) } + } + return null +} + +/** The construction the host derived for the room: its floor plate and its ceiling. */ +function derivedSurfaces(nodes: SceneNodes, levelId: string, zoneId: string) { + const children = Object.values(nodes).filter((node) => node.parentId === levelId) + const slab = children.find( + (node) => node.type === 'slab' && node.boundary === 'auto' && node.zoneIds?.includes(zoneId), + ) + const ceiling = children.find( + (node) => node.type === 'ceiling' && node.boundary === 'auto' && node.zoneId === zoneId, + ) + return { slabId: slab?.id ?? null, ceilingId: ceiling?.id ?? null } +} + +const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}` + +/** + * `create_room`: the editor's room command — walls where no wall runs yet, the zone that names the + * room — then the doors and windows declared by polygon edge, each placed as add_door / add_window + * place one. The floor plate and the ceiling are the host's to derive; the result reads them back. + */ +export const createRoom: AgentOperation = (nodes, input, context) => { + const level = targetLevel(nodes, input, context) + if (levelRole(nodes, level).role === 'roof') + refuse( + 'roof_level', + `${level.name || level.id} is a roof level, not a storey: build the room on the storey below it.`, + { levelId: level.id }, + ) + const polygon = input.polygon as Vec2[] + + let plan: ReturnType + try { + plan = createZone(nodes, { + levelId: level.id, + polygon, + name: input.name, + enclose: !input.outdoor, + ...(input.outdoor + ? { intent: { hasCeiling: false } } + : { + wall: { + ...(input.wallHeight === undefined ? {} : { height: input.wallHeight }), + ...(input.wallThickness === undefined ? {} : { thickness: input.wallThickness }), + }, + }), + mintId: generateId, + }) + } catch (error) { + if (!(error instanceof Error && error.name === 'Error')) throw error + if (/valid polygon/.test(error.message)) + refuse( + 'invalid_polygon', + 'The polygon is not a room: give at least three corners in order, edges that do not cross, an area above 0.01 m².', + ) + refuse('cannot_enclose', error.message, { levelId: level.id }) + } + if (plan.conflicts?.length) + refuse('outdoor_room_overlap', plan.conflicts.map((conflict) => conflict.message).join(' '), { + conflicts: plan.conflicts, + }) + + const roomChanges = structureChangeBatch(plan.changes) + if (input.color) + roomChanges.create = roomChanges.create.map((entry) => + entry.node.id === plan.zoneId + ? { ...entry, node: { ...entry.node, color: input.color } as AnyNode } + : entry, + ) + let scene = applySceneChanges(nodes, roomChanges) + + const openings: { node: AnyNode; parentId: string }[] = [] + const skippedOpenings: SkippedOpening[] = [] + const place = (kind: 'door' | 'window', specs: OpeningSpec[] = []) => + specs.flatMap(({ wallIndex, t = 0.5, ...spec }, index) => { + const skip = (code: string, message: string) => { + skippedOpenings.push({ kind, index, code, message }) + return [] + } + if (wallIndex >= polygon.length) + return skip( + 'edge_out_of_range', + `The polygon has ${polygon.length} edges (0–${polygon.length - 1}); there is no edge ${wallIndex}.`, + ) + const a = polygon[wallIndex]! + const b = polygon[(wallIndex + 1) % polygon.length]! + const host = wallAt(scene, level.id, a, b, lerp(a, b, t)) + if (!host) return skip('no_wall', `No wall runs along edge ${wallIndex} at t ${t}.`) + try { + const planned = planWallOpening(scene, { kind, ...spec, wallId: host.wallId, t: host.t }) + const create = { node: planned.node, parentId: planned.wallId } + scene = applySceneChanges(scene, { create: [create] }) + openings.push(create) + return [planned.node.id] + } catch (error) { + if (!isAgentRefusal(error)) throw error + return skip(error.code, error.message) + } + }) + const doorIds = place('door', input.doors) + const windowIds = place('window', input.windows) + + const wallIds = polygon.map( + (a, i) => wallsAlong(scene, level.id, a, polygon[(i + 1) % polygon.length]!)[0]?.id ?? null, + ) + const reusedWalls = wallIds.filter((id) => id && nodes[id]).length + const result = { + ok: true, + zoneId: plan.zoneId, + wallIds, + reusedWalls, + areaSqMeters: Math.round(polygonArea(polygon) * 100) / 100, + doorIds, + windowIds, + ...(skippedOpenings.length ? { skippedOpenings } : {}), + message: [ + `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')] : []), + ...(skippedOpenings.length + ? [`skipped ${plural(skippedOpenings.length, 'opening')} (see skippedOpenings)`] + : []), + ].join(', '), + } + return { + result, + changes: { ...roomChanges, create: [...roomChanges.create, ...openings] }, + afterReconcile: (derived) => ({ + result: { ...result, ...derivedSurfaces(derived, level.id, plan.zoneId) }, + }), + } +} diff --git a/packages/core/src/agent-operations/create-stair.ts b/packages/core/src/agent-operations/create-stair.ts new file mode 100644 index 0000000000..85df75021e --- /dev/null +++ b/packages/core/src/agent-operations/create-stair.ts @@ -0,0 +1,300 @@ +import { refuse } from '../agent-tools/refusal' +import { levelBuildingId } from '../building/level-duplication' +import { cutFloorOpening } from '../commands/structure/floor-opening' +import { adjacentLevelId } from '../lib/floor-opening-intent' +import { type AnyNode, type AnyNodeId, generateId, LevelNode, StairNode } from '../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +import { planOwnedFloorOpenings } from '../systems/owned-floor-openings' +import { planStairCreation } from '../systems/stair/stair-sizing' +import { refuseRoofLevel } from './add-wall' +import { applySceneChanges } from './apply-changes' +import { type LevelTargetInput, requireLevel, targetLevel } from './level-target' +import { finishSurface, requireMaterialRef } from './material-refs' +import { pointInPolygon, type Vec2 } from './plan-geometry' +import { levelsOf } from './scene-queries' +import type { AgentOperation, SceneChanges } from './types' + +type CreateStairInput = LevelTargetInput & { + x: number + z: number + rotation?: number + width?: number + length?: number + height?: number + steps?: number + toLevelId?: string + railingMode?: 'none' | 'left' | 'right' | 'both' + materialPreset?: string + name?: string + createDestinationSlabOpening?: boolean + createSourceCeilingOpening?: boolean + destinationSlabId?: string + sourceCeilingId?: string + openingWidth?: number + openingLength?: number + openingOffset?: number + openingCenter?: [number, number] + openingRotation?: number +} + +/** The margin round a stair's opening when none is given, as the editor's stair tool cuts it. */ +const OPENING_MARGIN = 0.08 + +type Pt = [number, number] + +/** + * The opening as asked, in level metres: its size plus the margin on every side, round its centre + * (by default the middle of the flight, along the climb), turned as asked (by default with the + * flight). An item's turn: local (dx, dz) goes to (cx + dx·cos + dz·sin, cz − dx·sin + dz·cos). + */ +function openingRing( + stair: { x: number; z: number; turn: number; width: number; length: number }, + input: CreateStairInput, +): Pt[] { + const margin = input.openingOffset ?? OPENING_MARGIN + const half = [ + (input.openingWidth ?? stair.width) / 2 + margin, + (input.openingLength ?? stair.length) / 2 + margin, + ] as const + const [cx, cz] = input.openingCenter ?? [ + stair.x + (stair.length / 2) * Math.sin(stair.turn), + stair.z + (stair.length / 2) * Math.cos(stair.turn), + ] + const turn = + input.openingRotation === undefined ? stair.turn : (input.openingRotation * Math.PI) / 180 + const [cos, sin] = [Math.cos(turn), Math.sin(turn)] + return ( + [ + [-half[0], -half[1]], + [half[0], -half[1]], + [half[0], half[1]], + [-half[0], half[1]], + ] as const + ).map(([dx, dz]): Pt => [cx + dx * cos + dz * sin, cz - dx * sin + dz * cos]) +} + +/** + * `create_stair`: a straight flight placed as the editor's stair tool places one — rising to the + * next level (made when there is none), its floor openings owned and cut by the stair. With the + * opening controls create_stair_between_levels had, the opening is cut as given instead, owned by + * the stair as main's are, and the stair's own opening is off so nothing is cut twice. + */ +export const createStair: AgentOperation = (nodes, input, context) => { + const from = targetLevel(nodes, input, context) + refuseRoofLevel(nodes, from.id, 'a stair') + const preset = + input.materialPreset === undefined + ? undefined + : requireMaterialRef(input.materialPreset, 'materialPreset', finishSurface('stair')) + if (input.destinationSlabId && nodes[input.destinationSlabId]?.type !== 'slab') + refuse( + 'slab_not_found', + `No slab ${input.destinationSlabId}: name the slab the flight arrives through, or leave it out.`, + { slabId: input.destinationSlabId }, + ) + if (input.sourceCeilingId && nodes[input.sourceCeilingId]?.type !== 'ceiling') + refuse( + 'ceiling_not_found', + `No ceiling ${input.sourceCeilingId}: name the ceiling the flight rises through, or leave it out.`, + { ceilingId: input.sourceCeilingId }, + ) + const cutFloor = input.createDestinationSlabOpening !== false + const cutCeiling = input.createSourceCeilingOpening !== false + const asGiven = + cutFloor !== cutCeiling || + [ + input.destinationSlabId, + input.sourceCeilingId, + input.openingWidth, + input.openingLength, + input.openingCenter, + input.openingRotation, + ].some((value) => value !== undefined) + const owned = cutFloor && cutCeiling && !asGiven + const buildingId = levelBuildingId(nodes as Record, from) + const building = buildingId ? nodes[buildingId] : undefined + if (building?.type !== 'building') + refuse('no_building', `Level ${from.id} is not in a building, so it has no floor above.`, { + levelId: from.id, + }) + const floors = levelsOf(nodes).filter( + (level) => level.parentId === building.id || building.children.includes(level.id), + ) + + const changes: Required = { create: [], update: [], delete: [], collections: {} } + let upper = input.toLevelId + ? requireLevel(nodes, input.toLevelId) + : floors.find((level) => level.level > from.level) + if (upper && (upper.level <= from.level || !floors.includes(upper))) + refuse( + 'not_above', + `${upper.id} is not above ${from.id} in its building: a flight rises to a higher floor.`, + { levelId: from.id, toLevelId: upper.id }, + ) + if (upper) refuseRoofLevel(nodes, upper.id, 'a stair') + if (!upper) { + upper = LevelNode.parse({ + parentId: building.id, + level: from.level + 1, + height: DEFAULT_LEVEL_HEIGHT, + children: [], + }) + changes.create.push({ node: upper, parentId: building.id }) + } + + const withUpper = applySceneChanges(nodes, changes) + const rotation = input.rotation ?? 0 + const width = input.width ?? 1 + const stairs = Object.values(nodes).filter((node) => node.type === 'stair').length + const railingMode = input.railingMode ?? 'both' + const draft = StairNode.parse({ + parentId: from.id, + name: input.name ?? `Staircase ${stairs + 1}`, + position: [input.x, 0, input.z], + rotation: (rotation * Math.PI) / 180, + stairType: 'straight', + uniformRisers: true, + fromLevelId: from.id, + toLevelId: upper.id, + slabOpeningMode: owned ? 'destination' : 'none', + openingOffset: input.openingOffset ?? OPENING_MARGIN, + width, + railingMode, + ...(preset ? { materialPreset: preset } : {}), + // No height: the flight follows its storey (no totalRise) and keeps tracking it. + ...(input.height === undefined ? {} : { totalRise: input.height }), + ...(asGiven ? { metadata: { openingManaged: 'floor-opening' } } : {}), + children: [], + }) + // As main sizes a new flight: the run and the risers from the stair's design targets unless + // given, the rise resolved against what the flight stands on and arrives at. + const { flight } = planStairCreation( + draft, + { + ...withUpper, + [from.id]: { + ...withUpper[from.id], + children: [...(withUpper[from.id] as LevelNode).children, draft.id], + } as AnyNode, + [draft.id]: draft, + }, + { + width, + attachmentSide: 'front', + fillToFloor: true, + ...(input.length === undefined ? {} : { length: input.length }), + ...(input.steps === undefined ? {} : { stepCount: input.steps }), + }, + ) + const segment = { ...flight, ...(preset ? { materialPreset: preset } : {}) } + const { length, stepCount } = segment + const stair = StairNode.parse({ ...draft, stepCount, children: [segment.id] }) + changes.create.push( + { node: stair, parentId: from.id }, + { node: { ...segment, parentId: stair.id }, parentId: stair.id }, + ) + + // The editor's opening pass: the stair owns a floor opening in each floor it passes; the live + // opening systems then find it in place. Openings as given are cut as main cuts them: owned by + // the stair, with the pose the live systems move them by. + const openingIds: string[] = [] + // A hole in the slab above, not one in the ceiling below: the floor upstairs is open. + let slabHoleCut = false + const built = applySceneChanges(nodes, changes) + const polygon = asGiven + ? openingRing({ x: input.x, z: input.z, turn: stair.rotation, width, length }, input) + : [] + // The surface the opening falls in, not the storey's first: a floor of several rooms has a + // slab (and a ceiling below) per room. + const centre: Vec2 = [ + polygon.reduce((sum, [x]) => sum + x, 0) / (polygon.length || 1), + polygon.reduce((sum, [, z]) => sum + z, 0) / (polygon.length || 1), + ] + const over = (type: 'slab' | 'ceiling', levelId: string) => + Object.values(built).find( + (node) => + node.type === type && + node.parentId === levelId && + pointInPolygon(centre, node.polygon as Vec2[]), + ) + const destinationSlab = input.destinationSlabId + ? built[input.destinationSlabId] + : over('slab', upper.id) + const sourceCeiling = input.sourceCeilingId + ? built[input.sourceCeilingId] + : over('ceiling', from.id) + const floorCut = asGiven && cutFloor && !!destinationSlab + const ceilingCut = asGiven && cutCeiling && !!sourceCeiling + const adjacent = adjacentLevelId(built, upper.id, -1) === from.id + const opened = (levelId: string, drawnOn: 'floor' | 'ceiling', cutsAdjacent: boolean) => + cutFloorOpening(built, { + levelId, + polygon, + drawnOn, + source: 'stair', + ownerId: stair.id, + cutsAdjacent, + mintId: generateId, + }).changes + const patches = owned + ? planOwnedFloorOpenings(built, { ownerIds: new Set([stair.id]) }) + : [ + ...(floorCut ? opened(upper.id, 'floor', ceilingCut && adjacent) : []), + ...(ceilingCut && (!floorCut || !adjacent) ? opened(from.id, 'ceiling', false) : []), + ] + for (const patch of patches) { + if (patch.op === 'create') { + const node = + !owned && patch.node.type === 'floor-opening' + ? { + ...patch.node, + metadata: { + ...patch.node.metadata, + ownerPose: { + position: stair.position, + rotation: stair.rotation, + width, + runLength: length, + }, + ownerOpeningTarget: patch.node.drawnOn === 'ceiling' ? 'source' : 'destination', + }, + } + : patch.node + changes.create.push({ node, parentId: node.parentId ?? undefined }) + if (node.type === 'floor-opening') { + openingIds.push(node.id) + if (node.drawnOn !== 'ceiling') slabHoleCut = true + } + } else if (patch.op === 'update') changes.update.push({ id: patch.id, data: patch.data }) + else changes.delete.push(patch.id) + } + + const createdUpperLevel = !nodes[upper.id] + const arrival = upper.name ?? `level ${upper.level}` + return { + result: { + ok: true, + stairId: stair.id, + segmentId: segment.id, + fromLevelId: from.id, + upperLevelId: upper.id, + createdUpperLevel, + stepCount, + rise: Math.round(segment.height * 1000) / 1000, + rotation, + width, + length, + railingMode, + slabHoleCut, + ...(openingIds.length ? { openingIds } : {}), + ...(floorCut ? { destinationSlabId: destinationSlab!.id } : {}), + ...(ceilingCut ? { sourceCeilingId: sourceCeiling!.id } : {}), + message: openingIds.length + ? `Created a staircase at (${input.x}, ${input.z}) with ${stepCount} steps up to ${arrival}, its ${asGiven ? 'opening cut as given' : 'floor opening cut'}.` + : !cutFloor && !cutCeiling + ? `Created a staircase at (${input.x}, ${input.z}) with ${stepCount} steps up to ${arrival}${createdUpperLevel ? ' (created for it)' : ''}, with no opening, as asked.` + : `Created a staircase at (${input.x}, ${input.z}) with ${stepCount} steps up to ${arrival}${createdUpperLevel ? ' (created for it)' : ''}, but no floor there covers the flight, so no opening was cut. Add or align the upper floor over the stair's footprint.`, + }, + changes, + } +} diff --git a/packages/core/src/agent-operations/duplicate-level.test.ts b/packages/core/src/agent-operations/duplicate-level.test.ts new file mode 100644 index 0000000000..dbdde60653 --- /dev/null +++ b/packages/core/src/agent-operations/duplicate-level.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, BuildingNode, LevelNode, WallNode } from '../schema' +import { applySceneChanges } from './apply-changes' +import { deleteNode } from './delete-node' +import { duplicateLevel } from './duplicate-level' +import type { SceneNodes } from './types' + +// An agent's build: delete floors 5–7 (they held only their plans), then copy floor 4 three +// times. Floor 8 must stay at index 7 and the copies must take 4, 5 and 6. +function eightFloors(): Record { + const building = BuildingNode.parse({ id: 'building_main' }) + const levels = Array.from({ length: 8 }, (_, index) => + LevelNode.parse({ id: `level_f${index + 1}`, parentId: building.id, level: index }), + ) + const walls = levels.map((level) => + WallNode.parse({ id: `wall_${level.id}`, parentId: level.id, start: [0, 0], end: [4, 0] }), + ) + const nodes = [ + { ...building, children: levels.map((level) => level.id) }, + ...levels.map((level) => ({ ...level, children: [`wall_${level.id}`] })), + ...walls, + ] + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +const context = { activeLevelId: null } + +describe('copying a floor up after deleting the floors above it', () => { + test('fills the freed floors and leaves the top floor where it was', () => { + let nodes = eightFloors() as SceneNodes + for (const id of ['level_f5', 'level_f6', 'level_f7']) + nodes = applySceneChanges(nodes as never, deleteNode(nodes, { id } as never, context).changes) + for (let copy = 0; copy < 3; copy++) + nodes = applySceneChanges( + nodes as never, + duplicateLevel(nodes, { levelId: 'level_f4' } as never, context).changes, + ) + const levels = Object.values(nodes) + .filter((node) => node.type === 'level') + .map((node) => (node as LevelNode).level) + .sort((a, b) => a - b) + expect(levels).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + expect((nodes.level_f8 as LevelNode).level).toBe(7) + }) +}) diff --git a/packages/core/src/agent-operations/duplicate-level.ts b/packages/core/src/agent-operations/duplicate-level.ts index 88d4db62f3..b88b9aefab 100644 --- a/packages/core/src/agent-operations/duplicate-level.ts +++ b/packages/core/src/agent-operations/duplicate-level.ts @@ -59,7 +59,9 @@ export const duplicateLevel: AgentOperation = (nodes, input shiftedLevelIds: shiftedLevels.map((entry) => entry.id), copied: countByType(create.map(({ node }) => node)), skipped: countByType(skippedNodes), - newNodeIds: create.map(({ node }) => node.id), + // Counts are in `copied`; a floor copy is hundreds of ids the model reads back every call. + newNodeIds: create.slice(0, 40).map(({ node }) => node.id), + ...(create.length > 40 ? { newNodeIdsOmitted: create.length - 40 } : {}), }, changes: { create, diff --git a/packages/core/src/agent-operations/find-by-type.ts b/packages/core/src/agent-operations/find-by-type.ts index b3b3f4f270..e3fc9012a7 100644 --- a/packages/core/src/agent-operations/find-by-type.ts +++ b/packages/core/src/agent-operations/find-by-type.ts @@ -1,5 +1,6 @@ import { isScriptedNode } from '../lib/geometry-script-node' import type { AnyNode, ItemNode } from '../schema' +import { requireLevel } from './level-target' import { levelIdOf } from './scene-queries' import type { AgentOperation } from './types' @@ -51,6 +52,8 @@ export const findByType: AgentOperation<{ type: string; levelId?: string }> = ( nodes, { type, levelId }, ) => { + // A level that is not there is said, as get_zones says it, not answered with nothing found. + if (levelId) requireLevel(nodes, levelId) const word = type.trim().toLowerCase().replace(/s$/, '') const results: Record[] = [] for (const node of Object.values(nodes)) { diff --git a/packages/core/src/agent-operations/furnish-room.ts b/packages/core/src/agent-operations/furnish-room.ts new file mode 100644 index 0000000000..db78921c8e --- /dev/null +++ b/packages/core/src/agent-operations/furnish-room.ts @@ -0,0 +1,319 @@ +import type { FURNISHED_ROOM_TYPES } from '../agent-tools/furnish-room' +import { refuse } from '../agent-tools/refusal' +import { type AnyNode, type AssetInput, ItemNode } from '../schema' +import { edgeProjection } from './create-room' +import { + collectDoorKeepouts, + itemBlocksDoorKeepout, + itemPlanAabb, + keepoutCoversPlanned, + keepoutForPolygonEdge, + type PlanAabb, +} from './door-clearance' +import { collectOccupiedFootprints, findValidPlacement } from './layout-clearance' +import { type LevelTargetInput, targetLevel } from './level-target' +import { polygonArea, polygonBounds, type Vec2 } from './plan-geometry' +import { levelIdOf, levelRole } from './scene-queries' +import type { AgentContext, AgentOperation, SceneNodes } from './types' + +type RoomType = (typeof FURNISHED_ROOM_TYPES)[number] + +type FurnishRoomInput = LevelTargetInput & { + zoneId?: string + polygon?: number[][] + roomType: RoomType + doorWallIndex?: number +} + +// 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 + +/** 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) { + if (input.zoneId) { + const zone = nodes[input.zoneId] + if (!zone) + refuse( + 'zone_not_found', + `Room not found: ${input.zoneId}. Use a zoneId create_room or get_zones returned.`, + { zoneId: input.zoneId }, + ) + if (zone.type !== 'zone') + refuse( + 'not_a_zone', + `Node ${input.zoneId} is a ${zone.type}, not a room: pass the room's zoneId (create_room, get_zones).`, + { zoneId: input.zoneId, type: zone.type }, + ) + const levelId = levelIdOf(nodes, zone.id) + if (!levelId) refuse('level_not_found', `Room ${zone.id} is not on a level.`) + return { levelId, polygon: zone.polygon as Vec2[] } + } + if (!input.polygon) + refuse('room_required', 'Say which room: its zoneId, or its polygon (and its level).') + return { levelId: targetLevel(nodes, input, context).id, polygon: input.polygon as Vec2[] } +} + +/** The doors of the level that stand on an edge of the room, by where they stand. */ +function doorsOfRoom(nodes: SceneNodes, levelId: string, polygon: Vec2[]) { + const found: { doorId: string; edge: number }[] = [] + for (const wall of Object.values(nodes)) { + if (wall.type !== 'wall' || wall.parentId !== levelId) continue + const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + if (length < 1e-9) continue + for (const childId of wall.children) { + const door = nodes[childId] + if (door?.type !== 'door') continue + const t = door.position[0] / length + const point: Vec2 = [ + wall.start[0] + t * (wall.end[0] - wall.start[0]), + wall.start[1] + t * (wall.end[1] - wall.start[1]), + ] + const edge = polygon.findIndex((a, i) => { + const along = edgeProjection(a, polygon[(i + 1) % polygon.length]!, point) + return along.distance < DOOR_EDGE_TOLERANCE && along.t >= 0 && along.t <= 1 + }) + if (edge >= 0) found.push({ doorId: door.id, edge }) + } + } + return found +} + +/** 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) { + 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 + const along = { x: (b[0] - a[0]) / length, z: (b[1] - a[1]) / length } + const mid: Vec2 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] + const towardCenter = (center[0] - mid[0]) * -along.z + (center[1] - mid[1]) * along.x + const inward = towardCenter >= 0 ? { x: -along.z, z: along.x } : { x: along.z, z: -along.x } + // An item's front is its +Z: turned to face into the room. + const facing = (Math.atan2(inward.x, inward.z) * 180) / Math.PI + return { mid, along, inward, length, facing } +} + +type Edge = ReturnType +type Pose = { + assetId: string + x: number + z: number + rotationDeg: number + along?: { x: number; z: number } + inward?: { x: number; z: number } +} + +/** Where each piece of a room type goes, sized from the catalog's own dimensions. */ +function layout( + roomType: RoomType, + polygon: Vec2[], + doorWall: number, + catalog: readonly AssetInput[], +): Pose[] { + const bounds = polygonBounds(polygon) + const center: Vec2 = [bounds.centerX, bounds.centerZ] + const n = polygon.length + const door = edgeFrame(polygon, doorWall, center) + const back = edgeFrame(polygon, doorWall + Math.floor(n / 2), center) + const side = edgeFrame(polygon, doorWall + 1, center) + const area = polygonArea(polygon) + const size = (assetId: string) => catalog.find((asset) => asset.id === assetId)?.dimensions + const width = (assetId: string) => size(assetId)?.[0] ?? 1 + const depth = (assetId: string) => size(assetId)?.[2] ?? 1 + const poses: Pose[] = [] + /** Back against `edge`, `lateral` along it from its middle, `out` further into the room. */ + const against = (edge: Edge, assetId: string, lateral = 0, out = 0) => { + const inset = depth(assetId) / 2 + WALL_GAP + out + poses.push({ + assetId, + x: edge.mid[0] + edge.inward.x * inset + edge.along.x * lateral, + z: edge.mid[1] + edge.inward.z * inset + edge.along.z * lateral, + rotationDeg: edge.facing, + along: edge.along, + inward: edge.inward, + }) + } + const free = (assetId: string, x: number, z: number, rotationDeg = 0) => + poses.push({ assetId, x, z, rotationDeg }) + + switch (roomType) { + case 'bedroom': { + const bed = Math.max(bounds.width, bounds.depth) >= 3.2 ? 'double-bed' : 'single-bed' + against(back, bed) + const table = width('bedside-table') + if (back.length >= width(bed) + 2 * table + 0.2) { + const lateral = width(bed) / 2 + table / 2 + 0.05 + against(back, 'bedside-table', -lateral) + against(back, 'bedside-table', lateral) + } + if (area >= 10) against(side, 'dresser', side.length * 0.22) + if (area >= 13) against(side, 'closet', -side.length * 0.22) + break + } + case 'kitchen': + against(back, back.length >= 2.6 ? 'kitchen' : 'kitchen-counter') + if (back.length >= 3.5) against(back, 'stove', back.length / 2 - width('stove') / 2 - 0.1) + against(side, 'fridge', side.length * 0.25) + break + case 'bathroom': + against(back, 'toilet', back.length * 0.25) + against(back, 'bathroom-sink', -back.length * 0.2) + if (area >= 6.5) against(side, 'bathtub') + else free('shower-square', bounds.centerX, bounds.centerZ) + break + case 'living': + against(back, 'sofa') + against(back, 'coffee-table', 0, depth('sofa') + 0.4) + against(side, 'livingroom-chair', -side.length * 0.18) + // The TV faces the sofa from the door wall; the placement slides it off the door's clear zone. + against(door, 'tv-stand') + break + case 'dining': { + free('dining-table', bounds.centerX, bounds.centerZ) + const ahead = depth('dining-table') / 2 + depth('dining-chair') / 2 + const beside = width('dining-table') / 2 + depth('dining-chair') / 2 + free('dining-chair', bounds.centerX, bounds.centerZ - ahead) + free('dining-chair', bounds.centerX, bounds.centerZ + ahead, 180) + if (Math.min(bounds.width, bounds.depth) >= 3) { + free('dining-chair', bounds.centerX - beside, bounds.centerZ, 90) + free('dining-chair', bounds.centerX + beside, bounds.centerZ, 270) + } + break + } + case 'laundry': + against(back, 'washing-machine', -0.55) + against(back, 'drying-rack', 0.65) + break + case 'entry': + case 'hallway': + if (Math.min(bounds.width, bounds.depth) >= 1.4) against(side, 'coat-rack') + break + case 'storage': + against(back, 'closet') + break + } + return poses +} + +const SKIP_REASONS = { + outside_bounds: 'outside the room', + overlaps_item: 'overlaps another item', +} as const + +const round = (value: number) => Math.round(value * 100) / 100 + +/** + * Why a piece was skipped, with its size: told only "bathtub: blocks door clearance", an agent + * set the same tub there with place_items. An item the room cannot hold in any turn says so and + * what instead; one in a door's way names the door. + */ +function skipReason( + asset: AssetInput, + pose: { x: number; z: number; rotationDeg: number }, + reason: string, + room: { minX: number; maxX: number; minZ: number; maxZ: number }, + doors: ReturnType, +) { + const [width = 1, , depth = 1] = asset.dimensions ?? [1, 1, 1] + const named = `${asset.id} (${round(width)} × ${round(depth)} m)` + const [roomWidth, roomDepth] = [room.maxX - room.minX, room.maxZ - room.minZ] + const fits = (a: number, b: number) => a <= roomWidth && b <= roomDepth + if (!fits(width, depth) && !fits(depth, width)) + return `${named}: too large for the room (${round(roomWidth)} × ${round(roomDepth)} m); a smaller one, or add_object at the room's size` + if (reason === 'blocks_door_clearance') { + const footprint = itemPlanAabb( + [pose.x, 0, pose.z], + asset.dimensions, + (pose.rotationDeg * Math.PI) / 180, + ) + const door = doors.find((keepout) => itemBlocksDoorKeepout(footprint, keepout)) + return `${named}: in the way of ${door ? `door ${door.doorId}` : 'the door wall kept clear for a door'}` + } + return `${named}: ${SKIP_REASONS[reason as keyof typeof SKIP_REASONS]}` +} + +/** + * `furnish_room`: a room type's pieces from the host's catalog, against the walls the door decides, + * each moved off door clear zones and other items or skipped with the reason. The door wall is the + * edge a door of the room stands on (found by position), else doorWallIndex, else edge 0. + */ +export const furnishRoom: AgentOperation = (nodes, input, context) => { + const catalog = context.catalog + if (!catalog) refuse('no_catalog', 'This host has no item catalog to furnish from.') + const { levelId, polygon } = roomToFurnish(nodes, input, context) + 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.doorWallIndex !== undefined && input.doorWallIndex >= polygon.length) + refuse( + 'edge_out_of_range', + `The room has ${polygon.length} edges (0–${polygon.length - 1}); there is no edge ${input.doorWallIndex}.`, + ) + + const doors = doorsOfRoom(nodes, levelId, polygon) + const doorWall = input.doorWallIndex ?? doors[0]?.edge ?? 0 + const all = Object.values(nodes) + const doorKeepouts = collectDoorKeepouts(all, { levelId }) + const keepouts: PlanAabb[] = doorKeepouts.map((door) => door.aabb) + // A door wall without a door yet keeps the middle of it clear for the one to come. + if (!doors.some((door) => door.edge === doorWall)) { + const planned = keepoutForPolygonEdge(polygon, doorWall, { t: 0.5, width: 0.9 }) + if (planned && !keepouts.some((keepout) => keepoutCoversPlanned(keepout, planned))) + keepouts.push(planned) + } + const occupied = collectOccupiedFootprints(all, { levelId, floorOnly: true }).map( + (footprint) => footprint.aabb, + ) + const { minX, maxX, minZ, maxZ } = polygonBounds(polygon) + + const items: AnyNode[] = [] + const skipped: string[] = [] + for (const pose of layout(input.roomType, polygon, doorWall, catalog)) { + const asset = catalog.find((entry) => entry.id === pose.assetId) + if (!asset) { + skipped.push(`${pose.assetId}: not in the catalog`) + continue + } + const { candidate, reason } = findValidPlacement({ + primary: { x: pose.x, z: pose.z, rotationDeg: pose.rotationDeg }, + dimensions: asset.dimensions, + doorKeepouts: keepouts, + occupied, + roomBounds: { minX, maxX, minZ, maxZ }, + along: pose.along, + inward: pose.inward, + }) + if (!candidate) { + skipped.push(skipReason(asset, pose, reason, { minX, maxX, minZ, maxZ }, doorKeepouts)) + continue + } + const rotation = (candidate.rotationDeg * Math.PI) / 180 + occupied.push(itemPlanAabb([candidate.x, 0, candidate.z], asset.dimensions, rotation)) + items.push( + ItemNode.parse({ + name: asset.name, + parentId: levelId, + position: [candidate.x, 0, candidate.z], + rotation: [0, rotation, 0], + asset, + }), + ) + } + + const itemIds = items.map((item) => item.id) + return { + result: { + ok: true, + placed: items.length, + itemIds, + skipped, + doorWallIndex: doorWall, + doorsDetected: doors.length, + message: `Furnished the ${input.roomType} with ${items.length} item${items.length === 1 ? '' : 's'}${skipped.length ? `, skipped ${skipped.length}` : ''}.`, + }, + ...(items.length + ? { changes: { create: items.map((node) => ({ node, parentId: levelId })) } } + : {}), + } +} diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index b6deae591f..b932c27b72 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -1,20 +1,35 @@ +import { addLevel } from './add-level' +import { addWall } from './add-wall' +import { createRoom } from './create-room' +import { createStair } from './create-stair' import { deleteNode } from './delete-node' import { duplicateLevel } from './duplicate-level' import { findByType } from './find-by-type' +import { furnishRoom } from './furnish-room' import { getNode } from './get-node' import { getLevelSummary, getWalls, getZones } from './level-reads' import { listLevels } from './list-levels' +import { placeItems } from './place-items' +import { ROOM_OPERATIONS } from './room-structure' +import { searchAssets } from './search-assets' import { fitStair, measureStairOperation } from './stairs' import { verifyScene } from './verify-scene' +export * from './achieved' export * from './add-column' +export * from './add-level' export * from './add-object' +export * from './add-wall' export * from './apply-changes' +export * from './apply-outcome' export * from './collections' +export * from './create-room' +export * from './create-stair' export * from './delete-node' export * from './door-clearance' export * from './duplicate-level' export * from './find-by-type' +export * from './furnish-room' export * from './get-node' export * from './hosted-services' export * from './layout-clearance' @@ -22,11 +37,19 @@ export * from './level-reads' export * from './level-target' export * from './list-levels' export * from './material-preset' +export * from './material-refs' +export * from './node-patch' +export * from './place-items' export * from './plan-geometry' +export * from './room-structure' +export * from './scene-measure' export * from './scene-queries' +export * from './scene-view' +export * from './search-assets' export * from './stairs' export * from './types' export * from './verify-scene' +export * from './wall-opening' /** Each shared agent tool's operation, by tool name: what every surface executes. */ export const AGENT_OPERATIONS = { @@ -39,6 +62,14 @@ export const AGENT_OPERATIONS = { get_zones: getZones, duplicate_level: duplicateLevel, verify_scene: verifyScene, + add_wall: addWall, + add_level: addLevel, + create_stair: createStair, + place_items: placeItems, delete_node: deleteNode, find_by_type: findByType, + create_room: createRoom, + furnish_room: furnishRoom, + search_assets: searchAssets, + ...ROOM_OPERATIONS, } as const diff --git a/packages/core/src/agent-operations/material-refs.test.ts b/packages/core/src/agent-operations/material-refs.test.ts new file mode 100644 index 0000000000..5454b203cc --- /dev/null +++ b/packages/core/src/agent-operations/material-refs.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test' +import { isAgentRefusal } from '../agent-tools/refusal' +import { getCatalogMaterialById } from '../material-library' +import { finishSurface, nearestLibraryMaterials, requireMaterialRef } from './material-refs' + +/** + * A roof's missing corrugated material got Brown brick, Buff brick and Prepared + * Drywall as its nearest, ranked by name, blind to the roof. What goes wrong, written first: a + * wall's or a floor's material offered for a roof; a nearest list that hides that the library has + * nothing of the kind. + */ +describe('the nearest library materials', () => { + // Roofing as the library tags it, not one material by name: which roofing a library holds is + // its data. + test('a roof is offered roofing first, never a wall or a floor material', () => { + const roof = nearestLibraryMaterials('metal-corrugated', 'roof') + const surfaces = (ref: string) => + getCatalogMaterialById(ref.replace(/^library:/, '').split(' ')[0]!)?.surfaces ?? [] + expect(surfaces(roof[0]!)).toContain('roof') + for (const ref of roof) expect(ref).not.toMatch(/brick|drywall/i) + }) + + test('when nothing of the kind is like the name, the refusal says so and points to a colour', () => { + let message = '' + try { + requireMaterialRef('library:corrugated', undefined, 'roof') + } catch (error) { + if (!isAgentRefusal(error)) throw error + message = error.message + } + expect(message).toContain('no roofing like it') + expect(message).toContain('library:roof-') + expect(message).toContain('a flat colour, library:preset-') + // Nothing shares a word with it: no alphabetical "nearest" (bricks, drywall) pads the answer. + expect(message).not.toMatch(/brick|drywall/i) + }) + + test("a roof's wall role is a wall; a kind with no tagged surface has none", () => { + expect(finishSurface('roof', 'top')).toBe('roof') + expect(finishSurface('roof', 'wall')).toBe('wall') + expect(finishSurface('roof-segment', 'wallMaterialPreset')).toBe('wall') + expect(finishSurface('wall', 'slots.a')).toBe('wall') + expect(finishSurface('zone')).toBeUndefined() + }) + + test('a known id passes, bare or as a ref', () => { + expect(requireMaterialRef('concrete-raw')).toBe('library:concrete-raw') + expect(requireMaterialRef('library:concrete-raw', undefined, 'roof')).toBe( + 'library:concrete-raw', + ) + }) +}) diff --git a/packages/core/src/agent-operations/material-refs.ts b/packages/core/src/agent-operations/material-refs.ts new file mode 100644 index 0000000000..b5c2b73469 --- /dev/null +++ b/packages/core/src/agent-operations/material-refs.ts @@ -0,0 +1,116 @@ +import { refuse } from '../agent-tools/refusal' +import { + getCatalogMaterialById, + MATERIAL_CATALOG, + type MaterialSurface, + parseMaterialRef, +} from '../material-library' + +const SURFACE_WORDS: Record = { + roof: 'roofing', + wall: 'wall material', + floor: 'flooring', + ceiling: 'ceiling material', + furniture: 'joinery material', + outdoor: 'outdoor material', +} + +/** The surface a kind's finish covers, as the library tags materials; a roof's `wall` role is a wall. */ +const KIND_SURFACES: Record = { + roof: 'roof', + 'roof-segment': 'roof', + dormer: 'roof', + wall: 'wall', + column: 'wall', + slab: 'floor', + stair: 'floor', + 'stair-segment': 'floor', + ceiling: 'ceiling', + fence: 'outdoor', + chimney: 'outdoor', + door: 'furniture', + window: 'furniture', + item: 'furniture', + cabinet: 'furniture', + shelf: 'furniture', +} + +/** The surface a node's finish goes on, by its kind and the role or field painted. */ +export function finishSurface(type: string, role?: string): MaterialSurface | undefined { + const surface = KIND_SURFACES[type] + return surface === 'roof' && role && /^wall(MaterialPreset)?$/.test(role) ? 'wall' : surface +} + +const wordsOf = (asked: string) => + asked + .toLowerCase() + .replace(/^library:/, '') + .split(/[^a-z0-9]+/) + .filter(Boolean) +const named = (entry: { id: string; label: string }) => `library:${entry.id} (${entry.label})` +const suits = (entry: { surfaces?: readonly MaterialSurface[] }, surface: MaterialSurface) => + entry.surfaces?.includes(surface) ?? false + +/** + * Library ids nearest a name asked for: those that suit the surface first (a roof's roofing, a + * wall's wall materials, as the library tags them), then by the words they share with it. + */ +export function nearestLibraryMaterials( + asked: string, + surface?: MaterialSurface, + count = 3, +): string[] { + const words = wordsOf(asked) + const score = (text: string) => words.filter((word) => text.includes(word)).length + return MATERIAL_CATALOG.map((entry) => ({ + entry, + fit: surface && suits(entry, surface) ? 1 : 0, + score: score(`${entry.id} ${entry.label}`.toLowerCase()), + })) + .sort((a, b) => b.fit - a.fit || b.score - a.score || a.entry.id.localeCompare(b.entry.id)) + .slice(0, count) + .map(({ entry }) => named(entry)) +} + +/** + * A material ref that renders: a library id the catalog holds (`library:`, or the bare id), or + * a scene material (`scene:`, which only the host's store can check). Anything else would fall + * back to a default with no word (an unknown preset came out grey), so it is refused, naming + * the nearest library ids for the surface; when the library has nothing of the kind like it, the + * refusal says so, and that a colour is the nearest (a corrugated roof was once offered bricks). + */ +export function requireMaterialRef( + asked: string, + field?: string, + surface?: MaterialSurface, + /** Whether to point to `paint` for a colour (paint passes it); else a flat library colour. */ + { paint = false } = {}, +): string { + const parsed = parseMaterialRef(asked.includes(':') ? asked : `library:${asked}`) + if (parsed?.kind === 'scene') return asked + // A community material (`library:mtl_…`): the host checks it against its catalog. + if (parsed?.kind === 'library' && parsed.id.startsWith('mtl_')) return asked + if (parsed?.kind === 'library' && getCatalogMaterialById(parsed.id)) return `library:${parsed.id}` + const words = wordsOf(asked) + const shares = (text: string) => words.some((word) => text.toLowerCase().includes(word)) + const kind = surface ? MATERIAL_CATALOG.filter((entry) => suits(entry, surface)) : [] + const alike = kind.filter((entry) => shares(`${entry.id} ${entry.label}`)) + const nearest = nearestLibraryMaterials(asked, surface) + // Only names sharing a word: with none, the ranking is alphabetical and names bricks for a roof. + const byName = nearestLibraryMaterials(asked).filter(shares) + const head = `${field ? `${field}: ` : ''}${asked} is not in the material library` + const colour = paint + ? 'a colour with paint' + : 'a flat colour, library:preset- (library:preset-midgrey, library:preset-tan)' + const message = + surface && !alike.length + ? `${head}, which has no ${SURFACE_WORDS[surface]} like it. Its ${SURFACE_WORDS[surface]}: ${ + kind.length ? kind.slice(0, 5).map(named).join(', ') : 'none' + }.${byName.length ? ` Nearest by name: ${byName.join(', ')}.` : ''} Or ${colour} is the nearest.` + : `${head}. Nearest: ${nearest.join(', ')}; or give ${colour}.` + return refuse('unknown_material', message, { + material: asked, + ...(field ? { field } : {}), + nearest, + }) +} diff --git a/packages/core/src/agent-operations/node-patch.test.ts b/packages/core/src/agent-operations/node-patch.test.ts new file mode 100644 index 0000000000..a09ebcfd5b --- /dev/null +++ b/packages/core/src/agent-operations/node-patch.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test' +import { AgentRefusal, isAgentRefusal } from '../agent-tools/refusal' +import { getCatalogMaterialById } from '../material-library' +import { ColumnNode, SlabNode, WallNode } from '../schema' +import { honestNodePatch } from './node-patch' + +/** + * An agent's edit of a node does what it says, or says why not. A pier's `material: {color}` + * reported "applied" and stored `{}`; `materialPreset: null` was refused; a `material` + * set under a preset never showed. What goes wrong, written first: a field the schema drops + * reported applied; a value the schema changes (an unknown preset turned "custom") kept quiet; a + * material the library lacks stored and rendered grey; no way to clear a field; an edit a set + * field hides; free-form metadata refused. + */ + +const pier = () => + ColumnNode.parse({ + id: 'column_pier', + parentId: 'level_p', + position: [8, 0, 2], + materialPreset: 'library:concrete-raw', + }) +const refusal = (run: () => unknown) => { + try { + run() + } catch (error) { + if (isAgentRefusal(error)) return error + throw error + } + throw new Error('not refused') +} + +describe('honest patches', () => { + test('a field the schema would drop is refused, naming its path and where a colour goes', () => { + const error = refusal(() => honestNodePatch(pier(), { material: { color: '#8a8a8a' } })) + expect(error.code).toBe('unknown_field') + expect(error.message).toContain('material.color') + expect(error.message).toContain('material.properties.color') + }) + + test('a value the schema would change is refused, naming it', () => { + const error = refusal(() => + honestNodePatch(pier(), { materialPreset: null, material: { preset: 'granite' } }), + ) + expect(error.code).toBe('unknown_field') + expect(error.message).toContain('material.preset') + }) + + test('a material the library lacks is refused, naming the nearest', () => { + const error = refusal(() => honestNodePatch(pier(), { materialPreset: 'library:grey-render' })) + expect(error.code).toBe('unknown_material') + expect(error.message).toContain('library:grey-render') + expect(error.message).toContain('Nearest') + }) + + test("a patch's unknown material is offered the materials of its node's surface", () => { + const slab = SlabNode.parse({ + id: 'slab_p', + parentId: 'level_p', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + }) + const error = refusal(() => honestNodePatch(slab, { slots: { surface: 'library:oak-boards' } })) + expect(error.code).toBe('unknown_material') + const nearest = (error.details as { nearest: string[] }).nearest + expect(nearest.length).toBeGreaterThan(0) + for (const ref of nearest) { + const id = ref.replace(/^library:/, '').split(' ')[0]! + expect(getCatalogMaterialById(id)?.surfaces).toContain('floor') + } + }) + + test('null clears an optional field; a required one is refused', () => { + expect(honestNodePatch(pier(), { materialPreset: null })).toEqual({ materialPreset: undefined }) + const wall = WallNode.parse({ id: 'wall_p', parentId: 'level_p', start: [0, 0], end: [4, 0] }) + expect(refusal(() => honestNodePatch(wall, { start: null })).code).toBe('field_required') + }) + + test('a material a set preset would hide is refused, naming the preset to clear', () => { + const material = { preset: 'custom', properties: { color: '#8a8a8a' } } + const error = refusal(() => honestNodePatch(pier(), { material })) + expect(error.code).toBe('shadowed_field') + expect(error.message).toContain('materialPreset') + expect(honestNodePatch(pier(), { material, materialPreset: null })).toEqual({ + material, + materialPreset: undefined, + }) + }) + + test('free-form metadata and a plain edit pass as sent', () => { + expect( + honestNodePatch(pier(), { metadata: { note: 'from run 3', any: { depth: 1 } } }), + ).toEqual({ + metadata: { note: 'from run 3', any: { depth: 1 } }, + }) + expect(honestNodePatch(pier(), { height: 2.4 })).toEqual({ height: 2.4 }) + expect(AgentRefusal).toBeDefined() + }) +}) diff --git a/packages/core/src/agent-operations/node-patch.ts b/packages/core/src/agent-operations/node-patch.ts new file mode 100644 index 0000000000..63bb060d88 --- /dev/null +++ b/packages/core/src/agent-operations/node-patch.ts @@ -0,0 +1,140 @@ +import { refuse } from '../agent-tools/refusal' +import { parseMaterialColor } from '../material-library' +import type { AnyNode } from '../schema' +import { parseNode } from '../schema/compiled-node-parsers' +import { finishSurface, requireMaterialRef } from './material-refs' + +/** + * An agent's edit of a node checked so it does what it says, the same on the MCP's apply_patch + * and the chat's update_node. A pier once took five attempts: `material: {color}` + * reported "applied" and stored `{}` (the schema drops unknown keys), `materialPreset: null` was + * refused (no way to clear), an unknown preset rendered grey, and a `material` under a set preset + * never showed. Returns the data to write: `null` (or an empty material ref) as `undefined`, which + * the store reads as "remove the field". Free-form records (metadata, slots) keep every key, so they + * pass as sent. + */ + +/** The pairs where the first, set, hides the second: the renderer reads a preset before a material. */ +const HIDES: readonly [string, string][] = [ + ['materialPreset', 'material'], + ['topMaterialPreset', 'topMaterial'], + ['edgeMaterialPreset', 'edgeMaterial'], + ['wallMaterialPreset', 'wallMaterial'], +] + +const isPreset = (key: string) => key === 'materialPreset' || key.endsWith('MaterialPreset') +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +/** The paths of `sent` the parsed node does not keep as sent: dropped, or changed (a value coerced). */ +function lostPaths(sent: unknown, kept: unknown, path: string): string[] { + if (isRecord(sent)) { + if (!isRecord(kept)) return [path] + return Object.entries(sent).flatMap(([key, value]) => + value === undefined + ? [] + : key in kept + ? lostPaths(value, kept[key], `${path}.${key}`) + : [`${path}.${key}`], + ) + } + if (Array.isArray(sent)) + return Array.isArray(kept) && kept.length === sent.length + ? sent.flatMap((value, i) => lostPaths(value, kept[i], `${path}[${i}]`)) + : [path] + return typeof sent === 'string' && sent !== kept + ? [`${path} (stored as ${JSON.stringify(kept)})`] + : [] +} + +/** Material refs the patch writes: a preset field, a slot, a region's or a room's finish. */ +function materialRefs(data: Record): [string, string][] { + const refs: [string, string][] = [] + for (const [key, value] of Object.entries(data)) { + if (isPreset(key) && typeof value === 'string') refs.push([key, value]) + if (key === 'slots' && isRecord(value)) + for (const [slot, ref] of Object.entries(value)) + if (typeof ref === 'string') refs.push([`slots.${slot}`, ref]) + if (key === 'faceRegions' && Array.isArray(value)) + value.forEach((region, i) => { + if (isRecord(region) && typeof region.finish === 'string') + refs.push([`faceRegions[${i}].finish`, region.finish]) + }) + } + return refs +} + +/** The node's current value at a field materialRefs names (a preset, `slots.`, a region). */ +function valueAt(node: AnyNode, field: string): unknown { + const record = node as unknown as Record + const slot = /^slots\.(.+)$/.exec(field) + if (slot) return isRecord(record.slots) ? record.slots[slot[1]!] : undefined + const region = /^faceRegions\[(\d+)\]\.finish$/.exec(field) + if (region) { + const regions = record.faceRegions + const entry = Array.isArray(regions) ? regions[Number(region[1])] : undefined + return isRecord(entry) ? entry.finish : undefined + } + return record[field] +} + +export function honestNodePatch( + current: AnyNode, + data: Readonly>, +): Record { + const sent: Record = {} + for (const [key, value] of Object.entries(data)) + sent[key] = value === null || (isPreset(key) && value === '') ? undefined : value + + const merged: Record = { ...current, ...sent } + for (const [key, value] of Object.entries(sent)) if (value === undefined) delete merged[key] + const parsed = parseNode(merged) + if (!parsed.success) { + const required = parsed.error.issues + .map((issue) => String(issue.path[0] ?? '')) + .find((key) => key in sent && sent[key] === undefined) + if (required) + refuse( + 'field_required', + `${current.type} ${current.id} needs ${required}: it cannot be cleared.`, + { + field: required, + }, + ) + // Any other schema failure is the caller's own refusal. + return sent + } + + const lost = Object.entries(sent).flatMap(([key, value]) => + value === undefined ? [] : lostPaths(value, (parsed.data as Record)[key], key), + ) + if (lost.length) + refuse( + 'unknown_field', + `${current.type} ${current.id} would not keep ${lost.join(', ')}: the patch would report it applied and drop it.${ + lost.some((path) => /(^|\.)color\b/.test(path)) + ? ' A colour goes in material.properties.color.' + : '' + }`, + { fields: lost }, + ) + + for (const [field, ref] of materialRefs(sent)) { + // Restating the node's own value passes, so an older node's bad value never blocks an edit; + // a slot also takes a plain colour (as unknownMaterialPresetRefusal reads them). + if (valueAt(current, field) === ref) continue + if (field.startsWith('slots.') && parseMaterialColor(ref)) continue + requireMaterialRef(ref, field, finishSurface(current.type, field)) + } + + for (const [preset, material] of HIDES) { + const hider = merged[preset] + if (sent[material] !== undefined && typeof hider === 'string' && hider) + refuse( + 'shadowed_field', + `${material} would not show: ${preset} (${hider}) is read first. Clear it in the same patch (${preset}: null).`, + { field: material, hiddenBy: preset }, + ) + } + return sent +} diff --git a/packages/core/src/agent-operations/operations.test.ts b/packages/core/src/agent-operations/operations.test.ts index 0166dfcbcf..6f41211b7d 100644 --- a/packages/core/src/agent-operations/operations.test.ts +++ b/packages/core/src/agent-operations/operations.test.ts @@ -2,8 +2,10 @@ 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 { type AnyNode, LevelNode, StairNode, StairSegmentNode } from '../schema' +import { reconcileSceneStructure } from '../lib/structure-reconcile' +import { type AnyNode, generateId, LevelNode, StairNode, StairSegmentNode } from '../schema' import { AGENT_TOOL_CASES } from './__fixtures__/cases' +import { applyAgentOutcome } from './apply-outcome' import { AGENT_OPERATIONS, applySceneChanges } from './index' // Layer 1 of 3: the core operations, on every shared tool's edge cases. @@ -14,7 +16,10 @@ describe('agent operations', () => { const nodes = c.scene().nodes as Record const operation = AGENT_OPERATIONS[c.tool as keyof typeof AGENT_OPERATIONS] expect(operation).toBeDefined() - const context = { activeLevelId: c.context?.activeLevelId ?? null } + const context = { + activeLevelId: c.context?.activeLevelId ?? null, + catalog: c.context?.catalog, + } // As on both surfaces, the operation gets the input its contract parsed ("36 in" → metres). const contract = AGENT_TOOL_CONTRACTS.find((candidate) => candidate.name === c.tool)! const input = z.object(contract.input).parse(c.input) @@ -30,25 +35,35 @@ describe('agent operations', () => { for (const text of c.expect.mentions ?? []) expect((error as Error).message).toContain(text) return } - const outcome = operation(nodes, input as never, context) - expect(outcome.result).toMatchObject(c.expect.result) - const after = applySceneChanges(nodes, outcome.changes) + // 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), { + getNodes: () => after, + applyChanges: (changes) => { + after = applySceneChanges(after, changes) + }, + reconcile: () => { + after = reconcileSceneStructure({ nodes: after, mintId: (kind) => generateId(kind) }) + .nodes as Record + }, + }) + expect(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 ?? {})) expect(after[id]).toMatchObject(fields) for (const [key, entries] of Object.entries(c.expect.contains ?? {})) for (const entry of entries) - expect((outcome.result as Record)[key]).toContainEqual( + expect((result as Record)[key]).toContainEqual( expect.objectContaining(entry), ) for (const [key, entries] of Object.entries(c.expect.lacks ?? {})) for (const entry of entries) - expect((outcome.result as Record)[key]).not.toContainEqual( + expect((result as Record)[key]).not.toContainEqual( expect.objectContaining(entry), ) - for (const text of c.expect.mentions ?? []) - expect(JSON.stringify(outcome.result)).toContain(text) + for (const text of c.expect.mentions ?? []) expect(JSON.stringify(result)).toContain(text) + expect(c.expect.check?.(result, after) ?? []).toEqual([]) }) } }) diff --git a/packages/core/src/agent-operations/place-items.ts b/packages/core/src/agent-operations/place-items.ts new file mode 100644 index 0000000000..42b8a57247 --- /dev/null +++ b/packages/core/src/agent-operations/place-items.ts @@ -0,0 +1,339 @@ +import { refuse } from '../agent-tools/refusal' +import { + flushMountRotation, + geometrySurfaceAt, + geometryUndersideAt, + mountsFlush, +} from '../lib/geometry-surfaces' +import { type AnyNode, type AssetInput, ItemNode, type WallNode } from '../schema' +import { getWallLocalFaceZ } from '../systems/wall/wall-frame' +import { + collectDoorKeepouts, + type DoorKeepout, + itemBlocksDoorKeepout, + itemPlanAabb, +} from './door-clearance' +import { findValidPlacement } from './layout-clearance' +import { type LevelTargetInput, targetLevel } from './level-target' +import { + pointInPolygon, + polygonBounds, + projectWorldPointToWallLocalX, + type Vec2, + wallLength, +} from './plan-geometry' +import type { AgentOperation, SceneChanges, SceneNodes } from './types' + +type Entry = { + assetId: string + x: number + z: number + y?: number + rotation?: number + targetNodeId?: string +} +type PlaceItemsInput = LevelTargetInput & { items: Entry[] } + +type Placed = + | { + ok: true + itemId: string + assetId: string + name: string + x: number + z: number + hostId?: string + side?: 'front' | 'back' + /** The authored host's part it came to rest on, or hangs from. */ + restingOn?: string + } + | { ok: false; assetId: string; code: string; error: string } + +/** + * What belongs on the lot rather than in a room. Anything else placed outside every room of a + * level that has rooms is refused: in production a model that had fallen back to raw place_items + * put a bed on the lawn (2026-09-04). + */ +const OUTDOOR_ASSET = + /tree|plant|shrub|bush|hedge|flower|palm|\bfir\b|bench|grill|bbq|barbecue|\bcar\b|vehicle|truck|bike|bicycle|pool|fence|gate|lamp ?post|street|outdoor|garden|patio|deck|swing|trampoline|planter|mailbox|umbrella|parasol/i + +const isOutdoor = (asset: AssetInput) => + OUTDOOR_ASSET.test(`${asset.name} ${asset.category ?? ''} ${(asset.tags ?? []).join(' ')}`) + +function roomsOn(nodes: SceneNodes, levelId: string): Vec2[][] { + return Object.values(nodes).flatMap((node) => + node.type === 'zone' && node.parentId === levelId && node.polygon.length >= 3 + ? [node.polygon as Vec2[]] + : [], + ) +} + +const refused = (assetId: string, code: string, error: string): Placed => ({ + ok: false, + assetId, + code, + error, +}) + +const round = (value: number) => Math.round(value * 100) / 100 + +/** + * Whether a floor item fits where it is put: an agent set the catalog's 2.34 m bathtub in front + * of a bath's door, after furnish_room had skipped it there, and place_items checked only + * that its centre was in a room. An item the room cannot hold in any turn, or one standing in the + * space a door needs (as verify_scene's blocked-door check sees it), is refused; the second with a + * spot in the room that clears every door, when there is one. Items overlapping is not refused: a + * chair under its table and a bed on its rug overlap by design. + */ +function floorFit( + entry: Entry, + asset: AssetInput, + room: Vec2[] | undefined, + doors: DoorKeepout[], +): Placed | null { + const [width = 1, , depth = 1] = asset.dimensions ?? [1, 1, 1] + const bounds = room ? polygonBounds(room) : undefined + if (bounds) { + const [roomWidth, roomDepth] = [bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ] + const fits = (a: number, b: number) => a <= roomWidth && b <= roomDepth + if (!fits(width, depth) && !fits(depth, width)) + return refused( + entry.assetId, + 'too_large_for_room', + `"${asset.name}" is ${round(width)} × ${round(depth)} m; the room it stands in is ${round(roomWidth)} × ${round(roomDepth)} m, so it fits in no turn. Pick a smaller one (search_assets), or build one at the room's size with add_object.`, + ) + } + const turn = entry.rotation ?? 0 + const footprint = itemPlanAabb([entry.x, 0, entry.z], asset.dimensions, (turn * Math.PI) / 180) + const door = doors.find((keepout) => itemBlocksDoorKeepout(footprint, keepout)) + if (!door) return null + const { candidate } = findValidPlacement({ + primary: { x: entry.x, z: entry.z, rotationDeg: turn }, + dimensions: asset.dimensions, + doorKeepouts: doors.map((keepout) => keepout.aabb), + occupied: [], + roomBounds: bounds, + }) + return refused( + entry.assetId, + 'blocks_door', + `"${asset.name}" at (${entry.x}, ${entry.z}) stands in the space door ${door.doorId} needs to open and be walked through.${ + candidate + ? ` A spot that fits: (${round(candidate.x)}, ${round(candidate.z)}), turned ${candidate.rotationDeg}°.` + : ' No spot in this room clears its doors: pick a smaller one, or none.' + }`, + ) +} + +/** Where an item goes on its host, in the host's frame, as the editor's placement puts it there. */ +type Pose = { + parentId: string + position: [number, number, number] + rotation: [number, number, number] + extra?: Partial + side?: 'front' | 'back' + restingOn?: string +} + +/** + * On a wall: along it from its start, the height given for the item's bottom, on the side of the + * wall the point is on (the front faces the wall's left, the back is turned round), centred in the + * wall or, for a wall-side fixture, on its face. Kept inside the wall's span and height, as the + * editor's wall placement keeps it. + */ +function onWall(wall: WallNode, entry: Entry, asset: AssetInput): Pose | Placed { + const [width = 0, height = 0] = asset.dimensions ?? [1, 1, 1] + const wallHeight = wall.height ?? 2.5 + if (entry.y === undefined) + return refused( + entry.assetId, + 'height_required', + `On wall ${wall.id}, give y: the height of the item's bottom above the floor (its top, y + ${height.toFixed(2)} m, must stay under the wall's ${wallHeight.toFixed(2)} m). Art centred at eye level is 1.5 − its height / 2; a sconce about 1.5.`, + ) + if (height > wallHeight) + return refused( + entry.assetId, + 'item_too_tall', + `"${asset.name}" is ${height.toFixed(2)} m tall; wall ${wall.id} is ${wallHeight.toFixed(2)} m. Pick a shorter item or a taller wall.`, + ) + const length = wallLength(wall) + const along = projectWorldPointToWallLocalX(wall, [entry.x, 0, entry.z]) + const x = Math.max(width / 2, Math.min(length - width / 2, along)) + const y = Math.max(0, Math.min(wallHeight - height, entry.y)) + const [dx, dz] = [wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]] + // The wall's left (its local +z) is its front. + 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 + return { + parentId: wall.id, + position: [x, y, z], + rotation: [0, turn, 0], + extra: { wallId: wall.id, wallT: length ? x / length : 0, side }, + side, + } +} + +/** + * On an item standing on the floor: in its frame (translation, turn, scale). An object built with + * add_object takes the item on its real surface under the point, or hangs a ceiling item from its + * underside above it; a catalog item, on its top. A height given (y above 0) is kept. + */ +function onItem(host: ItemNode, entry: Entry, asset: AssetInput): Pose { + const [hx, hy, hz] = host.position + const yaw = host.rotation[1] ?? 0 + const [dx, dz] = [entry.x - hx, entry.z - hz] + const lx = (Math.cos(yaw) * dx - Math.sin(yaw) * dz) / host.scale[0] + const lz = (Math.sin(yaw) * dx + Math.cos(yaw) * dz) / host.scale[2] + const turn = ((entry.rotation ?? 0) * Math.PI) / 180 - yaw + const hanging = asset.attachTo === 'ceiling' && !!host.source + const surface = host.source + ? hanging + ? geometryUndersideAt(host.source.manifest, lx, lz) + : geometrySurfaceAt(host.source.manifest, lx, lz) + : null + const explicit = !hanging && entry.y !== undefined && entry.y > 0 + const flush = hanging && mountsFlush(asset) + const drop = hanging ? (flush ? 0.02 : (asset.dimensions?.[1] ?? 0)) : 0 + const y = explicit + ? entry.y! - hy + : surface + ? surface.y * host.scale[1] - drop + : (host.asset.surface?.height ?? host.asset.dimensions[1]) * host.scale[1] + return { + parentId: host.id, + position: [lx * host.scale[0], y, lz * host.scale[2]], + // A flush fixture tilts with a sloped underside (a can in a vault plane). + rotation: + flush && surface && 'normal' in surface + ? flushMountRotation(surface.normal, turn) + : [0, turn, 0], + ...(!explicit && surface?.part ? { restingOn: surface.part } : {}), + } +} + +/** + * `place_items`: catalog items on a level's floor, or on the host each names (a wall, a ceiling, + * an item standing on the floor), each placed or refused on its own. + */ +export const placeItems: AgentOperation = (nodes, input, context) => { + const catalog = context.catalog + if (!catalog) + refuse('no_catalog', 'This host has no item library to place from; build it with add_object.') + const level = targetLevel(nodes, input, context) + const create: NonNullable = [] + const doorsOn: Record = {} + const make = (asset: AssetInput, pose: Pose) => { + const node = ItemNode.parse({ + name: asset.name, + parentId: pose.parentId, + position: pose.position, + rotation: pose.rotation, + asset, + ...pose.extra, + }) + create.push({ node, parentId: pose.parentId }) + return node + } + + const items = input.items.map((entry): Placed => { + const { assetId, x, z, rotation = 0, targetNodeId } = entry + const asset = catalog.find((candidate) => candidate.id === assetId) + if (!asset) + return refused( + assetId, + 'asset_not_found', + `Asset "${assetId}" is not in the library. Find a valid id with search_assets.`, + ) + const host = targetNodeId ? (nodes[targetNodeId] as AnyNode | undefined) : undefined + if (targetNodeId && !host) + return refused(assetId, 'host_not_found', `Host not found: ${targetNodeId}.`) + + if (host?.type === 'wall' || host?.type === 'ceiling' || host?.type === 'item') { + let pose: Pose | Placed + if (host.type === 'wall') pose = onWall(host, entry, asset) + else if (host.type === 'ceiling') + // Under its underside, its top flush with it. + pose = { + parentId: host.id, + position: [x, -(asset.dimensions?.[1] ?? 0), z], + rotation: [0, (rotation * Math.PI) / 180, 0], + } + else { + if (nodes[host.parentId ?? '']?.type !== 'level') + return refused( + assetId, + 'host_not_on_level', + `${host.name ?? host.id} rests on ${host.parentId}: only an item standing on a floor hosts another here.`, + ) + pose = onItem(host, entry, asset) + } + if ('ok' in pose) return pose + const node = make(asset, pose) + return { + ok: true, + itemId: node.id, + assetId, + name: asset.name, + x, + z, + hostId: host.id, + ...(pose.side ? { side: pose.side } : {}), + ...(pose.restingOn ? { restingOn: pose.restingOn } : {}), + } + } + + // A floor: the level named, or the level holding the room or slab named. + const floorId = !host + ? level.id + : host.type === 'level' + ? host.id + : host.type === 'zone' || host.type === 'slab' + ? (host.parentId ?? '') + : null + if (!floorId || nodes[floorId]?.type !== 'level') + return refused( + assetId, + 'unsupported_host', + `A ${host?.type} hosts no item: name a wall, a ceiling, an item standing on the floor, or a room, slab or level for its floor.`, + ) + const rooms = roomsOn(nodes, floorId) + const room = rooms.find((polygon) => pointInPolygon([x, z], polygon)) + if (rooms.length && !isOutdoor(asset) && !room) + return refused( + assetId, + 'outside_rooms', + `"${asset.name}" at (${x}, ${z}) is outside every room on this level: indoor items go inside a room (read the rooms with get_zones); trees and garden items may stand outside.`, + ) + doorsOn[floorId] ??= collectDoorKeepouts(Object.values(nodes), { levelId: floorId }) + const misfit = floorFit(entry, asset, room, doorsOn[floorId]) + if (misfit) return misfit + const node = make(asset, { + parentId: floorId, + position: [x, 0, z], + rotation: [0, (rotation * Math.PI) / 180, 0], + }) + return { + ok: true, + itemId: node.id, + assetId, + name: asset.name, + x, + z, + ...(host ? { hostId: floorId } : {}), + } + }) + const failed = items.length - create.length + return { + result: { + ok: failed === 0, + levelId: level.id, + items, + message: failed + ? `Placed ${create.length} of ${items.length} items (${failed} refused).` + : `Placed ${create.length} item${create.length === 1 ? '' : 's'}.`, + }, + ...(create.length ? { changes: { create } } : {}), + } +} diff --git a/packages/core/src/agent-operations/room-structure.test.ts b/packages/core/src/agent-operations/room-structure.test.ts new file mode 100644 index 0000000000..237612fd5e --- /dev/null +++ b/packages/core/src/agent-operations/room-structure.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test' +import { z } from 'zod' +import { setFloorFoundationTool, setZoneIntentTool } from '../agent-tools/room-structure' +import { FloorFoundationPatch, ZoneIntentPatch } from '../commands/structure' + +// The tools' patches are what agents may send, tuple-free; the commands validate them again with +// the node schemas. A field one has and the other lacks is refused or never offered. +function fields(schema: z.ZodType, path = ''): string[] { + let inner: z.ZodType = schema + while (inner instanceof z.ZodOptional || inner instanceof z.ZodNullable) + inner = inner.unwrap() as z.ZodType + if (!(inner instanceof z.ZodObject)) return [] + return Object.entries(inner.shape as Record) + .flatMap(([key, value]) => [`${path}${key}`, ...fields(value, `${path}${key}.`)]) + .sort() +} + +test('the room tools offer every field their commands take, and no other', () => { + expect(fields(setZoneIntentTool.input.patch)).toEqual(fields(ZoneIntentPatch)) + expect(fields(setFloorFoundationTool.input.patch)).toEqual(fields(FloorFoundationPatch)) +}) diff --git a/packages/core/src/agent-operations/room-structure.ts b/packages/core/src/agent-operations/room-structure.ts new file mode 100644 index 0000000000..1f36281eeb --- /dev/null +++ b/packages/core/src/agent-operations/room-structure.ts @@ -0,0 +1,328 @@ +import { refuse } from '../agent-tools/refusal' +import { + createMezzanine, + cutFloorOpening, + type DeleteZonePayload, + deleteZone, + divideZone, + duplicateZone, + type FloorFoundationPatch, + type FloorOpeningHint, + type HostedZoneTransformPlan, + lockOutsideFaces, + mergeZones, + type NodeChange, + type Point, + rebaseFloorReference, + removeFloorOpening, + resolveZoneTransformHosts, + rotateZone, + setFloorFoundation, + setRoomFloorConstruction, + setZoneIntent, + transformZone, + type ZoneIntentPatch, +} from '../commands/structure' +import { containsPoint } from '../lib/polygon-boolean' +import { type AnyNode, generateId } from '../schema' +import { mergeSceneChanges } from './apply-changes' +import type { AgentOperation, AgentOperationOutcome, SceneChanges, SceneNodes } from './types' + +type RoomPlan = HostedZoneTransformPlan & { + zoneId?: string + openingId?: string + openingIds?: string[] + hints?: FloorOpeningHint[] + payload?: DeleteZonePayload + separatorId?: string + separatorIds?: string[] +} + +/** A plan's ordered changes as one set, landing where applying them one by one does. */ +function sceneChanges(changes: readonly NodeChange[]): SceneChanges { + return mergeSceneChanges( + changes.map((change) => + change.op === 'create' + ? { create: [{ node: change.node, parentId: change.node.parentId ?? undefined }] } + : change.op === 'update' + ? { update: [{ id: change.id, data: change.data }] } + : { delete: [change.id] }, + ), + ) +} + +/** The commands throw a plain Error for an edit they cannot make ("Room not found: …"). */ +function plan(command: () => T): T { + try { + return command() + } catch (error) { + if (error instanceof Error && error.name === 'Error') refuse('structure_refused', error.message) + throw error + } +} + +/** + * A room or floor command's plan as a shared tool's outcome, as the editor applies it: conflicts + * are answers and change nothing unless forced; otherwise the plan's changes, then — once the host + * has re-derived rooms and ceilings — the copied fixtures onto their new ceilings, and the result's + * rooms read from the derived scene (a room the reconciler re-minted is found by its seed). + */ +function roomOutcome(nodes: SceneNodes, plan: RoomPlan, force = false): AgentOperationOutcome { + const result = (zoneId = plan.zoneId, zoneIds?: string[]) => ({ + changes: plan.changes.length, + ...(zoneId ? { zoneId } : {}), + ...(plan.openingId ? { openingId: plan.openingId } : {}), + ...(plan.openingIds ? { openingIds: plan.openingIds } : {}), + ...(plan.hints ? { hints: plan.hints } : {}), + ...(plan.idMap + ? { + idMap: Object.fromEntries( + Object.entries(plan.idMap).map(([id, targets]) => [ + id, + targets.map((target) => (target === plan.zoneId ? zoneId! : target)), + ]), + ), + } + : {}), + ...(zoneIds ? { zoneIds } : {}), + ...(plan.payload ? { payload: plan.payload } : {}), + ...(plan.separatorId ? { separatorId: plan.separatorId } : {}), + ...(plan.separatorIds ? { separatorIds: plan.separatorIds } : {}), + ...(plan.conflicts ? { conflicts: plan.conflicts } : {}), + }) + if (!plan.changes.length || (plan.conflicts?.length && !force)) return { result: result() } + + let plannedZone: AnyNode | undefined = plan.zoneId ? nodes[plan.zoneId] : undefined + for (const change of plan.changes) { + if (change.op === 'create' && change.node.id === plan.zoneId) plannedZone = change.node + else if (change.op === 'update' && change.id === plan.zoneId && plannedZone?.type === 'zone') + plannedZone = { ...plannedZone, ...change.data } as AnyNode + } + return { + result: result(), + changes: sceneChanges(plan.changes), + afterReconcile: (derived) => { + let zoneId = plan.zoneId + if (zoneId && !derived[zoneId] && plannedZone?.type === 'zone' && plannedZone.seed) { + const { seed, parentId } = plannedZone + zoneId = + Object.values(derived).find( + (node) => + node.type === 'zone' && + node.parentId === parentId && + containsPoint([{ outer: node.polygon, holes: node.holes }], seed), + )?.id ?? zoneId + } + const zoneIds = + plan.separatorIds?.length && !plan.conflicts?.length + ? Object.values(derived) + .filter( + (node) => + node.type === 'zone' && + plan.separatorIds!.some((id) => node.boundarySeparatorIds.includes(id)), + ) + .map((node) => node.id) + .sort() + : undefined + const hosts = resolveZoneTransformHosts(derived, plan) + return { + result: result(zoneId, zoneIds), + ...(hosts.length ? { changes: sceneChanges(hosts) } : {}), + } + }, + } +} + +const points = (list: number[][]) => list as Point[] + +type RoomTransformInput = { + zoneId: string + translate?: number[] + rotate?: { angle: number; pivot?: number[] } + force?: boolean +} +const transform = ({ translate, rotate, ...input }: RoomTransformInput) => ({ + ...input, + ...(translate ? { translate: translate as Point } : {}), + ...(rotate + ? { rotate: { angle: rotate.angle, ...(rotate.pivot ? { pivot: rotate.pivot as Point } : {}) } } + : {}), + mintId: generateId, +}) + +type SlabTarget = { slabId?: string; slabIds?: string[] } + +const cutFloorOpeningOperation: AgentOperation<{ + levelId?: string + zoneId?: string + levelIds?: string[] + polygon?: number[][] + rect?: { x: number; z: number; width: number; depth: number } + drawnOn?: 'floor' | 'ceiling' + cutsPrimary?: boolean + cutsAdjacent?: boolean +}> = (nodes, { polygon, ...input }) => + roomOutcome( + nodes, + plan(() => + cutFloorOpening(nodes, { + ...input, + ...(polygon ? { polygon: points(polygon) } : {}), + mintId: generateId, + }), + ), + ) + +const removeFloorOpeningOperation: AgentOperation<{ id: string }> = (nodes, { id }) => + roomOutcome( + nodes, + plan(() => removeFloorOpening(nodes, id)), + ) + +const setFloorFoundationOperation: AgentOperation = ( + nodes, + input, +) => + roomOutcome( + nodes, + plan(() => setFloorFoundation(nodes, { ...input, patch: input.patch as FloorFoundationPatch })), + ) + +const setRoomFloorConstructionOperation: AgentOperation<{ + zoneId: string + slabId?: string + patch: unknown +}> = (nodes, input) => + roomOutcome( + nodes, + plan(() => + setRoomFloorConstruction(nodes, { ...input, patch: input.patch as FloorFoundationPatch }), + ), + ) + +const rebaseFloorReferenceOperation: AgentOperation< + SlabTarget & { referenceFloorElevation: number | null } +> = (nodes, input) => + roomOutcome( + nodes, + plan(() => rebaseFloorReference(nodes, input)), + ) + +const createMezzanineOperation: AgentOperation<{ + hostZoneId: string + polygon: number[][] + elevation?: number + thickness?: number +}> = (nodes, input) => + roomOutcome( + nodes, + plan(() => + createMezzanine(nodes, { ...input, polygon: points(input.polygon), mintId: generateId }), + ), + ) + +const moveZoneOperation: AgentOperation = (nodes, input) => + roomOutcome( + nodes, + plan(() => transformZone(nodes, transform(input))), + input.force, + ) + +const duplicateZoneOperation: AgentOperation = ( + nodes, + input, +) => + roomOutcome( + nodes, + plan(() => duplicateZone(nodes, { ...transform(input), translate: input.translate as Point })), + input.force, + ) + +const rotateZoneOperation: AgentOperation<{ + zoneId: string + quarterTurns: number + gridStep?: number + force?: boolean +}> = (nodes, input) => + roomOutcome( + nodes, + plan(() => + rotateZone(nodes, { + ...input, + quarterTurns: input.quarterTurns as 1 | -1, + mintId: generateId, + }), + ), + input.force, + ) + +const lockOutsideFacesOperation: AgentOperation<{ levelId?: string; zoneIds?: string[] }> = ( + nodes, + { levelId, zoneIds }, +) => { + if (Boolean(levelId) === Boolean(zoneIds)) + refuse('target_required', 'Supply either levelId or zoneIds.') + return roomOutcome( + nodes, + plan(() => lockOutsideFaces(nodes, levelId ? { levelId } : { zoneIds: zoneIds! })), + ) +} + +const setZoneIntentOperation: AgentOperation<{ zoneId: string; patch: unknown }> = (nodes, input) => + roomOutcome( + nodes, + plan(() => setZoneIntent(nodes, { ...input, patch: input.patch as ZoneIntentPatch })), + ) + +const divideZoneOperation: AgentOperation<{ + zoneId: string + cut?: number[][] + path?: number[][] + closed?: boolean + startBoundaryId?: string + endBoundaryId?: string +}> = (nodes, { cut, path, ...input }) => + roomOutcome( + nodes, + plan(() => + divideZone(nodes, { + ...input, + ...(cut ? { cut: points(cut) as [Point, Point] } : {}), + ...(path ? { path: points(path) } : {}), + mintId: generateId, + }), + ), + ) + +const mergeZonesOperation: AgentOperation<{ zoneIds: string[] }> = (nodes, { zoneIds }) => + roomOutcome( + nodes, + plan(() => mergeZones(nodes, { zoneIds: zoneIds as [string, string] })), + ) + +const deleteZoneOperation: AgentOperation<{ zoneId: string; contents: 'delete' | 'keep' }> = ( + nodes, + input, +) => + roomOutcome( + nodes, + plan(() => deleteZone(nodes, input)), + ) + +/** The room and floor-construction tools' operations, by tool name. */ +export const ROOM_OPERATIONS = { + cut_floor_opening: cutFloorOpeningOperation, + remove_floor_opening: removeFloorOpeningOperation, + set_floor_foundation: setFloorFoundationOperation, + set_room_floor_construction: setRoomFloorConstructionOperation, + rebase_floor_reference: rebaseFloorReferenceOperation, + create_mezzanine: createMezzanineOperation, + move_zone: moveZoneOperation, + duplicate_zone: duplicateZoneOperation, + rotate_zone: rotateZoneOperation, + lock_outside_faces: lockOutsideFacesOperation, + set_zone_intent: setZoneIntentOperation, + divide_zone: divideZoneOperation, + merge_zones: mergeZonesOperation, + delete_zone: deleteZoneOperation, +} as const diff --git a/packages/core/src/agent-operations/scene-measure.ts b/packages/core/src/agent-operations/scene-measure.ts new file mode 100644 index 0000000000..983d622ddb --- /dev/null +++ b/packages/core/src/agent-operations/scene-measure.ts @@ -0,0 +1,127 @@ +import type { AnyNode } from '../schema' +import type { SceneNodes } from './types' + +type Spot = { x: number; z: number } +/** Where one of a family stands: rooms by their centre and name. */ +export type MeasurePlace = Spot & { name?: string } + +/** + * A family the measure counts on each floor, by place: windows, lit rooms, decks with a door. + * The module that knows a family registers it (the facade checks register theirs), so the measure + * imports none of them. + */ +export type MeasureFamily = { + family: string + /** One of them, in a message: "window", "room". */ + noun: string + /** What losing one means: "gone from their places", "no longer lit". */ + lost: string + /** Two places this close are the same. */ + tolerance: number + /** Where the family runs before or after others, in the result. */ + order: number + places: (nodes: Readonly>) => { levelId: string; place: MeasurePlace }[] +} + +const families: MeasureFamily[] = [] + +/** Counts a family in every measure from now on; registering a family again replaces it. */ +export function registerMeasureFamily(family: MeasureFamily) { + const at = families.findIndex((known) => known.family === family.family) + if (at >= 0) families[at] = family + else families.push(family) + families.sort((a, b) => a.order - b.order) +} + +/** What a scene holds of the registered families, by floor and place; small, and plain JSON. */ +export type SceneMeasure = { + levels: Record }> +} + +/** A measure kept under a name, before an edit, to compare the scene with after it. */ +export type SceneCheckpoint = { name: string; measure: SceneMeasure } + +export type MeasureChange = { + levelId: string + level: string + family: string + before: number + after: number + /** What the checkpoint had that is not there now, by place (rooms by name). */ + gone: string[] + /** How many stand where the checkpoint had none. */ + added: number +} + +export type LostSinceCheckpointIssue = { type: 'lost_since_checkpoint'; message: string } + +const round = (value: number) => Math.round(value * 100) / 100 + +export function measureScene(nodes: SceneNodes): SceneMeasure { + const all = nodes as Readonly> + const levels: SceneMeasure['levels'] = {} + for (const node of Object.values(all)) + if (node.type === 'level') + levels[node.id] = { name: node.name ?? `level ${node.level}`, families: {} } + for (const { family, places } of families) + for (const { levelId, place } of places(all)) { + const level = levels[levelId] + if (!level) continue + level.families[family] ??= [] + level.families[family].push({ ...place, x: round(place.x), z: round(place.z) }) + } + return { levels } +} + +/** The places of `from` with none of `to` within `tolerance`. */ +const missing = (from: readonly T[], to: readonly Spot[], tolerance: number) => + from.filter((a) => !to.some((b) => Math.hypot(a.x - b.x, a.z - b.z) < tolerance)) + +/** + * The scene now against a checkpoint: per floor and family, the count then and now, what is gone + * from its place and what is new. A family that lost more than it gained is an issue naming what is + * gone. On the Victor grid v1 cost 8 windows a floor and room mode 10–12, seen only by the MCP + * agent counting before and after (2026-10-03); a weaker model's edits lowered its own score 29.6% + * of the time (LEGO-Anything, 2026-09). + */ +export function changesSince( + checkpoint: SceneCheckpoint, + nodes: SceneNodes, +): { changes: MeasureChange[]; issues: LostSinceCheckpointIssue[] } { + const now = measureScene(nodes).levels + const then = checkpoint.measure.levels + const changes: MeasureChange[] = [] + const issues: LostSinceCheckpointIssue[] = [] + for (const levelId of new Set([...Object.keys(then), ...Object.keys(now)])) { + const before = then[levelId] + const after = now[levelId] + const level = after?.name ?? before?.name ?? levelId + for (const { family, tolerance, noun, lost } of families) { + const was = before?.families[family] ?? [] + const is = after?.families[family] ?? [] + const gone = missing(was, is, tolerance) + const added = missing(is, was, tolerance).length + if (!gone.length && !added) continue + const named = gone.map((s) => + typeof s.name === 'string' ? s.name : `${noun} at (${s.x}, ${s.z})`, + ) + changes.push({ + levelId, + level, + family, + before: was.length, + after: is.length, + gone: named, + added, + }) + if (is.length >= was.length) continue + const list = + named.slice(0, 8).join('; ') + (named.length > 8 ? `; and ${named.length - 8} more` : '') + issues.push({ + type: 'lost_since_checkpoint', + message: `${level} has ${is.length} ${family}, ${was.length} at checkpoint "${checkpoint.name}": ${lost}, ${list}${added ? `; ${added} stand in new places` : ''}. If the edit was not meant to lose them, restore the checkpoint or put them back.`, + }) + } + } + return { changes, issues } +} diff --git a/packages/core/src/agent-operations/scene-view.test.ts b/packages/core/src/agent-operations/scene-view.test.ts new file mode 100644 index 0000000000..f3d256c94c --- /dev/null +++ b/packages/core/src/agent-operations/scene-view.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, test } from 'bun:test' +import { PerspectiveCamera, Vector3 } from 'three' +import { isAgentRefusal } from '../agent-tools' +import { + type AnyNode, + BuildingNode, + ColumnNode, + DoorNode, + FenceNode, + GuideNode, + ItemNode, + LevelNode, + SlabNode, + StairNode, + WallNode, + WindowNode, +} from '../schema' +import { + photoCropSize, + type SceneViewBox, + sceneViewBounds, + sceneViewNote, + sceneViewPlan, + sceneViewPose, + VIEW_SIZE, +} from './scene-view' + +/** + * `view_scene`: the agent looks at what it built from a viewpoint it picks, to compare it with a + * reference (the facade against the photo). The ways it can go wrong, written before the tool: + * - the frame misses the target, or the eye stands inside the building; + * - a compass side read the wrong way round (north is the plan's top edge, z down); + * - an imported plan, larger than the building, sets the frame; + * - a street view not at street height; an elevation that cuts the face off; + * - a render from the photo's camera at another aspect than the photo's, so the two do not overlay. + * The capture itself is the host's: the chat's editor, or an editor tab the MCP asks. + */ + +// Two storeys of 3 m, a 20 × 10 m outline from (0, 0) to (20, 10), and a 60 m plan guide. +function building(): Record { + const site = BuildingNode.parse({ id: 'building_main', children: ['level_0', 'level_1'] }) + const levels = [0, 1].map((index) => + LevelNode.parse({ id: `level_${index}`, parentId: site.id, level: index, height: 3 }), + ) + const walls = levels.flatMap((level) => + ( + [ + [ + [0, 0], + [20, 0], + ], + [ + [20, 0], + [20, 10], + ], + [ + [20, 10], + [0, 10], + ], + [ + [0, 10], + [0, 0], + ], + ] as [number, number][][] + ).map(([start, end], index) => + WallNode.parse({ id: `wall_${level.id}_${index}`, parentId: level.id, start, end }), + ), + ) + const guide = GuideNode.parse({ + id: 'guide_plan', + parentId: 'level_0', + url: '/plans/floor.svg', + scale: 60, + }) + const nodes = [ + site, + ...levels.map((level) => ({ + ...level, + children: walls.filter((wall) => wall.parentId === level.id).map((wall) => wall.id), + })), + ...walls, + guide, + ] + return Object.fromEntries(nodes.map((node) => [node.id, node as AnyNode])) +} + +const box: SceneViewBox = { min: [0, 0, 0], max: [20, 6, 10] } + +/** Every corner of the box inside the frame of a camera at the pose. */ +function framesBox(pose: ReturnType, target: SceneViewBox) { + if (pose.projection !== 'perspective') throw new Error('perspective expected') + const camera = new PerspectiveCamera(pose.fov, VIEW_SIZE.w / VIEW_SIZE.h, 0.1, 10_000) + camera.position.fromArray(pose.position) + camera.lookAt(new Vector3(...pose.target)) + camera.updateMatrixWorld() + for (const x of [target.min[0], target.max[0]]) + for (const y of [target.min[1], target.max[1]]) + for (const z of [target.min[2], target.max[2]]) { + const ndc = new Vector3(x, y, z).project(camera) + if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1 || ndc.z > 1) return false + } + return true +} + +describe('what a view frames', () => { + test("a building's walls at their storeys' heights, not its imported plan", () => { + expect(sceneViewBounds(building())).toEqual({ min: [0, 0, 0], max: [20, 6, 10] }) + expect(sceneViewBounds(building(), 'building_main')).toEqual({ + min: [0, 0, 0], + max: [20, 6, 10], + }) + }) + + test('a level, or one wall, at its own storey', () => { + expect(sceneViewBounds(building(), 'level_1')).toEqual({ min: [0, 3, 0], max: [20, 6, 10] }) + expect(sceneViewBounds(building(), 'wall_level_0_0')).toEqual({ + min: [0, 0, 0], + max: [20, 3, 0], + }) + }) + + test('an unknown target is refused', () => { + let code: string | null = null + try { + sceneViewBounds(building(), 'wall_nowhere') + } catch (error) { + if (isAgentRefusal(error)) code = error.code + } + expect(code).toBe('target_not_found') + }) +}) + +describe('where the eye stands', () => { + test('from the north-west it stands past the north-west corner (north is the plan top, -z)', () => { + const pose = sceneViewPose(box, { from: 'north-west' }) + expect(pose.position[0]).toBeLessThan(0) + expect(pose.position[2]).toBeLessThan(0) + expect(pose.position[1]).toBeGreaterThan(0) + expect(pose.target).toEqual([10, 3, 5]) + expect(framesBox(pose, box)).toBe(true) + }) + + test('every side frames the whole target, from outside it', () => { + for (const from of ['north', 'east', 'south', 'west', 'south-east', 'above'] as const) { + const pose = sceneViewPose(box, { from }) + expect({ from, frames: framesBox(pose, box) }).toEqual({ from, frames: true }) + const [x, y, z] = pose.position + const inside = x > 0 && x < 20 && z > 0 && z < 10 && y < 6 + expect({ from, inside }).toEqual({ from, inside: false }) + } + }) + + test('a street view keeps the eye at the height asked, and still frames the building', () => { + const pose = sceneViewPose(box, { from: 'south', eyeHeight: 1.7 }) + expect(pose.position[1]).toBe(1.7) + expect(framesBox(pose, box)).toBe(true) + }) + + test('an orthographic elevation from the south covers the whole south face', () => { + const pose = sceneViewPose(box, { from: 'south', projection: 'orthographic' }) + if (pose.projection !== 'orthographic') throw new Error('orthographic expected') + expect(pose.position[2]).toBeGreaterThan(10) + expect(pose.viewWidth).toBeGreaterThanOrEqual(20) + // and its height, at the frame's aspect + expect(pose.viewWidth).toBeGreaterThanOrEqual((6 * VIEW_SIZE.w) / VIEW_SIZE.h) + }) + + test('an eye placed by hand stays where it was put, looking at the target', () => { + const pose = sceneViewPose(box, { position: [-5, 1.7, 30] }) + expect(pose.position).toEqual([-5, 1.7, 30]) + expect(pose.target).toEqual([10, 3, 5]) + }) +}) + +// An agent with no view over the MCP drew its own elevation from coordinates; +// straighten_facade_photo gives the photo's camera (camera.pose) to render the build from. +describe("the photo's camera", () => { + const pose = { + projection: 'perspective', + position: [8, 1.6, 24], + target: [8, 2.5, 5], + up: [0, 1, 0], + fov: 52, + aspect: 1.5, + shift: 0, + anchoredBy: 'row height', + focalAssumed: true, + edgeResidualPx: 1.2, + } + + test('the render stands where the photo was taken, at the photo’s aspect', () => { + const plan = sceneViewPlan(building(), { camera: pose }) + expect(plan.pose).toEqual({ + projection: 'perspective', + position: [8, 1.6, 24], + target: [8, 2.5, 5], + fov: 52, + }) + expect(plan.size).toEqual({ w: VIEW_SIZE.w, h: Math.round(VIEW_SIZE.w / 1.5) }) + }) + + test('a camera and a viewpoint of its own are refused together', () => { + let code: string | null = null + try { + sceneViewPlan(building(), { camera: pose, from: 'south' }) + } catch (error) { + if (isAgentRefusal(error)) code = error.code + } + expect(code).toBe('camera_and_viewpoint') + }) + + test('without one, the view frames the target at the standard size', () => { + const plan = sceneViewPlan(building(), { from: 'south', eyeHeight: 1.7 }) + expect(plan.size).toEqual({ ...VIEW_SIZE }) + expect(plan.pose.position[1]).toBe(1.7) + }) +}) + +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') + }) +}) + +// An agent that compared whole facades only settled for a plain door against the photo's door +// with three glass strips. A view frames one opening or item at detail scale, an opening from its +// outside face, so it can be laid beside the photo's crop of the same element. +describe('a close-up of one element', () => { + /** A 10 m wall along x drawn so its outside is the north (-z) side, a door and a window in it. */ + function facade(outside: 'front' | 'back') { + const wall = WallNode.parse({ + id: 'wall_face', + parentId: 'level_face', + start: outside === 'back' ? [0, 0] : [10, 0], + end: outside === 'back' ? [10, 0] : [0, 0], + thickness: 0.2, + height: 2.8, + frontSide: outside === 'front' ? 'exterior' : 'interior', + backSide: outside === 'front' ? 'interior' : 'exterior', + children: ['door_face', 'window_face'], + }) + const along = (x: number) => (outside === 'back' ? x : 10 - x) + const door = DoorNode.parse({ + id: 'door_face', + parentId: wall.id, + wallId: wall.id, + position: [along(3), 1.05, 0], + width: 0.9, + height: 2.1, + }) + const window = WindowNode.parse({ + id: 'window_face', + parentId: wall.id, + wallId: wall.id, + position: [along(7), 1.5, 0], + width: 1.2, + height: 1.2, + }) + const lamp = ItemNode.parse({ + id: 'item_lamp', + parentId: 'level_face', + position: [5, 0, 4], + asset: { + id: 'floor-lamp', + name: 'Floor lamp', + category: 'lighting', + thumbnail: '/items/floor-lamp/thumbnail.webp', + src: '/items/floor-lamp/model.glb', + dimensions: [0.4, 1.6, 0.4], + }, + }) + const level = LevelNode.parse({ + id: 'level_face', + parentId: 'building_face', + level: 0, + height: 2.8, + children: [wall.id, lamp.id], + }) + const building = BuildingNode.parse({ id: 'building_face', children: [level.id] }) + return Object.fromEntries( + [building, level, wall, door, window, lamp].map((node) => [node.id, node]), + ) as Record + } + + test('a door frames its own box, at detail scale', () => { + const box = sceneViewBounds(facade('back'), 'door_face') + expect(box.min.map((v) => Math.round(v * 100) / 100)).toEqual([2.55, 0, -0.1]) + expect(box.max.map((v) => Math.round(v * 100) / 100)).toEqual([3.45, 2.1, 0.1]) + }) + + test('an opening is seen from its outside face, whichever way its wall was drawn', () => { + for (const outside of ['back', 'front'] as const) { + const { pose } = sceneViewPlan(facade(outside), { target: 'door_face' }) + expect(pose.position[2]).toBeLessThan(0) + const [cx, , cz] = pose.target as number[] + expect(Math.hypot(pose.position[0] - cx!, pose.position[2] - cz!)).toBeLessThan(6) + } + }) + + test('a window frames its own box too, and a floor item by its dimensions', () => { + const window = sceneViewBounds(facade('back'), 'window_face') + expect(window.max[1] - window.min[1]).toBeCloseTo(1.2, 6) + expect(window.max[0] - window.min[0]).toBeCloseTo(1.2, 6) + const lamp = sceneViewBounds(facade('back'), 'item_lamp') + expect(lamp.min.map((v) => Math.round(v * 100) / 100)).toEqual([4.8, 0, 3.8]) + expect(lamp.max.map((v) => Math.round(v * 100) / 100)).toEqual([5.2, 1.6, 4.2]) + }) +}) + +// The other half: the photo's crop of the same element comes back beside the close-up, in one +// call. The region is in the photo's pixels, as an agent measures it; the host crops. +describe("the photo's crop beside the view", () => { + const scene = () => { + const wall = WallNode.parse({ id: 'wall_p', parentId: 'level_p', start: [0, 0], end: [8, 0] }) + const level = LevelNode.parse({ id: 'level_p', parentId: 'building_p', children: [wall.id] }) + const building = BuildingNode.parse({ id: 'building_p', children: [level.id] }) + return Object.fromEntries([building, level, wall].map((n) => [n.id, n])) as Record< + string, + AnyNode + > + } + + test('a region of the photo is passed on to crop, whole numbers of pixels', () => { + const { crop } = sceneViewPlan(scene(), { + photo: { source: 'data:image/png;base64,AAAA', region: [60.4, 200, 620, 470.6] }, + }) + expect(crop).toEqual({ source: 'data:image/png;base64,AAAA', region: [60, 200, 620, 471] }) + }) + + test('an empty or inverted region is refused', () => { + for (const region of [ + [100, 100, 100, 200], + [300, 100, 200, 200], + ]) { + let code = '' + try { + sceneViewPlan(scene(), { photo: { source: 'x', region } }) + } catch (error) { + if (isAgentRefusal(error)) code = error.code + } + expect(code).toBe('photo_region_invalid') + } + }) +}) + +// Both hosts return a crop at most 1280 px long, as a view is (the hosted one capped, the chat's +// did not: a whole 4000-px photo as the region went to the model at full size). +describe("a crop's size", () => { + test('kept below 1280 px on its longer side', () => { + expect(photoCropSize(4000, 3000)).toEqual({ width: 1280, height: 960 }) + expect(photoCropSize(560, 270)).toEqual({ width: 560, height: 270 }) + }) + + // A front door's crop came back 85 × 155 px, its four glass strips + // about 8 px each, under what a vision model resolves. A small crop is enlarged to 512 px: no + // new detail, but the strips stand apart. + test('a small crop is enlarged to 512 px on its longer side', () => { + expect(photoCropSize(85, 155)).toEqual({ width: 281, height: 512 }) + }) +}) + +// S10 live (20:33): view_scene could not look at the steps it built (nothing_to_view: "no walls +// to look at"). A stair, a column, a fence or a slab frames by its own bounds, as an opening does. +describe('a close-up of a site element', () => { + function site() { + const column = ColumnNode.parse({ + id: 'column_s', + parentId: 'level_s', + position: [2, 0, 3], + height: 2.5, + }) + const fence = FenceNode.parse({ + id: 'fence_s', + parentId: 'level_s', + start: [0, 8], + end: [6, 8], + height: 1.8, + }) + const lawn = SlabNode.parse({ + id: 'slab_lawn', + parentId: 'level_s', + polygon: [ + [0, 4], + [6, 4], + [6, 7], + [0, 7], + ], + elevation: 0.01, + }) + const steps = StairNode.parse({ + id: 'stair_s', + parentId: 'level_s', + position: [4, 0, 1], + width: 1.2, + totalRise: 0.45, + fromLevelId: 'level_s', + toLevelId: null, + }) + const level = LevelNode.parse({ + id: 'level_s', + parentId: 'building_s', + level: 0, + children: [column.id, fence.id, lawn.id, steps.id], + }) + const building = BuildingNode.parse({ id: 'building_s', children: [level.id] }) + return Object.fromEntries( + [building, level, column, fence, lawn, steps].map((n) => [n.id, n]), + ) as Record + } + const r = (v: number) => Math.round(v * 100) / 100 + + // furnish_from_plan live (22:30): a level with furniture and no walls yet answered + // nothing_to_view. A level, a building or the scene frames all it holds when it has no walls. + test('a level, its building or the scene with no walls frames everything on it', () => { + for (const target of ['level_s', 'building_s', undefined]) { + const box = sceneViewBounds(site(), target) + expect(box.min[0]).toBeLessThanOrEqual(0) + expect(box.max[0]).toBeGreaterThanOrEqual(6) + expect(box.max[2]).toBeGreaterThanOrEqual(8) + expect(box.max[1]).toBeGreaterThan(box.min[1]) + } + const bed = ItemNode.parse({ + id: 'item_bed', + parentId: 'level_i', + position: [3, 0, 2], + asset: { + id: 'double-bed', + category: 'furniture', + name: 'Double bed', + thumbnail: '', + src: '/items/double-bed/model.glb', + dimensions: [1.6, 0.5, 2.1], + }, + }) + const level = LevelNode.parse({ id: 'level_i', parentId: 'building_i', children: [bed.id] }) + const building = BuildingNode.parse({ id: 'building_i', children: [level.id] }) + const nodes = Object.fromEntries([building, level, bed].map((n) => [n.id, n])) as Record< + string, + AnyNode + > + const box = sceneViewBounds(nodes, 'level_i') + expect([r(box.min[0]), r(box.max[0]), r(box.min[2]), r(box.max[2])]).toEqual([ + 2.2, 3.8, 0.95, 3.05, + ]) + }) + + test('a column, a fence and a slab frame their own boxes', () => { + const column = sceneViewBounds(site(), 'column_s') + expect([r(column.min[1]), r(column.max[1])]).toEqual([0, 2.5]) + expect(column.min[0]).toBeLessThan(2) + expect(column.max[0]).toBeGreaterThan(2) + const fence = sceneViewBounds(site(), 'fence_s') + // Along its run, padded by half its thickness. + expect(fence.min[0]).toBeCloseTo(-0.04, 6) + expect(fence.max[0]).toBeCloseTo(6.04, 6) + expect(r(fence.max[1])).toBe(1.8) + const lawn = sceneViewBounds(site(), 'slab_lawn') + expect([r(lawn.min[0]), r(lawn.max[0]), r(lawn.min[2]), r(lawn.max[2])]).toEqual([0, 6, 4, 7]) + expect(lawn.max[1] - lawn.min[1]).toBeGreaterThan(0.2) + }) + + test('steps frame round their foot, as tall as they rise', () => { + const steps = sceneViewBounds(site(), 'stair_s') + expect(steps.min[0]).toBeLessThan(4) + expect(steps.max[0]).toBeGreaterThan(4) + expect(r(steps.max[1])).toBeGreaterThanOrEqual(0.45) + const { pose } = sceneViewPlan(site(), { target: 'stair_s' }) + expect(pose.position.every(Number.isFinite)).toBe(true) + }) +}) diff --git a/packages/core/src/agent-operations/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts new file mode 100644 index 0000000000..41d9675b25 --- /dev/null +++ b/packages/core/src/agent-operations/scene-view.ts @@ -0,0 +1,437 @@ +import { refuse } from '../agent-tools/refusal' +import type { VIEW_SIDES } from '../agent-tools/view-scene' +import type { + AnyNode, + AnyNodeId, + ColumnNode, + FenceNode, + ItemNode, + SlabNode, + StairNode, + WallNode, +} from '../schema' +import { getLevelElevations } from '../services/storey' +import { resolveWallExteriorSide } from '../systems/wall/wall-assembly' + +/** + * Where `view_scene` looks from, the same on every surface; the picture is the host's: the chat's + * editor renders it, the MCP asks an editor tab open on the project. + */ + +export type SceneViewBox = { min: [number, number, number]; max: [number, number, number] } +export type SceneViewSide = (typeof VIEW_SIDES)[number] + +export type SceneViewInput = { + target?: string + from?: SceneViewSide + position?: number[] + elevation?: number + eyeHeight?: number + fov?: number + projection?: 'perspective' | 'orthographic' + camera?: { position: number[]; target: number[]; fov: number; aspect: number } + photo?: { source: string; region: number[] } +} + +/** A region of the reference photo the host crops and returns beside the view. */ +export type SceneViewCrop = { source: string; region: [number, number, number, number] } + +function cropOf(photo: SceneViewInput['photo']): SceneViewCrop | undefined { + if (!photo) return undefined + const [left, top, right, bottom] = photo.region.map(Math.round) as [ + number, + number, + number, + number, + ] + if (!(right > left && bottom > top)) + refuse( + 'photo_region_invalid', + `The region [${photo.region.join(', ')}] holds nothing: give [left, top, right, bottom] in the photo's pixels, right of left and below top.`, + { region: photo.region }, + ) + return { source: photo.source, region: [left, top, right, bottom] } +} + +export type SceneViewPose = + | { + projection: 'perspective' + position: [number, number, number] + target: [number, number, number] + fov: number + } + | { + projection: 'orthographic' + position: [number, number, number] + target: [number, number, number] + viewWidth: number + } + +/** The picture's size: enough to read a facade's bays, few tokens. */ +export const VIEW_SIZE = { w: 1280, h: 800 } as const +const ASPECT = VIEW_SIZE.w / VIEW_SIZE.h + +/** A crop comes back at most this long on either host: the region as the photo has it, never larger than a view. */ +export const PHOTO_CROP_LONGEST = VIEW_SIZE.w + +/** A crop comes back at least this long, enlarged if smaller, so a small element's detail reads. */ +export const PHOTO_CROP_SHORTEST_LONG_SIDE = 512 + +/** The size a crop of `width` × `height` pixels comes back at. */ +export function photoCropSize(width: number, height: number) { + const longest = Math.max(width, height) + const scale = + longest < PHOTO_CROP_SHORTEST_LONG_SIDE + ? PHOTO_CROP_SHORTEST_LONG_SIDE / longest + : Math.min(1, PHOTO_CROP_LONGEST / longest) + return { width: Math.round(width * scale), height: Math.round(height * scale) } +} +const DEFAULT_FOV = 45 +const DEFAULT_ELEVATION = 12 +const MARGIN = 1.08 + +type Pt = [number, number] +type V3 = [number, number, number] + +/** In plan, x runs east and z south: north is the plan's top edge. */ +const COMPASS: Record, Pt> = { + north: [0, -1], + 'north-east': [Math.SQRT1_2, -Math.SQRT1_2], + east: [1, 0], + 'south-east': [Math.SQRT1_2, Math.SQRT1_2], + south: [0, 1], + 'south-west': [-Math.SQRT1_2, Math.SQRT1_2], + west: [-1, 0], + 'north-west': [-Math.SQRT1_2, -Math.SQRT1_2], +} + +/** + * What a view frames: the walls of the target (a building, a level, a wall) or a zone's outline, + * each at its storey's height; every wall by default. Plan guides never count: an imported plan + * is drawn much larger than its building. + */ +/** A door or a window and the wall it is in, when the target is one. */ +function openingOf(nodes: Readonly>, target: AnyNode | undefined) { + if (target?.type !== 'door' && target?.type !== 'window') return null + const wall = nodes[target.wallId ?? target.parentId ?? ''] + return wall?.type === 'wall' && !target.roofSegmentId ? { opening: target, wall } : null +} + +/** + * An opening's box, at detail scale: along its wall, centred at its height on the storey, + * as deep as the wall. + */ +function openingBox( + { + opening, + wall, + }: { opening: AnyNode & { position: number[]; width: number; height: number }; wall: WallNode }, + baseY: number, +): SceneViewBox { + const [dx, dz] = [wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]] + const length = Math.hypot(dx, dz) || 1 + const [ux, uz] = [dx / length, dz / length] + const [nx, nz] = [-uz, ux] + const along = opening.position[0]! + const [cx, cz] = [wall.start[0] + ux * along, wall.start[1] + uz * along] + const [half, depth] = [opening.width / 2, (wall.thickness ?? 0.2) / 2] + const xs = [-1, 1].flatMap((a) => [-1, 1].map((n) => cx + ux * half * a + nx * depth * n)) + const zs = [-1, 1].flatMap((a) => [-1, 1].map((n) => cz + uz * half * a + nz * depth * n)) + const y = baseY + opening.position[1]! + return { + min: [Math.min(...xs), y - opening.height / 2, Math.min(...zs)], + max: [Math.max(...xs), y + opening.height / 2, Math.max(...zs)], + } +} + +/** An item standing on a floor, by its asset's dimensions and its turn. */ +function itemBox(item: ItemNode, baseY: number): SceneViewBox { + const [w, h, d] = item.asset.dimensions + const turn = item.rotation?.[1] ?? 0 + const [c, s] = [Math.abs(Math.cos(turn)), Math.abs(Math.sin(turn))] + const [hx, hz] = [(w * c + d * s) / 2, (w * s + d * c) / 2] + const [x, y, z] = item.position + return { min: [x - hx, baseY + y, z - hz], max: [x + hx, baseY + y + h, z + hz] } +} + +/** A box of at least `least` metres tall, so a flat element still frames. */ +const tall = (box: SceneViewBox, least: number): SceneViewBox => + box.max[1] - box.min[1] >= least + ? box + : { min: box.min, max: [box.max[0], box.min[1] + least, box.max[2]] } + +/** + * A site element's box, at detail scale (S10 live: view_scene could not look at the steps it + * built): a column round its position, a fence along its run, a slab over its outline, a stair + * round its foot as far as it could reach. + */ +function siteBox(node: AnyNode, baseY: number): SceneViewBox | null { + if (node.type === 'column') { + const column = node as ColumnNode + const half = + column.crossSection === 'round' ? column.radius : Math.max(column.width, column.depth) / 2 + const [x, , z] = column.position + return { min: [x - half, baseY, z - half], max: [x + half, baseY + column.height, z + half] } + } + if (node.type === 'fence') { + const fence = node as FenceNode + const points = [fence.start, fence.end, ...(fence.path ?? [])] + const half = fence.thickness / 2 + return { + min: [ + Math.min(...points.map((p) => p[0])) - half, + baseY, + Math.min(...points.map((p) => p[1])) - half, + ], + max: [ + Math.max(...points.map((p) => p[0])) + half, + baseY + fence.height, + Math.max(...points.map((p) => p[1])) + half, + ], + } + } + if (node.type === 'slab') { + const polygon = (node as SlabNode).polygon as [number, number][] + const top = baseY + ((node as SlabNode).elevation ?? 0) + return tall( + { + min: [Math.min(...polygon.map((p) => p[0])), top, Math.min(...polygon.map((p) => p[1]))], + max: [Math.max(...polygon.map((p) => p[0])), top, Math.max(...polygon.map((p) => p[1]))], + }, + 0.3, + ) + } + if (node.type === 'stair') { + const stair = node as StairNode + const rise = stair.totalRise ?? 1 + const reach = Math.max(stair.width ?? 1, (rise / 0.17) * 0.28) + const [x, , z] = stair.position + return { + min: [x - reach, baseY, z - reach], + max: [x + reach, baseY + Math.max(rise, 0.3), z + reach], + } + } + return null +} + +/** + * The side an opening is seen from by default: its outside, when its wall knows it; else the + * side it faces. Its wall's +normal is perp(end - start) = (-dz, dx). + */ +function outsideSide(opening: AnyNode & { rotation?: number[] }, wall: WallNode): SceneViewSide { + const [dx, dz] = [wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]] + const facing = Math.abs(opening.rotation?.[1] ?? 0) > Math.PI / 2 ? -1 : 1 + const sign = resolveWallExteriorSide(wall) ?? facing + const [nx, nz] = [-dz * sign, dx * sign] + return (Object.keys(COMPASS) as (keyof typeof COMPASS)[]).reduce((best, side) => + COMPASS[side][0] * nx + COMPASS[side][1] * nz > COMPASS[best][0] * nx + COMPASS[best][1] * nz + ? side + : best, + ) +} + +export function sceneViewBounds( + nodes: Readonly>, + targetId?: string, +): SceneViewBox { + const target = targetId ? nodes[targetId] : undefined + if (targetId && !target) + refuse('target_not_found', `Nothing to look at: ${targetId} is not in the scene.`, { + target: targetId, + }) + const elevations = getLevelElevations(nodes as Record) + const baseOf = (levelId: string | null | undefined) => + (levelId ? elevations.get(levelId)?.baseY : undefined) ?? 0 + const opening = openingOf(nodes, target) + if (opening) return openingBox(opening as never, baseOf(opening.wall.parentId)) + if (target?.type === 'item' && nodes[target.parentId ?? '']?.type === 'level') + return itemBox(target, baseOf(target.parentId)) + const site = + target && nodes[target.parentId ?? '']?.type === 'level' + ? siteBox(target, baseOf(target.parentId)) + : null + if (site) return site + const levelOf = (node: AnyNode) => (node.parentId ? nodes[node.parentId] : undefined) + const outlines: { points: Pt[]; levelId: string }[] = [] + // With no walls yet, what the level holds frames it: furniture placed from a plan before the + // walls are built, a site with its paving and fences. + const held: SceneViewBox[] = [] + for (const node of Object.values(nodes)) { + const level = levelOf(node) + if (level?.type !== 'level') continue + const inTarget = + !target || + target.id === node.id || + target.id === level.id || + (target.type === 'building' && level.parentId === target.id) + if (!inTarget) continue + if (node.type === 'wall') outlines.push({ points: [node.start, node.end], levelId: level.id }) + else if (node.type === 'zone' && target?.id === node.id) + outlines.push({ points: node.polygon as Pt[], levelId: level.id }) + else if (node.type === 'item') held.push(itemBox(node, baseOf(level.id))) + else { + const box = siteBox(node, baseOf(level.id)) + if (box) held.push(box) + } + } + if (!outlines.length && held.length) + return { + min: [0, 1, 2].map((axis) => Math.min(...held.map((box) => box.min[axis]!))) as V3, + max: [0, 1, 2].map((axis) => Math.max(...held.map((box) => box.max[axis]!))) as V3, + } + if (!outlines.length) + refuse( + 'nothing_to_view', + target + ? `${target.type} ${target.id} holds nothing built to look at: give a building, a level, a wall, a zone or what was placed.` + : 'The scene has nothing built yet.', + targetId ? { target: targetId } : {}, + ) + const min: V3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] + const max: V3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY] + for (const { points, levelId } of outlines) { + const storey = elevations.get(levelId) ?? { baseY: 0, height: 3 } + min[1] = Math.min(min[1], storey.baseY) + max[1] = Math.max(max[1], storey.baseY + storey.height) + for (const [x, z] of points) { + min[0] = Math.min(min[0], x) + max[0] = Math.max(max[0], x) + min[2] = Math.min(min[2], z) + max[2] = Math.max(max[2], z) + } + } + return { min, max } +} + +const sub = (a: V3, b: V3): V3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +const cross = (a: V3, b: V3): V3 => [ + 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 unit = (a: V3): V3 => { + const length = Math.hypot(...a) || 1 + return [a[0] / length, a[1] / length, a[2] / length] +} +const round = (value: number) => Math.round(value * 100) / 100 + +/** + * Where the eye stands: on the side asked (south-west by default), a few degrees up, far enough + * that the whole target is in frame; or at a street-level height; or exactly where it is put. + * The orthographic view looks square on and is as wide as the target seen from there. + */ +export function sceneViewPose(box: SceneViewBox, input: SceneViewInput): SceneViewPose { + const centre: V3 = [ + (box.min[0] + box.max[0]) / 2, + (box.min[1] + box.max[1]) / 2, + (box.min[2] + box.max[2]) / 2, + ] + const radius = Math.hypot(...sub(box.max, box.min)) / 2 || 1 + const side = input.from ?? 'south-west' + const elevation = + ((side === 'above' ? 89 : (input.elevation ?? DEFAULT_ELEVATION)) * Math.PI) / 180 + const [dx, dz] = side === 'above' ? COMPASS.south : COMPASS[side] + const direction: V3 = [Math.cos(elevation) * dx, Math.sin(elevation), Math.cos(elevation) * dz] + const corners: V3[] = [] + for (const x of [box.min[0], box.max[0]]) + for (const y of [box.min[1], box.max[1]]) + for (const z of [box.min[2], box.max[2]]) corners.push([x, y, z]) + const placed = input.position as V3 | undefined + + if (input.projection === 'orthographic') { + const position: V3 = placed ?? [ + centre[0] + direction[0] * radius * 4, + centre[1] + direction[1] * radius * 4, + centre[2] + direction[2] * radius * 4, + ] + const forward = unit(sub(centre, position)) + const right = unit(cross(forward, [0, 1, 0])) + const up = cross(right, forward) + let halfWidth = 0 + let halfHeight = 0 + for (const corner of corners) { + const offset = sub(corner, centre) + halfWidth = Math.max(halfWidth, Math.abs(dot(offset, right))) + halfHeight = Math.max(halfHeight, Math.abs(dot(offset, up))) + } + return { + projection: 'orthographic', + position: position.map(round) as V3, + target: centre, + viewWidth: round(Math.max(halfWidth * 2, halfHeight * 2 * ASPECT) * MARGIN + 0.01), + } + } + + const fov = input.fov ?? DEFAULT_FOV + if (placed) return { projection: 'perspective', position: placed, target: centre, fov } + const vertical = (fov * Math.PI) / 180 + const horizontal = 2 * Math.atan(Math.tan(vertical / 2) * ASPECT) + const distance = (radius / Math.sin(Math.min(vertical, horizontal) / 2)) * MARGIN + const position: V3 = + input.eyeHeight === undefined + ? [ + centre[0] + direction[0] * distance, + centre[1] + direction[1] * distance, + centre[2] + direction[2] * distance, + ] + : [centre[0] + dx * distance, input.eyeHeight, centre[2] + dz * distance] + return { + projection: 'perspective', + position: (input.eyeHeight === undefined + ? position.map(round) + : [round(position[0]), input.eyeHeight, round(position[2])]) as V3, + target: centre, + fov, + } +} + +/** + * The view to render and its size: from a photo's camera at the photo's aspect, so the two lay + * one beside the other; else framing the target at the standard size. + */ +/** What a view comes with, on both surfaces: a picture to compare, not a measure. */ +export function sceneViewNote() { + return 'A picture to compare with the reference, not a measure: take sizes and counts from the tools.' +} + +export function sceneViewPlan( + nodes: Readonly>, + input: SceneViewInput, +): { pose: SceneViewPose; size: { w: number; h: number }; crop?: SceneViewCrop } { + const { camera } = input + const crop = cropOf(input.photo) + if (camera) { + const own = ( + ['from', 'position', 'elevation', 'eyeHeight', 'fov', 'projection'] as const + ).filter((key) => input[key] !== undefined) + if (own.length) + refuse( + 'camera_and_viewpoint', + `Give the photo's camera or a viewpoint of your own, not both (${own.join(', ')}).`, + { fields: own }, + ) + return { + pose: { + projection: 'perspective', + position: camera.position as V3, + target: camera.target as V3, + fov: camera.fov, + }, + size: { w: VIEW_SIZE.w, h: Math.round(VIEW_SIZE.w / camera.aspect) }, + ...(crop ? { crop } : {}), + } + } + const opening = openingOf(nodes, input.target ? nodes[input.target] : undefined) + const from = + input.from ?? + (opening && !input.position ? outsideSide(opening.opening, opening.wall) : undefined) + return { + pose: sceneViewPose(sceneViewBounds(nodes, input.target), { + ...input, + ...(from ? { from } : {}), + }), + size: { ...VIEW_SIZE }, + ...(crop ? { crop } : {}), + } +} diff --git a/packages/core/src/agent-operations/search-assets.ts b/packages/core/src/agent-operations/search-assets.ts new file mode 100644 index 0000000000..04fa973c1d --- /dev/null +++ b/packages/core/src/agent-operations/search-assets.ts @@ -0,0 +1,47 @@ +import { refuse } from '../agent-tools/refusal' +import type { AssetInput } from '../schema' +import type { AgentOperation } from './types' + +type SearchAssetsInput = { queries: { query: string; category?: string }[] } + +function matches(item: AssetInput, { query, category }: SearchAssetsInput['queries'][number]) { + if (category && item.category !== category) return false + const haystack = [item.id, item.name, item.category, ...(item.tags ?? [])].join(' ').toLowerCase() + return query + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((term) => haystack.includes(term)) +} + +/** + * `search_assets`: the host's item library, one group of hits per query. A miss points to + * add_object, the way to build what no library holds, without naming a design for it. + */ +export const searchAssets: AgentOperation = (_nodes, { queries }, context) => { + const catalog = context.catalog + if (!catalog) + refuse('no_catalog', 'This host has no item library to search; nothing can be placed from one.') + const groups = queries.map((entry) => { + const results = catalog + .filter((item) => matches(item, entry)) + .map((item) => ({ + id: item.id, + name: item.name, + category: item.category, + dimensions: item.dimensions ?? [1, 1, 1], + attachTo: item.attachTo ?? null, + })) + return { query: entry.query, total: results.length, results } + }) + const missed = groups.filter((group) => group.total === 0).map((group) => `"${group.query}"`) + return { + result: { + groups, + total: groups.reduce((sum, group) => sum + group.total, 0), + ...(missed.length && { + hint: `Nothing in the library matches ${missed.join(', ')}. Build what it lacks with add_object.`, + }), + }, + } +} diff --git a/packages/core/src/agent-operations/types.ts b/packages/core/src/agent-operations/types.ts index 7b1969f0a5..7e5a0a3d5b 100644 --- a/packages/core/src/agent-operations/types.ts +++ b/packages/core/src/agent-operations/types.ts @@ -1,9 +1,19 @@ -import type { AnyNode, Collection } from '../schema' +import type { AnyNode, AssetInput, Collection } from '../schema' export type SceneNodes = Readonly> -/** What a surface knows beyond the scene: the chat knows the floor a person is viewing. */ -export type AgentContext = { activeLevelId: string | null } +/** + * 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. + */ +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 } + catalog?: readonly AssetInput[] +} /** Edits an operation asks for; each surface applies them its own way, in one undo step. */ export type SceneChanges = { @@ -15,7 +25,20 @@ export type SceneChanges = { collections?: Record } -export type AgentOperationOutcome = { result: Record; changes?: SceneChanges } +export type AgentOperationOutcome = { + result: Record + changes?: SceneChanges + /** + * For an edit that depends on construction the host derives from `changes` (rooms re-derived + * from walls and separators, auto ceilings, floor plates), whose ids an operation cannot know: + * the host reconciles after `changes`, calls this with the scene it then holds, applies what it + * returns, reconciles again and answers with its result — one undo step (`applyAgentOutcome`). + */ + afterReconcile?: (nodes: SceneNodes) => { + result: Record + changes?: SceneChanges + } +} /** * One agent tool's behaviour, shared by every surface: plan from the scene, or refuse. Input is diff --git a/packages/core/src/agent-operations/verify-scene.ts b/packages/core/src/agent-operations/verify-scene.ts index 9cb09ccf5c..84e10776f6 100644 --- a/packages/core/src/agent-operations/verify-scene.ts +++ b/packages/core/src/agent-operations/verify-scene.ts @@ -9,6 +9,7 @@ import { checkOpeningWithinWall, formatOpeningBoundsIssue } from '../validation/ import { layoutIssuesFromScene } from './layout-clearance' import { wallResolvedHeight } from './level-reads' import { pointInPolygon, polygonContainsPolygon, type Vec2 } from './plan-geometry' +import { changesSince, type SceneCheckpoint } from './scene-measure' import { type ContentCounts, contentCounts, @@ -22,6 +23,47 @@ import type { AgentOperation, SceneNodes } from './types' /** A problem verify_scene found, typed so it can be counted and acted on. */ export type SceneIssue = { type: string; message: string; severity?: 'info' } +/** What verify_scene was asked: its contract's input, which other modules may extend. */ +export type VerifySceneInput = Readonly> + +/** + * A check verify_scene runs beyond its own, registered by the module that knows it (the facade + * checks, the photo's), so verify_scene imports none of them. Checks before CHECKPOINT_ORDER run + * ahead of the checkpoint's comparison, the rest after it. + */ +export type SceneCheck = { + name: string + order: number + run: (nodes: SceneNodes, input: VerifySceneInput) => SceneIssue[] +} + +export const CHECKPOINT_ORDER = 100 + +const checks: SceneCheck[] = [] + +/** + * Fields verify_scene's result carries beyond its own, from the module that knows them (the + * reference inventory's counts and what is unbuilt). + */ +export type SceneReport = { name: string; run: (nodes: SceneNodes) => Record } + +const reports: SceneReport[] = [] + +/** Adds a report's fields to every verify_scene from now on; registering again replaces it. */ +export function registerSceneReport(report: SceneReport) { + const at = reports.findIndex((known) => known.name === report.name) + if (at >= 0) reports[at] = report + else reports.push(report) +} + +/** Runs a check in every verify_scene from now on; registering a check again replaces it. */ +export function registerSceneCheck(check: SceneCheck) { + const at = checks.findIndex((known) => known.name === check.name) + if (at >= 0) checks[at] = check + else checks.push(check) + checks.sort((a, b) => a.order - b.order) +} + type StairNode = AnyNode & { type: 'stair' } const occupiedContent = (counts: ContentCounts) => counts.walls + @@ -158,7 +200,11 @@ function schemaErrors(nodes: SceneNodes) { * with no stair, openings off their wall, stairs off their slab, blocked doors, overlapping * furniture, nodes their schema rejects. */ -export const verifyScene: AgentOperation = (nodes, _input, context) => { +export const verifyScene: AgentOperation = ( + nodes, + input, + context, +) => { const onLevel = (levelId: string) => nodesOnLevel(nodes, levelId) const ofType = (content: readonly AnyNode[], type: T) => content.filter((node): node is Extract => node.type === type) @@ -352,6 +398,20 @@ export const verifyScene: AgentOperation = (nodes, _input, context) => { .map((hole, index) => ({ slab, hole, index })) .filter((entry) => holeBelongsToStair(entry.slab, entry.index, stair.id)), ) + // Since owned floor openings, the stair owns a floor-opening on the floor above, and the + // slab hole it cuts names the opening, not the stair (else 14 false reports on one build). + const owned = onLevel(targetLevelId).filter( + (node): node is AnyNode & { type: 'floor-opening' } => + node.type === 'floor-opening' && + node.source === 'stair' && + node.ownerId === stair.id && + node.drawnOn === 'floor', + ) + for (const opening of owned) { + const slab = + targetSlabs.find((candidate) => candidate.id === opening.surfaceId) ?? targetSlabs[0]! + holes.push({ slab, hole: opening.polygon, index: -1 }) + } if (holes.length === 0) { report( 'stair_no_opening', @@ -376,6 +436,15 @@ export const verifyScene: AgentOperation = (nodes, _input, context) => { // Door keep-outs and item–item footprint overlaps (rotation-aware). issues.push(...layoutIssuesFromScene(Object.values(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 + ? changesSince(context.checkpoint as SceneCheckpoint, nodes) + : null + if (since) issues.push(...since.issues) + for (const check of checks) + if (check.order >= CHECKPOINT_ORDER) issues.push(...check.run(nodes, input ?? {})) const occupiedStoryCount = levels.filter((level) => level.isOccupiedStory).length return { @@ -391,6 +460,30 @@ export const verifyScene: AgentOperation = (nodes, _input, context) => { emptyLevelIds: empty.map((level) => level.levelId), issues, 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 }, + }), }, } } + +/** Objects built by add_object, with what each stands in for: each is a gap in what Pascal builds. */ +function authoredObjects(nodes: SceneNodes) { + const objects = Object.values(nodes) + .flatMap((node) => + node.type === 'item' && node.source + ? [ + { + id: node.id, + name: node.name ?? node.asset.name, + category: node.asset.category, + reason: typeof node.metadata?.reason === 'string' ? node.metadata.reason : null, + }, + ] + : [], + ) + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) + return objects.length ? { authoredObjects: objects } : {} +} diff --git a/packages/core/src/agent-operations/wall-opening.test.ts b/packages/core/src/agent-operations/wall-opening.test.ts new file mode 100644 index 0000000000..a6d9c1c459 --- /dev/null +++ b/packages/core/src/agent-operations/wall-opening.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { addDoorOutput, addWindowOutput } from '../agent-tools' +import { OPENING_SCENE, openingScene } from '../building/__fixtures__/wall-opening-cases' +import { applySceneChanges } from './apply-changes' +import type { SceneNodes } from './types' +import { addWallOpening } from './wall-opening' + +// The MCP and the chat answered add_door and add_window with two envelopes around one +// operation (coordinateSystem and position on one side, ok, message and wallId on the other) and +// neither said what the scene held after. One result now, from core, that both pass through. + +const scene = () => openingScene().nodes as unknown as SceneNodes + +describe('add_door and add_window answer once, for every surface', () => { + test('a door: the node to create, and the result the contract declares', () => { + const nodes = scene() + const { result, changes } = addWallOpening(nodes, { + kind: 'door', + wallId: OPENING_SCENE.main, + t: 0.5, + }) + expect(z.object(addDoorOutput).strict().parse(result)).toEqual(result) + expect(result).toMatchObject({ + ok: true, + wallId: OPENING_SCENE.main, + t: 0.5, + localX: 2, + clamped: false, + coordinateSystem: 'wall-local-meters', + achieved: { created: { door: 1 }, updated: 0, deleted: {} }, + }) + const after = applySceneChanges(nodes as never, changes) as SceneNodes + expect(after[result.doorId!]).toMatchObject({ type: 'door', parentId: OPENING_SCENE.main }) + }) + + test('a window says its sill height, 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.achieved.created).toEqual({ window: 1 }) + }) +}) diff --git a/packages/core/src/agent-operations/wall-opening.ts b/packages/core/src/agent-operations/wall-opening.ts new file mode 100644 index 0000000000..07f5d7f1cf --- /dev/null +++ b/packages/core/src/agent-operations/wall-opening.ts @@ -0,0 +1,72 @@ +import { planWallOpening, type WallOpeningInput } from '../building/wall-openings' +import { type Achieved, achievedChanges } from './achieved' +import type { SceneChanges, SceneNodes } from './types' + +/** + * add_door and add_window for every surface: the editor's placement rules (`planWallOpening`), + * the node to create, and the one result the MCP and the chat both pass through as it is. + */ +export function addWallOpening(nodes: SceneNodes, input: WallOpeningInput) { + const plan = planWallOpening(nodes as never, input) + const changes: SceneChanges = { + create: [{ node: plan.node as never, parentId: plan.wallId }], + } + return { + changes, + result: { + ok: true as const, + ...(input.kind === 'door' ? { doorId: plan.node.id } : { windowId: plan.node.id }), + wallId: plan.wallId, + localX: plan.localX, + t: plan.t, + wallLength: plan.wallLength, + clamped: plan.clamped, + coordinateSystem: 'wall-local-meters' as const, + ...(plan.sillHeight === undefined ? {} : { sillHeight: plan.sillHeight }), + message: `Added ${input.kind} "${plan.node.name}" on wall ${plan.wallId}${plan.clamped ? ', slid to fit' : ''}`, + achieved: achievedChanges(nodes, changes), + }, + } +} + +type PlacedOpening = { + type: string + name?: string + position: [number, number, number] + height: number + wallId?: string +} + +/** + * add_door / add_window with a nodeId, rebuilt from its script: the same answer as a new opening, + * read from where it stands after the rebuild, with what the script reported. + */ +export function rebuiltOpeningResult( + nodesAfter: SceneNodes, + nodeId: string, + summary: Record, + achieved: Achieved, +) { + const node = nodesAfter[nodeId] as unknown as PlacedOpening + const wall = node.wallId + ? (nodesAfter[node.wallId] as unknown as { type: string; start: number[]; end: number[] }) + : undefined + const wallLength = + wall?.type === 'wall' + ? Math.hypot(wall.end[0]! - wall.start[0]!, wall.end[1]! - wall.start[1]!) + : 0 + return { + ok: true as const, + ...(node.type === 'door' ? { doorId: nodeId } : { windowId: nodeId }), + wallId: node.wallId ?? '', + localX: node.position[0], + t: wallLength ? node.position[0] / wallLength : 0, + wallLength, + clamped: false, + coordinateSystem: 'wall-local-meters' as const, + ...(node.type === 'window' ? { sillHeight: node.position[1] - node.height / 2 } : {}), + message: `Rebuilt ${node.type} "${node.name ?? nodeId}" from its script`, + ...summary, + achieved, + } +} diff --git a/packages/core/src/agent-tools/achieved.ts b/packages/core/src/agent-tools/achieved.ts new file mode 100644 index 0000000000..5d1d38b88f --- /dev/null +++ b/packages/core/src/agent-tools/achieved.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +/** What the scene holds after a call that changes it (agent-operations `achievedChanges`). */ +export const achievedOutput = z.object({ + created: z.record(z.string(), z.number()), + updated: z.number(), + deleted: z.record(z.string(), z.number()), + unchanged: z.literal(true).optional(), +}) diff --git a/packages/core/src/agent-tools/add-object.ts b/packages/core/src/agent-tools/add-object.ts index 5823d1bca1..a25b0d3fc2 100644 --- a/packages/core/src/agent-tools/add-object.ts +++ b/packages/core/src/agent-tools/add-object.ts @@ -10,7 +10,7 @@ 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. +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. 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. @@ -72,6 +72,14 @@ export const addObjectTool = { .max(60) .optional() .describe('What it is, one word or two ("porch", "lantern", "ceiling", "trim").'), + reason: z + .string() + .min(1) + .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.', + ), }, } diff --git a/packages/core/src/agent-tools/assets.ts b/packages/core/src/agent-tools/assets.ts new file mode 100644 index 0000000000..af9d7b18be --- /dev/null +++ b/packages/core/src/agent-tools/assets.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +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.", + input: { + queries: z + .array( + z.object({ + query: z.string().min(1).describe('Search words, e.g. "sofa", "dining chair", "lamp".'), + category: z + .string() + .optional() + .describe('Only items of this category (furniture, kitchen, bathroom, outdoor, …).'), + }), + ) + .min(1) + .describe('One or more searches to run in a single call.'), + }, +} diff --git a/packages/core/src/agent-tools/create-room.ts b/packages/core/src/agent-tools/create-room.ts new file mode 100644 index 0000000000..ca63ee30c0 --- /dev/null +++ b/packages/core/src/agent-tools/create-room.ts @@ -0,0 +1,101 @@ +import { z } from 'zod' +import { DOOR_STYLES, WINDOW_STYLES } from '../building/opening-style-presets' +import { levelTarget } from './levels' +import { measurement } from './measurement' + +// A list of two, not a tuple: a tuple's list-form schema is refused by some clients. +const point = z.array(z.number()).length(2) + +const onEdge = { + wallIndex: z + .number() + .int() + .min(0) + .describe('The polygon edge it sits on: edge i runs from corner i to corner i + 1.'), + t: z + .number() + .min(0) + .max(1) + .optional() + .describe('Position along that edge: 0 = its start, 1 = its end (default 0.5).'), +} + +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.", + input: { + ...levelTarget, + name: z.string().min(1).describe('Room name, e.g. "Bedroom", "Kitchen".'), + polygon: z.array(point).min(3).describe('The corners in order, as [x, z] in metres.'), + color: z.string().optional().describe('Hex colour of the room zone, e.g. "#3b82f6".'), + wallHeight: measurement('length', 'm', { + positive: true, + description: "Height of the walls it builds (default: the storey's).", + }).optional(), + wallThickness: measurement('length', 'm', { + positive: true, + description: 'Thickness of the walls it builds.', + }).optional(), + outdoor: z + .boolean() + .optional() + .describe( + "true for an outdoor room (a terrace): closed with separators (the editor's Separator: a room boundary with no wall) where no wall runs, no walls of its own and no ceiling.", + ), + doors: z + .array( + z.object({ + ...onEdge, + width: measurement('length', 'm', { + positive: true, + description: 'Door width (default 0.9 m).', + }).optional(), + height: measurement('length', 'm', { + positive: true, + description: 'Door height (default 2.1 m).', + }).optional(), + hingesSide: z.enum(['left', 'right']).optional().describe('Hinge side (default left).'), + swingDirection: z + .enum(['inward', 'outward']) + .optional() + .describe('Which way the door opens (default inward).'), + style: z + .enum(DOOR_STYLES) + .optional() + .describe( + "Visual preset: 'panel' (default raised-panel), 'glass' (mostly glass), 'modern' (flush slab), 'paneled-glass' (glass top, panel bottom — an entry door), 'french' (two narrow glass leaves), 'shaker' (one flat panel), 'six-panel' (3 × 2 traditional grid), 'craftsman' (4 stacked vertical panels), 'half-louvered' (slats on top, panel below — closet, laundry), 'barn' (wide recessed panel). One style per room type across the home; the entry door may differ.", + ), + }), + ) + .optional() + .describe('The entry door and the doors to the rooms next to it.'), + windows: z + .array( + z.object({ + ...onEdge, + width: measurement('length', 'm', { + positive: true, + description: 'Window width (default 1.5 m).', + }).optional(), + 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 0.9 m).', + }).optional(), + style: z + .enum(WINDOW_STYLES) + .optional() + .describe( + "Visual preset: 'single' (default single pane), 'double-hung', 'triple-hung' (stacked sashes), 'casement' (two panes side by side), 'sliding' (three side by side — wide), 'grid' (2 × 2), 'tall-grid' (2 × 3), 'wide-grid' (3 × 2), 'horizontal-bands' (4 stacked bands), 'transom' (a small row over a larger pane), 'picture' (one large fixed pane). One style for all the windows of a room, and per room type across the home.", + ), + }), + ) + .optional() + .describe('Windows, on exterior edges only: not on an edge shared with another room.'), + }, +} diff --git a/packages/core/src/agent-tools/furnish-room.ts b/packages/core/src/agent-tools/furnish-room.ts new file mode 100644 index 0000000000..ba8a5cff2e --- /dev/null +++ b/packages/core/src/agent-tools/furnish-room.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { levelTarget } from './levels' +import { NodeId } from './node-id' + +export const FURNISHED_ROOM_TYPES = [ + 'bedroom', + 'kitchen', + 'bathroom', + 'living', + 'dining', + 'hallway', + 'entry', + 'laundry', + 'storage', +] as const + +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.", + input: { + 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."), + roomType: z.enum(FURNISHED_ROOM_TYPES).describe('What the room is for.'), + doorWallIndex: z + .number() + .int() + .min(0) + .optional() + .describe( + 'The polygon edge (i → i + 1) to treat as the door wall: for a room with no door yet, or to override the one found.', + ), + }, +} diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index fa51ed107b..359405e9c4 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,31 +1,49 @@ import { addObjectTool, getSourceTool } from './add-object' +import { searchAssetsTool } from './assets' import { editCollectionTool, listCollectionsTool } from './collections' import { addColumnTool } from './columns' +import { createRoomTool } from './create-room' import { findByTypeTool } from './find-by-type' +import { furnishRoomTool } from './furnish-room' import { + addLevelTool, duplicateLevelTool, getLevelSummaryTool, getWallsTool, getZonesTool, listLevelsTool, - verifySceneTool, } from './levels' import { deleteNodeTool, getNodeTool } from './nodes' -import { fitStairTool, measureStairTool } from './stairs' +import { placeItemsTool } from './place-items' +import { ROOM_TOOL_CONTRACTS } from './room-structure' +import { createStairTool, fitStairTool, measureStairTool } from './stairs' +import { verifySceneTool } from './verify-scene' +import { viewSceneTool } from './view-scene' import { addDoorTool, addWindowTool } from './wall-openings' +import { addWallTool } from './walls' +export * from './achieved' export * from './add-object' +export * from './assets' export * from './collections' export * from './columns' +export * from './create-room' export * from './find-by-type' +export * from './furnish-room' export * from './hosted-services' export * from './levels' export * from './measurement' export { NodeId } from './node-id' export * from './nodes' +export * from './place-items' export * from './refusal' +export * from './room-structure' export * from './stairs' +export * from './verify-scene' +export * from './view-scene' export * from './wall-openings' +export * from './walls' +export * from './write-target' /** * Tools defined once for every agent surface — the MCP server and the hosted AI chat register @@ -36,6 +54,7 @@ export const AGENT_TOOL_CONTRACTS = [ addColumnTool, measureStairTool, fitStairTool, + viewSceneTool, addDoorTool, addWindowTool, listLevelsTool, @@ -45,10 +64,18 @@ export const AGENT_TOOL_CONTRACTS = [ getZonesTool, duplicateLevelTool, verifySceneTool, + addWallTool, + addLevelTool, + placeItemsTool, + createStairTool, deleteNodeTool, addObjectTool, getSourceTool, findByTypeTool, editCollectionTool, listCollectionsTool, + createRoomTool, + furnishRoomTool, + searchAssetsTool, + ...ROOM_TOOL_CONTRACTS, ] as const diff --git a/packages/core/src/agent-tools/levels.ts b/packages/core/src/agent-tools/levels.ts index d567a224ca..e8cd3e3121 100644 --- a/packages/core/src/agent-tools/levels.ts +++ b/packages/core/src/agent-tools/levels.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { measurement } from './measurement' import { NodeId } from './node-id' export const listLevelsTool = { @@ -10,7 +11,7 @@ export const listLevelsTool = { } // `level` is the chat's older name for the same parameter; threads and habits still use it. -const levelTarget = { +export const levelTarget = { levelId: NodeId.optional().describe( 'The level, by an id list_levels returned; an id made up from a name ("level_1", "level_ground") does not exist. Default: the floor the person is viewing, else the lowest floor.', ), @@ -41,6 +42,32 @@ export const getZonesTool = { input: levelTarget, } +export const addLevelTool = { + name: 'add_level', + title: 'Add level', + description: + 'Add an empty level to a building, as the editor does: above its highest level, or below its lowest for a basement. On a scene with no building (a new or cleared scene), it starts the scene as the editor does: a site, a building and its ground level. To copy a floor with its content, use duplicate_level.', + input: { + buildingId: NodeId.optional().describe( + "The building. Default: the building of the floor the person is viewing, else the scene's only building.", + ), + position: z + .enum(['above', 'below']) + .optional() + .describe('above (default): over the highest level; below: under the lowest, a basement.'), + name: z + .string() + .min(1) + .max(120) + .optional() + .describe('Name of the level, e.g. "Basement", "Attic".'), + height: measurement('length', 'm', { + positive: true, + description: 'Floor-to-floor storey height (default 2.5 m).', + }).optional(), + }, +} + export const duplicateLevelTool = { name: 'duplicate_level', title: 'Duplicate level', @@ -68,11 +95,3 @@ export const duplicateLevelTool = { ), }, } - -export const verifySceneTool = { - name: 'verify_scene', - title: 'Verify scene', - 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.', - input: {}, -} diff --git a/packages/core/src/agent-tools/place-items.ts b/packages/core/src/agent-tools/place-items.ts new file mode 100644 index 0000000000..6ef3afe37f --- /dev/null +++ b/packages/core/src/agent-tools/place-items.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { levelTarget } from './levels' +import { measurement } from './measurement' +import { NodeId } from './node-id' + +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).', + input: { + items: z + .array( + z.object({ + assetId: z.string().min(1).describe('A catalog id from search_assets.'), + x: z.number().describe('X in level coordinates (metres).'), + z: z.number().describe('Z in level coordinates (metres).'), + y: z + .number() + .optional() + .describe( + "Height above the floor (metres): on a wall, of the item's bottom (required: art centred at eye level is 1.5 − its height / 2, a sconce about 1.5); on an item, a resting height you set rather than its top. Not used on a floor or a ceiling.", + ), + targetNodeId: NodeId.optional().describe( + "The host: a wall, a ceiling or an item standing on the floor; a room, a slab or a level means its floor. Default: the level's floor.", + ), + rotation: measurement('angle', 'deg', { + description: 'Turn about the vertical (default 0).', + }).optional(), + }), + ) + .min(1) + .max(64) + .describe('The items to place.'), + ...levelTarget, + }, +} diff --git a/packages/core/src/agent-tools/room-structure.ts b/packages/core/src/agent-tools/room-structure.ts new file mode 100644 index 0000000000..26e84c8f62 --- /dev/null +++ b/packages/core/src/agent-tools/room-structure.ts @@ -0,0 +1,249 @@ +import { z } from 'zod' +import { NodeId } from './node-id' + +// Room transforms and floor construction, as the editor's room and floor commands do them. Points +// are arrays of two, not tuples: a tuple's list-form schema is refused by some clients. The patches +// are what agents may send; the commands validate them again with the node schemas. + +const point = z.array(z.number().finite()).length(2) +const finish = z.union([z.string(), z.record(z.string(), z.unknown())]) +const paintRegions = z.array(z.object({ id: z.string(), polygon: z.array(point), finish })) + +const zoneIntentPatch = z.strictObject({ + name: z.string().nullable().optional(), + floor: z + .strictObject({ + footprint: z.string().min(1).nullable().optional(), + thickness: z.number().min(0.02).nullable().optional(), + elevation: z.number().nullable().optional(), + finish: finish.nullable().optional(), + regions: paintRegions.nullable().optional(), + }) + .nullable() + .optional(), + ceiling: z.strictObject({ regions: paintRegions.nullable().optional() }).nullable().optional(), + floorStepFinish: z.string().nullable().optional(), + floorStepOverrides: z + .array( + z.object({ key: z.string(), step: z.number().int().min(0).optional(), finish: z.string() }), + ) + .nullable() + .optional(), + floorEdgeFinish: z.string().nullable().optional(), + wallMaterial: z.string().nullable().optional(), + hasFloor: z.boolean().nullable().optional(), + hasCeiling: z.boolean().nullable().optional(), +}) + +const floorFoundationPatch = z.strictObject({ + thickness: z + .number() + .finite() + .min(0) + .optional() + .describe( + 'Slab thickness in meters. On the ground it sits on the foundation (or the ground) and grows upward: a thicker slab raises the floor top and everything on it. Never below 0.01 m (smaller values are clamped). Upstairs the underside stays on the walls below.', + ), + foundationHeight: z + .number() + .finite() + .min(0) + .optional() + .describe( + 'Ground-bearing floors only: height of the foundation under the slab, in meters. 0 = on the ground (no foundation); > 0 = raised on a solid foundation. The floor top is derived: grade + foundationHeight + thickness.', + ), + floorHeight: z + .number() + .finite() + .nullable() + .optional() + .describe( + 'Legacy: a target floor top in level-local meters. On the ground it is mapped to foundationHeight = top - grade - thickness (never below 0); null = on the ground. Prefer thickness and foundationHeight.', + ), + foundation: z.object({ type: z.enum(['solid', 'none']), material: finish.optional() }).optional(), + slots: z + .strictObject({ + edge: z.string().optional(), + riser: z.string().optional(), + underside: z.string().optional(), + }) + .optional(), +}) + +const roomTransform = { + zoneId: NodeId, + translate: point.optional(), + rotate: z.object({ angle: z.number().finite(), pivot: point.optional() }).optional(), + force: z.boolean().optional(), +} + +export const cutFloorOpeningTool = { + name: 'cut_floor_opening', + title: 'Cut floor or ceiling opening', + description: + 'Create a persistent opening on a room or level from a polygon or rectangle. A floor cut also opens the automatic ceiling directly below; a ceiling cut can also open the floor above. Use cutsAdjacent:false for a one-surface cut. A mezzanine room cuts only its own plate. levelIds repeats the opening as a shaft on several levels.', + input: { + levelId: NodeId.optional(), + zoneId: NodeId.optional(), + levelIds: z.array(NodeId).min(1).optional(), + polygon: z.array(point).min(3).optional(), + rect: z + .object({ + x: z.number().finite(), + z: z.number().finite(), + width: z.number().finite().positive(), + depth: z.number().finite().positive(), + }) + .optional(), + drawnOn: z.enum(['floor', 'ceiling']).optional(), + cutsPrimary: z.boolean().optional(), + cutsAdjacent: z.boolean().optional(), + }, +} + +export const removeFloorOpeningTool = { + name: 'remove_floor_opening', + title: 'Remove floor opening', + description: + 'Remove an authored floor or ceiling opening and restore the derived surfaces it cut.', + input: { id: NodeId }, +} + +export const setFloorFoundationTool = { + name: 'set_floor_foundation', + title: 'Set floor and foundation', + description: + 'Supply slabId or slabIds; grouped plates receive the same floor-top displacement, while edge slots apply only to the first plate. A ground-contact floor has two inputs: thickness (slab, min 0.01 m, grows upward from the foundation) and foundationHeight (0 = on the ground, no foundation; > 0 = raised on a solid foundation; foundation.type "solid" alone raises it 0.3 m, "none" puts it on the ground). The floor top is derived and never floats: grade + foundationHeight + thickness; everything the floor carries (walls, openings, items, rooms, storeys above) moves with the top. Legacy floorHeight (a target top) maps to foundationHeight = top - grade - thickness, never below 0; null = on the ground. On upper plates the underside stays on the walls below: thickness (or floorHeight as a target top) changes the top; only ground-contact plates accept a foundation. Explicit room floors translate with the floor to preserve steps.', + input: { + slabId: NodeId.optional(), + slabIds: z.array(NodeId).min(1).optional(), + patch: floorFoundationPatch, + }, +} + +export const setRoomFloorConstructionTool = { + name: 'set_room_floor_construction', + title: 'Set room floor construction', + description: + 'Edit the single floor volume associated with a room, with the same patch as set_floor_foundation. On a ground-contact floor: thickness (slab on the foundation, grows upward, min 0.01 m) and foundationHeight (0 = on the ground; > 0 = raised on a solid foundation); the top is derived as grade + foundationHeight + thickness and carries everything on the floor. A drawn slab takes thickness, finishes and a numeric floorHeight (its top) only. Supply slabId when the room spans multiple construction domains; an absorbed legacy slab is edited through its derived base plate.', + input: { zoneId: NodeId, slabId: NodeId.optional(), patch: floorFoundationPatch }, +} + +export const rebaseFloorReferenceTool = { + name: 'rebase_floor_reference', + title: 'Rebase floor reference', + description: + 'Change or clear one or more footprint reference datums in one atomic edit, compensating supported storey offsets so existing world geometry stays in place. Supply slabIds together for a shared storey.', + input: { + slabId: NodeId.optional(), + slabIds: z.array(NodeId).min(1).optional(), + referenceFloorElevation: z.number().finite().nullable(), + }, +} + +export const createMezzanineTool = { + name: 'create_mezzanine', + title: 'Create mezzanine', + description: + 'Add an open-below room inside a host reference polygon, with a separate thin plate and railings on open edges. Minimum area 1 m²; elevation defaults to half the storey snapped to 0.05 m, thickness to 0.2 m. Elevation must exceed thickness and stay at least 0.3 m below the resolved footprint plane; overlapping mezzanines are refused. The host floor and walls stay intact.', + input: { + hostZoneId: NodeId, + polygon: z.array(point).min(3), + elevation: z.number().finite().optional(), + thickness: z.number().finite().min(0.02).optional(), + }, +} + +export const moveZoneTool = { + name: 'move_zone', + title: 'Move or rotate room', + description: + 'Move room intent and contents. Shared walls and openings stay with neighbours; the room takes plain copies. Rotation is radians about Y, before translation, about the room centroid unless a pivot is supplied. Crossings split walls and overlaps re-derive rooms. Force can reposition obstructing openings when they fit; otherwise the edit is refused.', + input: roomTransform, +} + +export const duplicateZoneTool = { + name: 'duplicate_zone', + title: 'Duplicate room', + description: + 'Copy room intent and contents with fresh IDs; shared walls are copied without openings. Rotation is radians about Y before translation. Collinear destination walls are merged, preserving their IDs and hosted children. Crossings split walls and rooms re-derive; overlap is allowed. Force can reposition obstructing openings only when they fit.', + input: { ...roomTransform, translate: point }, +} + +export const rotateZoneTool = { + name: 'rotate_zone', + title: 'Rotate room a quarter turn', + description: + 'Rotate a room by quarterTurns 1 or -1 about its centroid, then align its first reference vertex to gridStep (default 0.5 m). Crossings split walls and collinear overlaps merge. Force can reposition obstructing openings only when they fit.', + input: { + zoneId: NodeId, + quarterTurns: z.number().int().min(-1).max(1).describe('1 or -1.'), + gridStep: z.number().finite().positive().optional(), + force: z.boolean().optional(), + }, +} + +export const lockOutsideFacesTool = { + name: 'lock_outside_faces', + title: 'Keep outside dimensions', + description: + "Keep the building's outer size: put exterior wall faces on their fixed reference lines, moving wall bodies inward so walls thicken inward and later thickness edits preserve outer dimensions. Supply either levelId or zoneIds.", + input: { levelId: NodeId.optional(), zoneIds: z.array(NodeId).min(1).optional() }, +} + +export const setZoneIntentTool = { + name: 'set_zone_intent', + title: 'Set room intent', + description: + 'Update room name, floor finish/elevation/regions, floor.footprint ("new" mints a unique floor key tied to this creator room, an existing key joins that floor on this level, null returns to the shared floor). Touching rooms with the same key share a base plate; disconnected pieces share construction settings. Conversion preserves world geometry in one edit and refuses when infeasible. get_zones exposes floor_choices. A keyed floor uses " floor" while the creator remains on that key; otherwise it uses the largest named room. Existing keys such as "own" without a creator use that fallback. A user-given plate name wins; the shared floor stays "Shared floor". Keyed rooms can have raised or sunken floor.elevation, stored as a level-local walking top. Slab thickness, foundation height and edge finish use set_room_floor_construction, updating every piece of the key. Update ceiling paint regions (ceiling.regions: [x, z] polygons with a finish, later wins), step finishes (floorStepFinish for every step of the room; floorStepOverrides: [{ key, step?, finish }] per doorway, key = the door id the step sits under or the lower room id), wall material and construction opt-outs. Absent fields are unchanged; null clears.', + input: { zoneId: NodeId, patch: zoneIntentPatch }, +} + +export const divideZoneTool = { + name: 'divide_zone', + title: 'Divide room', + description: + "Divide a room with an open path (endpoints snap to its boundary; the seed side keeps its id) or a closed island (outer room keeps its id). The two rooms are parted by a separator (the editor's Separator: a room boundary with no wall), not a wall. Supply path or the legacy two-point cut. Islands need 0.25 m² and 5 cm wall clearance.", + input: { + zoneId: NodeId, + cut: z.array(point).length(2).optional(), + path: z.array(point).min(2).optional(), + closed: z.boolean().optional(), + startBoundaryId: NodeId.optional(), + endBoundaryId: NodeId.optional(), + }, +} + +export const mergeZonesTool = { + name: 'merge_zones', + title: 'Merge rooms', + description: + "Merge two rooms by removing the separators they share (the editor's Separator: a room boundary with no wall). Walls are preserved; wall-only boundaries return a conflict.", + input: { zoneIds: z.array(NodeId).length(2) }, +} + +export const deleteZoneTool = { + name: 'delete_zone', + title: 'Delete room', + description: + 'Delete a room. A room Divide made, which shares a separator (the editor\'s Separator: a room boundary with no wall) with a room on its outline, merges back into that room: the separators go, walls and items stay (mode "merge"). A room whose every boundary is a wall shared with other rooms is refused with a "shared-walls" conflict and nothing changes (mode "blocked"); delete one of its walls instead. Otherwise the room goes with its unshared walls, separators and their openings (mode "delete"), and contents chooses whether its items are kept or deleted. Returns the disposition payload.', + input: { zoneId: NodeId, contents: z.enum(['delete', 'keep']) }, +} + +/** Every room and floor-construction tool, in the order the surfaces list them. */ +export const ROOM_TOOL_CONTRACTS = [ + cutFloorOpeningTool, + removeFloorOpeningTool, + setFloorFoundationTool, + setRoomFloorConstructionTool, + rebaseFloorReferenceTool, + createMezzanineTool, + moveZoneTool, + duplicateZoneTool, + rotateZoneTool, + lockOutsideFacesTool, + setZoneIntentTool, + divideZoneTool, + mergeZonesTool, + deleteZoneTool, +] as const diff --git a/packages/core/src/agent-tools/room-vocabulary.test.ts b/packages/core/src/agent-tools/room-vocabulary.test.ts new file mode 100644 index 0000000000..54b56b0ee0 --- /dev/null +++ b/packages/core/src/agent-tools/room-vocabulary.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'bun:test' +import { createRoomTool } from './create-room' +import { deleteZoneTool, divideZoneTool, mergeZonesTool } from './room-structure' + +// An agent names a room boundary with no wall the way the editor labels it, so what it says is what +// the person finds in the Scene panel. "Divider" is the door and window panels' word for the bars +// between panes. +test("room tools call a boundary with no wall a separator, the editor's Separator", () => { + for (const description of [ + divideZoneTool.description, + mergeZonesTool.description, + deleteZoneTool.description, + createRoomTool.input.outdoor.description, + ]) { + expect(description).toContain("the editor's Separator: a room boundary with no wall") + expect(description).not.toMatch(/divider/i) + } +}) diff --git a/packages/core/src/agent-tools/stairs.ts b/packages/core/src/agent-tools/stairs.ts index f9825d20b3..287defd037 100644 --- a/packages/core/src/agent-tools/stairs.ts +++ b/packages/core/src/agent-tools/stairs.ts @@ -1,5 +1,7 @@ import { z } from 'zod' import { StairDesignTargets } from '../schema/nodes/stair-design-targets' +import { levelTarget } from './levels' +import { measurement } from './measurement' import { NodeId } from './node-id' export const measureStairTool = { @@ -33,5 +35,87 @@ export const fitStairTool = { }, } +export const createStairTool = { + name: 'create_stair', + title: 'Create stair', + description: + "Create a straight staircase rising from a level to the level above, as the editor's stair tool does: it owns the floor opening it cuts in every floor it passes, and when no level stands above, a blank one is created for it. Refused with a code: a flight to or from a declared roof level, and a toLevelId that is not above the level it rises from.\n\nGEOMETRY (get this wrong and the stair pokes through a wall):\n- (x, z) is the BACK-CENTRE of the first (bottom) step, not the centre of the footprint.\n- The footprint is width × length: width side to side, length along the climb. At rotation 0 it covers x − width/2 .. x + width/2 and z .. z + length.\n- rotation in degrees about Y: 0 climbs toward +Z, 90 → +X, 180 → −Z, 270 → −X. Point the climb into the room, away from the wall the bottom step sits against.\n- Leave at least 0.5 m clear at the foot of the flight, and keep the whole footprint inside one room on both levels (a hall is typical): read the room's outline first (get_zones).\n\nOPENINGS: by default the stair owns the opening over its flight and keeps it as the flight moves (openingOffset widens it). An opening of another size, centre or turn, through a slab or ceiling you name, or through one of the two only, is cut as an opening the stair owns, as given.", + input: { + x: z.number().describe('X of the back-centre of the first step.'), + z: z.number().describe('Z of the back-centre of the first step.'), + rotation: measurement('angle', 'deg', { + description: 'The climb direction (default 0 = toward +Z; 90 = +X, 180 = −Z, 270 = −X).', + }).optional(), + width: measurement('length', 'm', { + positive: true, + description: 'Side-to-side width, across the climb (default 1.0 m).', + }).optional(), + length: measurement('length', 'm', { + positive: true, + description: 'Horizontal run along the climb (default 3.0 m).', + }).optional(), + height: measurement('length', 'm', { + positive: true, + description: + "Vertical rise. Omit it unless asked for a specific rise: the flight then follows the storey's floor-to-floor height and keeps tracking it.", + }).optional(), + steps: z + .number() + .int() + .min(3) + .optional() + .describe( + 'Number of risers. Omit it to derive ~18 cm risers from the rise; pass it when the run was planned from a step count.', + ), + ...levelTarget, + toLevelId: NodeId.optional().describe( + 'The level the flight arrives on. Default: the next level above, created when there is none.', + ), + railingMode: z + .enum(['none', 'left', 'right', 'both']) + .optional() + .describe('Which sides have a railing, looking up the flight (default both).'), + materialPreset: z + .string() + .optional() + .describe('A library material for the flight (library:); an unknown one is refused.'), + name: z.string().optional().describe('Its name (default Staircase N).'), + createDestinationSlabOpening: z + .boolean() + .optional() + .describe('Cut the floor it arrives through (default true).'), + createSourceCeilingOpening: z + .boolean() + .optional() + .describe('Cut the ceiling of the floor it leaves (default true).'), + destinationSlabId: NodeId.optional().describe( + 'The slab it arrives through. Default: the floor of the level it arrives on.', + ), + sourceCeilingId: NodeId.optional().describe( + 'The ceiling it rises through. Default: the ceiling of the level it leaves.', + ), + openingWidth: measurement('length', 'm', { + positive: true, + description: 'The opening across the climb (default the flight width).', + }).optional(), + openingLength: measurement('length', 'm', { + positive: true, + description: 'The opening along the climb (default the run length).', + }).optional(), + openingOffset: measurement('length', 'm', { + min: 0, + description: 'A margin round the opening on every side (default 0.08 m).', + }).optional(), + openingCenter: z + .array(z.number()) + .length(2) + .optional() + .describe('The opening centre (x, z). Default: the middle of the flight.'), + openingRotation: measurement('angle', 'deg', { + description: "The opening's turn (default the flight's).", + }).optional(), + }, +} + export type MeasureStairInput = z.infer> export type FitStairInput = z.infer> diff --git a/packages/core/src/agent-tools/verify-scene.ts b/packages/core/src/agent-tools/verify-scene.ts new file mode 100644 index 0000000000..5b3d0d8d11 --- /dev/null +++ b/packages/core/src/agent-tools/verify-scene.ts @@ -0,0 +1,30 @@ +import type { z } from 'zod' + +/** + * `verify_scene`'s contract. Other modules extend it with an input and a sentence for the check + * they register (the photo's: `photo`), so it stays one tool agents know, with one contract on + * both surfaces, and imports none of them. + */ +const extensions: { input: Record; description: string }[] = [] + +/** Adds inputs and a sentence of description to verify_scene, for a check registered with it. */ +export function extendVerifyScene(extension: { + input: Record + description: string +}) { + extensions.push(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.' + +export const verifySceneTool = { + name: 'verify_scene', + title: 'Verify scene', + get description() { + return [DESCRIPTION, ...extensions.map((extension) => extension.description)].join(' ') + }, + get input(): Record { + return Object.assign({}, ...extensions.map((extension) => extension.input)) + }, +} diff --git a/packages/core/src/agent-tools/view-scene.ts b/packages/core/src/agent-tools/view-scene.ts new file mode 100644 index 0000000000..047534723b --- /dev/null +++ b/packages/core/src/agent-tools/view-scene.ts @@ -0,0 +1,88 @@ +import { z } from 'zod' + +const point = z.array(z.number()).length(3) + +export const VIEW_SIDES = [ + 'north', + 'north-east', + 'east', + 'south-east', + 'south', + 'south-west', + 'west', + 'north-west', + 'above', +] as const + +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. 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.", + input: { + target: z + .string() + .optional() + .describe( + 'A building, level, wall, zone, door, window or floor item id to frame. Default: every wall in the scene.', + ), + from: z + .enum(VIEW_SIDES) + .optional() + .describe('The side the eye stands on, as a compass on the plan. Default south-west.'), + elevation: z + .number() + .min(-10) + .max(89) + .optional() + .describe('Degrees above the horizon the eye looks down from. Default 12.'), + eyeHeight: z + .number() + .min(0) + .optional() + .describe('Metres above the ground for a street view (1.7), instead of elevation.'), + // Arrays of three, not tuples: a tuple's list-form schema is refused by some clients. + position: point + .optional() + .describe('The eye exactly, [x, height, z] in metres; it looks at the target.'), + fov: z + .number() + .min(10) + .max(100) + .optional() + .describe('Vertical field of view in degrees for a perspective view. Default 45.'), + projection: z + .enum(['perspective', 'orthographic']) + .optional() + .describe('orthographic for a square-on elevation of a face, no perspective.'), + camera: z + .looseObject({ + position: point, + target: point, + fov: z.number().min(5).max(120), + aspect: z.number().min(0.2).max(5), + }) + .optional() + .describe( + "A photo's camera (position, target, fov, aspect): the render takes its eye, aim and field of view at the photo's aspect. Not with from, position, elevation, eyeHeight, fov or projection.", + ), + photo: z + .looseObject({ + source: z + .string() + .min(1) + .max(8_000_000) + .describe( + 'The photo as a data:image/...;base64 URL (read the file and encode it); in the chat, the URL of a file the user attached works too.', + ), + region: z + .array(z.number().min(0)) + .length(4) + .describe("[left, top, right, bottom] in the photo's pixels: the element to compare."), + }) + .optional() + .describe( + "The photo's crop of the element the view frames, returned beside it in the same call: a door's glass, a lamp's shape, a window's panes, at detail scale, to say what differs.", + ), + }, +} diff --git a/packages/core/src/agent-tools/wall-openings.ts b/packages/core/src/agent-tools/wall-openings.ts index 21fbb09826..b9feae4552 100644 --- a/packages/core/src/agent-tools/wall-openings.ts +++ b/packages/core/src/agent-tools/wall-openings.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { DOOR_STYLES, WINDOW_STYLES } from '../building/opening-style-presets' import { DoorType, WindowType } from '../schema/nodes/opening-types' +import { achievedOutput } from './achieved' import { scriptParams } from './add-object' import { measurement } from './measurement' import { NodeId } from './node-id' @@ -54,7 +55,7 @@ export const addDoorTool = { name: 'add_door', title: 'Add door', description: - 'Add a door to an existing straight wall at t (0..1 along it). The door slides to stay on the wall and reports clamped. Refused with a code, as in the editor: curved walls, walls shorter than the door, and overlapping another door, window or wall item unless force is set. Match the reference with the outline (rectangle, rounded, arch), doorType and style.', + 'Add a door to an existing straight wall at t (0..1 along it), or a passage with no leaf (openingKind opening: a cased opening, an arch). The door slides to stay on the wall and reports clamped. Refused with a code, as in the editor: curved walls, walls shorter than the door, and overlapping another door, window or wall item unless force is set. Match the reference with the outline (rectangle, rounded, arch), doorType and style; what they cannot express (glass strips, a pattern of lites, carved panels) is written as a script in code, never left as not possible.', input: { wallId: NodeId.optional().describe('The wall to add the door to.'), nodeId: NodeId.optional().describe( @@ -69,6 +70,12 @@ export const addDoorTool = { positive: true, description: 'Door height (default 2.1 m).', }).optional(), + openingKind: z + .enum(['door', 'opening']) + .optional() + .describe( + "door (default), or opening: a passage with no leaf, framed or arched by the outline, as the editor's door panel offers.", + ), hingesSide: z.enum(['left', 'right']).optional().describe('Hinge side (default left).'), swingDirection: z .enum(['inward', 'outward']) @@ -92,7 +99,7 @@ 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, and overlapping another door, window or wall item unless force is set. Match the reference with the outline (rectangle, rounded, arch), windowType and panes (columns × rows, or a style).", + "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, and overlapping another door, window or wall item unless force is set. 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( @@ -136,3 +143,27 @@ export const addWindowTool = { ), }, } + +/** + * What add_door and add_window answer, on every surface: one result from the core operation, which + * the MCP and the chat pass through as it is. `localX` is metres along the wall from its start. + */ +const openingOutput = { + ok: z.literal(true), + wallId: z.string(), + localX: z.number(), + t: z.number(), + wallLength: z.number(), + clamped: z.boolean(), + coordinateSystem: z.literal('wall-local-meters'), + message: z.string(), + achieved: achievedOutput, +} + +export const addDoorOutput = { doorId: z.string(), ...openingOutput } + +export const addWindowOutput = { + windowId: z.string(), + ...openingOutput, + sillHeight: z.number().optional(), +} diff --git a/packages/core/src/agent-tools/walls.ts b/packages/core/src/agent-tools/walls.ts new file mode 100644 index 0000000000..930c1bb746 --- /dev/null +++ b/packages/core/src/agent-tools/walls.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { levelTarget } from './levels' +import { measurement } from './measurement' + +/** + * A wall's bend: a rounded corner could only be made with apply_patch, its sign documented + * nowhere. + */ +export const WALL_CURVE_OFFSET_INPUT = measurement('length', 'm', { + description: + "Bends the wall into an arc: the sagitta, from the middle of the straight line start→end to the middle of the arc. Positive bows the middle toward the back face (the −normal side, normal (−Δz, Δx) of start→end), the arc's centre on the front side; negative the other way. At most half the chord (a half circle); more is clamped. A quarter round of radius r at a corner: end both walls r short of the corner and join their ends with an arc of curveOffset ±0.293·r, the sign that bows it outward.", +}) + +const planPoint = (end: string) => + z.array(z.number()).length(2).describe(`[x, z] ${end} point in level coordinates (metres).`) + +export const addWallTool = { + name: 'add_wall', + title: 'Add wall', + description: + 'Add a single wall between two points on a level, straight or, with curveOffset, an arc. Refused with a code: a level that is not a storey (a declared roof level), and a wall shorter than 1 cm.', + input: { + start: planPoint('start'), + end: planPoint('end'), + thickness: measurement('length', 'm', { + positive: true, + description: 'Wall thickness (default 0.1 m).', + }).optional(), + height: measurement('length', 'm', { + positive: true, + description: "Wall height (default: the storey's height).", + }).optional(), + curveOffset: WALL_CURVE_OFFSET_INPUT.optional(), + ...levelTarget, + }, +} diff --git a/packages/core/src/agent-tools/write-target.ts b/packages/core/src/agent-tools/write-target.ts new file mode 100644 index 0000000000..278294f55d --- /dev/null +++ b/packages/core/src/agent-tools/write-target.ts @@ -0,0 +1,12 @@ +/** + * Where a write went, on every surface: the project it reached, or `null` and `unsaved` on a + * scratch scene no project holds, so an agent that missed a load error does not build on into + * nothing (add_wall answered ok with no project bound). Its own key: a result's + * `note` is the operation's. + */ +export const NOT_SAVED_NOTE = + 'Not saved to any project: create_project or load_scene first, or save_scene to keep it.' + +export function writeTarget(project: string | null): { project: string | null; unsaved?: string } { + return { project, ...(project ? {} : { unsaved: NOT_SAVED_NOTE }) } +} diff --git a/packages/core/src/building/__fixtures__/wall-opening-cases.ts b/packages/core/src/building/__fixtures__/wall-opening-cases.ts index d203f814e1..e9e2857aa5 100644 --- a/packages/core/src/building/__fixtures__/wall-opening-cases.ts +++ b/packages/core/src/building/__fixtures__/wall-opening-cases.ts @@ -27,6 +27,10 @@ export const OPENING_SCENE = { /** 4 m wall carrying a 1.2 m wall-mounted shelf centred at 2.0 m (1.4–2.6 m, 1.0–1.6 m high). */ shelved: 'wall_shelved', wallShelf: 'item_wall_shelf', + /** 4 m outside wall, its outside at the front (+normal). */ + outFront: 'wall_out_front', + /** 4 m outside wall drawn the other way: its outside at the back, as a garage wall can be. */ + outBack: 'wall_out_back', } as const const wall = (id: string, z: number, length: number, extra: Record = {}) => @@ -40,7 +44,7 @@ const wall = (id: string, z: number, length: number, extra: Record expect: | { refusal: WallOpeningRefusal; mentions?: string[] } - | { localX: number; centerY: number; clamped: boolean; glassPanels?: boolean } + | { + localX: number + centerY: number + clamped: boolean + glassPanels?: boolean + /** Fields the opening built has. */ + node?: Record + } } -const { main, short, exact, busy, curved, storey, shelved, wallShelf, levelId, existingDoor } = - OPENING_SCENE +const { + main, + short, + exact, + busy, + curved, + storey, + shelved, + wallShelf, + levelId, + existingDoor, + outFront, + outBack, +} = OPENING_SCENE export const WALL_OPENING_CASES: readonly WallOpeningCase[] = [ + // Which way it faces. A door's swing and a garage door's track run behind its facing, so a door + // on an outside wall faces out whichever way the wall was drawn (a garage door's track once ran + // on the street). An inside wall keeps the wall's front. + { + name: 'a door on an outside wall faces out', + tool: 'add_door', + input: { wallId: outFront, t: 0.5 }, + expect: { localX: 2, centerY: 1.05, clamped: false, node: { side: 'front', rotation: [0, 0, 0] } }, + }, + { + name: 'a garage door on an outside wall drawn the other way faces out, its track inside', + tool: 'add_door', + input: { wallId: outBack, t: 0.5, width: 2.4, doorType: 'garage-sectional' }, + expect: { + localX: 2, + centerY: 1.05, + clamped: false, + node: { side: 'back', rotation: [0, Math.PI, 0] }, + }, + }, + { + name: "a door on an inside wall keeps the wall's front", + tool: 'add_door', + input: { wallId: main, t: 0.5 }, + expect: { localX: 2, centerY: 1.05, clamped: false, node: { rotation: [0, 0, 0] } }, + }, // Where it goes { name: 'a door at t 0.5 is centred', @@ -155,6 +206,18 @@ export const WALL_OPENING_CASES: readonly WallOpeningCase[] = [ input: { wallId: storey, t: 0.5 }, expect: { localX: 2, centerY: 1.05, clamped: false }, }, + // A gap's advice says to reopen a passage, and no tool made an opening with no leaf; the editor's door panel has Door / Opening. + { + name: 'a passage with no leaf is an opening, in the outline asked', + tool: 'add_door', + input: { wallId: main, t: 0.5, openingKind: 'opening', openingShape: 'arch' }, + expect: { + localX: 2, + centerY: 1.05, + clamped: false, + node: { openingKind: 'opening', openingShape: 'arch' }, + }, + }, { name: 'a door style changes the panels, not the size', tool: 'add_door', diff --git a/packages/core/src/building/level-duplication.ts b/packages/core/src/building/level-duplication.ts index 14ff05e7df..f49158e1d2 100644 --- a/packages/core/src/building/level-duplication.ts +++ b/packages/core/src/building/level-duplication.ts @@ -128,14 +128,21 @@ export function buildLevelDuplicateCreateOps({ const { clonedNodes, newLevelId, idMap } = cloneLevelSubtree(nodes, level.id) const parentBuildingId = levelBuildingId(nodes, level) const nextLevelNumber = position === 'above' ? level.level + 1 : level.level - const shiftedLevels = levels + // Only the floors in the way move up: a free floor index above the copy (floors deleted before + // copying, as the agents' build guide does) is filled, not carried up. Pushing every floor above + // sent a tall build's floor 8 from index 7 to 10. + const shiftedLevels: { id: string; level: number }[] = [] + let free = nextLevelNumber + for (const entry of levels .filter( - (entry) => (position === 'below' || entry.id !== level.id) && entry.level >= nextLevelNumber, + (candidate) => + (position === 'below' || candidate.id !== level.id) && candidate.level >= nextLevelNumber, ) - .map((entry) => ({ - id: entry.id, - level: entry.level + 1, - })) + .sort((a, b) => a.level - b.level)) { + if (entry.level > free) break + shiftedLevels.push({ id: entry.id, level: entry.level + 1 }) + free = entry.level + 1 + } const filteredNodes = clonedNodes .filter((node) => shouldKeepNode(node, preset)) diff --git a/packages/core/src/building/wall-openings.test.ts b/packages/core/src/building/wall-openings.test.ts index c280305067..a66a920a26 100644 --- a/packages/core/src/building/wall-openings.test.ts +++ b/packages/core/src/building/wall-openings.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from 'bun:test' import { type AgentRefusal, isAgentRefusal } from '../agent-tools/refusal' +import { addDoorTool, addWindowTool } from '../agent-tools/wall-openings' import { openingScene, WALL_OPENING_CASES } from './__fixtures__/wall-opening-cases' -import { planWallOpening } from './wall-openings' +import { placedDoorFace, planWallOpening } from './wall-openings' // Layer 1 of 3: the operation both agent surfaces call. The cases are the spec. describe('planWallOpening', () => { @@ -27,6 +28,37 @@ describe('planWallOpening', () => { expect(planned.node.position[1]).toBeCloseTo(c.expect.centerY, 6) expect(planned.clamped).toBe(c.expect.clamped) if (c.expect.glassPanels) expect(JSON.stringify(planned.node)).toContain('"glass"') + if (c.expect.node) expect(planned.node).toMatchObject(c.expect.node) }) } }) + +// 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', () => { + test('add_door and add_window say what outline, type and style cannot express goes in code', () => { + for (const tool of [addDoorTool, addWindowTool]) { + expect(tool.description).toContain('code') + expect(tool.description).toContain('glass strips') + } + }) +}) + +// The user (2026-10-05 19:25): one rule for agents and people. A door placed by hand on an outside +// wall faces out whichever face is hovered (R still flips it); any other wall, the face hovered. +describe('the face a door placed by hand takes', () => { + const { nodes } = openingScene() + const wallOf = (id: string) => nodes[id] as never + + test('out on an outside wall, from either face', () => { + for (const hovered of ['front', 'back'] as const) { + expect(placedDoorFace(wallOf('wall_out_back'), hovered)).toBe('back') + expect(placedDoorFace(wallOf('wall_out_front'), hovered)).toBe('front') + } + }) + + test('the face hovered on a wall that does not know its outside', () => { + expect(placedDoorFace(wallOf('wall_main'), 'back')).toBe('back') + expect(placedDoorFace(wallOf('wall_main'), 'front')).toBe('front') + }) +}) diff --git a/packages/core/src/building/wall-openings.ts b/packages/core/src/building/wall-openings.ts index 2d4890830d..181b2d90db 100644 --- a/packages/core/src/building/wall-openings.ts +++ b/packages/core/src/building/wall-openings.ts @@ -16,6 +16,7 @@ import { import { getCurtainWallConfig } from '../schema/nodes/curtain-wall' import type { DoorType, WindowType } from '../schema/nodes/opening-types' import { getWallPlaneTop } 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 { @@ -174,6 +175,8 @@ export type WallOpeningInput = { sillHeight?: number hingesSide?: 'left' | 'right' swingDirection?: 'inward' | 'outward' + /** A door, or a passage with no leaf (a cased opening, an arch), as the editor's door panel. */ + openingKind?: 'door' | 'opening' style?: string force?: boolean openingShape?: 'rectangle' | 'rounded' | 'arch' @@ -203,6 +206,27 @@ const metres = (value: number) => `${value.toFixed(2)} m` * operation behind `add_door` / `add_window` on every agent surface. The caller creates * `node` under `wallId`. */ +/** + * Which way a door on `wall` faces: out, when the wall knows its outside. A door's swing and a + * garage door's track run behind its facing (a garage door's track once ran on the street), so a door facing out opens and rolls inside, whichever way the wall was drawn. A wall + * that does not know its outside, or an inside wall, keeps its front. + */ +export function doorFacing(wall: Pick): { + side?: 'front' | 'back' + rotation: [number, number, number] +} { + const outside = resolveWallExteriorSide(wall) + if (outside === -1) return { side: 'back', rotation: [0, Math.PI, 0] } + if (outside === 1) return { side: 'front', rotation: [0, 0, 0] } + return { rotation: [0, 0, 0] } +} + +/** The face a door placed by hand takes: out on an outside wall, else the face hovered. */ +export const placedDoorFace = ( + wall: Pick, + hovered: 'front' | 'back', +): 'front' | 'back' => doorFacing(wall).side ?? hovered + export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { refuseParamsWithoutScript(nodes, input) const { kind, wallId } = input @@ -301,10 +325,12 @@ export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { kind === 'door' ? DoorNode.parse({ ...base, + ...doorFacing(wall), hingesSide: input.hingesSide ?? 'left', swingDirection: input.swingDirection ?? 'inward', ...getDoorStyleOverrides(input.style as DoorStyle | undefined), ...(input.doorType ? { doorType: input.doorType } : {}), + ...(input.openingKind ? { openingKind: input.openingKind } : {}), }) : WindowNode.parse({ ...base, diff --git a/packages/core/src/commands/structure/divide-zone-path.test.ts b/packages/core/src/commands/structure/divide-zone-path.test.ts index 467a858222..8602dae965 100644 --- a/packages/core/src/commands/structure/divide-zone-path.test.ts +++ b/packages/core/src/commands/structure/divide-zone-path.test.ts @@ -7,6 +7,7 @@ import { LevelNode, type SeparatorNode, WallNode, type ZoneNode } from '../../sc import { subscribeSceneCommits } from '../../store/history-control' import useScene, { clearSceneHistory } from '../../store/use-scene' import { getWallCurveFrameAt } from '../../systems/wall/wall-curve' +import { applyZoneTransformPlan } from './apply-zone-transform' import { createZone } from './create-zone' import { divideZone } from './divide-zone' import { applyToScratch, boundaries, type Point, structureChangeBatch } from './shared' @@ -432,3 +433,26 @@ test.each([ clearSceneHistory() } }) + +// divide_zone over the hosted MCP crashed with "Cannot +// read properties of undefined (reading 'getState')". A host that brings its own runtime keeps its +// own history; the editor's scene store may not be there at all. +test('a headless host divides a room without the scene store', () => { + const fixture = setup() + const plan = divideZone(fixture.nodes, { ...fixture, path: elbow }) + let nodes = fixture.nodes + const temporal = useScene.temporal + Object.assign(useScene, { temporal: undefined }) + try { + applyZoneTransformPlan(plan, { + getNodes: () => nodes, + applyChanges: (changes) => { + nodes = applyToScratch(nodes, structureChangeBatch(changes)) + }, + reconcile: () => {}, + }) + } finally { + Object.assign(useScene, { temporal }) + } + expect(Object.values(nodes).filter((n) => n.type === 'separator')).toHaveLength(2) +}) diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 647920da9c..a9bec11e7d 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -932,7 +932,6 @@ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[ 'locked', 'openingManaged', 'ownerOpeningTarget', - 'placementAdjusted', 'plateMigration', 'plateMigration.demoted', 'porch', @@ -954,13 +953,7 @@ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[ 'ownerPose.runLength', 'ownerPose.width', ]), - ...described('Display or authoring label written by the MCP tools.', [ - 'label', - 'name', - 'roomName', - 'roomType', - 'mcpTool', - ]), + ...described('Display or authoring label written by the MCP tools.', ['label', 'name']), ...described('IFC attribute copy: a value or IFC label, not an id.', [ 'elevation', 'height', @@ -1002,6 +995,7 @@ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[ ]), ...described('Registry extension key, not scene-node metadata.', ['pascal:editor/floorplan']), ...described('Next.js page metadata export, not scene-node metadata.', ['title']), + ...described('An authored object: what it stands in for, as add_object was told.', ['reason']), ] 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 cd6bcfb69c..eaee93705b 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -218,6 +218,14 @@ export interface SnapshotCaptureFailedEvent { error: string } +/** An ephemeral capture's frame, handed back to the caller that asked for it. */ +export interface SnapshotCapturedEvent { + requestId: string + blob: Blob + width: number + height: number +} + export interface ThumbnailGenerateEvent { projectId: string requestId?: string @@ -263,6 +271,11 @@ export interface ThumbnailGenerateEvent { } /** Re-aim the sun at the face the pose looks at, for this frame. */ lightFace?: boolean + /** + * The frame is the caller's alone: it comes back on `snapshot:captured` (matched by `requestId`) + * and the host stores nothing — no snapshot, no thumbnail. An agent looking at the scene. + */ + ephemeral?: boolean /** World clipping planes for this frame (a section's cut). */ clip?: readonly { normal: [number, number, number]; constant: number }[] /** Render the canvas at this multiple of its size for the frame (print-scale pictures). */ @@ -339,6 +352,7 @@ type ThumbnailEvents = { type SnapshotEvents = { 'snapshot:saved': undefined | SnapshotSavedEvent 'snapshot:capture-failed': SnapshotCaptureFailedEvent + 'snapshot:captured': SnapshotCapturedEvent 'camera:go-to-position': { position: [number, number, number]; target: [number, number, number] } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bff5cebc93..f17120d8ea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,6 +36,7 @@ export type { SiteEvent, SkylightEvent, SlabEvent, + SnapshotCapturedEvent, SnapshotCaptureFailedEvent, SnapshotCapturePose, SnapshotSavedEvent, diff --git a/packages/core/src/lib/outdoor-wall-sides.test.ts b/packages/core/src/lib/outdoor-wall-sides.test.ts new file mode 100644 index 0000000000..c48cd758b0 --- /dev/null +++ b/packages/core/src/lib/outdoor-wall-sides.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, test } from 'bun:test' +import { applyAgentOutcome } from '../agent-operations/apply-outcome' +import { AGENT_OPERATIONS, applySceneChanges } from '../agent-operations/index' +import { addWallOpening } from '../agent-operations/wall-opening' +import { doorFacing } from '../building/wall-openings' +import { + type AnyNode, + type AnyNodeId, + generateId, + LevelNode, + SeparatorNode, + WallNode, + type ZoneNode, +} from '../schema' +import { reconcileSceneStructure } from './structure-reconcile' + +/** + * An agent made the porch an outdoor room, so the house wall behind it had a room + * on both faces and knew no outside. Its front door faced the house, view_scene showed its + * "outside" from the hall, and the porch face took the inside finish. What goes wrong, written + * first: a wall between a room and an outdoor room is interior on both faces; an indoor room + * that only lost its ceiling, or a kitchen open to a terrace, turns outside; the answer depends on + * the way the walls were drawn (each case runs both windings). + */ + +type Point = [number, number] +type Nodes = Record +const LEVEL = 'level_outdoor' + +const reconcile = (nodes: Nodes) => + reconcileSceneStructure({ nodes, mintId: (kind) => generateId(kind) }).nodes as Nodes + +function levelScene(): Nodes { + const level = LevelNode.parse({ id: LEVEL, children: [] }) + return { [level.id]: level } +} + +function createRoom(nodes: Nodes, input: Record) { + let after = nodes + const result = applyAgentOutcome( + AGENT_OPERATIONS.create_room(after, { levelId: LEVEL, ...input } as never, { + activeLevelId: LEVEL, + }), + { + getNodes: () => after, + applyChanges: (changes) => { + after = applySceneChanges(after, changes) + }, + reconcile: () => { + after = reconcile(after) + }, + }, + ) as { zoneId: string; wallIds: (string | null)[] } + return { nodes: after, ...result } +} + +/** Walls and separators drawn by hand, then reconciled into rooms. */ +function boundaries( + nodes: Nodes, + edges: { kind: 'wall' | 'separator'; start: Point; end: Point }[], +): Nodes { + const next = { ...nodes } + const level = next[LEVEL] as LevelNode + const ids: string[] = [] + for (const { kind, start, end } of edges) { + const node = + kind === 'wall' + ? WallNode.parse({ id: generateId('wall'), parentId: LEVEL, start, end, thickness: 0.2 }) + : SeparatorNode.parse({ id: generateId('separator'), parentId: LEVEL, start, end }) + next[node.id] = node + ids.push(node.id) + } + next[LEVEL] = { ...level, children: [...level.children, ...ids] } as LevelNode + return reconcile(next) +} + +const zoneAt = (nodes: Nodes, point: Point) => + Object.values(nodes).find( + (node): node is ZoneNode => node.type === 'zone' && inside(point, node.polygon as Point[]), + )! + +function inside([x, z]: Point, polygon: Point[]) { + let hit = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, zi] = polygon[i]! + const [xj, zj] = polygon[j]! + if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) hit = !hit + } + return hit +} + +/** A ceiling taken off a room, as the editor's "remove ceiling" does. */ +function withoutCeiling(nodes: Nodes, point: Point) { + const zone = zoneAt(nodes, point) + return reconcile({ ...nodes, [zone.id]: { ...zone, hasCeiling: false } as ZoneNode }) +} + +const wallOn = (nodes: Nodes, a: Point, b: Point) => + Object.values(nodes).find( + (node): node is WallNode => + node.type === 'wall' && + [node.start, node.end].every((p) => + [a, b].some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) < 1e-6), + ), + )! + +/** The wall's side toward a point: front is the +normal, perp(end - start) = (-dz, dx). */ +function sideToward(wall: WallNode, [x, z]: Point) { + const [mx, mz] = [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] + const [nx, nz] = [-(wall.end[1] - wall.start[1]), wall.end[0] - wall.start[0]] + return (x - mx) * nx + (z - mz) * nz > 0 ? wall.frontSide : wall.backSide +} + +/** Whether a door on the wall faces the point (its front, rotated by doorFacing). */ +function doorFaces(wall: WallNode, [x, z]: Point) { + const { rotation } = doorFacing(wall) + const sign = Math.abs(rotation[1]) > Math.PI / 2 ? -1 : 1 + const [mx, mz] = [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] + const [nx, nz] = [-(wall.end[1] - wall.start[1]) * sign, (wall.end[0] - wall.start[0]) * sign] + return (x - mx) * nx + (z - mz) * nz > 0 +} + +const ccw = (points: Point[]) => points +const cw = (points: Point[]) => [points[0]!, ...points.slice(1).reverse()] + +for (const [winding, order] of [ + ['counter-clockwise', ccw], + ['clockwise', cw], +] as const) { + describe(`outdoor rooms, house drawn ${winding}`, () => { + test("a porch's wall is outside on the porch face: the door faces it", () => { + const house = createRoom(levelScene(), { + name: 'Living', + polygon: order([ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ]), + }) + const { nodes } = createRoom(house.nodes, { + name: 'Porch', + outdoor: true, + polygon: [ + [1, 5], + [4, 5], + [4, 7], + [1, 7], + ], + }) + const wall = wallOn(nodes, [0, 5], [6, 5]) + expect(sideToward(wall, [3, 6])).toBe('exterior') + expect(sideToward(wall, [3, 2])).toBe('interior') + expect(doorFaces(wall, [3, 6])).toBe(true) + const street = wallOn(nodes, [0, 0], [6, 0]) + expect([sideToward(street, [3, -1]), sideToward(street, [3, 1])]).toEqual([ + 'exterior', + 'interior', + ]) + // add_door's placement on every surface faces the porch. + const door = addWallOpening(nodes, { kind: 'door', wallId: wall.id, t: 0.5, style: 'modern' }) + const placed = applySceneChanges(nodes, door.changes)[door.result.doorId as AnyNodeId] + expect(placed).toMatchObject(doorFacing(wall)) + }) + + // Held out: not tuned on the porch case. + test("an alfresco in an L's inside corner is outside on both house walls", () => { + const house = createRoom(levelScene(), { + name: 'House', + polygon: order([ + [0, 0], + [8, 0], + [8, 4], + [4, 4], + [4, 8], + [0, 8], + ]), + }) + const { nodes } = createRoom(house.nodes, { + name: 'Alfresco', + outdoor: true, + polygon: [ + [4, 4], + [8, 4], + [8, 8], + [4, 8], + ], + }) + for (const [a, b] of [ + [ + [4, 4], + [8, 4], + ], + [ + [4, 4], + [4, 8], + ], + ] as [Point, Point][]) { + const wall = wallOn(nodes, a, b) + expect(sideToward(wall, [6, 6])).toBe('exterior') + expect(doorFaces(wall, [6, 6])).toBe(true) + } + }) + + test('a room that only lost its ceiling keeps its inside faces', () => { + const living = createRoom(levelScene(), { + name: 'Living', + polygon: order([ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ]), + }) + const nodes = withoutCeiling(living.nodes, [3, 2]) + expect(zoneAt(nodes, [3, 2]).hasCeiling).toBe(false) + const wall = wallOn(nodes, [0, 5], [6, 5]) + expect([sideToward(wall, [3, 2]), sideToward(wall, [3, 6])]).toEqual(['interior', 'exterior']) + }) + + // A limit, written down so a later rule shows here: a courtyard walled on every side, with no + // separator out, reads as a room open to the sky, not as outside. + test('a courtyard walled on every side stays inside', () => { + const house = createRoom(levelScene(), { + name: 'Living', + polygon: order([ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ]), + }) + const court = createRoom(house.nodes, { + name: 'Courtyard', + polygon: order([ + [6, 0], + [9, 0], + [9, 5], + [6, 5], + ]), + }) + const nodes = withoutCeiling(court.nodes, [7.5, 2.5]) + expect(zoneAt(nodes, [7.5, 2.5]).hasCeiling).toBe(false) + const wall = wallOn(nodes, [6, 0], [6, 5]) + expect([sideToward(wall, [3, 2]), sideToward(wall, [7.5, 2.5])]).toEqual([ + 'interior', + 'interior', + ]) + }) + + test('a kitchen with a ceiling, open to a terrace, stays inside', () => { + const kitchen = order([ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ]) + const edges = kitchen.map((start, i) => ({ start, end: kitchen[(i + 1) % 4]! })) + const open = (e: { start: Point; end: Point }) => e.start[1] === 4 && e.end[1] === 4 + let nodes = boundaries( + levelScene(), + edges.map((e) => ({ kind: open(e) ? 'separator' : 'wall', ...e })), + ) + nodes = createRoom(nodes, { + name: 'Terrace', + outdoor: true, + polygon: [ + [0, 4], + [4, 4], + [4, 7], + [0, 7], + ], + }).nodes + expect(zoneAt(nodes, [2, 2]).hasCeiling).not.toBe(false) + expect(zoneAt(nodes, [2, 6]).hasCeiling).toBe(false) + const wall = wallOn(nodes, [4, 0], [4, 4]) + expect([sideToward(wall, [2, 2]), sideToward(wall, [5, 2])]).toEqual(['interior', 'exterior']) + }) + + // What a carport does, written down: a garage without a ceiling and with an open side is + // outside, so its walls are exterior on the carport faces, and the house wall it leans on is + // exterior on that face too. + test('a carport, a garage open on one side without a ceiling, is outside', () => { + const house = createRoom(levelScene(), { + name: 'Living', + polygon: order([ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ]), + }) + let nodes = boundaries(house.nodes, [ + { kind: 'wall', start: [6, 0], end: [9, 0] }, + { kind: 'wall', start: [6, 5], end: [9, 5] }, + { kind: 'separator', start: [9, 0], end: [9, 5] }, + ]) + nodes = withoutCeiling(nodes, [7.5, 2.5]) + expect(zoneAt(nodes, [7.5, 2.5]).hasCeiling).toBe(false) + const shared = wallOn(nodes, [6, 0], [6, 5]) + expect([sideToward(shared, [3, 2]), sideToward(shared, [7.5, 2.5])]).toEqual([ + 'interior', + 'exterior', + ]) + const side = wallOn(nodes, [6, 0], [9, 0]) + expect([sideToward(side, [7.5, 2.5]), sideToward(side, [7.5, -1])]).toEqual([ + 'exterior', + 'exterior', + ]) + }) + }) +} diff --git a/packages/core/src/lib/structure-kernel.ts b/packages/core/src/lib/structure-kernel.ts index eb5838e1e5..4bf9f78beb 100644 --- a/packages/core/src/lib/structure-kernel.ts +++ b/packages/core/src/lib/structure-kernel.ts @@ -112,15 +112,72 @@ function equal(a: unknown, b: unknown) { return JSON.stringify(a) === JSON.stringify(b) } +/** Span overlap below this (a fraction of the boundary) is a shared endpoint, not a shared edge. */ +const SPAN_OVERLAP = 1e-6 + +/** + * The rooms that are outside: without a ceiling, and left without crossing a wall, through a + * separator no room stands behind or into another such room. A porch made a room for its floor + * left its wall with no outside: the front door faced the hall. A room that only lost its + * ceiling is walled in and a kitchen open to a terrace keeps its ceiling, so both stay inside; so + * does a courtyard walled on every side, a limit of this rule. + */ +export function outdoorRoomIds( + rooms: readonly { spans: readonly BoundarySpan[]; hasCeiling: boolean }[], +): Set { + const separatorSpans = rooms.flatMap((room) => + room.spans.filter((span) => span.kind === 'separator'), + ) + const across = (span: BoundarySpan) => + separatorSpans + .filter( + (other) => + other.boundaryId === span.boundaryId && + other.face !== span.face && + Math.min(span.t1, other.t1) - Math.max(span.t0, other.t0) > SPAN_OVERLAP, + ) + .sort((a, b) => a.t0 - b.t0) + const exposed = (span: BoundarySpan) => { + let reach = span.t0 + for (const other of across(span)) { + if (other.t0 > reach + SPAN_OVERLAP) return true + reach = Math.max(reach, other.t1) + } + return reach < span.t1 - SPAN_OVERLAP + } + const open = new Map( + rooms.flatMap((room) => + !room.hasCeiling && room.spans[0] ? [[room.spans[0].roomId, room.spans] as const] : [], + ), + ) + const queue = [...open].flatMap(([id, spans]) => + spans.some((span) => span.kind === 'separator' && exposed(span)) ? [id] : [], + ) + const outdoor = new Set(queue) + for (let id = queue.pop(); id !== undefined; id = queue.pop()) + for (const span of open.get(id)!) + if (span.kind === 'separator') + for (const other of across(span)) + if (open.has(other.roomId) && !outdoor.has(other.roomId)) { + outdoor.add(other.roomId) + queue.push(other.roomId) + } + return outdoor +} + +/** A wall face is inside when an indoor room stands on it; an outdoor room's face is outside. */ export function classifyWallSides( wall: WallNode, spans: readonly BoundarySpan[], + outdoor: ReadonlySet = new Set(), ): Pick { const boundary = spans.filter((span) => span.boundaryId === wall.id) if (!boundary.length) return { frontSide: wall.frontSide, backSide: wall.backSide } + const indoor = (face: BoundarySpan['face']) => + boundary.some((span) => span.face === face && !outdoor.has(span.roomId)) return { - frontSide: boundary.some((span) => span.face === 'a') ? 'interior' : 'exterior', - backSide: boundary.some((span) => span.face === 'b') ? 'interior' : 'exterior', + frontSide: indoor('a') ? 'interior' : 'exterior', + backSide: indoor('b') ? 'interior' : 'exterior', } } @@ -709,8 +766,14 @@ function planLevelStructure({ for (const components of ceilingComponentsByFace.values()) for (const ceiling of components) ceiling.children = [...new Set(ceiling.children)].sort() const spans = snapshot.rooms.flatMap((room) => room.spans) + const outdoor = outdoorRoomIds( + [...zoneByFace].map(([index, zone]) => ({ + spans: faces[index]!.spans, + hasCeiling: ((next.get(zone.id) ?? zone) as ZoneNode).hasCeiling !== false, + })), + ) for (const wall of walls) - put({ ...wall, ...next.get(wall.id), ...classifyWallSides(wall, spans) } as WallNode) + put({ ...wall, ...next.get(wall.id), ...classifyWallSides(wall, spans, outdoor) } as WallNode) for (const ceiling of allCeilings) { if (deleted.has(ceiling.id)) continue const updated = (next.get(ceiling.id) ?? ceiling) as CeilingNode diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index e98db21fbb..871df5824d 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -33,6 +33,7 @@ import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stai import { WindowNode as WindowNodeSchema } from '../schema/nodes/window' import { SceneMaterial, type SceneMaterialId } from '../schema/scene-material' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' import { ensureSceneOpenings } from '../utils/ensure-scene-openings' import { migrateFloorPlates, migrateSlabSlots } from '../utils/floor-plate-migration' import { healSceneNodes } from '../utils/heal-scene-graph' @@ -1602,7 +1603,7 @@ const useScene: UseSceneStore = createSceneStore( parentId: building.id, level: 0, children: [], - height: 2.5, + height: DEFAULT_LEVEL_HEIGHT, }) // Define all nodes flat diff --git a/packages/core/src/systems/wall/wall-topology.ts b/packages/core/src/systems/wall/wall-topology.ts index 8091a842d2..14ea9904d5 100644 --- a/packages/core/src/systems/wall/wall-topology.ts +++ b/packages/core/src/systems/wall/wall-topology.ts @@ -13,7 +13,7 @@ import { import { getWallArcData, getWallCurveFrameAt, getWallCurveLength, isCurvedWall } from './wall-curve' import type { WallPlanPoint } from './wall-move' -const WALL_MIN_LENGTH = 0.01 +export const WALL_MIN_LENGTH = 0.01 const WALL_SPLIT_ENDPOINT_EPSILON = 0.02 const WALL_INTERSECTION_EPSILON = 1e-6 diff --git a/packages/editor/src/components/editor/snapshot-capture.test.ts b/packages/editor/src/components/editor/snapshot-capture.test.ts index 7f31b17b80..b0c238d0df 100644 --- a/packages/editor/src/components/editor/snapshot-capture.test.ts +++ b/packages/editor/src/components/editor/snapshot-capture.test.ts @@ -1,14 +1,17 @@ import { describe, expect, test } from 'bun:test' -import type { - SnapshotCaptureFailedEvent, - SnapshotCapturePose, - ThumbnailGenerateEvent, +import { + emitter, + type SnapshotCapturedEvent, + type SnapshotCaptureFailedEvent, + type SnapshotCapturePose, + type ThumbnailGenerateEvent, } from '@pascal-app/core' import { Euler, PerspectiveCamera, Quaternion, Vector3 } from 'three' import { applySnapshotCapturePose, captureSnapshotScene, createSnapshotQueue, + deliverSnapshot, enqueueSnapshotCapture, isOverlaySnapshotSave, runSnapshotCapture, @@ -420,3 +423,40 @@ describe('snapshot request correlation', () => { expect(failures).toEqual([]) }) }) + +// An agent looks at the scene from a viewpoint of its own (`view_scene` in the hosted chat): the +// frame is for the agent only, never a project snapshot or the project's thumbnail. +describe('an ephemeral capture', () => { + const cameraData = { position: [0, 0, 0] as [number, number, number], target: null } + + test('comes back to its caller on snapshot:captured and never reaches the host storage', async () => { + const stored: Blob[] = [] + const received: SnapshotCapturedEvent[] = [] + const onCaptured = (event: SnapshotCapturedEvent) => received.push(event) + emitter.on('snapshot:captured', onCaptured) + try { + const blob = new Blob(['frame'], { type: 'image/webp' }) + await deliverSnapshot( + { requestId: 'view-1', ephemeral: true }, + blob, + { ...cameraData, resolution: { w: 1280, h: 800 } }, + (frame) => { + stored.push(frame) + }, + ) + expect(stored).toEqual([]) + expect(received).toEqual([{ requestId: 'view-1', blob, width: 1280, height: 800 }]) + } finally { + emitter.off('snapshot:captured', onCaptured) + } + }) + + test('any other capture goes to the host as before', async () => { + const stored: Blob[] = [] + const blob = new Blob(['frame']) + await deliverSnapshot({ requestId: 'shot-1' }, blob, cameraData, (frame) => { + stored.push(frame) + }) + expect(stored).toEqual([blob]) + }) +}) diff --git a/packages/editor/src/components/editor/snapshot-capture.ts b/packages/editor/src/components/editor/snapshot-capture.ts index 513e533e93..6683633992 100644 --- a/packages/editor/src/components/editor/snapshot-capture.ts +++ b/packages/editor/src/components/editor/snapshot-capture.ts @@ -1,8 +1,9 @@ -import type { - SnapshotCaptureFailedEvent, - SnapshotCapturePose, - SnapshotSavedEvent, - ThumbnailGenerateEvent, +import { + emitter, + type SnapshotCaptureFailedEvent, + type SnapshotCapturePose, + type SnapshotSavedEvent, + type ThumbnailGenerateEvent, } from '@pascal-app/core' import { MathUtils, type PerspectiveCamera } from 'three' @@ -143,3 +144,25 @@ export async function runSnapshotCapture( busy.current = false } } + +/** + * Where a captured frame goes: to the host, which stores it (a snapshot, the project's + * thumbnail), or for an ephemeral capture straight back to its caller, stored nowhere. + */ +export function deliverSnapshot( + event: Pick, + blob: Blob, + cameraData: T, + onCapture: (blob: Blob, cameraData: T) => void | Promise, +): void | Promise { + if (event.ephemeral && event.requestId) { + emitter.emit('snapshot:captured', { + requestId: event.requestId, + blob, + width: cameraData.resolution?.w ?? 0, + height: cameraData.resolution?.h ?? 0, + }) + return + } + return onCapture(blob, cameraData) +} diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 50e3f766ee..ed72c6c731 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -38,6 +38,7 @@ import { applySnapshotCapturePose, captureSnapshotScene, createSnapshotQueue, + deliverSnapshot, enqueueSnapshotCapture, runSnapshotCapture, } from './snapshot-capture' @@ -970,7 +971,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro } trace('callback', { bytes: blob.size }) await within( - Promise.resolve(onCapture(blob, cameraData)), + Promise.resolve(deliverSnapshot(event, blob, cameraData, onCapture)), CAPTURE_SETTLE_MS, 'The snapshot host never took the frame.', ) @@ -1107,7 +1108,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro } trace('callback', { bytes: blob.size }) await within( - Promise.resolve(onCapture(blob, cameraData)), + Promise.resolve(deliverSnapshot(event, blob, cameraData, onCapture)), CAPTURE_SETTLE_MS, 'The snapshot host never took the frame.', ) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index d9cc1f4dcf..e7322e2b2f 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -115,7 +115,7 @@ The flow is intentionally local and lightweight: 1. Open or create a scene in the editor so it is saved in the local database. 2. Load that scene through MCP with `load_scene`. 3. Run MCP mutation tools such as `create_room`, `add_door`, `furnish_room`, - `create_wall`, `place_item`, or `set_zone`. + `add_wall`, `place_items`, or `set_zone`. Each mutation version-checks the saved scene before writing. If the browser or another MCP process saved a newer version first, the MCP tool returns @@ -318,9 +318,8 @@ This lands flat on the ground (Y = 0), about 6 m along a heading 30° off the +X axis and 4 m along its perpendicular — i.e. occupying world (x, z) directly. One separate gotcha: wall-attached coordinates are wall-local, not plan -coordinates. Stored door/window `position[0]`, and `place_item` `position[0]` -when the target is a wall, are metres along the wall; wall-attached rotations -are wall-local too. +coordinates. Stored door/window `position[0]` is metres along the wall, and +wall-attached rotations are wall-local too. ## Tools @@ -338,9 +337,9 @@ captured by Zundo's temporal middleware as a single undoable step. | `get_walls` | Walls on a level with length, stored and resolved height, and child doors/windows. | `{ levelId?, level? }` | `{ levelId, walls[] }` | | `get_zones` | Room/zone polygons with holes, areas (holes taken out), bounds and floor choices. | `{ levelId?, level? }` | `{ levelId, zones[] }` | | `measure` | Distance between two nodes' world-space reference points (hosted doors, windows and items resolved through their host; every node kind); area when applicable. | `{ fromId, toId }` | `{ distanceMeters, fromPoint?, toPoint?, areaSqMeters?, units: 'meters' }` | -| `search_assets` | Search the built-in MCP item catalog. | `{ query, category? }` | `{ results, total }` | +| `search_assets` | Search the host's item library (the built-in list on a standalone server), several queries in one call; a query matches name, id, category or tags. A query that finds nothing gets a hint to build the item with `add_object`. | `{ queries: [{ query, category? }] }` | `{ groups: [{ query, total, results[] }], total, hint? }`; refusal `no_catalog` | | `create_story_shell` | Create one level-owned story shell from a footprint: the perimeter walls. The floor plate and ceiling are derived from the enclosed rooms; `createSlab` / `createCeiling` / `slabElevation` are recorded as room intent. Use once per story. | `{ levelId, footprint, wallHeight?, wallThickness?, createSlab?, createCeiling? }` | `{ wallIds, zoneIds, slabId, ceilingId, createdIds }` | -| `create_stair_between_levels` | Create a straight stair and one rectangular manual opening in the destination slab/source ceiling, with auto-opening disabled. | `{ fromLevelId, toLevelId, position, width?, runLength?, totalRise? }` | `{ stairId, stairSegmentId, openingPolygon }` | +| `create_stair` | Create a straight stair as the editor's stair tool does: from a level to the next one above (made when there is none), owning the floor openings it cuts. `(x, z)` is the back-centre of the bottom step, `rotation` in degrees (0 climbs toward +Z). | `{ x, z, levelId?, toLevelId?, rotation?, width?, length?, height?, steps? }` | `{ stairId, segmentId, upperLevelId, createdUpperLevel, stepCount, slabHoleCut, openingIds? }`; refusals `roof_level`, `not_above`, `level_not_found` | | `measure_stair` | Read actual risers, going at the walking line, slope, uniformity, arrival, headroom against floors/ceilings/stair bodies, and design-target diagnostics. | `{ stairId, available?: { width, length } }` | `{ measurements, layouts }` | | `fit_stair` | Fit uniform risers or replace the chain with a straight/L/U preset with width-sized landings or quarter-turn winders in one undoable edit. | `{ stairId, fitRun?, targets?, layout?, turn?, width?, landingDepth?, turningStrategy?, innerGap?, walkingLineOffset?, division? }` | `{ stairId, measurements }` | | `create_roof` | Create a roof container and one roof segment. By default creates a dedicated roof level above the reference occupied level for solo/exploded views. | `{ levelId, width, depth, roofType?, roofHeight?, roofLevelId?, useDedicatedRoofLevel? }` | `{ roofLevelId, createdRoofLevelId, roofId, roofSegmentId }` | @@ -349,11 +348,10 @@ captured by Zundo's temporal middleware as a single undoable step. | `add_window` | Add a window to a wall using parametric placement and sill height. | `{ wallId, t, width?, height?, sillHeight? }` | `{ windowId, localX, sillHeight }` | | `furnish_room` | Place realistic furniture for a room type inside a polygon. | `{ levelId, roomType, polygon, doorWallIndex? }` | `{ placed, itemIds, skipped }` | | `apply_patch` | Batched create/update/delete/move, validated and dry-run before commit. Batch-first is the default: send all create/update/delete ops for a build step in one atomic call (stable order, later ops may reference earlier created ids); do not loop one-op calls. A create with an id already in the scene is refused (`node_exists`); delete it earlier in the same patch to replace it. Updates cannot change `id` or `type` (`identity_change`), `object` or `children` (`immutable_field`), move a node under a missing or childless parent (`invalid_parent`), or add schema issues (`invalid_update`); default gutters and downspouts a delete regenerates are addressable only in a later call (`regenerated_default`). Refusals are tool errors with JSON text `{ code, patchIndex, id, message }`. | `{ patches: Patch[] }` | `{ applied: number }` | -| `create_level` | Add a new level to a building. | `{ buildingId, elevation, height, label? }` | `{ levelId }` | -| `create_wall` | Add a wall to a level. | `{ levelId, start, end, thickness?, height? }` | `{ wallId }` | -| `place_item` | Place a catalog item on a level/slab/zone, ceiling, wall, or site. Slab/zone targets resolve to the parent level so floor items render and validate. | `{ catalogItemId, targetNodeId, position, rotation? }` | `{ itemId, status }` | +| `add_level` | Add an empty level to a building, as the editor does: above the highest, or below the lowest for a basement. Omit `buildingId` for the scene's only building. | `{ buildingId?, position?, name?, height? }` | `{ levelId, buildingId, floorIndex, height }`; refusals `building_required`, `building_not_found`, `no_building` | +| `add_wall` | Add a wall to a level, straight or, with `curveOffset`, an arc. Omit `levelId` for the lowest storey. | `{ start, end, levelId?, thickness?, height?, curveOffset? }` | `{ wallId, levelId, length }`; refusals `roof_level`, `wall_too_short`, `level_not_found` | +| `place_items` | Place catalog items on a level's floor in one call, each placed or refused on its own: `asset_not_found` for an id the library lacks, `outside_rooms` for an indoor item outside every room. | `{ items: [{ assetId, x, z, rotation? }], levelId? }` | `{ levelId, items: [{ ok, itemId?, code? }] }` | | `place_design` | Create one design (procedural item recipe, object or JSON string) that passes `validate_design`. Its mounting picks the host: level/slab/zone or a design surface (`surfaceId`), a straight wall face, or a ceiling. Create-only, one undo step, inline designs up to 24 KiB, with coded refusals. | `{ design, hostId, position, rotation?, side?, surfaceId?, parameters?, slots?, name?, id? }` | `{ designId, parentId, surfaceId }` | -| `cut_opening` | Cut a door or window opening into a wall. `position` is 0..1 along the wall and is stored as wall-local meters. | `{ wallId, type: 'door' \| 'window', position, width, height }` | `{ openingId }` | | `set_zone` | Create a zone/room polygon on a level. | `{ levelId, polygon, label, properties? }` | `{ zoneId }` | | `duplicate_level` | Copy a level as the editor does (units whose rooms are all on it included; plan references, scans and spawns left behind), above or below, shifting the floors past it. | `{ levelId, position?, name?, preset? }` | `{ newLevelId, name, floorIndex, shiftedLevelIds, copied, skipped, newNodeIds[] }` | | `delete_node` | Delete a node with everything under it, as the editor's Delete does. | `{ id }` | `{ deletedIds: [] }` | diff --git a/packages/mcp/docs/layout-clearance-error-log.md b/packages/mcp/docs/layout-clearance-error-log.md index 833e9deed3..f1efdbe066 100644 --- a/packages/mcp/docs/layout-clearance-error-log.md +++ b/packages/mcp/docs/layout-clearance-error-log.md @@ -7,9 +7,11 @@ Use when changing `door-clearance.ts`, `layout-clearance.ts`, `furnish_room`, `v | Source | Message / skip reason | Meaning | |---|---|---| -| `furnish_room` skip | `blocks door clearance` | Pose hits door keep-out (real or planned) | -| `furnish_room` skip | `overlaps another item` | Pose hits another floor item (gap required) | -| `furnish_room` skip | `outside room bounds` | Pose leaves room polygon bounds | +| `furnish_room` skip | ` (w × d m): in the way of door ` | Pose hits door keep-out (real or planned) | +| `furnish_room` skip | ` (w × d m): too large for the room (w × d m)` | The item fits the room in no turn | +| `furnish_room` skip | ` (w × d m): overlaps another item` | Pose hits another floor item (gap required) | +| `furnish_room` skip | ` (w × d m): outside the room` | Pose leaves room polygon bounds | +| `place_items` refusal | `blocks_door`, `too_large_for_room` | A floor item set in a door's keep-out, or too large for its room; names a spot that fits | | `verify_scene` | `Door … is blocked by item …` | Existing item in door keep-out | | `verify_scene` / `check_collisions` | `Items overlap: A and B` | Item–item footprint conflict | | `check_collisions` | `kind: item-aabb` | Same as overlap, structured | diff --git a/packages/mcp/examples/embed-in-agent.ts b/packages/mcp/examples/embed-in-agent.ts index d553833b8a..d838ce7571 100644 --- a/packages/mcp/examples/embed-in-agent.ts +++ b/packages/mcp/examples/embed-in-agent.ts @@ -47,7 +47,7 @@ async function main(): Promise { if (levelId) { const created = await client.callTool({ - name: 'create_wall', + name: 'add_wall', arguments: { levelId, start: [0, 0], diff --git a/packages/mcp/examples/generate-apartment.md b/packages/mcp/examples/generate-apartment.md index 6ce2d20f2f..80e21233cd 100644 --- a/packages/mcp/examples/generate-apartment.md +++ b/packages/mcp/examples/generate-apartment.md @@ -157,36 +157,34 @@ them by name: ### 5. Cut doors and windows -The agent uses `cut_opening` to add entry doors on each interior partition -and windows on the south and east façades: +The agent uses `add_door` for the entry doors on each interior partition and +`add_window` for the windows on the south and east façades: ```jsonc -// tool: cut_opening (called once per opening) +// tool: add_window (called once per window) { - "name": "cut_opening", + "name": "add_window", "arguments": { "wallId": "wall-south", // perimeter wall [0,0] → [10,0] - "type": "window", - "position": 0.25, // 25% along centerline + "t": 0.25, // 25% along centerline "width": 1.2, "height": 1.2 } } -// → { "openingId": "window-south-1" } +// → { "windowId": "window-south-1", ... } ``` ```jsonc { - "name": "cut_opening", + "name": "add_door", "arguments": { "wallId": "wall-bed1", // partition wall to Bedroom 1 - "type": "door", - "position": 0.4, + "t": 0.4, "width": 0.9, "height": 2.1 } } -// → { "openingId": "door-bed1" } +// → { "doorId": "door-bed1", ... } ``` The agent repeats this for Bedroom 2's door, the bathroom door, and two diff --git a/packages/mcp/examples/photo-to-scene.md b/packages/mcp/examples/photo-to-scene.md index ddb347ddc3..6a1527df88 100644 --- a/packages/mcp/examples/photo-to-scene.md +++ b/packages/mcp/examples/photo-to-scene.md @@ -110,7 +110,7 @@ operate on it without reloading: > **User:** add a door on the south wall between Living and Kitchen. The agent calls `find_nodes({ type: "wall" })`, picks the appropriate -wall, and issues `cut_opening` — no extra wiring needed. +wall, and issues `add_door` — no extra wiring needed. ## Takeaways diff --git a/packages/mcp/examples/renovate-from-photos.md b/packages/mcp/examples/renovate-from-photos.md index 09fc4a0fef..7fcf273015 100644 --- a/packages/mcp/examples/renovate-from-photos.md +++ b/packages/mcp/examples/renovate-from-photos.md @@ -129,16 +129,15 @@ Bedroom polygons from the floorplan rooms. ### 4. Cut the identified openings -For each window the vision tool reported, the agent calls `cut_opening` +For each window the vision tool reported, the agent calls `add_window` against the corresponding perimeter wall: ```jsonc { - "name": "cut_opening", + "name": "add_window", "arguments": { "wallId": "wall-south", - "type": "window", - "position": 0.5, + "t": 0.5, "width": 1.4, "height": 1.5 } diff --git a/packages/mcp/scripts/smoke.ts b/packages/mcp/scripts/smoke.ts index 0fb0a50296..ce8b616bed 100644 --- a/packages/mcp/scripts/smoke.ts +++ b/packages/mcp/scripts/smoke.ts @@ -47,14 +47,14 @@ async function main(): Promise { } console.log('[smoke] get_scene: OK') - // create_level — buildingId may not match a real node depending on the + // add_level — buildingId may not match a real node depending on the // default scene; we just verify the tool returns a structured response // rather than crash. - const createLevel = await client.callTool({ - name: 'create_level', - arguments: { buildingId: 'tbd', elevation: 1, height: 3 }, + const addLevel = await client.callTool({ + name: 'add_level', + arguments: { buildingId: 'tbd', height: 3 }, }) - console.log('[smoke] create_level:', createLevel.isError ? 'structured error (ok)' : 'OK') + console.log('[smoke] add_level:', addLevel.isError ? 'structured error (ok)' : 'OK') const validate = await client.callTool({ name: 'validate_scene', diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index f71d6ce103..17bd433e38 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,4 +2,6 @@ export { SceneBridge } from './bridge/scene-bridge' export { createSceneOperations, type SceneOperations } from './operations' export { type CreatePascalMcpServerOptions, createPascalMcpServer } from './server' export type { GeometryScriptHost, ScriptedKind } from './tools/add-object' +export { type AssetCatalog, builtInCatalog } from './tools/asset-catalog' +export type { SceneViewCapture, SceneViewHost } from './tools/view-scene' export { version } from './version' diff --git a/packages/mcp/src/prompts/from-brief.ts b/packages/mcp/src/prompts/from-brief.ts index 2062930100..5acc1efe4d 100644 --- a/packages/mcp/src/prompts/from-brief.ts +++ b/packages/mcp/src/prompts/from-brief.ts @@ -37,7 +37,7 @@ export function buildFromBriefPrompt(args: { '## Task', 'Produce tool calls that realise the brief within the stated constraints.', '1. Bind a scene — `create_project` (new), `list_scenes` then `load_scene` (existing), or `create_house_from_brief` (starter template).', - '2. Build the design — `create_story_shell` for exterior shells, `create_room`/`add_door`/`add_window`/`create_stair_between_levels`/`furnish_room` for interior layout, `create_roof` for roofing, `apply_patch` for exact bulk graph work.', + '2. Build the design — `create_story_shell` for exterior shells, `create_room`/`add_door`/`add_window`/`create_stair`/`furnish_room` for interior layout, `create_roof` for roofing, `apply_patch` for exact bulk graph work.', '3. Finish — call `validate_scene`, `verify_scene`, and `get_project_status`, then return the `editorUrl`.', 'Call `save_scene` with `saveMode: "checkpoint"` only when the design reaches a meaningful milestone.', ) diff --git a/packages/mcp/src/prompts/iterate-on-feedback.ts b/packages/mcp/src/prompts/iterate-on-feedback.ts index 5a465b62a8..2f932fa6e1 100644 --- a/packages/mcp/src/prompts/iterate-on-feedback.ts +++ b/packages/mcp/src/prompts/iterate-on-feedback.ts @@ -10,7 +10,7 @@ const PREAMBLE = [ ' - Prefer updates over create+delete pairs when a field change will do.', ' - Do not re-create nodes that already exist.', ' - Do not touch nodes that are unrelated to the feedback.', - ' - Prefer semantic tools such as create_room, add_door, add_window, furnish_room, and place_item when they match the request.', + ' - Prefer semantic tools such as create_room, add_door, add_window, furnish_room, and place_items when they match the request.', ' - Bundle related mutations into a single `apply_patch` call so they share one undo step.', ' - For multi-room changes, call verify_scene after the mutation and fix reported issues.', SCENE_DESIGN_GUIDANCE, diff --git a/packages/mcp/src/prompts/scene-guidance.ts b/packages/mcp/src/prompts/scene-guidance.ts index 3501045910..5ea5f1a5ae 100644 --- a/packages/mcp/src/prompts/scene-guidance.ts +++ b/packages/mcp/src/prompts/scene-guidance.ts @@ -11,13 +11,13 @@ export const SCENE_DESIGN_GUIDANCE = [ '', 'Preferred phased tool workflow:', ' - Query first with list_levels, get_level_summary, get_walls, or get_zones when editing an existing scene.', - ' - Create visible massing early: create_level as needed, then create_story_shell once per story.', + ' - Create visible massing early: add_level as needed, then create_story_shell once per story.', ' - For rooms, prefer create_room, then add_door/add_window, then furnish_room.', - ' - For stairs between floors, prefer create_stair_between_levels so slab/ceiling openings stay rectangular and do not duplicate auto-generated holes.', + ' - For stairs between floors, use create_stair: the stair owns the floor openings it cuts, so moving or deleting it moves or closes them.', ' - For roofs, prefer create_roof and let it create/use a dedicated roof level above the top occupied story so solo/exploded level views can isolate the roof.', ' - verify_scene reports both levelCount and occupiedStoryCount. Use occupiedStoryCount when checking whether a one-story/two-story brief was satisfied.', ' - add_door/add_window use t = 0..1 along a wall: 0 is start, 0.5 is center, 1 is end.', - ' - Use search_assets before place_item when placing a specific catalog item.', + ' - Use search_assets before place_items when placing catalog items.', ' - Use apply_patch for precise bulk edits that the semantic tools cannot express.', ' - After each major phase, call get_level_summary or pascal://scene/current/summary so progress is visible and errors are easier to localize.', ' - After multi-room or full-floor work, call validate_scene and verify_scene, then fix reported issues before finishing.', diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index c976697b44..d67a6e3ced 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -10,7 +10,7 @@ export const AGENT_GUIDE = [ '', '1. Read this guide or call `get_capabilities` if available.', '2. If the user asks for a new project, call `create_project` first.', - '3. For quick starts, call `create_house_from_brief`. For precise edits, build with semantic tools: `create_story_shell`, `create_room`, `add_door`, `add_window`, `furnish_room`, `create_roof`, `place_item`.', + '3. For quick starts, call `create_house_from_brief`. For precise edits, build with semantic tools: `create_story_shell`, `create_room`, `add_door`, `add_window`, `furnish_room`, `create_roof`, `place_items`.', '4. Let semantic tools update the browser-visible draft. Call `save_scene` with `saveMode: "draft"` for autosave-style progress, or `saveMode: "checkpoint"` only for meaningful milestones.', '5. Call `validate_scene`, `verify_scene`, then `get_project_status`.', '6. Return the final `editorUrl` from tool output. Do not infer routes.', diff --git a/packages/mcp/src/resources/catalog-items.ts b/packages/mcp/src/resources/catalog-items.ts index 8692932201..bc951f8284 100644 --- a/packages/mcp/src/resources/catalog-items.ts +++ b/packages/mcp/src/resources/catalog-items.ts @@ -1,28 +1,29 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneOperations } from '../operations' -import { MCP_CATALOG_ITEMS } from '../tools/asset-catalog' +import { type AssetCatalog, builtInCatalog } from '../tools/asset-catalog' /** - * `pascal://catalog/items` — small built-in item catalog for standalone MCP. - * - * The editor UI owns the full catalog. MCP intentionally keeps a dependency-free - * subset so headless agents can still place realistic furniture and fixtures. + * `pascal://catalog/items` — the items place_items draws from: the host's library when it passes + * one, else a dependency-free built-in subset, so headless agents can still place furniture. */ -export function registerCatalogItems(server: McpServer, _bridge: SceneOperations): void { +export function registerCatalogItems( + server: McpServer, + _bridge: SceneOperations, + catalog: AssetCatalog = builtInCatalog, +): void { server.registerResource( 'catalog-items', 'pascal://catalog/items', { title: 'Item catalog', - description: - 'Dependency-free catalog subset of placeable items available in standalone MCP mode.', + description: "Placeable items: the host's library when it has one, else a built-in subset.", mimeType: 'application/json', }, async (uri) => { const payload = { status: 'ok' as const, - items: MCP_CATALOG_ITEMS, - note: 'Standalone MCP catalog subset; host applications can still expose a larger catalog separately.', + items: await catalog(), + note: 'A fixture this catalog lacks (a wall light, a house number) is built as a design: place_design.', } return { contents: [ diff --git a/packages/mcp/src/resources/index.ts b/packages/mcp/src/resources/index.ts index 73765ec235..f4873b82c4 100644 --- a/packages/mcp/src/resources/index.ts +++ b/packages/mcp/src/resources/index.ts @@ -1,5 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneOperations } from '../operations' +import type { AssetCatalog } from '../tools/asset-catalog' import { registerAgentGuide } from './agent-guide' import { registerCatalogItems } from './catalog-items' import { registerConstraints } from './constraints' @@ -19,11 +20,15 @@ import { registerSceneSummary } from './scene-summary' * - `pascal://agent-guide` — text/markdown, MCP-first agent guide * - `pascal://agent/guide` — text/markdown, legacy alias */ -export function registerResources(server: McpServer, operations: SceneOperations): void { +export function registerResources( + server: McpServer, + operations: SceneOperations, + catalog?: AssetCatalog, +): void { registerAgentGuide(server, operations) registerSceneCurrent(server, operations) registerSceneSummary(server, operations) - registerCatalogItems(server, operations) + registerCatalogItems(server, operations, catalog) registerConstraints(server, operations) registerDesignSchema(server, operations) } diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 2da6137a88..46409f82e7 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -208,3 +208,22 @@ async function toolText(client: Client, name: string): Promise { + test('a host adds its own lines', async () => { + const bridge = new SceneBridge() + bridge.loadDefault() + const line = 'When a tool needs the editor tab in front: ask the user to bring it forward.' + const server = createPascalMcpServer({ bridge, instructions: line }) + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'instructions-test', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + try { + expect(client.getInstructions()).toBe(line) + } finally { + await client.close() + await server.close() + } + }) +}) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index f7c19a200f..29554cf0dd 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -7,8 +7,10 @@ import { registerResources } from './resources' import type { SceneStore } from './storage/types' import { registerTools } from './tools' import type { GeometryScriptHost } from './tools/add-object' +import { type AssetCatalog, cachedCatalog } from './tools/asset-catalog' import { registerHostedServiceTools } from './tools/hosted-services' import { normalizeToolSchemaDialect } from './tools/normalize-schema-dialect' +import type { SceneViewHost } from './tools/view-scene' import { registerVisionTools } from './tools/vision' import { version } from './version' @@ -33,24 +35,39 @@ export type CreatePascalMcpServerOptions = { * Experimental task-based tool registrations are outside this hook. */ executeTool?: PascalMcpToolExecutor + /** + * The items search_assets, place_items and furnish_room draw from, read once per server. The + * hosted app passes its published library; without it, a small built-in list. + */ + catalog?: AssetCatalog /** Runs and stores `add_object` modules; without it the tool answers `scripts_unavailable`. */ geometryScripts?: GeometryScriptHost /** Optional authenticated hosted services; local scene tools remain usable without them. */ services?: HostedServiceExecutor + /** Asks an editor open on the project for a picture (`view_scene`); without it, refused. */ + sceneViews?: SceneViewHost + /** Lines the host adds to what a client reads at connect (the person's own settings). */ + instructions?: string } export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpServer { - const server = new McpServer({ - name: opts.name ?? 'pascal-mcp-server', - version: opts.version ?? version, - }) + // A client shows these before the agent's first call. + const server = new McpServer( + { name: opts.name ?? 'pascal-mcp-server', version: opts.version ?? version }, + opts.instructions ? { instructions: opts.instructions } : undefined, + ) if (opts.executeTool) installToolExecutor(server, opts.executeTool) const operations = opts.operations ?? createSceneOperations({ bridge: opts.bridge, store: opts.store }) - registerTools(server, operations, opts.geometryScripts) + const catalog = opts.catalog ? cachedCatalog(opts.catalog) : undefined + registerTools(server, operations, { + catalog, + geometryScripts: opts.geometryScripts, + sceneViews: opts.sceneViews, + }) registerVisionTools(server, operations) if (opts.services) registerHostedServiceTools(server, opts.services) - registerResources(server, operations) + registerResources(server, operations, catalog) registerPrompts(server, operations) normalizeToolSchemaDialect(server) return server diff --git a/packages/mcp/src/tools/achieved.test.ts b/packages/mcp/src/tools/achieved.test.ts new file mode 100644 index 0000000000..6bd0c5d09e --- /dev/null +++ b/packages/mcp/src/tools/achieved.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { SceneBridge } from '../bridge/scene-bridge' +import { registerSharedTools } from './shared-tools' + +// Live over the MCP (2026-10-05): a write tool answered achieved: unchanged right after creating its +// nodes. achieved says what the scene holds after the call, so it must agree with what the call +// created. + +async function call(name: string, args: Record) { + const bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + const server = new McpServer({ name: 'achieved', version: '1' }) + registerSharedTools(server, bridge) + const [a, b] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'achieved', version: '1' }) + await Promise.all([server.connect(a), client.connect(b)]) + try { + const result = await client.callTool({ name, arguments: args }) + return JSON.parse((result.content as { text: string }[])[0]!.text) as Record + } finally { + await client.close() + await server.close() + } +} + +describe('what a write tool achieved, over the MCP', () => { + test('a wall the call built is counted as created', async () => { + const result = await call('add_wall', { start: [0, 0], end: [5, 0] }) + expect(result.achieved).toMatchObject({ created: { wall: 1 } }) + expect(result.achieved).not.toHaveProperty('unchanged') + }) +}) diff --git a/packages/mcp/src/tools/add-object.test.ts b/packages/mcp/src/tools/add-object.test.ts new file mode 100644 index 0000000000..4648880bb2 --- /dev/null +++ b/packages/mcp/src/tools/add-object.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { PASCAL_TYPES } from '@pascal-app/core/agent-operations' +import type { AnyNodeId, CompiledGeometryScript } from '@pascal-app/core/schema' +import { ADD_OBJECT_CASES } from '../../../core/src/agent-operations/__fixtures__/add-object-cases' +import { SceneBridge } from '../bridge/scene-bridge' +import { createPascalMcpServer } from '../server' +import { SqliteSceneStore } from '../storage/sqlite-scene-store' +import { type GeometryScriptHost, registerAddObject } from './add-object' + +// Layer 2 of 3: add_object through a real client, the host's compile answering with the case's. +type Result = { isError?: boolean; content: Array<{ type: string; text: string }> } + +describe('add_object over MCP', () => { + let bridge: SceneBridge + let client: Client + let compiled: CompiledGeometryScript + let compiles = 0 + + const host: GeometryScriptHost = { + compile: async () => { + compiles++ + return { ...compiled, glb: new Uint8Array() } + }, + storeArtifact: async () => {}, + readArtifact: async () => null, + } + + beforeEach(async () => { + bridge = new SceneBridge() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerAddObject(server, bridge, host) + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + }) + + for (const c of ADD_OBJECT_CASES) { + if (c.surfaces && !c.surfaces.includes('mcp')) continue + test(c.name, async () => { + const { nodes, rootNodeIds } = c.scene() + bridge.setScene(nodes as never, rootNodeIds as never) + bridge.setActiveScene({ id: 'scene_cases', name: 'Cases' } as never) + compiled = c.compiled + compiles = 0 + const result = (await client.callTool({ name: 'add_object', arguments: c.input })) as Result + const payload = JSON.parse(result.content[0]!.text) as Record + if ('refusal' in c.expect) { + expect(result.isError).toBe(true) + expect(payload.code).toBe(c.expect.refusal) + for (const text of c.expect.mentions ?? []) expect(String(payload.error)).toContain(text) + if (c.expect.beforeCompile) expect(compiles).toBe(0) + return + } + expect(result.isError).toBeFalsy() + expect(payload).toMatchObject(c.expect.result) + // Every write answers what the scene now holds: the object built, or rebuilt. + expect(payload.achieved).toMatchObject({ created: expect.any(Object), deleted: {} }) + expect(payload.achieved).not.toHaveProperty('unchanged') + if (c.expect.node) + expect(bridge.getNode(payload.nodeId as AnyNodeId)).toMatchObject(c.expect.node) + for (const text of c.expect.mentions ?? []) expect(JSON.stringify(payload)).toContain(text) + }) + } +}) + +// A hint that names a tool the server does not offer sends the agent to a call that fails. +test('every tool an add_object hint names is one the server offers', async () => { + const directory = mkdtempSync(join(tmpdir(), 'pascal-mcp-hints-')) + const server = createPascalMcpServer({ + bridge: new SceneBridge(), + store: new SqliteSceneStore({ databasePath: join(directory, 'pascal.db') }), + }) + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'hint-test-client', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + try { + const offered = new Set((await client.listTools()).tools.map((tool) => tool.name)) + const named = PASCAL_TYPES.flatMap(([, hint]) => hint.match(/\b[a-z]+(?:_[a-z]+)+\b/g) ?? []) + expect(named.length).toBeGreaterThan(0) + expect(named.filter((name) => !offered.has(name))).toEqual([]) + } finally { + await client.close() + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/mcp/src/tools/add-object.ts b/packages/mcp/src/tools/add-object.ts index 332528d1d4..64cc711b91 100644 --- a/packages/mcp/src/tools/add-object.ts +++ b/packages/mcp/src/tools/add-object.ts @@ -1,10 +1,13 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { type AddObjectInput, + achievedChanges, addObject, authoredObject, editedScriptParams, readSourceResult, + requireAddObjectReason, + type SceneNodes, } from '@pascal-app/core/agent-operations' import { addObjectTool, getSourceTool, isAgentRefusal, refuse } from '@pascal-app/core/agent-tools' import { @@ -120,6 +123,7 @@ export function registerAddObject( const args = input as Omit let compiled: CompiledGeometryScript try { + requireAddObjectReason(args) const code = args.code ?? (args.nodeId @@ -134,13 +138,12 @@ export function registerAddObject( code: 'script_failed', }) } + // A copy: the hosted bridge writes its map in place, and a "before" that grows with the call + // reads every creation as unchanged. + const before = { ...(bridge.getNodes() as Record) } let outcome: ReturnType try { - outcome = addObject( - bridge.getNodes() as Record, - { ...args, compiled }, - { activeLevelId: null }, - ) + outcome = addObject(before, { ...args, compiled }, { activeLevelId: null }) } catch (error) { return refusalResult(error) } @@ -148,6 +151,8 @@ export function registerAddObject( if (patches.length) bridge.applyPatch(patches) const payload = { ...outcome.result, + // What the scene holds now, as every write answers. + achieved: achievedChanges(before as SceneNodes, outcome.changes ?? {}), ...persistencePayload(await publishLiveSceneSnapshot(bridge, addObjectTool.name)), } return { diff --git a/packages/mcp/src/tools/apply-patch.test.ts b/packages/mcp/src/tools/apply-patch.test.ts index ecf20dd5fb..801d0bd151 100644 --- a/packages/mcp/src/tools/apply-patch.test.ts +++ b/packages/mcp/src/tools/apply-patch.test.ts @@ -52,6 +52,44 @@ describe('apply_patch', () => { expect((stored as { thickness?: number }).thickness).toBe(0.2) }) + // A pier's `material: {color}` reported applied and stored {}, and + // `materialPreset: null` was refused, so the agent could not clear the preset hiding it. + test('an update the node would drop is refused, naming the path; null clears a field', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const wall = WallNode.parse({ + start: [0, 0], + end: [5, 0], + materialPreset: 'library:concrete-raw', + }) + await client.callTool({ + name: 'apply_patch', + arguments: { patches: [{ op: 'create', node: wall, parentId: level.id }] }, + }) + const dropped = await client.callTool({ + name: 'apply_patch', + arguments: { + patches: [{ op: 'update', id: wall.id, data: { material: { color: '#8a8a8a' } } }], + }, + }) + expect(dropped.isError).toBe(true) + const refusal = JSON.parse((dropped.content as Array<{ text: string }>)[0]!.text) + // Registered as a patch guard, it answers as it did when apply_patch held it. + expect(refusal).toEqual({ + code: 'unknown_field', + patchIndex: 0, + id: wall.id, + message: expect.stringContaining( + `unknown_field: patches[0] wall ${wall.id} would not keep material.color: the patch would report it applied and drop it.`, + ), + }) + const cleared = await client.callTool({ + name: 'apply_patch', + arguments: { patches: [{ op: 'update', id: wall.id, data: { materialPreset: null } }] }, + }) + expect(cleared.isError).toBeFalsy() + expect(bridge.getNode(wall.id)).not.toHaveProperty('materialPreset') + }) + test('syncs derived stair openings after stair patches', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! diff --git a/packages/mcp/src/tools/apply-patch.ts b/packages/mcp/src/tools/apply-patch.ts index c9b42682ce..91c0eb9275 100644 --- a/packages/mcp/src/tools/apply-patch.ts +++ b/packages/mcp/src/tools/apply-patch.ts @@ -4,9 +4,10 @@ import { z } from 'zod' import type { Patch as BridgePatch } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations' import { DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' -import { ErrorCode, throwMcpError } from './errors' +import { ErrorCode, McpError, throwMcpError } from './errors' +import './honest-patch-guard' import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' -import { assertPatchKeepsIdentity, PatchRefusedError } from './patch-guards' +import { assertPatchKeepsIdentity, PatchRefusedError, runPatchGuards } from './patch-guards' import { PatchSchema } from './schemas' export const applyPatchInput = { @@ -55,6 +56,7 @@ export function registerApplyPatch(server: McpServer, bridge: SceneOperations): }) try { + runPatchGuards(bridgePatches, bridge.getNodes() as Record) const planDeletion = (bridge as { planDeletion?: SceneOperations['planDeletion'] }) .planDeletion assertPatchKeepsIdentity( @@ -90,6 +92,7 @@ export function registerApplyPatch(server: McpServer, bridge: SceneOperations): isError: true, } } + if (err instanceof McpError) throw err const msg = err instanceof Error ? err.message : String(err) throwMcpError(ErrorCode.InvalidParams, msg) } diff --git a/packages/mcp/src/tools/asset-catalog.ts b/packages/mcp/src/tools/asset-catalog.ts index e2cf310025..872d4e7398 100644 --- a/packages/mcp/src/tools/asset-catalog.ts +++ b/packages/mcp/src/tools/asset-catalog.ts @@ -295,21 +295,24 @@ export const MCP_CATALOG_ITEMS: AssetInput[] = [ }, ] -export function findCatalogItem(id: string): AssetInput | undefined { - return MCP_CATALOG_ITEMS.find((item) => item.id === id) -} +/** + * The items search_assets, place_items and furnish_room draw from. A host passes its own (the hosted + * app: its published library, as the chat searches); a standalone server keeps the built-in list. + */ +export type AssetCatalog = () => Promise -export function searchCatalogItems(args: { - query: string - category?: string | undefined -}): AssetInput[] { - const terms = args.query.trim().toLowerCase().split(/\s+/).filter(Boolean) +export const builtInCatalog: AssetCatalog = async () => MCP_CATALOG_ITEMS + +/** A host's catalog, read once by the first call that needs it; a failed read is tried again. */ +export function cachedCatalog(load: AssetCatalog): AssetCatalog { + let items: Promise | null = null + return () => + (items ??= load().catch((error) => { + items = null + throw error + })) +} - return MCP_CATALOG_ITEMS.filter((item) => { - if (args.category && item.category !== args.category) return false - const haystack = [item.id, item.name, item.category, ...(item.tags ?? [])] - .join(' ') - .toLowerCase() - return terms.every((term) => haystack.includes(term)) - }) +export function findCatalogItem(items: readonly AssetInput[], id: string): AssetInput | undefined { + return items.find((item) => item.id === id) } diff --git a/packages/mcp/src/tools/construction-tools.test.ts b/packages/mcp/src/tools/construction-tools.test.ts index 77647d9344..0827b14917 100644 --- a/packages/mcp/src/tools/construction-tools.test.ts +++ b/packages/mcp/src/tools/construction-tools.test.ts @@ -212,7 +212,7 @@ describe('construction tools', () => { expect(bridge.validateScene().valid).toBe(true) }) - test('create_stair_between_levels creates one persistent floor opening', async () => { + test('create_stair owns the openings it cuts: they follow the stair, undo with it and go with it', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! const upper = LevelNode.parse({ name: 'Second Floor', level: 1, metadata: { height: 2.8 } }) @@ -236,161 +236,56 @@ describe('construction tools', () => { } const result = await client.callTool({ - name: 'create_stair_between_levels', - arguments: { - fromLevelId: ground.id, - toLevelId: upper.id, - position: [0, 0, -1], - width: 1, - runLength: 3, - totalRise: 2.8, - openingOffset: 0.2, - }, + name: 'create_stair', + arguments: { levelId: ground.id, x: 0, z: -1, width: 1, length: 3 }, }) expect(result.isError).toBeFalsy() const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - expect(parsed.openingPolygon).toHaveLength(4) - expect(parsed.openingIds).toHaveLength(1) - expect(bridge.getNode(parsed.openingIds[0])).toMatchObject({ - type: 'floor-opening', - source: 'stair', - polygon: parsed.openingPolygon, - cutsPrimary: true, - cutsAdjacent: true, - }) - - const stair = bridge.getNode(parsed.stairId) - expect(stair?.type).toBe('stair') - if (stair?.type === 'stair') expect(stair.slabOpeningMode).toBe('none') - - const destinationSlab = bridge.getNode(parsed.destinationSlabId) - expect(destinationSlab?.type).toBe('slab') - if (destinationSlab?.type === 'slab') { - expect(destinationSlab.holes).toHaveLength(1) - expect(destinationSlab.holes[0]).toHaveLength(4) - expect(destinationSlab.holeMetadata).toEqual([ - { source: 'floor-opening', openingId: parsed.openingIds[0] }, - ]) - } + expect(parsed).toMatchObject({ upperLevelId: upper.id, slabHoleCut: true }) + // The floor above is opened, and the ceiling below it. + const openings = (parsed.openingIds as AnyNodeId[]).map((id) => bridge.getNode(id)) + for (const [parentId, drawnOn] of [ + [upper.id, 'floor'], + [ground.id, 'ceiling'], + ]) + expect(openings).toContainEqual( + expect.objectContaining({ + type: 'floor-opening', + source: 'stair', + ownerId: parsed.stairId, + parentId, + drawnOn, + }), + ) + const opening = openings.find((node) => node?.parentId === upper.id) + if (opening?.type !== 'floor-opening') throw new Error('no floor opening above') + const openingId = opening.id as AnyNodeId + const cutBy = (id: string) => + Object.values(bridge.getNodes()).filter( + (node) => + (node.type === 'slab' || node.type === 'ceiling') && + node.holeMetadata?.some((hole) => hole.openingId === id), + ) + expect(cutBy(openingId).map((node) => node.type)).toContain('slab') - const sourceCeiling = bridge.getNode(parsed.sourceCeilingId) - expect(sourceCeiling?.type).toBe('ceiling') - if (sourceCeiling?.type === 'ceiling') { - expect(sourceCeiling.holes).toHaveLength(1) - expect(sourceCeiling.holes[0]).toHaveLength(4) - expect(sourceCeiling.holeMetadata).toEqual([ - { source: 'floor-opening', openingId: parsed.openingIds[0] }, - ]) - } bridge.clearHistory() bridge.updateNode(parsed.stairId, { position: [1, 0, -1] }) - const movedOpening = bridge.getNode(parsed.openingIds[0]) - expect(movedOpening?.type).toBe('floor-opening') - if (movedOpening?.type === 'floor-opening') - expect(movedOpening.polygon[0]?.[0]).toBeCloseTo(parsed.openingPolygon[0][0] + 1) + const moved = bridge.getNode(openingId) + if (moved?.type !== 'floor-opening') throw new Error('the opening went with the move') + // The ring may start at another corner once re-planned: its extent is what moves. + const minX = (ring: [number, number][]) => Math.min(...ring.map(([x]) => x)) + expect(minX(moved.polygon as [number, number][])).toBeCloseTo( + minX(opening.polygon as [number, number][]) + 1, + ) expect(bridge.getHistory().pastCount).toBe(1) expect(bridge.undo()).toBe(1) - const restored = bridge.getNode(parsed.openingIds[0]) - if (restored?.type === 'floor-opening') expect(restored.polygon).toEqual(parsed.openingPolygon) + expect(bridge.getNode(openingId)).toMatchObject({ polygon: opening.polygon }) expect(bridge.redo()).toBe(1) bridge.deleteNode(parsed.stairId, true) - expect(bridge.getNode(parsed.openingIds[0])).toBeNull() + expect(bridge.getNode(openingId)).toBeNull() expect(bridge.validateScene().valid).toBe(true) }) - test('create_stair_between_levels defaults opening offset to zero', async () => { - const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! - const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const upper = LevelNode.parse({ name: 'Second Floor', level: 1, metadata: { height: 2.8 } }) - bridge.createNode(upper, building.id) - - for (const level of [ground, upper]) { - const result = await client.callTool({ - name: 'create_story_shell', - arguments: { - levelId: level.id, - footprint: [ - [-4, -3], - [4, -3], - [4, 3], - [-4, 3], - ], - wallHeight: 2.8, - }, - }) - expect(result.isError).toBeFalsy() - } - - const result = await client.callTool({ - name: 'create_stair_between_levels', - arguments: { - fromLevelId: ground.id, - toLevelId: upper.id, - position: [0, 0, -1], - width: 1, - totalRise: 2.8, - }, - }) - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - const stair = bridge.getNode(parsed.stairId) - - expect(stair?.type).toBe('stair') - if (stair?.type === 'stair') { - expect(stair.openingOffset).toBe(0) - const flight = Object.values(bridge.getNodes()).find( - (node) => node.type === 'stair-segment' && node.parentId === stair.id, - ) - expect(flight?.type).toBe('stair-segment') - if (flight?.type === 'stair-segment') { - expect(flight.height / flight.stepCount).toBeLessThanOrEqual(0.18) - expect(flight.length / flight.stepCount).toBeGreaterThanOrEqual(0.25) - expect(stair.stepCount).toBe(flight.stepCount) - expect( - measureStair(stair, bridge.getNodes()).diagnostics.some( - (issue) => issue.severity === 'error', - ), - ).toBe(false) - } - } - }) - - test('stair creation sizes the resolved rise above a raised support without viewer state', async () => { - const building = Object.values(bridge.getNodes()).find((node) => node.type === 'building')! - const ground = Object.values(bridge.getNodes()).find((node) => node.type === 'level')! - bridge.updateNode(ground.id, { height: 3 }) - const upper = LevelNode.parse({ level: 1, height: 3 }) - bridge.createNode(upper, building.id) - const polygon: [number, number][] = [ - [-8, -8], - [8, -8], - [8, 8], - [-8, 8], - ] - bridge.createNode(SlabNode.parse({ elevation: 0.6, polygon, autoFromWalls: false }), ground.id) - bridge.createNode(SlabNode.parse({ elevation: 0, polygon, autoFromWalls: false }), upper.id) - const result = await client.callTool({ - name: 'create_stair_between_levels', - arguments: { - fromLevelId: ground.id, - toLevelId: upper.id, - position: [0, 0, 0], - }, - }) - expect(result.isError).toBeFalsy() - const payload = JSON.parse((result.content as Array<{ text: string }>)[0]!.text) - const stair = bridge.getNode(payload.stairId) - if (stair?.type !== 'stair') throw new Error('Missing stair') - const flight = bridge.getNode(stair.children[0]!) - expect(flight?.type).toBe('stair-segment') - if (flight?.type === 'stair-segment') { - expect(flight.height).toBeCloseTo(2.4) - expect(flight.height / flight.stepCount).toBeLessThanOrEqual(0.18) - expect(flight.length / flight.stepCount).toBeGreaterThanOrEqual(0.25) - expect(measureStair(stair, bridge.getNodes()).totalRise).toBeCloseTo(2.4) - } - }) - test('verify_scene flags suspicious multi-story wall heights', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! @@ -472,14 +367,8 @@ describe('construction tools', () => { expect(shell.isError).toBe(true) const stair = await client.callTool({ - name: 'create_stair_between_levels', - arguments: { - fromLevelId: level.id, - toLevelId: roofLevel.id, - position: [0, 0, 0], - runLength: 3, - totalRise: 2.8, - }, + name: 'create_stair', + arguments: { levelId: level.id, toLevelId: roofLevel.id, x: 0, z: 0 }, }) expect(stair.isError).toBe(true) }) @@ -539,117 +428,77 @@ describe('construction tools', () => { 'dedicated roof level', ) }) - test('stair measurement is read-only and explicit sizing is one reversible operation', async () => { - const { StairNode, StairSegmentNode } = await import('@pascal-app/core') - const level = Object.values(bridge.getNodes()).find((node) => node.type === 'level')! - const stair = StairNode.parse({ - totalRise: 3, - construction: { mode: 'center-stringer', finishThickness: 0.03 }, - }) - const flight = StairSegmentNode.parse({ height: 3, length: 3, stepCount: 10 }) - bridge.createNode(stair, level.id) - bridge.createNode(flight, stair.id) - const before = JSON.stringify(bridge.getNodes()) - const measured = await client.callTool({ - name: 'measure_stair', - arguments: { stairId: stair.id }, - }) - expect(measured.isError).toBeFalsy() - expect(JSON.stringify(bridge.getNodes())).toBe(before) - const measurement = JSON.parse((measured.content as Array<{ text: string }>)[0]!.text) - expect(measurement.measurements.flights[0].construction.mode).toBe('center-stringer') - expect(measurement.measurements.flights[0].construction.finishThickness).toBe(0.03) - const result = await client.callTool({ - name: 'fit_stair', - arguments: { stairId: stair.id, fitRun: true }, - }) - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text) - expect(parsed.measurements.riserCount).toBeGreaterThan(0) - const fitted = bridge.getNodes()[flight.id] as typeof flight - expect(fitted.height).toBe(3) - expect(fitted.length / fitted.stepCount).toBeGreaterThanOrEqual( - parsed.measurements.targets.minimumGoing, - ) - expect(fitted.height / fitted.stepCount).toBeLessThanOrEqual( - parsed.measurements.targets.maxRiserHeight, - ) - bridge.undo() - const restored = bridge.getNodes()[flight.id] as typeof flight - expect(restored.stepCount).toBe(10) - expect(restored.length).toBe(3) - expect((bridge.getNodes()[stair.id] as typeof stair).uniformRisers).toBeUndefined() - }) - test('stair layout alternatives are read-only and U presets replace the chain atomically', async () => { - const { StairNode, StairSegmentNode } = await import('@pascal-app/core') - const level = Object.values(bridge.getNodes()).find((node) => node.type === 'level')! - const stair = StairNode.parse({ totalRise: 3 }) - const flight = StairSegmentNode.parse({ height: 3, length: 3 }) - bridge.createNode(stair, level.id) - bridge.createNode(flight, stair.id) - const before = JSON.stringify(bridge.getNodes()) - const measurement = await client.callTool({ - name: 'measure_stair', - arguments: { stairId: stair.id, available: { width: 2, length: 4 } }, - }) - expect(measurement.isError).toBeFalsy() - const alternatives = JSON.parse( - (measurement.content as Array<{ text: string }>)[0]!.text, - ).layouts - expect(alternatives).toHaveLength(5) - expect(JSON.stringify(bridge.getNodes())).toBe(before) + + // Main's sizing of a new flight (#1000), on create_stair: the run and the risers come from the + // stair design targets, and the flight's rise is resolved against what it stands on. + test('create_stair sizes its flight from the design targets', async () => { + const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! + const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const upper = LevelNode.parse({ name: 'Second Floor', level: 1, metadata: { height: 2.8 } }) + bridge.createNode(upper, building.id) + for (const level of [ground, upper]) { + const result = await client.callTool({ + name: 'create_story_shell', + arguments: { + levelId: level.id, + footprint: [ + [-4, -3], + [4, -3], + [4, 3], + [-4, 3], + ], + wallHeight: 2.8, + }, + }) + expect(result.isError).toBeFalsy() + } const result = await client.callTool({ - name: 'fit_stair', - arguments: { stairId: stair.id, layout: 'u', turn: 'right' }, + name: 'create_stair', + arguments: { levelId: ground.id, toLevelId: upper.id, x: 0, z: -1, width: 1, height: 2.8 }, }) expect(result.isError).toBeFalsy() - const changed = bridge.getNodes()[stair.id] as typeof stair - expect(changed.children).toHaveLength(4) - expect(changed.children[0]).toBe(flight.id) - expect((bridge.getNodes()[changed.children[3]!] as typeof flight).attachmentSide).toBe('right') - expect(bridge.undo()).toBe(1) - expect(JSON.stringify(bridge.getNodes())).toBe(before) + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + const stair = bridge.getNode(parsed.stairId) + if (stair?.type !== 'stair') throw new Error('Missing stair') + const flight = bridge.getNode(stair.children[0]!) + if (flight?.type !== 'stair-segment') throw new Error('Missing flight') + expect(flight.height / flight.stepCount).toBeLessThanOrEqual(0.18) + expect(flight.length / flight.stepCount).toBeGreaterThanOrEqual(0.25) + expect(stair.stepCount).toBe(flight.stepCount) + expect( + measureStair(stair, bridge.getNodes()).diagnostics.some( + (issue) => issue.severity === 'error', + ), + ).toBe(false) }) - test('winder presets preserve walking-line settings and undo the whole chain', async () => { - const { StairNode, StairSegmentNode } = await import('@pascal-app/core') - const level = Object.values(bridge.getNodes()).find((node) => node.type === 'level')! - const stair = StairNode.parse({ totalRise: 3 }) - const flight = StairSegmentNode.parse({ height: 3, length: 3 }) - bridge.createNode(stair, level.id) - bridge.createNode(flight, stair.id) - const before = JSON.stringify(bridge.getNodes()) + + test('create_stair resolves the rise above a raised support', async () => { + const building = Object.values(bridge.getNodes()).find((node) => node.type === 'building')! + const ground = Object.values(bridge.getNodes()).find((node) => node.type === 'level')! + bridge.updateNode(ground.id, { height: 3 }) + const upper = LevelNode.parse({ level: 1, height: 3 }) + bridge.createNode(upper, building.id) + const polygon: [number, number][] = [ + [-8, -8], + [8, -8], + [8, 8], + [-8, 8], + ] + bridge.createNode(SlabNode.parse({ elevation: 0.6, polygon, autoFromWalls: false }), ground.id) + bridge.createNode(SlabNode.parse({ elevation: 0, polygon, autoFromWalls: false }), upper.id) const result = await client.callTool({ - name: 'fit_stair', - arguments: { - stairId: stair.id, - layout: 'u', - turn: 'right', - turningStrategy: 'winder', - innerGap: 0.2, - walkingLineOffset: 0.45, - division: 'equal-angle', - }, + name: 'create_stair', + arguments: { levelId: ground.id, toLevelId: upper.id, x: 0, z: 0 }, }) expect(result.isError).toBeFalsy() - const changed = bridge.getNodes()[stair.id] as typeof stair - const winders = changed.children - .map((id) => bridge.getNodes()[id] as typeof flight) - .filter((node) => node.winder) - expect(winders).toHaveLength(2) - for (const winder of winders) - expect(winder.winder).toEqual({ - turn: 'right', - innerGap: 0.2, - walkingLineOffset: 0.45, - division: 'equal-angle', - }) - const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text) - for (const winder of winders) - expect( - parsed.measurements.flights.find((entry: { nodeId: string }) => entry.nodeId === winder.id) - .going, - ).toBeGreaterThan(0) - expect(bridge.undo()).toBe(1) - expect(JSON.stringify(bridge.getNodes())).toBe(before) + const payload = JSON.parse((result.content as Array<{ text: string }>)[0]!.text) + const stair = bridge.getNode(payload.stairId) + if (stair?.type !== 'stair') throw new Error('Missing stair') + const flight = bridge.getNode(stair.children[0]!) + if (flight?.type !== 'stair-segment') throw new Error('Missing flight') + expect(flight.height).toBeCloseTo(2.4) + expect(flight.height / flight.stepCount).toBeLessThanOrEqual(0.18) + expect(flight.length / flight.stepCount).toBeGreaterThanOrEqual(0.25) + expect(measureStair(stair, bridge.getNodes()).totalRise).toBeCloseTo(2.4) }) }) diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index eaf8734802..82b97356ea 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -1,24 +1,11 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { - adjacentLevelId, - createZone, - cutFloorOpening, - generateId, - planStairCreation, -} from '@pascal-app/core' +import { createZone, generateId } from '@pascal-app/core' import { unknownMaterialPresetRefusal } from '@pascal-app/core/agent-operations' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' -import { - getActiveRoofHeight, - LevelNode, - RoofNode, - RoofSegmentNode, - StairNode, - StairSegmentNode, -} from '@pascal-app/core/schema' +import { getActiveRoofHeight, LevelNode, RoofNode, RoofSegmentNode } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneOperations } from '../operations' -import { ADDITIVE_TOOL_ANNOTATIONS, DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' +import { ADDITIVE_TOOL_ANNOTATIONS } from './annotations' import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas' @@ -33,8 +20,6 @@ const ROOF_TYPES = [ 'flat', 'conical', ] as const -const RAILING_MODES = ['none', 'left', 'right', 'both'] as const - export const createStoryShellInput = { levelId: NodeIdSchema, footprint: z.array(Vec2Schema).min(3), @@ -117,59 +102,6 @@ export const createRoofOutput = { ...liveSyncOutput, } -export const createStairBetweenLevelsInput = { - fromLevelId: NodeIdSchema, - toLevelId: NodeIdSchema, - position: Vec3Schema, - rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).default(0), - width: measurement('length', 'm', { positive: true, description: 'Stair width.' }).default(1), - runLength: measurement('length', 'm', { - positive: true, - description: 'Horizontal run length; omitted derives from shared stair design targets.', - }).optional(), - totalRise: measurement('length', 'm', { - positive: true, - description: 'Total vertical rise.', - }).optional(), - stepCount: z.number().int().min(2).optional(), - railingMode: z.enum(RAILING_MODES).default('both'), - destinationSlabId: NodeIdSchema.optional(), - sourceCeilingId: NodeIdSchema.optional(), - createDestinationSlabOpening: z.boolean().default(true), - createSourceCeilingOpening: z.boolean().default(true), - openingWidth: measurement('length', 'm', { - positive: true, - description: 'Floor opening width.', - }).optional(), - openingLength: measurement('length', 'm', { - positive: true, - description: 'Floor opening length.', - }).optional(), - openingOffset: measurement('length', 'm', { min: 0, description: 'Opening offset.' }).default(0), - openingCenter: Vec2Schema.optional(), - openingRotation: measurement('angle', 'rad', { description: 'Opening rotation.' }).optional(), - materialPreset: z.string().optional(), - name: z.string().optional(), -} - -export const createStairBetweenLevelsOutput = { - stairId: z.string(), - stairSegmentId: z.string(), - destinationSlabId: z.string().nullable(), - sourceCeilingId: z.string().nullable(), - openingPolygon: z.array(Vec2Schema), - openingIds: z.array(z.string()), - openingHints: z.array( - z.object({ - code: z.literal('manual-ceiling'), - openingId: z.string(), - surfaceIds: z.array(z.string()), - message: z.string(), - }), - ), - ...liveSyncOutput, -} - function textResult>(payload: T) { return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], @@ -216,56 +148,6 @@ function nextLevelIndex( return existing.includes(candidate) ? Math.max(candidate, ...existing) + 1 : candidate } -function nodesOnLevel(bridge: SceneOperations, levelId: string): AnyNode[] { - return Object.values(bridge.getNodes()).filter( - (node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId, - ) -} - -function firstNodeOnLevel( - bridge: SceneOperations, - levelId: string, - type: 'slab' | 'ceiling', -): AnyNode | null { - return nodesOnLevel(bridge, levelId).find((node) => node.type === type) ?? null -} - -function rotatePoint(x: number, z: number, rotation: number): [number, number] { - const cos = Math.cos(rotation) - const sin = Math.sin(rotation) - return [x * cos + z * sin, -x * sin + z * cos] -} - -function rectangularOpening(args: { - position: [number, number, number] - rotation: number - width: number - length: number - offset: number - center?: [number, number] | undefined - openingRotation?: number | undefined -}): [number, number][] { - const width = args.width + args.offset * 2 - const length = args.length + args.offset * 2 - const center: [number, number] = args.center ?? [ - args.position[0], - args.position[2] + args.length / 2, - ] - const rotation = args.openingRotation ?? args.rotation - const halfW = width / 2 - const halfL = length / 2 - const local: [number, number][] = [ - [-halfW, -halfL], - [halfW, -halfL], - [halfW, halfL], - [-halfW, halfL], - ] - return local.map(([x, z]) => { - const [rx, rz] = rotatePoint(x, z, rotation) - return [center[0] + rx, center[1] + rz] - }) -} - /** Room zones that appeared on the level while this tool call ran. */ function newRoomZoneIds(bridge: SceneOperations, levelId: string, before: ReadonlySet) { return Object.values(bridge.getNodes()) @@ -533,186 +415,4 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat }) }, ) - - server.registerTool( - 'create_stair_between_levels', - { - title: 'Create stair between levels', - description: - 'Create a straight stair and a persistent floor-opening node for its destination floor and the ceiling directly below. This disables stair auto-opening mode to avoid duplicate cuts.', - inputSchema: createStairBetweenLevelsInput, - outputSchema: createStairBetweenLevelsOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async ({ - fromLevelId, - toLevelId, - position, - rotation, - width, - runLength: requestedRunLength, - totalRise, - stepCount: requestedStepCount, - railingMode, - destinationSlabId, - sourceCeilingId, - createDestinationSlabOpening, - createSourceCeilingOpening, - openingWidth, - openingLength, - openingOffset, - openingCenter, - openingRotation, - materialPreset, - name, - }) => { - const preset = unknownMaterialPresetRefusal({ materialPreset }) - if (preset) throw new Error(preset) - const fromLevel = assertNode(bridge, fromLevelId, 'level') - const toLevel = assertNode(bridge, toLevelId, 'level') - if (isRoofLevel(fromLevel) || isRoofLevel(toLevel)) { - throw new Error( - 'Roof support levels are not occupied stories; create a separate occupied attic/story level if a stair-accessible attic is required', - ) - } - - const stairDraft = StairNode.parse({ - name: name ?? 'Stair', - position: position as [number, number, number], - rotation, - stairType: 'straight', - parentId: fromLevelId, - uniformRisers: true, - fromLevelId, - toLevelId, - slabOpeningMode: 'none', - openingOffset, - width, - ...(totalRise !== undefined ? { totalRise } : {}), - stepCount: requestedStepCount, - railingMode, - children: [], - ...(materialPreset ? { materialPreset } : {}), - metadata: { - openingManaged: 'floor-opening', - }, - }) - const riseNodes = { - ...bridge.getNodes(), - [fromLevel.id]: { - ...fromLevel, - children: [...(fromLevel as Extract).children, stairDraft.id], - }, - [stairDraft.id]: stairDraft, - } as Record - const dimensions = { - width, - ...(requestedRunLength !== undefined ? { length: requestedRunLength } : {}), - ...(requestedStepCount !== undefined ? { stepCount: requestedStepCount } : {}), - } - const { flight } = planStairCreation(stairDraft, riseNodes, dimensions) - const segment = StairSegmentNode.parse({ - ...flight, - ...(materialPreset ? { materialPreset } : {}), - }) - const runLength = segment.length - const stair = { ...stairDraft, stepCount: segment.stepCount, children: [segment.id] } - - const openingPolygon = rectangularOpening({ - position: position as [number, number, number], - rotation, - width: openingWidth ?? width, - length: openingLength ?? runLength, - offset: openingOffset, - center: openingCenter as [number, number] | undefined, - openingRotation, - }) - - const patches: Array< - | { op: 'create'; node: AnyNode; parentId: AnyNodeId } - | { op: 'update'; id: AnyNodeId; data: Partial } - > = [ - { op: 'create', node: stair, parentId: fromLevelId as AnyNodeId }, - { op: 'create', node: segment, parentId: stair.id as AnyNodeId }, - ] - - const destinationSlab = - destinationSlabId !== undefined - ? assertNode(bridge, destinationSlabId, 'slab') - : firstNodeOnLevel(bridge, toLevelId, 'slab') - const sourceCeiling = - sourceCeilingId !== undefined - ? assertNode(bridge, sourceCeilingId, 'ceiling') - : firstNodeOnLevel(bridge, fromLevelId, 'ceiling') - const floorCut = createDestinationSlabOpening && destinationSlab?.type === 'slab' - const ceilingCut = createSourceCeilingOpening && sourceCeiling?.type === 'ceiling' - const adjacentSource = adjacentLevelId(bridge.getNodes(), toLevelId, -1) === fromLevelId - const openingPlans = [ - ...(floorCut - ? [ - cutFloorOpening(bridge.getNodes(), { - levelId: toLevelId, - polygon: openingPolygon, - source: 'stair', - ownerId: stair.id, - cutsAdjacent: ceilingCut && adjacentSource, - mintId: generateId, - }), - ] - : []), - ...((!floorCut || !adjacentSource) && ceilingCut - ? [ - cutFloorOpening(bridge.getNodes(), { - levelId: fromLevelId, - polygon: openingPolygon, - drawnOn: 'ceiling', - source: 'stair', - ownerId: stair.id, - cutsAdjacent: false, - mintId: generateId, - }), - ] - : []), - ] - for (const plan of openingPlans) - for (const change of plan.changes) - patches.push( - change.op === 'create' - ? { - ...change, - parentId: change.node.parentId as AnyNodeId, - node: { - ...change.node, - metadata: { - ...change.node.metadata, - ownerPose: { - position: stair.position, - rotation: stair.rotation, - width: stair.width, - runLength, - }, - ownerOpeningTarget: - change.node.type === 'floor-opening' && change.node.drawnOn === 'ceiling' - ? 'source' - : 'destination', - }, - }, - } - : (change as Extract<(typeof patches)[number], { op: 'update' }>), - ) - - bridge.applyPatch(patches) - const persistence = await publishLiveSceneSnapshot(bridge, 'create_stair_between_levels') - return textResult({ - stairId: stair.id, - stairSegmentId: segment.id, - destinationSlabId: destinationSlab?.id ?? null, - sourceCeilingId: sourceCeiling?.id ?? null, - openingPolygon, - openingIds: openingPlans.flatMap((plan) => plan.openingIds), - openingHints: openingPlans.flatMap((plan) => plan.hints), - ...persistencePayload(persistence), - }) - }, - ) } diff --git a/packages/mcp/src/tools/create-level.test.ts b/packages/mcp/src/tools/create-level.test.ts deleted file mode 100644 index 268876b618..0000000000 --- a/packages/mcp/src/tools/create-level.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { SceneBridge } from '../bridge/scene-bridge' -import { registerCreateLevel } from './create-level' - -describe('create_level', () => { - let client: Client - let bridge: SceneBridge - - beforeEach(async () => { - bridge = new SceneBridge() - bridge.setScene({}, []) - bridge.loadDefault() - const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerCreateLevel(server, bridge) - const [srvT, cliT] = InMemoryTransport.createLinkedPair() - client = new Client({ name: 'test-client', version: '0.0.0' }) - await Promise.all([server.connect(srvT), client.connect(cliT)]) - }) - - test('appends a level on a building with stored height', async () => { - const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! - const result = await client.callTool({ - name: 'create_level', - arguments: { buildingId: building.id, elevation: 99, height: 3.1, label: 'Second' }, - }) - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - expect(parsed.levelId).toMatch(/^level_/) - const created = bridge.getNode(parsed.levelId) - expect(created).not.toBeNull() - expect(created!.type).toBe('level') - expect((created as { height: number; level: number }).level).toBe(1) - expect((created as { height: number; level: number }).height).toBe(3.1) - expect(created?.metadata.height).toBeUndefined() - }) - - test('rejects unknown building id', async () => { - const result = await client.callTool({ - name: 'create_level', - arguments: { buildingId: 'building_nope' }, - }) - expect(result.isError).toBe(true) - }) - - test('rejects non-building parent', async () => { - const wallLike = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const result = await client.callTool({ - name: 'create_level', - arguments: { buildingId: wallLike.id }, - }) - expect(result.isError).toBe(true) - }) -}) diff --git a/packages/mcp/src/tools/create-level.ts b/packages/mcp/src/tools/create-level.ts deleted file mode 100644 index a947a1cc1e..0000000000 --- a/packages/mcp/src/tools/create-level.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { DEFAULT_LEVEL_HEIGHT } from '@pascal-app/core' -import type { AnyNodeId } from '@pascal-app/core/schema' -import { LevelNode } from '@pascal-app/core/schema' -import { z } from 'zod' -import type { SceneOperations } from '../operations' -import { ADDITIVE_TOOL_ANNOTATIONS } from './annotations' -import { ErrorCode, throwMcpError } from './errors' -import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' -import { measurement } from './measurement' -import { NodeIdSchema } from './schemas' - -export const createLevelInput = { - buildingId: NodeIdSchema, - elevation: z - .number() - .optional() - .describe("Legacy parameter; new levels are appended above the building's current top level."), - height: measurement('length', 'm', { - min: 0, - description: 'Stored floor-to-floor storey height.', - }).optional(), - label: z.string().optional(), -} - -export const createLevelOutput = { - levelId: z.string(), - ...liveSyncOutput, -} - -export function registerCreateLevel(server: McpServer, bridge: SceneOperations): void { - server.registerTool( - 'create_level', - { - title: 'Create level', - description: - "Append a new level above the given building's current top level. height is stored as the level's floor-to-floor storey height.", - inputSchema: createLevelInput, - outputSchema: createLevelOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async ({ buildingId, height, label }) => { - const parent = bridge.getNode(buildingId as AnyNodeId) - if (!parent) { - throwMcpError(ErrorCode.InvalidParams, `Building not found: ${buildingId}`) - } - if (parent.type !== 'building') { - throwMcpError( - ErrorCode.InvalidParams, - `Node ${buildingId} is a ${parent.type}, expected building`, - ) - } - - const metadata: Record = {} - if (label !== undefined) metadata.label = label - const existingOrdinals = bridge - .getChildren(buildingId as AnyNodeId) - .filter((node) => node.type === 'level') - .map((node) => node.level) - const nextOrdinal = Math.max(-1, ...existingOrdinals) + 1 - - const levelNode = LevelNode.parse({ - level: nextOrdinal, - height: height ?? DEFAULT_LEVEL_HEIGHT, - children: [], - ...(Object.keys(metadata).length > 0 ? { metadata } : {}), - ...(label !== undefined ? { name: label } : {}), - }) - - const id = bridge.createNode(levelNode, buildingId as AnyNodeId) - const persistence = await publishLiveSceneSnapshot(bridge, 'create_level') - const payload = { levelId: id as string, ...persistencePayload(persistence) } - return { - content: [{ type: 'text' as const, text: JSON.stringify(payload) }], - structuredContent: payload, - } - }, - ) -} diff --git a/packages/mcp/src/tools/create-wall.test.ts b/packages/mcp/src/tools/create-wall.test.ts deleted file mode 100644 index 0e4b979f25..0000000000 --- a/packages/mcp/src/tools/create-wall.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' -import { SceneBridge } from '../bridge/scene-bridge' -import { createSceneOperations } from '../operations' -import type { SceneMeta, SceneStore } from '../storage/types' -import { registerCreateWall } from './create-wall' - -describe('create_wall', () => { - let client: Client - let bridge: SceneBridge - - beforeEach(async () => { - bridge = new SceneBridge() - bridge.setScene({}, []) - bridge.loadDefault() - const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerCreateWall(server, bridge) - const [srvT, cliT] = InMemoryTransport.createLinkedPair() - client = new Client({ name: 'test-client', version: '0.0.0' }) - await Promise.all([server.connect(srvT), client.connect(cliT)]) - }) - - test('creates a wall with custom thickness', async () => { - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const result = await client.callTool({ - name: 'create_wall', - arguments: { - levelId: level.id, - start: [0, 0], - end: [4, 0], - thickness: 0.15, - }, - }) - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - expect(parsed.wallId).toMatch(/^wall_/) - const created = bridge.getNode(parsed.wallId) - expect(created).not.toBeNull() - expect((created as { thickness?: number }).thickness).toBe(0.15) - }) - - test('accepts a natural-language thickness and canonicalizes to meters', async () => { - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const result = await client.callTool({ - name: 'create_wall', - arguments: { - levelId: level.id, - start: [0, 0], - end: [4, 0], - thickness: '6 in', - height: '2.5m', - }, - }) - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - const created = bridge.getNode(parsed.wallId) as { thickness?: number; height?: number } - expect(created.thickness).toBeCloseTo(0.1524, 6) - expect(created.height).toBeCloseTo(2.5, 6) - }) - - test('rejects an out-of-unit-family value', async () => { - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const result = await client.callTool({ - name: 'create_wall', - arguments: { levelId: level.id, start: [0, 0], end: [4, 0], thickness: 'banana' }, - }) - expect(result.isError).toBe(true) - }) - - test('publishes a live scene snapshot when bound to a saved scene', async () => { - const now = new Date().toISOString() - const savedMeta: SceneMeta = { - id: 'live-scene', - name: 'Live Scene', - projectId: null, - thumbnailUrl: null, - version: 1, - createdAt: now, - updatedAt: now, - ownerId: null, - sizeBytes: 0, - nodeCount: Object.keys(bridge.getNodes()).length, - } - const savedGraphs: SceneGraph[] = [] - const eventKinds: string[] = [] - const store: SceneStore = { - backend: 'sqlite', - async save(opts) { - expect(opts.id).toBe(savedMeta.id) - expect(opts.expectedVersion).toBe(1) - savedGraphs.push(opts.graph) - return { - ...savedMeta, - version: 2, - updatedAt: new Date().toISOString(), - sizeBytes: JSON.stringify(opts.graph).length, - nodeCount: Object.keys(opts.graph.nodes).length, - } - }, - async load() { - return null - }, - async list() { - return [] - }, - async delete() { - return { deleted: false, hidden: false } - }, - async rename() { - return savedMeta - }, - async appendSceneEvent(opts) { - eventKinds.push(opts.kind) - return { - eventId: 1, - sceneId: opts.sceneId, - version: opts.version, - kind: opts.kind, - createdAt: new Date().toISOString(), - graph: opts.graph, - } - }, - } - const liveServer = new McpServer({ name: 'test-live', version: '0.0.0' }) - const liveClient = new Client({ name: 'test-live-client', version: '0.0.0' }) - const operations = createSceneOperations({ bridge, store }) - operations.setActiveScene(savedMeta) - registerCreateWall(liveServer, operations) - const [srvT, cliT] = InMemoryTransport.createLinkedPair() - await Promise.all([liveServer.connect(srvT), liveClient.connect(cliT)]) - - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const result = await liveClient.callTool({ - name: 'create_wall', - arguments: { - levelId: level.id, - start: [0, 1], - end: [4, 1], - }, - }) - - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - expect(savedGraphs).toHaveLength(1) - expect(savedGraphs[0]!.nodes[parsed.wallId]).toBeDefined() - expect(eventKinds).toEqual(['create_wall']) - expect(bridge.getActiveScene()?.version).toBe(2) - }) - - test('rejects unknown level id', async () => { - const result = await client.callTool({ - name: 'create_wall', - arguments: { - levelId: 'level_nope', - start: [0, 0], - end: [1, 0], - }, - }) - expect(result.isError).toBe(true) - }) - - test('rejects invalid start tuple', async () => { - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const result = await client.callTool({ - name: 'create_wall', - arguments: { - levelId: level.id, - start: [0], - end: [1, 0], - }, - }) - expect(result.isError).toBe(true) - }) -}) diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts deleted file mode 100644 index ebe1fd67b2..0000000000 --- a/packages/mcp/src/tools/create-wall.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { AnyNodeId } from '@pascal-app/core/schema' -import { WallNode } from '@pascal-app/core/schema' -import { z } from 'zod' -import type { SceneOperations } from '../operations' -import { ADDITIVE_TOOL_ANNOTATIONS } from './annotations' -import { ErrorCode, throwMcpError } from './errors' -import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' -import { measurement } from './measurement' -import { NodeIdSchema, Vec2Schema } from './schemas' - -export const createWallInput = { - levelId: NodeIdSchema, - start: Vec2Schema, - end: Vec2Schema, - thickness: measurement('length', 'm', { - positive: true, - description: 'Wall thickness.', - }).optional(), - height: measurement('length', 'm', { positive: true, description: 'Wall height.' }).optional(), -} - -export const createWallOutput = { - wallId: z.string(), - ...liveSyncOutput, -} - -export function registerCreateWall(server: McpServer, bridge: SceneOperations): void { - server.registerTool( - 'create_wall', - { - title: 'Create wall', - description: - 'Create a new wall on the given level between two 2D points. Thickness and height default to the core library defaults when omitted.', - inputSchema: createWallInput, - outputSchema: createWallOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async ({ levelId, start, end, thickness, height }) => { - const parent = bridge.getNode(levelId as AnyNodeId) - if (!parent) { - throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`) - } - if (parent.type !== 'level') { - throwMcpError( - ErrorCode.InvalidParams, - `Node ${levelId} is a ${parent.type}, expected level`, - ) - } - if ( - typeof parent.metadata === 'object' && - parent.metadata !== null && - 'role' in parent.metadata && - parent.metadata.role === 'roof' - ) { - throwMcpError( - ErrorCode.InvalidParams, - `Roof support level ${levelId} is not an occupied story; create walls on an occupied level instead`, - ) - } - - const wall = WallNode.parse({ - start: start as [number, number], - end: end as [number, number], - ...(thickness !== undefined ? { thickness } : {}), - ...(height !== undefined ? { height } : {}), - }) - const id = bridge.createNode(wall, levelId as AnyNodeId) - const persistence = await publishLiveSceneSnapshot(bridge, 'create_wall') - const payload = { wallId: id as string, ...persistencePayload(persistence) } - return { - content: [{ type: 'text' as const, text: JSON.stringify(payload) }], - structuredContent: payload, - } - }, - ) -} diff --git a/packages/mcp/src/tools/cut-opening.test.ts b/packages/mcp/src/tools/cut-opening.test.ts deleted file mode 100644 index 75521837ca..0000000000 --- a/packages/mcp/src/tools/cut-opening.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { WallNode } from '@pascal-app/core/schema' -import { SceneBridge } from '../bridge/scene-bridge' -import { registerCutOpening } from './cut-opening' - -describe('cut_opening', () => { - let client: Client - let bridge: SceneBridge - - beforeEach(async () => { - bridge = new SceneBridge() - bridge.setScene({}, []) - bridge.loadDefault() - const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerCutOpening(server, bridge) - const [srvT, cliT] = InMemoryTransport.createLinkedPair() - client = new Client({ name: 'test-client', version: '0.0.0' }) - await Promise.all([server.connect(srvT), client.connect(cliT)]) - }) - - test('creates a door opening on a wall', async () => { - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) - bridge.createNode(wall, level.id) - - const result = await client.callTool({ - name: 'cut_opening', - arguments: { - wallId: wall.id, - type: 'door', - position: 0.5, - width: 0.9, - height: 2.1, - }, - }) - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - expect(parsed.openingId).toMatch(/^door_/) - const created = bridge.getNode(parsed.openingId) - expect((created as { wallId?: string }).wallId).toBe(wall.id) - expect((created as { width: number }).width).toBe(0.9) - expect((created as { position: [number, number, number] }).position[0]).toBeCloseTo(2.5, 3) - }) - - test('creates a window opening on a wall', async () => { - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) - bridge.createNode(wall, level.id) - - const result = await client.callTool({ - name: 'cut_opening', - arguments: { - wallId: wall.id, - type: 'window', - position: 0.25, - width: 1.2, - height: 1.2, - }, - }) - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - expect(parsed.openingId).toMatch(/^window_/) - const created = bridge.getNode(parsed.openingId) - expect((created as { position: [number, number, number] }).position[0]).toBeCloseTo(1.25, 3) - expect((created as { position: [number, number, number] }).position[1]).toBeCloseTo(1.5, 3) - }) - - test('rejects unknown wall id', async () => { - const result = await client.callTool({ - name: 'cut_opening', - arguments: { - wallId: 'wall_nope', - type: 'door', - position: 0.5, - width: 1, - height: 2, - }, - }) - expect(result.isError).toBe(true) - }) - - test('rejects out-of-range position', async () => { - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) - bridge.createNode(wall, level.id) - - const result = await client.callTool({ - name: 'cut_opening', - arguments: { - wallId: wall.id, - type: 'door', - position: 1.5, - width: 1, - height: 2, - }, - }) - expect(result.isError).toBe(true) - }) -}) diff --git a/packages/mcp/src/tools/cut-opening.ts b/packages/mcp/src/tools/cut-opening.ts deleted file mode 100644 index 382ea399a6..0000000000 --- a/packages/mcp/src/tools/cut-opening.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { wallLength, wallLocalXFromT } from '@pascal-app/core/agent-operations' -import type { AnyNodeId } from '@pascal-app/core/schema' -import { DoorNode, WindowNode } from '@pascal-app/core/schema' -import { z } from 'zod' -import type { SceneOperations } from '../operations' -import { ADDITIVE_TOOL_ANNOTATIONS } from './annotations' -import { ErrorCode, throwMcpError } from './errors' -import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' -import { measurement } from './measurement' -import { NodeIdSchema } from './schemas' - -export const cutOpeningInput = { - wallId: NodeIdSchema, - type: z.enum(['door', 'window']), - position: z.number().min(0).max(1), - width: measurement('length', 'm', { positive: true, description: 'Opening width.' }), - height: measurement('length', 'm', { positive: true, description: 'Opening height.' }), -} - -export const cutOpeningOutput = { - openingId: z.string(), - ...liveSyncOutput, -} - -export function registerCutOpening(server: McpServer, bridge: SceneOperations): void { - server.registerTool( - 'cut_opening', - { - title: 'Cut opening', - description: - 'Cut a door or window opening into an existing wall. position is a parametric 0..1 offset along the wall centreline.', - inputSchema: cutOpeningInput, - outputSchema: cutOpeningOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async ({ wallId, type, position, width, height }) => { - const wall = bridge.getNode(wallId as AnyNodeId) - if (!wall) { - throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`) - } - if (wall.type !== 'wall') { - throwMcpError(ErrorCode.InvalidParams, `Node ${wallId} is a ${wall.type}, expected wall`) - } - - const length = wallLength(wall) - if (length < width) { - throwMcpError( - ErrorCode.InvalidParams, - `Wall ${wallId} is ${length.toFixed(2)}m long, too short for a ${width.toFixed(2)}m opening`, - ) - } - - // `position` is public MCP ergonomics: 0..1 along the wall. Door/window - // nodes store wall-local meters in position[0], so convert before writing. - const base = { - wallId, - width, - height, - position: [wallLocalXFromT(wall, position, width), height / 2, 0] as [ - number, - number, - number, - ], - } - - const opening = - type === 'door' - ? DoorNode.parse(base) - : WindowNode.parse({ - ...base, - position: [base.position[0], 0.9 + height / 2, 0], - }) - const id = bridge.createNode(opening, wallId as AnyNodeId) - const persistence = await publishLiveSceneSnapshot(bridge, 'cut_opening') - - const payload = { openingId: id as string, ...persistencePayload(persistence) } - return { - content: [{ type: 'text' as const, text: JSON.stringify(payload) }], - structuredContent: payload, - } - }, - ) -} diff --git a/packages/mcp/src/tools/floor-foundation.test.ts b/packages/mcp/src/tools/floor-foundation.test.ts index d775d08f4a..ff1e76d6db 100644 --- a/packages/mcp/src/tools/floor-foundation.test.ts +++ b/packages/mcp/src/tools/floor-foundation.test.ts @@ -133,18 +133,27 @@ test('set_floor_foundation sets one footprint in one call and one undo; raw upda expect(bridge.getHistory().pastCount).toBe(1) bridge.undo() expect(bridge.getNodes()).toEqual(unchanged) - const refused = await call('create_room', { - levelId, - name: 'Overlapping terrace', - polygon: [ - [1, 1], - [3, 1], - [3, 3], - [1, 3], - ], - outdoor: true, + const refused = await client.callTool({ + name: 'create_room', + arguments: { + levelId, + name: 'Overlapping terrace', + polygon: [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ], + outdoor: true, + }, + }) + expect(refused.isError).toBe(true) + expect( + JSON.parse((refused.content as Array<{ type: string; text: string }>)[0]!.text), + ).toMatchObject({ + code: 'outdoor_room_overlap', + conflicts: [{ code: 'outdoor-room-overlap' }], }) - expect(refused.conflicts?.[0]).toMatchObject({ code: 'outdoor-room-overlap' }) expect(bridge.getNodes()).toEqual(unchanged) } finally { await client.close() diff --git a/packages/mcp/src/tools/honest-patch-guard.ts b/packages/mcp/src/tools/honest-patch-guard.ts new file mode 100644 index 0000000000..5fbb28046f --- /dev/null +++ b/packages/mcp/src/tools/honest-patch-guard.ts @@ -0,0 +1,34 @@ +import { honestNodePatch } from '@pascal-app/core/agent-operations' +import { isAgentRefusal } from '@pascal-app/core/agent-tools' +import type { AnyNode } from '@pascal-app/core/schema' +import type { Patch } from '../bridge/scene-bridge' +import { type PatchRefusalCode, PatchRefusedError, registerPatchGuard } from './patch-guards' + +/** + * Each update checked to do what it says, in the batch's order (a node an earlier op created is + * the one updated): a field the node would drop, a material the library lacks or a field a set + * one hides is refused, naming it; `null` clears. The cleaned data is what is written. + */ +function honestUpdates(patches: Patch[], scene: Readonly>) { + const nodes: Record = { ...scene } + for (const [index, patch] of patches.entries()) { + if (patch.op === 'create') nodes[patch.node.id] = patch.node + else if (patch.op === 'delete') delete nodes[patch.id] + else { + const current = nodes[patch.id] + if (!current) continue + try { + patch.data = honestNodePatch( + current, + patch.data as Record, + ) as Partial + } catch (error) { + if (!isAgentRefusal(error)) throw error + throw new PatchRefusedError(error.code as PatchRefusalCode, index, patch.id, error.message) + } + nodes[patch.id] = { ...current, ...patch.data } as AnyNode + } + } +} + +registerPatchGuard({ name: 'honest-updates', order: 20, run: honestUpdates }) diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 2ea78af920..c07f51b23c 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -3,12 +3,10 @@ import type { SceneOperations } from '../operations' import { registerAddColumn } from './add-column' import { type GeometryScriptHost, registerAddObject, registerGetSource } from './add-object' import { registerApplyPatch } from './apply-patch' +import type { AssetCatalog } from './asset-catalog' import { registerCheckCollisions } from './check-collisions' import { registerConstructionTools } from './construction-tools' -import { registerCreateLevel } from './create-level' import { registerCreateUnit } from './create-unit' -import { registerCreateWall } from './create-wall' -import { registerCutOpening } from './cut-opening' import { registerDescribeNode } from './describe-node' import { registerExportGlb } from './export-glb' import { registerExportJson } from './export-json' @@ -18,19 +16,18 @@ import { registerListUnits } from './list-units' import { registerMeasure } from './measure' import { registerPhotoToSceneTool } from './photo-to-scene' import { registerPlaceDesign } from './place-design' -import { registerPlaceItem } from './place-item' import { registerRedo } from './redo' import { registerRoomTools } from './room-tools' import { registerSceneLifecycleTools } from './scene-lifecycle' import { registerSetUnitMembers } from './set-unit-members' import { registerSetZone } from './set-zone' import { registerSharedTools } from './shared-tools' -import { registerStructureTools } from './structure-tools' import { registerTemplateTools } from './templates' import { registerUndo } from './undo' import { registerValidateDesign } from './validate-design' import { registerValidateScene } from './validate-scene' import { registerVariantTools } from './variants' +import { registerViewScene, type SceneViewHost } from './view-scene' /** * Register every non-vision MCP tool against the given server. @@ -40,32 +37,35 @@ import { registerVariantTools } from './variants' * Scene-lifecycle tools (save/load/list/delete/rename scene) are registered * when persistence operations are available. */ +/** What the host lends the tools: its item library, script compiles, a view. */ +export type ToolHosts = { + catalog?: AssetCatalog + geometryScripts?: GeometryScriptHost + sceneViews?: SceneViewHost +} + export function registerTools( server: McpServer, operations: SceneOperations, - geometryScripts?: GeometryScriptHost, + { catalog, geometryScripts, sceneViews }: ToolHosts = {}, ): void { registerGetScene(server, operations) registerDescribeNode(server, operations) registerFindNodes(server, operations) - registerSharedTools(server, operations) + registerSharedTools(server, operations, catalog) registerAddColumn(server, operations, geometryScripts) registerAddObject(server, operations, geometryScripts) registerGetSource(server, operations, geometryScripts) registerMeasure(server, operations) + registerViewScene(server, operations, sceneViews) registerConstructionTools(server, operations) registerRoomTools(server, operations, geometryScripts) registerApplyPatch(server, operations) - registerCreateLevel(server, operations) registerCreateUnit(server, operations) registerSetUnitMembers(server, operations) registerListUnits(server, operations) - registerCreateWall(server, operations) - registerPlaceItem(server, operations) registerPlaceDesign(server, operations) - registerCutOpening(server, operations) registerSetZone(server, operations) - registerStructureTools(server, operations) registerUndo(server, operations) registerRedo(server, operations) registerExportJson(server, operations) diff --git a/packages/mcp/src/tools/live-sync.test.ts b/packages/mcp/src/tools/live-sync.test.ts index b7ed56229e..6ff7a2789e 100644 --- a/packages/mcp/src/tools/live-sync.test.ts +++ b/packages/mcp/src/tools/live-sync.test.ts @@ -2,17 +2,18 @@ import { describe, expect, test } from 'bun:test' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { NOT_SAVED_NOTE } from '@pascal-app/core/agent-tools' import { SceneBridge } from '../bridge/scene-bridge' import { createSceneOperations, type SceneOperations } from '../operations' import type { SceneStore } from '../storage/types' -import { registerCreateWall } from './create-wall' -import { publishLiveSceneSnapshot } from './live-sync' +import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' import { createTestSceneOperations, InMemorySceneStore, parseToolText, type StoredTextContent, } from './scene-lifecycle/test-utils' +import { registerSharedTools } from './shared-tools' function createBridge(): SceneBridge { const bridge = new SceneBridge() @@ -33,22 +34,19 @@ function withoutSceneEvents(base: InMemorySceneStore): SceneStore { } } -async function connectCreateWall(operations: SceneOperations): Promise { +async function connectAddWall(operations: SceneOperations): Promise { const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerCreateWall(server, operations) + registerSharedTools(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() const client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) return client } -async function callCreateWall( - client: Client, - bridge: SceneBridge, -): Promise> { +async function callAddWall(client: Client, bridge: SceneBridge): Promise> { const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! const result = await client.callTool({ - name: 'create_wall', + name: 'add_wall', arguments: { levelId: level.id, start: [0, 0], end: [4, 0] }, }) expect(result.isError).toBeFalsy() @@ -59,9 +57,9 @@ describe('live sync persistence reporting', () => { test('warns unbound when no active scene is bound', async () => { const bridge = createBridge() const { store, operations } = createTestSceneOperations({ bridge }) - const client = await connectCreateWall(operations) + const client = await connectAddWall(operations) - const parsed = await callCreateWall(client, bridge) + const parsed = await callAddWall(client, bridge) const persistence = parsed.persistence as { status: string; warning: string } expect(persistence.status).toBe('unbound') expect(typeof persistence.warning).toBe('string') @@ -74,13 +72,13 @@ describe('live sync persistence reporting', () => { const { store, operations } = createTestSceneOperations({ bridge }) const meta = await store.save({ name: 'Live Scene', graph: operations.exportSceneGraph() }) operations.setActiveScene(meta) - const client = await connectCreateWall(operations) + const client = await connectAddWall(operations) - const parsed = await callCreateWall(client, bridge) + const parsed = await callAddWall(client, bridge) expect(parsed.persistence).toBeUndefined() const events = await store.listSceneEvents(meta.id) expect(events).toHaveLength(1) - expect(events[0]!.kind).toBe('create_wall') + expect(events[0]!.kind).toBe('add_wall') const saved = await store.load(meta.id) expect(saved!.version).toBe(meta.version + 1) expect(saved!.graph.nodes[parsed.wallId as string]).toBeDefined() @@ -92,9 +90,9 @@ describe('live sync persistence reporting', () => { const operations = createSceneOperations({ bridge, store: withoutSceneEvents(base) }) const meta = await base.save({ name: 'Live Scene', graph: operations.exportSceneGraph() }) operations.setActiveScene(meta) - const client = await connectCreateWall(operations) + const client = await connectAddWall(operations) - const parsed = await callCreateWall(client, bridge) + const parsed = await callAddWall(client, bridge) const persistence = parsed.persistence as { status: string; warning: string } expect(persistence.status).toBe('events_unsupported') expect(typeof persistence.warning).toBe('string') @@ -102,9 +100,21 @@ describe('live sync persistence reporting', () => { }) describe('publishLiveSceneSnapshot', () => { + // On a session with no project, add_wall answered ok and the work reached + // nothing. Every write says where it went, top-level. + test('a write with no project says so: project null, and how to keep it', () => { + expect(persistencePayload({ status: 'unbound', project: null })).toMatchObject({ + project: null, + unsaved: NOT_SAVED_NOTE, + }) + expect(persistencePayload({ status: 'published', project: 'project_a' })).toEqual({ + project: 'project_a', + }) + }) + test('returns unbound without an active scene', async () => { const { operations } = createTestSceneOperations({ bridge: createBridge() }) - expect(await publishLiveSceneSnapshot(operations, 'test')).toBe('unbound') + expect((await publishLiveSceneSnapshot(operations, 'test')).status).toBe('unbound') }) test('returns events_unsupported when the store lacks scene events', async () => { @@ -115,14 +125,14 @@ describe('publishLiveSceneSnapshot', () => { }) const meta = await base.save({ name: 'Live Scene', graph: operations.exportSceneGraph() }) operations.setActiveScene(meta) - expect(await publishLiveSceneSnapshot(operations, 'test')).toBe('events_unsupported') + expect((await publishLiveSceneSnapshot(operations, 'test')).status).toBe('events_unsupported') }) test('returns published when bound to an event-capable store', async () => { const { store, operations } = createTestSceneOperations({ bridge: createBridge() }) const meta = await store.save({ name: 'Live Scene', graph: operations.exportSceneGraph() }) operations.setActiveScene(meta) - expect(await publishLiveSceneSnapshot(operations, 'test')).toBe('published') + expect((await publishLiveSceneSnapshot(operations, 'test')).status).toBe('published') expect(await store.listSceneEvents(meta.id)).toHaveLength(1) }) }) diff --git a/packages/mcp/src/tools/live-sync.ts b/packages/mcp/src/tools/live-sync.ts index dc564a327a..b606f363b8 100644 --- a/packages/mcp/src/tools/live-sync.ts +++ b/packages/mcp/src/tools/live-sync.ts @@ -1,3 +1,4 @@ +import { writeTarget } from '@pascal-app/core/agent-tools' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { z } from 'zod' import type { SceneOperations } from '../operations' @@ -6,6 +7,9 @@ import { ErrorCode, McpError, throwMcpError } from './errors' export type LiveSyncStatus = 'published' | 'unbound' | 'events_unsupported' +/** Where a write went: the project it reached, or none (a scratch scene). */ +export type LiveSync = { status: LiveSyncStatus; project: string | null } + type LiveSyncSkip = Exclude /** @@ -14,6 +18,8 @@ type LiveSyncSkip = Exclude * structured-content validation. */ export const liveSyncOutput = { + project: z.string().nullable().optional(), + unsaved: z.string().optional(), persistence: z .object({ status: z.enum(['unbound', 'events_unsupported']), @@ -30,14 +36,26 @@ const LIVE_SYNC_WARNINGS: Record = { } /** - * Payload fragment matching `liveSyncOutput`: empty after a successful - * publish, a `persistence` warning when the mutation stayed in-memory. + * Payload fragment matching `liveSyncOutput`: the project the write reached, or `null` and a note + * when none is bound; a `persistence` warning when the mutation stayed in-memory. */ -export function persistencePayload(status: LiveSyncStatus): { +export function persistencePayload({ status, project }: LiveSync): { + project: string | null + unsaved?: string persistence?: { status: LiveSyncSkip; warning: string } } { - if (status === 'published') return {} - return { persistence: { status, warning: LIVE_SYNC_WARNINGS[status] } } + return { + ...writeTarget(project), + ...(status === 'published' + ? {} + : { persistence: { status, warning: LIVE_SYNC_WARNINGS[status] } }), + } +} + +/** Where the session's writes go, for a call that publishes nothing (an undo with nothing to undo). */ +export function currentLiveSync(operations: SceneOperations): LiveSync { + const project = operations.getActiveScene()?.projectId ?? null + return { status: project ? 'published' : 'unbound', project } } const LIVE_SYNC_VERSION_CONFLICT = 'live_sync_version_conflict' @@ -60,10 +78,11 @@ export function isLiveSyncVersionConflict(error: unknown): boolean { export async function publishLiveSceneSnapshot( operations: SceneOperations, kind: string, -): Promise { +): Promise { const active = operations.getActiveScene() - if (!active) return 'unbound' - if (!operations.canAppendSceneEvents) return 'events_unsupported' + if (!active) return { status: 'unbound', project: null } + const project = active.projectId ?? active.id + if (!operations.canAppendSceneEvents) return { status: 'events_unsupported', project } const graph = operations.exportSceneGraph() @@ -98,7 +117,7 @@ export async function publishLiveSceneSnapshot( const message = error instanceof Error ? error.message : String(error) throwMcpError(ErrorCode.InternalError, `live_sync_failed: ${message}`) } - return 'published' + return { status: 'published', project } } export async function appendLiveSceneEvent( diff --git a/packages/mcp/src/tools/normalize-schema-dialect.ts b/packages/mcp/src/tools/normalize-schema-dialect.ts index b41dbc68bb..8812aeb37f 100644 --- a/packages/mcp/src/tools/normalize-schema-dialect.ts +++ b/packages/mcp/src/tools/normalize-schema-dialect.ts @@ -9,6 +9,23 @@ type HandlerRegistry = { _requestHandlers: Map } +/** + * A tuple of one item schema, `items: [A, A]` (draft-07), as 2020-12 writes an array of that length: + * `items: A` with minItems and maxItems. Clients reading 2020-12 refuse the list form, and Claude + * Code left out 9 tools over it (2026-10-03). The tools still validate with their own tuples. + */ +function untuple(record: Record, key: 'items' | 'prefixItems'): void { + const list = record[key] + if (!(Array.isArray(list) && list.length > 0)) return + const first = JSON.stringify(list[0]) + if (!list.every((entry) => JSON.stringify(entry) === first)) return + delete record.prefixItems + delete record.additionalItems + record.items = list[0] + record.minItems ??= list.length + record.maxItems ??= list.length +} + function retargetDialect(value: unknown): void { if (Array.isArray(value)) { for (const item of value) retargetDialect(item) @@ -17,6 +34,8 @@ function retargetDialect(value: unknown): void { if (value && typeof value === 'object') { const record = value as Record if (typeof record.$schema === 'string') record.$schema = DIALECT_2020_12 + untuple(record, 'items') + untuple(record, 'prefixItems') for (const key of Object.keys(record)) retargetDialect(record[key]) } } diff --git a/packages/mcp/src/tools/patch-guard-registry.test.ts b/packages/mcp/src/tools/patch-guard-registry.test.ts new file mode 100644 index 0000000000..478ae3b902 --- /dev/null +++ b/packages/mcp/src/tools/patch-guard-registry.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test' +import { registerPatchGuard, runPatchGuards } from './patch-guards' + +// Patch guards register from the module that owns what they protect: the honest-update check from +// its own module, any other from the tools it guards. +describe('patch guards', () => { + test('run by order whatever registers first, and a name registered again replaces it', () => { + const ran: string[] = [] + registerPatchGuard({ name: 'test-late', order: 900, run: () => void ran.push('late') }) + registerPatchGuard({ name: 'test-early', order: 800, run: () => void ran.push('early') }) + registerPatchGuard({ name: 'test-early', order: 800, run: () => void ran.push('early again') }) + runPatchGuards([], {}) + expect(ran).toEqual(['early again', 'late']) + }) +}) diff --git a/packages/mcp/src/tools/patch-guards.ts b/packages/mcp/src/tools/patch-guards.ts index 4cd4ef59be..677ed05e1d 100644 --- a/packages/mcp/src/tools/patch-guards.ts +++ b/packages/mcp/src/tools/patch-guards.ts @@ -21,6 +21,38 @@ export type PatchRefusalCode = | 'invalid_update' | 'regenerated_default' | 'scripted_field' + // An update that would not do what it says (honestNodePatch). + | 'unknown_field' + | 'unknown_material' + | 'field_required' + | 'shadowed_field' + +/** + * A check apply_patch runs over the whole patch before applying it, registered by the module that + * owns what it protects, so a feature brings its own check (the honest-update check is the + * first). It may rewrite an update's data (the cleaned data is what is written) and + * refuses by throwing: a PatchRefusedError comes back as `{ code, patchIndex, id, message }`, an + * McpError as it is. Lower `order` runs first. + */ +export type PatchGuard = { + name: string + order: number + run(patches: Patch[], nodes: Readonly>): void +} + +const guards: PatchGuard[] = [] + +/** Runs a guard on every apply_patch from now on; registering a guard again replaces it. */ +export function registerPatchGuard(guard: PatchGuard) { + const at = guards.findIndex((known) => known.name === guard.name) + if (at >= 0) guards[at] = guard + else guards.push(guard) + guards.sort((a, b) => a.order - b.order) +} + +export function runPatchGuards(patches: Patch[], nodes: Readonly>) { + for (const guard of guards) guard.run(patches, nodes) +} /** * A patch op refused because it would break node identity, the hierarchy or diff --git a/packages/mcp/src/tools/place-item.test.ts b/packages/mcp/src/tools/place-item.test.ts deleted file mode 100644 index 1d0c10c7e4..0000000000 --- a/packages/mcp/src/tools/place-item.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { SlabNode, WallNode } from '@pascal-app/core/schema' -import { SceneBridge } from '../bridge/scene-bridge' -import { registerPlaceItem } from './place-item' - -describe('place_item', () => { - let client: Client - let bridge: SceneBridge - - beforeEach(async () => { - bridge = new SceneBridge() - bridge.setScene({}, []) - bridge.loadDefault() - const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerPlaceItem(server, bridge) - const [srvT, cliT] = InMemoryTransport.createLinkedPair() - client = new Client({ name: 'test-client', version: '0.0.0' }) - await Promise.all([server.connect(srvT), client.connect(cliT)]) - }) - - test('places an item on a wall and derives wallT', async () => { - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const wall = WallNode.parse({ start: [0, 0], end: [10, 0] }) - bridge.createNode(wall, level.id) - - const result = await client.callTool({ - name: 'place_item', - arguments: { - catalogItemId: 'shelf', - targetNodeId: wall.id, - position: [5, 0, 0], - }, - }) - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - expect(parsed.itemId).toMatch(/^item_/) - expect(parsed.status).toBe('ok') - const item = bridge.getNode(parsed.itemId) - expect(item).not.toBeNull() - // Midpoint of a [0..10] wall at x=5 → wallT = 0.5. - expect((item as { wallT?: number }).wallT).toBeCloseTo(0.5, 3) - expect((item as { position: [number, number, number] }).position[0]).toBeCloseTo(5, 3) - }) - - test('places a floor item through a slab target but parents it to the level for rendering', async () => { - const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! - const slab = SlabNode.parse({ - polygon: [ - [0, 0], - [4, 0], - [4, 4], - [0, 4], - ], - }) - bridge.createNode(slab, level.id) - - const result = await client.callTool({ - name: 'place_item', - arguments: { - catalogItemId: 'sofa', - targetNodeId: slab.id, - position: [2, 0, 2], - }, - }) - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - const item = bridge.getNode(parsed.itemId) - expect(item?.parentId).toBe(level.id) - expect(bridge.validateScene().valid).toBe(true) - }) - - test('rejects placement on an unsupported node', async () => { - const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! - const result = await client.callTool({ - name: 'place_item', - arguments: { - catalogItemId: 'foo', - targetNodeId: building.id, - position: [0, 0, 0], - }, - }) - expect(result.isError).toBe(true) - }) - - test('rejects unknown target', async () => { - const result = await client.callTool({ - name: 'place_item', - arguments: { - catalogItemId: 'foo', - targetNodeId: 'wall_nope', - position: [0, 0, 0], - }, - }) - expect(result.isError).toBe(true) - }) -}) diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts deleted file mode 100644 index 8cd920c3ff..0000000000 --- a/packages/mcp/src/tools/place-item.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { - flushMountRotation, - geometrySurfaceAt, - geometryUndersideAt, - mountsFlush, -} from '@pascal-app/core' -import { projectWorldPointToWallLocalX, wallLength } from '@pascal-app/core/agent-operations' -import type { AnyNodeId } from '@pascal-app/core/schema' -import { ItemNode } from '@pascal-app/core/schema' -import { z } from 'zod' -import type { SceneOperations } from '../operations' -import { ADDITIVE_TOOL_ANNOTATIONS } from './annotations' -import { findCatalogItem } from './asset-catalog' -import { ErrorCode, throwMcpError } from './errors' -import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' -import { measurement } from './measurement' -import { NodeIdSchema, Vec3Schema } from './schemas' - -export const placeItemInput = { - catalogItemId: z.string().min(1), - targetNodeId: NodeIdSchema, - position: Vec3Schema, - rotation: measurement('angle', 'rad', { description: 'Y-axis rotation.' }).optional(), -} - -export const placeItemOutput = { - itemId: z.string(), - status: z.string().optional(), - restingOn: z.string().optional(), - ...liveSyncOutput, -} - -export function registerPlaceItem(server: McpServer, bridge: SceneOperations): void { - server.registerTool( - 'place_item', - { - title: 'Place item', - description: - 'Place a catalog item into the scene. Target a level/slab/zone for floor items, a wall for wall-attached items, a ceiling for ceiling-attached items, or an item to rest on it (position in level coordinates; on an object built with add_object it lands on the real surface below the point, such as a porch landing, and a ceiling item hangs from the underside above it, such as a vaulted ceiling, unless position[1] is set above 0). Do not target the site node directly.', - inputSchema: placeItemInput, - outputSchema: placeItemOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async ({ catalogItemId, targetNodeId, position, rotation }) => { - const target = bridge.getNode(targetNodeId as AnyNodeId) - if (!target) { - throwMcpError(ErrorCode.InvalidParams, `Target node not found: ${targetNodeId}`) - } - const targetType = target.type - if ( - targetType !== 'level' && - targetType !== 'slab' && - targetType !== 'zone' && - targetType !== 'wall' && - targetType !== 'ceiling' && - targetType !== 'item' - ) { - throwMcpError( - ErrorCode.InvalidRequest, - `Cannot place item on ${targetType}; target must be a level, slab, zone, wall, ceiling or item. Site-level placement is not supported yet because site.children is reserved for buildings.`, - ) - } - - const catalogAsset = findCatalogItem(catalogItemId) - const baseAsset = catalogAsset ?? { - id: catalogItemId, - name: catalogItemId, - category: 'unknown', - thumbnail: '', - src: 'asset://placeholder', - dimensions: [0.5, 0.5, 0.5] as [number, number, number], - offset: [0, 0, 0] as [number, number, number], - rotation: [0, 0, 0] as [number, number, number], - scale: [1, 1, 1] as [number, number, number], - } - - const requestedPosition = position as [number, number, number] - const parentId = - targetType === 'slab' || targetType === 'zone' - ? bridge.resolveLevelId(targetNodeId as AnyNodeId) - : targetNodeId - - if (!parentId) { - throwMcpError( - ErrorCode.InvalidParams, - `Could not resolve a level parent for target ${targetNodeId}`, - ) - } - - const wallExtras: { wallId: string; wallT: number } | Record = {} - let itemPosition = requestedPosition - - if (targetType === 'wall') { - const localX = projectWorldPointToWallLocalX(target, requestedPosition) - const length = wallLength(target) - itemPosition = [localX, requestedPosition[1], 0] - Object.assign(wallExtras, { - wallId: targetNodeId, - wallT: length === 0 ? 0 : localX / length, - }) - } - - let restingOn: string | undefined - let tilt: [number, number, number] | undefined - if (target.type === 'item') { - const host = bridge.getNode(target.parentId as AnyNodeId) - if (host?.type !== 'level') { - throwMcpError( - ErrorCode.InvalidRequest, - `Item ${targetNodeId} rests on a ${host?.type ?? 'missing parent'}; only items standing on a level can host another item here.`, - ) - } - // Level coordinates → the host item's frame (translation + yaw). - const [hx, hy, hz] = target.position - const yaw = target.rotation[1] ?? 0 - const dx = requestedPosition[0] - hx - const dz = requestedPosition[2] - hz - const lx = (Math.cos(yaw) * dx - Math.sin(yaw) * dz) / target.scale[0] - const lz = (Math.sin(yaw) * dx + Math.cos(yaw) * dz) / target.scale[2] - const hanging = baseAsset.attachTo === 'ceiling' && target.source - const surface = target.source - ? hanging - ? geometryUndersideAt(target.source.manifest, lx, lz) - : geometrySurfaceAt(target.source.manifest, lx, lz) - : null - const explicitY = !hanging && requestedPosition[1] > 0 - restingOn = explicitY ? undefined : surface?.part - // A ceiling item hangs below the underside (its top flush); others rest on top. - const flush = Boolean(hanging) && mountsFlush(baseAsset) - const drop = hanging ? (flush ? 0.02 : (baseAsset.dimensions?.[1] ?? 0)) : 0 - const ly = explicitY - ? requestedPosition[1] - hy - : surface - ? surface.y * target.scale[1] - drop - : (target.asset.surface?.height ?? target.asset.dimensions[1]) * target.scale[1] - itemPosition = [lx * target.scale[0], ly, lz * target.scale[2]] - // A recessed fixture tilts with a sloped underside (a can in a vault plane). - // Its turn is relative to the host's. - tilt = - flush && surface && 'normal' in surface - ? flushMountRotation(surface.normal, (rotation ?? 0) - yaw) - : [0, (rotation ?? 0) - yaw, 0] - } - - const item = ItemNode.parse({ - position: itemPosition, - rotation: tilt ?? [0, rotation ?? 0, 0], - asset: baseAsset, - ...wallExtras, - }) - const id = bridge.createNode(item, parentId as AnyNodeId) - const persistence = await publishLiveSceneSnapshot(bridge, 'place_item') - const payload = { - itemId: id as string, - status: catalogAsset ? 'ok' : 'catalog_unavailable', - ...(restingOn ? { restingOn } : {}), - ...persistencePayload(persistence), - } - return { - content: [{ type: 'text' as const, text: JSON.stringify(payload) }], - structuredContent: payload, - } - }, - ) -} diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index 70401bc8a8..5c562ce9d0 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -40,6 +40,7 @@ const TOOL_POLICIES = [ 'validate_design', 'validate_scene', 'verify_scene', + 'view_scene', ], }, { @@ -59,22 +60,22 @@ const TOOL_POLICIES = [ }, tools: [ 'add_door', + 'add_level', + 'add_wall', 'add_window', - 'create_level', 'create_mezzanine', 'create_project', 'create_roof', 'create_room', + 'create_stair', 'create_story_shell', 'create_unit', - 'create_wall', - 'cut_opening', 'cut_floor_opening', 'duplicate_level', 'furnish_room', 'generate_variants', 'place_design', - 'place_item', + 'place_items', 'set_zone', 'set_zone_intent', 'set_floor_foundation', @@ -94,7 +95,6 @@ const TOOL_POLICIES = [ 'edit_collection', 'create_from_template', 'create_house_from_brief', - 'create_stair_between_levels', 'delete_node', 'fit_stair', 'remove_floor_opening', @@ -188,3 +188,42 @@ describe('MCP tool annotations', () => { } }) }) + +// 2026-10-03, a Claude Code run on the hosted MCP: the client left out 9 tools (divide_zone, +// merge_zones, create_mezzanine…) whose input schemas held tuples, `items: [...]`. A pair is one +// `items` schema with minItems and maxItems, as Vec2Schema writes it. +describe('MCP tool input schemas', () => { + test('hold no tuple, so every client can call every tool', async () => { + const bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + const directory = mkdtempSync(join(tmpdir(), 'pascal-mcp-schemas-')) + const store = new SqliteSceneStore({ databasePath: join(directory, 'pascal.db') }) + const server = createPascalMcpServer({ bridge, store }) + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'schema-test-client', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + try { + const tuples = (schema: unknown, path: string): string[] => { + if (!schema || typeof schema !== 'object') return [] + const record = schema as Record + const own = Array.isArray(record.items) || 'prefixItems' in record ? [path] : [] + return [ + ...own, + ...Object.entries(record).flatMap(([key, value]) => + Array.isArray(value) + ? value.flatMap((entry, index) => tuples(entry, `${path}.${key}[${index}]`)) + : tuples(value, `${path}.${key}`), + ), + ] + } + const listed = await client.listTools() + const found = listed.tools.flatMap((tool) => tuples(tool.inputSchema, tool.name)) + expect(found).toEqual([]) + } finally { + await client.close() + await server.close() + rmSync(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/mcp/src/tools/redo.ts b/packages/mcp/src/tools/redo.ts index c0336c8bdd..62adfc51e4 100644 --- a/packages/mcp/src/tools/redo.ts +++ b/packages/mcp/src/tools/redo.ts @@ -2,7 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import type { SceneOperations } from '../operations' import { DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' -import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { + currentLiveSync, + liveSyncOutput, + persistencePayload, + publishLiveSceneSnapshot, +} from './live-sync' export const redoInput = { steps: z.number().int().positive().optional(), @@ -27,7 +32,7 @@ export function registerRedo(server: McpServer, bridge: SceneOperations): void { async ({ steps }) => { const redone = bridge.redo(steps ?? 1) const persistence = - redone > 0 ? await publishLiveSceneSnapshot(bridge, 'redo') : ('published' as const) + redone > 0 ? await publishLiveSceneSnapshot(bridge, 'redo') : currentLiveSync(bridge) const payload = { redone, ...persistencePayload(persistence) } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/room-tools.test.ts b/packages/mcp/src/tools/room-tools.test.ts index ec0c2b47f2..6af09d3d0c 100644 --- a/packages/mcp/src/tools/room-tools.test.ts +++ b/packages/mcp/src/tools/room-tools.test.ts @@ -6,6 +6,7 @@ import type { AnyNodeId } from '@pascal-app/core/schema' import { CeilingNode, LevelNode, SlabNode } from '@pascal-app/core/schema' import { SceneBridge } from '../bridge/scene-bridge' import { registerRoomTools } from './room-tools' +import { registerSharedTools } from './shared-tools' describe('room tools', () => { let client: Client @@ -16,23 +17,13 @@ describe('room tools', () => { bridge.setScene({}, []) bridge.loadDefault() const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerSharedTools(server, bridge) registerRoomTools(server, bridge) const [srvT, cliT] = InMemoryTransport.createLinkedPair() client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) }) - test('search_assets returns built-in catalog matches', async () => { - const result = await client.callTool({ - name: 'search_assets', - arguments: { query: 'sofa' }, - }) - expect(result.isError).toBeFalsy() - const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) - expect(parsed.total).toBeGreaterThan(0) - expect(parsed.results.map((item: { id: string }) => item.id)).toContain('sofa') - }) - test('create_room writes walls and a room zone only; floor and ceiling are derived', async () => { const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! const result = await client.callTool({ @@ -214,7 +205,7 @@ describe('room tools', () => { const door = JSON.parse((doorResult.content as Array<{ type: string; text: string }>)[0]!.text) expect(door.localX).toBeCloseTo(2.5, 3) expect(door.t).toBe(0.5) - expect(door.position).toBe(0.5) + expect(door.achieved).toMatchObject({ created: { door: 1 } }) expect(door.wallLength).toBeCloseTo(5, 3) expect(door.coordinateSystem).toBe('wall-local-meters') expect( @@ -228,7 +219,7 @@ describe('room tools', () => { const win = JSON.parse((windowResult.content as Array<{ type: string; text: string }>)[0]!.text) expect(win.localX).toBeCloseTo(1.25, 3) expect(win.t).toBe(0.25) - expect(win.position).toBe(0.25) + expect(win.achieved).toMatchObject({ created: { window: 1 } }) expect(win.wallLength).toBeCloseTo(5, 3) expect(win.coordinateSystem).toBe('wall-local-meters') expect( @@ -407,6 +398,51 @@ describe('room tools', () => { expect(findBlockedDoors({ nodes })).toEqual([]) }) + // The front door behind an outdoor porch faced the hall, whichever way it was drawn. + for (const polygon of [ + [ + [0, 0], + [0, 5], + [6, 5], + [6, 0], + ], + [ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ], + ]) + test(`a door on the wall behind an outdoor porch faces the porch (${polygon[1]})`, async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const call = async (name: string, args: Record) => { + const result = await client.callTool({ name, arguments: args }) + expect(result.isError).toBeFalsy() + return JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + } + const house = await call('create_room', { levelId: level.id, name: 'Hall', polygon }) + await call('create_room', { + levelId: level.id, + name: 'Porch', + outdoor: true, + polygon: [ + [1, 5], + [4, 5], + [4, 7], + [1, 7], + ], + }) + const wallId = (house.wallIds as string[]).find((id) => { + const wall = bridge.getNodes()[id as AnyNodeId] as { start: number[]; end: number[] } + return wall.start[1] === 5 && wall.end[1] === 5 + })! + const wall = bridge.getNodes()[wallId as AnyNodeId] as { start: number[]; end: number[] } + const { doorId } = await call('add_door', { wallId, t: 0.5, style: 'modern' }) + const door = bridge.getNodes()[doorId as AnyNodeId] as { rotation: number[] } + const sign = Math.abs(door.rotation[1]!) > Math.PI / 2 ? -1 : 1 + expect((wall.end[0]! - wall.start[0]!) * sign).toBeGreaterThan(0) + }) + test('furnish_room records door-clearance skips when a door sits on the furniture wall', async () => { const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! // Large bedroom so bed placement is near the "back" wall (edge opposite doorWallIndex). @@ -446,9 +482,7 @@ describe('room tools', () => { const bedPlaced = Object.values(bridge.getNodes()).some( (n) => n.type === 'item' && (n.name === 'Double Bed' || n.name === 'Single Bed'), ) - const doorSkips = (parsed.skipped as string[]).filter((s) => - s.includes('blocks door clearance'), - ) + const doorSkips = (parsed.skipped as string[]).filter((s) => s.includes('in the way of')) expect(bedPlaced || doorSkips.length > 0).toBe(true) if (bedPlaced) { expect(doorSkips.length).toBe(0) diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index aa1ec8baa4..70302d641a 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -1,152 +1,34 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { createZone, generateId } from '@pascal-app/core' import { - collectDoorKeepouts, - collectOccupiedFootprints, + achievedChanges, + addWallOpening, editedScriptParams, - findValidPlacement, - itemPlanAabb, - keepoutCoversPlanned, - keepoutForPolygonEdge, - type PlanAabb, - polygonArea, - polygonBounds, + rebuiltOpeningResult, rescriptOpening, - type Vec2, + type SceneNodes, } from '@pascal-app/core/agent-operations' -import { addDoorTool, addWindowTool, isAgentRefusal } from '@pascal-app/core/agent-tools' -import { planWallOpening } from '@pascal-app/core/building' +import { + addDoorOutput as addDoorResult, + addDoorTool, + addWindowOutput as addWindowResult, + addWindowTool, + isAgentRefusal, +} from '@pascal-app/core/agent-tools' import type { AnyNode, - AnyNodeId, - AssetInput, CompiledGeometryScript, GeometryScriptParamValue, - WallNode as WallNodeType, } from '@pascal-app/core/schema' -import { ItemNode } from '@pascal-app/core/schema' -import { z } from 'zod' import type { SceneOperations } from '../operations' import { compileAndStore, type GeometryScriptHost, readScript } from './add-object' -import { ADDITIVE_TOOL_ANNOTATIONS, READ_ONLY_TOOL_ANNOTATIONS } from './annotations' -import { findCatalogItem, searchCatalogItems } from './asset-catalog' -import { ErrorCode, refusalResult, throwMcpError, toolError } from './errors' -import { - type LiveSyncStatus, - liveSyncOutput, - persistencePayload, - publishLiveSceneSnapshot, -} from './live-sync' -import { measurement } from './measurement' -import { NodeIdSchema, Vec2Schema } from './schemas' +import { ADDITIVE_TOOL_ANNOTATIONS } from './annotations' +import { refusalResult, toolError } from './errors' +import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' import { toPatches } from './shared-tools' -const ROOM_TYPES = [ - 'bedroom', - 'kitchen', - 'bathroom', - 'living', - 'dining', - 'hallway', - 'entry', - 'laundry', - 'storage', -] as const - -export const searchAssetsInput = { - query: z.string().min(1), - category: z.string().optional(), -} - -export const searchAssetsOutput = { - results: z.array(z.record(z.string(), z.unknown())), - total: z.number(), -} - -export const createRoomInput = { - levelId: NodeIdSchema, - name: z.string().min(1), - polygon: z.array(Vec2Schema).min(3), - color: z.string().optional(), - wallHeight: measurement('length', 'm', { - positive: true, - description: 'Wall height.', - }).optional(), - wallThickness: measurement('length', 'm', { - positive: true, - description: 'Wall thickness.', - }).optional(), - outdoor: z - .boolean() - .optional() - .describe( - 'true for an outdoor room (a terrace): closed with separators where no wall runs, no walls of its own and no ceiling.', - ), -} - -export const createRoomOutput = { - zoneId: z.string(), - /** Derived floor plate. `null` when the bridge has not reconciled yet. */ - slabId: z.string().nullable(), - /** Derived ceiling. `null` when the bridge has not reconciled yet. */ - ceilingId: z.string().nullable(), - /** One entry per polygon edge; `null` where no wall covers that edge. */ - wallIds: z.array(z.string().nullable()), - reusedWalls: z.number(), - areaSqMeters: z.number(), - conflicts: z - .array(z.object({ code: z.string(), nodeIds: z.array(z.string()), message: z.string() })) - .optional(), - ...liveSyncOutput, -} - -export const addDoorOutput = { - doorId: z.string(), - localX: z.number(), - t: z.number(), - position: z.number(), - wallLength: z.number(), - clamped: z.boolean(), - coordinateSystem: z.literal('wall-local-meters'), - ...liveSyncOutput, -} - -export const addWindowOutput = { - windowId: z.string(), - localX: z.number(), - t: z.number(), - position: z.number(), - wallLength: z.number(), - clamped: z.boolean(), - coordinateSystem: z.literal('wall-local-meters'), - sillHeight: z.number(), - ...liveSyncOutput, -} - -export const furnishRoomInput = { - levelId: NodeIdSchema.optional(), - zoneId: NodeIdSchema.optional(), - roomType: z.enum(ROOM_TYPES), - polygon: z.array(Vec2Schema).min(3).optional(), - doorWallIndex: z.number().int().min(0).optional(), -} - -export const furnishRoomOutput = { - placed: z.number(), - itemIds: z.array(z.string()), - skipped: z.array(z.string()), - ...liveSyncOutput, -} - -type Placement = { - assetId: string - x: number - z: number - rotationDeg?: number - /** Optional axes for smart re-place (along wall / into room). */ - along?: { x: number; z: number } - inward?: { x: number; z: number } -} +/** The contract's one result (core `addWallOpening`), and the live sync's note. */ +export const addDoorOutput = { ...addDoorResult, ...liveSyncOutput } +export const addWindowOutput = { ...addWindowResult, ...liveSyncOutput } function textResult>(payload: T) { return { @@ -155,393 +37,6 @@ function textResult>(payload: T) { } } -function assertLevel(bridge: SceneOperations, levelId: string): AnyNode { - const level = bridge.getNode(levelId as AnyNodeId) - if (!level) throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`) - if (level.type !== 'level') { - throwMcpError(ErrorCode.InvalidParams, `Node ${levelId} is a ${level.type}, expected level`) - } - if ( - typeof level.metadata === 'object' && - level.metadata !== null && - 'role' in level.metadata && - level.metadata.role === 'roof' - ) { - throwMcpError( - ErrorCode.InvalidParams, - `Roof support level ${levelId} is not an occupied story; create rooms or furnishings on an occupied level instead`, - ) - } - return level -} - -function assertWall(bridge: SceneOperations, wallId: string): AnyNode & { type: 'wall' } { - const wall = bridge.getNode(wallId as AnyNodeId) - if (!wall) throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`) - if (wall.type !== 'wall') { - throwMcpError(ErrorCode.InvalidParams, `Node ${wallId} is a ${wall.type}, expected wall`) - } - return wall -} - -function inferRoomGeometry( - bridge: SceneOperations, - levelId: string | undefined, - polygon: Vec2[] | undefined, - zoneId: string | undefined, -) { - if (levelId && polygon) return { levelId, polygon } - if (!zoneId) { - throwMcpError( - ErrorCode.InvalidParams, - 'Provide either levelId + polygon or zoneId so the room can be furnished', - ) - } - const zone = bridge.getNode(zoneId as AnyNodeId) - if (!zone) throwMcpError(ErrorCode.InvalidParams, `Zone not found: ${zoneId}`) - if (zone.type !== 'zone') { - throwMcpError(ErrorCode.InvalidParams, `Node ${zoneId} is a ${zone.type}, expected zone`) - } - const inferredLevelId = levelId ?? zone.parentId ?? undefined - if (!inferredLevelId) { - throwMcpError(ErrorCode.InvalidParams, `Zone ${zoneId} is missing a parent level`) - } - return { - levelId: inferredLevelId, - polygon: polygon ?? (zone.polygon as Vec2[]), - } -} - -function makeItemAsset(asset: AssetInput) { - return { - id: asset.id, - name: asset.name, - category: asset.category, - thumbnail: asset.thumbnail ?? '', - src: asset.src, - dimensions: asset.dimensions ?? [1, 1, 1], - offset: asset.offset ?? [0, 0, 0], - rotation: asset.rotation ?? [0, 0, 0], - scale: asset.scale ?? [1, 1, 1], - ...(asset.attachTo ? { attachTo: asset.attachTo } : {}), - ...(asset.tags ? { tags: asset.tags } : {}), - ...(asset.surface ? { surface: asset.surface } : {}), - ...(asset.interactive ? { interactive: asset.interactive } : {}), - } -} - -function buildRoomPlacements( - roomType: (typeof ROOM_TYPES)[number], - polygon: Vec2[], - doorWallIndex = 0, -) { - const bounds = polygonBounds(polygon) - const n = polygon.length - const backIdx = (doorWallIndex + Math.floor(n / 2)) % n - const backStart = polygon[backIdx]! - const backEnd = polygon[(backIdx + 1) % n]! - const backMidX = (backStart[0] + backEnd[0]) / 2 - const backMidZ = (backStart[1] + backEnd[1]) / 2 - const inwardX = bounds.centerX - backMidX - const inwardZ = bounds.centerZ - backMidZ - const inwardLen = Math.sqrt(inwardX * inwardX + inwardZ * inwardZ) || 1 - const inX = inwardX / inwardLen - const inZ = inwardZ / inwardLen - const facingRot = (Math.atan2(inX, inZ) * 180) / Math.PI - - const alongX = backEnd[0] - backStart[0] - const alongZ = backEnd[1] - backStart[1] - const alongLen = Math.sqrt(alongX * alongX + alongZ * alongZ) || 1 - const ax = alongX / alongLen - const az = alongZ / alongLen - - const backPos = (inset: number, lateral = 0): [number, number] => [ - backMidX + inX * inset + ax * lateral, - backMidZ + inZ * inset + az * lateral, - ] - - const sideIdx = (doorWallIndex + 1) % n - const sideStart = polygon[sideIdx]! - const sideEnd = polygon[(sideIdx + 1) % n]! - const sideMidX = (sideStart[0] + sideEnd[0]) / 2 - const sideMidZ = (sideStart[1] + sideEnd[1]) / 2 - const sideInX = bounds.centerX - sideMidX - const sideInZ = bounds.centerZ - sideMidZ - const sideInLen = Math.sqrt(sideInX * sideInX + sideInZ * sideInZ) || 1 - const snX = sideInX / sideInLen - const snZ = sideInZ / sideInLen - const sideRot = (Math.atan2(snX, snZ) * 180) / Math.PI - const sideAlongX = sideEnd[0] - sideStart[0] - const sideAlongZ = sideEnd[1] - sideStart[1] - const sideAlongLen = Math.sqrt(sideAlongX * sideAlongX + sideAlongZ * sideAlongZ) || 1 - const sax = sideAlongX / sideAlongLen - const saz = sideAlongZ / sideAlongLen - const sidePos = (inset: number, lateral = 0): [number, number] => [ - sideMidX + snX * inset + sax * lateral, - sideMidZ + snZ * inset + saz * lateral, - ] - - const placements: Placement[] = [] - const area = polygonArea(polygon) - - const addBack = (assetId: string, inset: number, lateral = 0, rotationDeg = facingRot) => { - const [x, z] = backPos(inset, lateral) - placements.push({ - assetId, - x, - z, - rotationDeg, - along: { x: ax, z: az }, - inward: { x: inX, z: inZ }, - }) - } - const addSide = (assetId: string, inset: number, lateral = 0, rotationDeg = sideRot) => { - const [x, z] = sidePos(inset, lateral) - placements.push({ - assetId, - x, - z, - rotationDeg, - along: { x: sax, z: saz }, - inward: { x: snX, z: snZ }, - }) - } - - switch (roomType) { - case 'bedroom': { - const bedId = Math.max(bounds.width, bounds.depth) >= 3.2 ? 'double-bed' : 'single-bed' - const bed = findCatalogItem(bedId) - const [bedW = 2, , bedD = 2.5] = bed?.dimensions ?? [] - addBack(bedId, bedD / 2 + 0.1) - if (alongLen > bedW + 1.1) { - addBack('bedside-table', 0.35, -(bedW / 2 + 0.35)) - addBack('bedside-table', 0.35, bedW / 2 + 0.35) - } - if (area >= 10) addSide('dresser', 0.55, sideAlongLen * 0.22) - if (area >= 13) addSide('closet', 0.6, -sideAlongLen * 0.22) - break - } - case 'kitchen': - addBack(alongLen >= 2.6 ? 'kitchen' : 'kitchen-counter', 0.55) - if (alongLen >= 3.5) addBack('stove', 0.55, alongLen / 2 - 0.65) - addSide('fridge', 0.6, sideAlongLen * 0.25) - break - case 'bathroom': - addBack('toilet', 0.55, alongLen * 0.25) - addBack('bathroom-sink', 0.8, -alongLen * 0.2) - if (area >= 6.5) addSide('bathtub', 0.85) - else placements.push({ assetId: 'shower-square', x: bounds.centerX, z: bounds.centerZ }) - break - case 'living': { - addBack('sofa', 0.9) - addBack('coffee-table', 2.1) - addSide('livingroom-chair', 0.85, -sideAlongLen * 0.18) - // TV faces the sofa from the door wall: use door-wall axes so smart - // re-place nudges into the room (along wall / inward), not world X/Z. - const doorIdx = doorWallIndex % n - const doorStart = polygon[doorIdx]! - const doorEnd = polygon[(doorIdx + 1) % n]! - const doorMidX = (doorStart[0] + doorEnd[0]) / 2 - const doorMidZ = (doorStart[1] + doorEnd[1]) / 2 - // Door-wall inward is opposite of "back wall" inward (into room from door). - const doorInX = -inX - const doorInZ = -inZ - placements.push({ - assetId: 'tv-stand', - x: doorMidX + doorInX * 0.35, - z: doorMidZ + doorInZ * 0.35, - rotationDeg: facingRot + 180, - along: { x: ax, z: az }, - inward: { x: doorInX, z: doorInZ }, - }) - break - } - case 'dining': - placements.push({ assetId: 'dining-table', x: bounds.centerX, z: bounds.centerZ }) - placements.push({ assetId: 'dining-chair', x: bounds.centerX, z: bounds.centerZ - 0.85 }) - placements.push({ - assetId: 'dining-chair', - x: bounds.centerX, - z: bounds.centerZ + 0.85, - rotationDeg: 180, - }) - if (Math.min(bounds.width, bounds.depth) >= 3) { - placements.push({ - assetId: 'dining-chair', - x: bounds.centerX - 0.85, - z: bounds.centerZ, - rotationDeg: 270, - }) - placements.push({ - assetId: 'dining-chair', - x: bounds.centerX + 0.85, - z: bounds.centerZ, - rotationDeg: 90, - }) - } - break - case 'laundry': - addBack('washing-machine', 0.6, -0.55) - addBack('drying-rack', 0.65, 0.65) - break - case 'entry': - case 'hallway': - if (Math.min(bounds.width, bounds.depth) >= 1.4) addSide('coat-rack', 0.35) - break - case 'storage': - addBack('closet', 0.6) - break - } - - return { placements, bounds } -} - -export function registerSearchAssets(server: McpServer): void { - server.registerTool( - 'search_assets', - { - title: 'Search assets', - description: - 'Search the built-in MCP item catalog by keyword. Call before place_item when you need a valid catalogItemId.', - inputSchema: searchAssetsInput, - outputSchema: searchAssetsOutput, - annotations: READ_ONLY_TOOL_ANNOTATIONS, - }, - async ({ query, category }) => { - const results = searchCatalogItems({ query, category }).map((item) => ({ - id: item.id, - name: item.name, - category: item.category, - tags: item.tags ?? [], - dimensions: item.dimensions, - attachTo: item.attachTo ?? null, - })) - return textResult({ results, total: results.length }) - }, - ) -} - -const WALL_EDGE_TOLERANCE = 0.2 - -function pointToEdgeDistance(a: Vec2, b: Vec2, point: readonly [number, number]) { - const dx = b[0] - a[0] - const dz = b[1] - a[1] - const length = Math.hypot(dx, dz) - if (length < 1e-9) return Math.hypot(point[0] - a[0], point[1] - a[1]) - return Math.abs((point[0] - a[0]) * dz - (point[1] - a[1]) * dx) / length -} - -function wallCoversEdge(wall: AnyNode & { type: 'wall' }, start: Vec2, end: Vec2) { - const dx = end[0] - start[0], - dz = end[1] - start[1] - const lengthSq = dx * dx + dz * dz - const station = (p: Vec2) => ((p[0] - start[0]) * dx + (p[1] - start[1]) * dz) / lengthSq - const a = station(wall.start), - b = station(wall.end) - return ( - Math.min(1, Math.max(a, b)) - Math.max(0, Math.min(a, b)) > 1e-6 && - pointToEdgeDistance(start, end, wall.start) < WALL_EDGE_TOLERANCE && - pointToEdgeDistance(start, end, wall.end) < WALL_EDGE_TOLERANCE - ) -} - -/** The construction the reconciler derived for a room, for the tool payload. */ -function derivedRoomSurfaces(bridge: SceneOperations, levelId: string, zoneId: string) { - const children = Object.values(bridge.getNodes()).filter((node) => node.parentId === levelId) - const plate = children.find( - (node) => node.type === 'slab' && node.boundary === 'auto' && node.zoneIds?.includes(zoneId), - ) - const ceiling = children.find( - (node) => node.type === 'ceiling' && node.boundary === 'auto' && node.zoneId === zoneId, - ) - return { slabId: plate?.id ?? null, ceilingId: ceiling?.id ?? null } -} - -export function registerCreateRoom(server: McpServer, bridge: SceneOperations): void { - server.registerTool( - 'create_room', - { - title: 'Create room', - description: - 'Create a room on a level from a polygon: one wall per edge (reusing or splitting the walls already there) plus 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 a room, and never pass boundary/autoFromWalls. Returns wallIds in polygon edge order and the derived slabId / ceilingId. outdoor: true draws a terrace instead: separators where no wall runs and no ceiling (wallIds are null for its own sides).', - inputSchema: createRoomInput, - outputSchema: createRoomOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async ({ levelId, name, polygon, color, wallHeight, wallThickness, outdoor }) => { - assertLevel(bridge, levelId) - const points = polygon as Vec2[] - const wallDefaults: Partial = { - ...(wallHeight !== undefined ? { height: wallHeight } : {}), - ...(wallThickness !== undefined ? { thickness: wallThickness } : {}), - metadata: { mcpTool: 'create_room', roomName: name }, - } - - const before = bridge.getNodes() - const plan = createZone(before, { - levelId, - polygon: points, - name, - enclose: !outdoor, - ...(outdoor ? { intent: { hasCeiling: false } } : { wall: wallDefaults }), - mintId: generateId, - }) - if (plan.conflicts?.length) - return textResult({ - zoneId: '', - slabId: null, - ceilingId: null, - wallIds: [], - reusedWalls: 0, - areaSqMeters: 0, - conflicts: plan.conflicts, - }) - bridge.runAsSingleHistoryStep(() => { - bridge.applyPatch( - plan.changes.map((change) => - change.op === 'create' - ? { - ...change, - node: - change.node.type === 'zone' - ? { - ...change.node, - color: color ?? '#60a5fa', - metadata: { mcpTool: 'create_room' }, - } - : change.node, - parentId: change.node.parentId as AnyNodeId, - } - : change, - ), - ) - bridge.deriveStructure([levelId as AnyNodeId]) - }) - const wallIds = points.map( - (start, i) => - Object.values(bridge.getNodes()).find( - (node) => - node.type === 'wall' && - node.parentId === levelId && - wallCoversEdge(node, start, points[(i + 1) % points.length]!), - )?.id ?? null, - ) - const reusedWalls = wallIds.filter((id) => id && before[id as AnyNodeId]).length - - const persistence = await publishLiveSceneSnapshot(bridge, 'create_room') - - return textResult({ - zoneId: plan.zoneId, - ...derivedRoomSurfaces(bridge, levelId, plan.zoneId), - wallIds, - reusedWalls, - areaSqMeters: Math.round(polygonArea(points) * 100) / 100, - ...persistencePayload(persistence), - }) - }, - ) -} - /** * `add_door` / `add_window` with a nodeId: rebuild that opening from new code, * or its stored script with new params, through the shared operation. @@ -575,27 +70,18 @@ async function rebuildOpening( code: 'script_failed', }) } + const achieved = outcome.changes + ? achievedChanges(nodes as SceneNodes, outcome.changes) + : achievedChanges(nodes as SceneNodes, {}) if (outcome.changes) bridge.applyPatch(toPatches(outcome.changes)) - const node = bridge.getNodes()[input.nodeId as AnyNodeId] as AnyNode & { - position: [number, number, number] - height: number - wallId?: string - } - const wall = node.wallId - ? (bridge.getNodes()[node.wallId as AnyNodeId] as WallNodeType) - : undefined - const wallLength = wall ? Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) : 0 const persistence = await publishLiveSceneSnapshot(bridge, `add_${kind}`) return textResult({ - [kind === 'door' ? 'doorId' : 'windowId']: input.nodeId, - localX: node.position[0], - t: wallLength ? node.position[0] / wallLength : 0, - position: wallLength ? node.position[0] / wallLength : 0, - wallLength, - clamped: false, - coordinateSystem: 'wall-local-meters' as const, - ...(kind === 'window' ? { sillHeight: node.position[1] - node.height / 2 } : {}), - ...outcome.result, + ...rebuiltOpeningResult( + bridge.getNodes() as SceneNodes, + input.nodeId, + outcome.result as Record, + achieved, + ), ...persistencePayload(persistence), }) } @@ -648,9 +134,9 @@ export function registerAddDoor( return rebuildOpening('door', bridge, geometryScripts, { ...input, nodeId: input.nodeId }) const compiled = await compileOpeningScript('door', bridge, geometryScripts, input) if ('error' in compiled) return compiled.error - let planned: ReturnType + let opening: ReturnType try { - planned = planWallOpening(bridge.getNodes() as Record, { + opening = addWallOpening(bridge.getNodes() as SceneNodes, { kind: 'door', ...input, compiled: compiled.script, @@ -658,18 +144,9 @@ export function registerAddDoor( } catch (error) { return refusalResult(error) } - const id = bridge.createNode(planned.node, planned.wallId as AnyNodeId) + bridge.applyPatch(toPatches(opening.changes)) const persistence = await publishLiveSceneSnapshot(bridge, 'add_door') - return textResult({ - doorId: id, - localX: planned.localX, - t: planned.t, - position: planned.t, - wallLength: planned.wallLength, - clamped: planned.clamped, - coordinateSystem: 'wall-local-meters', - ...persistencePayload(persistence), - }) + return textResult({ ...opening.result, ...persistencePayload(persistence) }) }, ) } @@ -693,9 +170,9 @@ export function registerAddWindow( return rebuildOpening('window', bridge, geometryScripts, { ...input, nodeId: input.nodeId }) const compiled = await compileOpeningScript('window', bridge, geometryScripts, input) if ('error' in compiled) return compiled.error - let planned: ReturnType + let opening: ReturnType try { - planned = planWallOpening(bridge.getNodes() as Record, { + opening = addWallOpening(bridge.getNodes() as SceneNodes, { kind: 'window', ...input, compiled: compiled.script, @@ -703,155 +180,19 @@ export function registerAddWindow( } catch (error) { return refusalResult(error) } - const id = bridge.createNode(planned.node, planned.wallId as AnyNodeId) + bridge.applyPatch(toPatches(opening.changes)) const persistence = await publishLiveSceneSnapshot(bridge, 'add_window') - return textResult({ - windowId: id, - localX: planned.localX, - t: planned.t, - position: planned.t, - wallLength: planned.wallLength, - clamped: planned.clamped, - coordinateSystem: 'wall-local-meters', - sillHeight: planned.sillHeight ?? 0, - ...persistencePayload(persistence), - }) - }, - ) -} - -export function registerFurnishRoom(server: McpServer, bridge: SceneOperations): void { - server.registerTool( - 'furnish_room', - { - title: 'Furnish room', - description: - 'Place furniture for a room type (levelId+polygon or zoneId). Skips or nudges poses that block door clear zones or overlap existing items (rotation-aware). Parent floor items to the level.', - inputSchema: furnishRoomInput, - outputSchema: furnishRoomOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async ({ levelId, zoneId, roomType, polygon, doorWallIndex }) => { - const room = inferRoomGeometry(bridge, levelId, polygon as Vec2[] | undefined, zoneId) - assertLevel(bridge, room.levelId) - const points = room.polygon - const resolvedDoorWallIndex = doorWallIndex ?? 0 - const { placements, bounds } = buildRoomPlacements(roomType, points, resolvedDoorWallIndex) - const skipped: string[] = [] - const items: AnyNode[] = [] - - const allNodes = Object.values(bridge.getNodes()) - // Doors on THIS level only (stacked floors must not interact in plan). - const existingKeepouts = collectDoorKeepouts(allNodes, { levelId: room.levelId }) - const doorKeepoutAabbs: PlanAabb[] = existingKeepouts.map((k) => k.aabb) - // Always protect this room's door-wall edge when no keep-out already covers it - // (other rooms may already have doors elsewhere on the same level). - const planned = keepoutForPolygonEdge(points, resolvedDoorWallIndex, { - t: 0.5, - width: 0.9, - }) - if ( - planned && - !doorKeepoutAabbs.some((existing) => keepoutCoversPlanned(existing, planned)) - ) { - doorKeepoutAabbs.push(planned) - } - - // Existing floor items on this level + footprints we place in this batch. - const occupied: PlanAabb[] = collectOccupiedFootprints(allNodes, { - levelId: room.levelId, - floorOnly: true, - }).map((f) => f.aabb) - - const roomBounds = { - minX: bounds.minX, - maxX: bounds.maxX, - minZ: bounds.minZ, - maxZ: bounds.maxZ, - } - - for (const placement of placements) { - const asset = findCatalogItem(placement.assetId) - if (!asset) { - skipped.push(`${placement.assetId}: asset not found`) - continue - } - - const primary = { - x: placement.x, - z: placement.z, - rotationDeg: placement.rotationDeg ?? 0, - } - const resolved = findValidPlacement({ - primary, - dimensions: asset.dimensions, - doorKeepouts: doorKeepoutAabbs, - occupied, - roomBounds, - along: placement.along, - inward: placement.inward, - }) - - if (!resolved.candidate) { - const reason = - resolved.reason === 'blocks_door_clearance' - ? 'blocks door clearance' - : resolved.reason === 'outside_bounds' - ? 'outside room bounds' - : 'overlaps another item' - skipped.push(`${asset.id}: ${reason}`) - continue - } - - const { x, z, rotationDeg } = resolved.candidate - const rotRad = (rotationDeg * Math.PI) / 180 - const planAabb = itemPlanAabb([x, 0, z], asset.dimensions, rotRad) - occupied.push(planAabb) - items.push( - ItemNode.parse({ - name: asset.name, - position: [x, 0, z], - rotation: [0, rotRad, 0], - asset: makeItemAsset(asset), - metadata: { - mcpTool: 'furnish_room', - roomType, - ...(x !== primary.x || z !== primary.z ? { placementAdjusted: true } : {}), - }, - }), - ) - } - - let persistence: LiveSyncStatus = 'published' - if (items.length > 0) { - bridge.applyPatch( - items.map((item) => ({ - op: 'create' as const, - node: item, - parentId: room.levelId as AnyNodeId, - })), - ) - persistence = await publishLiveSceneSnapshot(bridge, 'furnish_room') - } - - return textResult({ - placed: items.length, - itemIds: items.map((item) => item.id), - skipped, - ...persistencePayload(persistence), - }) + return textResult({ ...opening.result, ...persistencePayload(persistence) }) }, ) } +/** add_door and add_window; create_room and furnish_room are shared tools (shared-tools.ts). */ export function registerRoomTools( server: McpServer, bridge: SceneOperations, geometryScripts?: GeometryScriptHost, ): void { - registerSearchAssets(server) - registerCreateRoom(server, bridge) registerAddDoor(server, bridge, geometryScripts) registerAddWindow(server, bridge, geometryScripts) - registerFurnishRoom(server, bridge) } diff --git a/packages/mcp/src/tools/scene-lifecycle/create-project.ts b/packages/mcp/src/tools/scene-lifecycle/create-project.ts index 85750e7dc0..be58f2667d 100644 --- a/packages/mcp/src/tools/scene-lifecycle/create-project.ts +++ b/packages/mcp/src/tools/scene-lifecycle/create-project.ts @@ -54,6 +54,7 @@ export function registerCreateProject(server: McpServer, operations: SceneOperat ) } try { + const bound = operations.getActiveScene() const status = await operations.createProject({ name, ...(id !== undefined ? { id } : {}), @@ -67,9 +68,35 @@ export function registerCreateProject(server: McpServer, operations: SceneOperat thumbnailUrl: status.thumbnailUrl, version: status.version, }) + // A session bound to another project starts the new one empty: carried over, that + // project's scene was saved into the new one (2026-10-03). Unbound work is kept: it is + // what the new project is for. Emptied once bound to the new project, so nothing that + // follows the scene can write the empty scene to the old one. + if (bound && bound.projectId !== status.projectId) { + operations.loadJSON({ nodes: {}, rootNodeIds: [] }) + operations.loadDefault() + operations.clearHistory() + } + // A scene from the first call: its first draft is what this session holds (the empty + // scene, or the unbound work it was made for), so any session can load it at once. Unsaved, + // another session's load_scene answered scene_not_found until a first save. + operations.setActiveScene( + await operations.saveScene({ + id: status.id, + name: status.name, + projectId: status.projectId, + ownerId: status.ownerId, + thumbnailUrl: status.thumbnailUrl, + graph: operations.exportSceneGraph(), + saveMode: 'draft', + publish: false, + operation: 'create_project', + }), + ) + const saved = (await operations.getProjectStatus(status.id)) ?? status const payload = { ...projectStatusPayload( - status, + saved, 'The project is now bound to this MCP session. Open editorUrl now; semantic tools will update the browser-visible draft. Call save_scene with saveMode: "checkpoint" only when you want a meaningful version.', ), ...currentLevelContext(operations), diff --git a/packages/mcp/src/tools/scene-lifecycle/project-status.test.ts b/packages/mcp/src/tools/scene-lifecycle/project-status.test.ts index 68032c345b..a997eeaadb 100644 --- a/packages/mcp/src/tools/scene-lifecycle/project-status.test.ts +++ b/packages/mcp/src/tools/scene-lifecycle/project-status.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { type AnyNodeId, WallNode } from '@pascal-app/core' +import type { SceneOperations } from '../../operations' import { registerCreateProject } from './create-project' import { registerGetProjectStatus } from './get-project-status' import { @@ -14,10 +16,11 @@ import { describe('project lifecycle tools', () => { let client: Client let store: InMemorySceneStore + let operations: SceneOperations beforeEach(async () => { store = new InMemorySceneStore() - const { operations } = createTestSceneOperations({ store }) + ;({ operations } = createTestSceneOperations({ store })) const server = new McpServer({ name: 'test', version: '0.0.0' }) registerCreateProject(server, operations) registerGetProjectStatus(server, operations) @@ -36,10 +39,24 @@ describe('project lifecycle tools', () => { expect(parsed.name).toBe('Dogfood house') expect(typeof parsed.projectId).toBe('string') expect(parsed.editorUrl).toBe(`/editor/${parsed.projectId}`) - expect(parsed.nodeCount).toBe(0) expect(parsed.nextStep).toContain('save_scene') }) + // A project made in one session answered scene_not_found to load_scene from any + // other until its first save; an agent whose session reset could not open it again. + test('a new project is a saved scene at once, loadable from any session', async () => { + const result = await client.callTool({ + name: 'create_project', + arguments: { name: 'Fresh house' }, + }) + const parsed = parseToolText(result.content as StoredTextContent[]) + const elsewhere = createTestSceneOperations({ store }).operations + const scene = await elsewhere.loadStoredScene(parsed.projectId as string) + expect(scene).not.toBeNull() + expect(Object.keys(scene!.graph.nodes).length).toBe(parsed.nodeCount as number) + expect(parsed.nodeCount).toBeGreaterThan(0) + }) + test('reports status for an existing project', async () => { const project = await store.createProject({ name: 'Status house' }) const result = await client.callTool({ @@ -52,4 +69,46 @@ describe('project lifecycle tools', () => { expect(parsed.editorUrl).toBe(`/editor/${project.projectId}`) expect(parsed.nodeCount).toBe(0) }) + + // 2026-10-03, a Claude Code run on :3101: create_project in a session bound to another project + // returned that project's levels, and the next save would have written it into the new one. + const withAWall = () => { + operations.loadDefault() + const level = Object.values(operations.getNodes()).find((node) => node.type === 'level')! + operations.createNode( + WallNode.parse({ start: [0, 0], end: [4, 0] }) as never, + level.id as AnyNodeId, + ) + return level.id + } + const walls = () => Object.values(operations.getNodes()).filter((node) => node.type === 'wall') + + test('a session bound to another project starts the new one on an empty scene', async () => { + const other = await store.createProject({ name: 'Hawkesbury' }) + const otherLevel = withAWall() + operations.setActiveScene({ + id: other.id, + name: other.name, + projectId: other.projectId, + ownerId: other.ownerId, + thumbnailUrl: other.thumbnailUrl, + version: other.version, + }) + const result = await client.callTool({ + name: 'create_project', + arguments: { name: 'The Victor' }, + }) + const parsed = parseToolText(result.content as StoredTextContent[]) + expect(parsed.levelIds).not.toContain(otherLevel) + expect(walls()).toHaveLength(0) + expect(operations.getHistory().pastCount).toBe(0) + }) + + test('work not bound to any project becomes the new project', async () => { + withAWall() + const before = walls().length + await client.callTool({ name: 'create_project', arguments: { name: 'Built first' } }) + expect(walls().length).toBe(before) + expect(before).toBeGreaterThan(0) + }) }) diff --git a/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts index 7c8199e61a..038ce21133 100644 --- a/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts +++ b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts @@ -227,4 +227,65 @@ describe('save_scene', () => { }) expect(result.isError).toBe(true) }) + + // 2026-10-03, a Claude Code run on the hosted MCP: the server reloaded, the session started over + // on a blank scene, and save_scene(projectId) wrote it over the project's draft — 8 levels and + // 10 imported plans lost. + test('refuses to write a scene this session did not load over a project that holds one', async () => { + const project = await store.createProject({ name: 'The Victor' }) + const walls = Array.from({ length: 4 }, (_, index) => + WallNode.parse({ start: [index, 0], end: [index + 1, 0] }), + ) + await store.save({ + id: project.projectId, + name: 'The Victor', + projectId: project.projectId, + graph: { + nodes: Object.fromEntries(walls.map((wall) => [wall.id, wall])), + rootNodeIds: [], + } as never, + }) + const blank = await client.callTool({ + name: 'save_scene', + arguments: { name: 'The Victor', projectId: project.projectId }, + }) + expect(blank.isError).toBe(true) + expect(JSON.stringify(blank.content)).toContain('scene_not_loaded') + expect((await store.getProjectStatus(project.projectId))?.nodeCount).toBe(4) + // Said on purpose, it replaces. + const replaced = await client.callTool({ + name: 'save_scene', + arguments: { name: 'The Victor', projectId: project.projectId, replace: true }, + }) + expect(replaced.isError).toBeFalsy() + }) + + test('saves into a project that holds nothing yet', async () => { + const project = await store.createProject({ name: 'Empty' }) + const result = await client.callTool({ + name: 'save_scene', + arguments: { name: 'Empty', projectId: project.projectId }, + }) + expect(result.isError).toBeFalsy() + }) + + // The first save after create_project was refused "projectId is + // required for Supabase store. Call create_project first.", though the session held its project. + test("without a target, saves to the session's project", async () => { + const project = await store.createProject({ name: 'Hawkesbury' }) + const meta = await store.save({ + id: project.projectId, + name: 'Hawkesbury', + projectId: project.projectId, + graph: { nodes: bridge.getNodes(), rootNodeIds: bridge.getRootNodeIds() } as never, + }) + bridge.setActiveScene(meta) + const result = await client.callTool({ + name: 'save_scene', + arguments: { name: 'Hawkesbury', saveMode: 'checkpoint' }, + }) + expect(result.isError).toBeFalsy() + const saved = parseToolText(result.content as StoredTextContent[]) + expect(saved).toMatchObject({ id: project.projectId, projectId: project.projectId, version: 2 }) + }) }) diff --git a/packages/mcp/src/tools/scene-lifecycle/save-scene.ts b/packages/mcp/src/tools/scene-lifecycle/save-scene.ts index 89d1e57051..f84a5834e4 100644 --- a/packages/mcp/src/tools/scene-lifecycle/save-scene.ts +++ b/packages/mcp/src/tools/scene-lifecycle/save-scene.ts @@ -15,6 +15,12 @@ export const saveSceneInput = { name: z.string().min(1).max(200), projectId: z.string().optional(), expectedVersion: z.number().int().positive().optional(), + replace: z + .boolean() + .optional() + .describe( + "Write this session's scene over a project or scene it was not loaded from, replacing what that one holds.", + ), saveMode: z .enum(['draft', 'checkpoint']) .default('draft') @@ -61,13 +67,43 @@ export const saveSceneOutput = { defaultLevelId: z.string().nullable(), } +/** A saved scene that is more than the default site, building and level. */ +const HOLDS_CONTENT = 3 + +/** + * The session's scene goes over a project or scene only when it was loaded from it (or created + * for it). After a server reload a session starts over on a blank scene, and saving it by id wrote + * it over the project's draft (2026-10-03: 8 levels and 10 imported plans lost). + */ +async function requireSceneLoadedFrom( + bridge: SceneOperations, + target: { id?: string; projectId?: string }, +) { + if (target.id === undefined && target.projectId === undefined) return + const active = bridge.getActiveScene() + if (target.id !== undefined ? active?.id === target.id : active?.projectId === target.projectId) + return + const nodeCount = + target.projectId !== undefined && bridge.canGetProjectStatus + ? ((await bridge.getProjectStatus(target.projectId))?.nodeCount ?? 0) + : target.id !== undefined + ? Object.keys((await bridge.loadStoredScene(target.id))?.graph.nodes ?? {}).length + : 0 + if (nodeCount <= HOLDS_CONTENT) return + throwMcpError( + ErrorCode.InvalidRequest, + `scene_not_loaded: this session's scene was not loaded from ${target.projectId ?? target.id}, which holds ${nodeCount} nodes — the server may have reloaded and this session started over. Call load_scene with it first, or pass replace: true to write this scene over it.`, + { ...target, nodeCount }, + ) +} + export function registerSaveScene(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'save_scene', { title: 'Save scene', description: - 'Save the current scene (or a provided graph) to the SceneStore. Defaults to a browser-visible draft save so agents can iterate without creating many project versions. Use saveMode: "checkpoint" for meaningful version history.', + 'Save the current scene (or a provided graph) to the SceneStore. Without id or projectId it saves to the project this session is bound to (create_project or load_scene). Defaults to a browser-visible draft save so agents can iterate without creating many project versions. Use saveMode: "checkpoint" for meaningful version history.', inputSchema: saveSceneInput, outputSchema: saveSceneOutput, annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, @@ -82,8 +118,21 @@ export function registerSaveScene(server: McpServer, bridge: SceneOperations): v thumbnail, includeCurrentScene, graph, + replace, }) => { + // Without a target, the session's scene goes where it came from (create_project, load_scene, + // an earlier save). An agent was refused "Call create_project first" on its first save, right + // after create_project bound the session to its project. + const bound = + includeCurrentScene && id === undefined && projectId === undefined + ? bridge.getActiveScene() + : null + if (bound) { + id = bound.id + projectId = bound.projectId ?? undefined + } let sceneGraph: SceneGraph + if (includeCurrentScene && !replace) await requireSceneLoadedFrom(bridge, { id, projectId }) if (includeCurrentScene) { const validation = bridge.validateScene() if (!validation.valid) { diff --git a/packages/mcp/src/tools/scene-query.test.ts b/packages/mcp/src/tools/scene-query.test.ts index c178902517..1b3beffc2c 100644 --- a/packages/mcp/src/tools/scene-query.test.ts +++ b/packages/mcp/src/tools/scene-query.test.ts @@ -364,7 +364,10 @@ describe('scene query tools', () => { ) }) - test('verify_scene reports stair wall obstructions and missing destination slab openings', async () => { + // The live store cuts the stair's destination opening itself (an owned floor-opening on the upper + // floor), so only the obstruction is real; reporting the opening missing was a false + // stair_no_opening. + test('verify_scene reports a stair wall obstruction, not the opening the store cut', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! const upper = LevelNode.parse({ name: 'Upper Floor', level: 1 }) @@ -409,8 +412,8 @@ describe('scene query tools', () => { expect(parsed.issues.map((issue: { message: string }) => issue.message).join('\n')).toContain( 'obstructs stair Main Stair', ) - expect(parsed.issues.map((issue: { message: string }) => issue.message).join('\n')).toContain( - 'no destination slab opening', - ) + expect( + parsed.issues.map((issue: { message: string }) => issue.message).join('\n'), + ).not.toContain('no destination slab opening') }) }) diff --git a/packages/mcp/src/tools/shared-tools.test.ts b/packages/mcp/src/tools/shared-tools.test.ts index eeba39417c..16a96492fa 100644 --- a/packages/mcp/src/tools/shared-tools.test.ts +++ b/packages/mcp/src/tools/shared-tools.test.ts @@ -5,6 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId } from '@pascal-app/core/schema' import { AGENT_TOOL_CASES } from '../../../core/src/agent-operations/__fixtures__/cases' import { SceneBridge } from '../bridge/scene-bridge' +import { type AssetCatalog, builtInCatalog } from './asset-catalog' import { registerSharedTools } from './shared-tools' // Layer 2 of 3: the MCP tools, through a real client, on every shared tool's edge cases. @@ -17,11 +18,13 @@ type Result = { describe('shared tools over MCP', () => { let bridge: SceneBridge let client: Client + // The host's library for the case at hand: its own, else the server's built-in list. + let catalog: AssetCatalog = builtInCatalog beforeEach(async () => { bridge = new SceneBridge() const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerSharedTools(server, bridge) + registerSharedTools(server, bridge, () => catalog()) const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) @@ -33,6 +36,8 @@ describe('shared tools over MCP', () => { test(`${c.tool}: ${c.name}`, async () => { const { nodes, rootNodeIds } = c.scene() bridge.setScene(nodes as never, rootNodeIds as never) + const own = c.context?.catalog + catalog = own ? async () => own : builtInCatalog const result = (await client.callTool({ name: c.tool, arguments: c.input })) as Result const payload = JSON.parse(result.content[0]!.text) as Record if ('refusal' in c.expect) { @@ -58,6 +63,43 @@ describe('shared tools over MCP', () => { expect.objectContaining(entry), ) for (const text of c.expect.mentions ?? []) expect(JSON.stringify(payload)).toContain(text) + expect(c.expect.check?.(payload, bridge.getNodes()) ?? []).toEqual([]) }) } }) + +// The hosted MCP searched 23 built-in items, no light or plant, +// while the chat searched the app's library; the agent found no wall light and left them out. +describe("search_assets over MCP reads the host's catalog", () => { + async function search(query: string, catalog?: AssetCatalog) { + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerSharedTools(server, new SceneBridge(), catalog) + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + const result = (await client.callTool({ + name: 'search_assets', + arguments: { queries: [{ query }] }, + })) as Result + const payload = JSON.parse(result.content[0]!.text) + return payload.groups[0].results.map((item: { id: string }) => item.id) as string[] + } + + test('a standalone server searches its built-in list', async () => { + expect(await search('sofa')).toContain('sofa') + }) + + test('a host searches the library it passes, not the built-in list', async () => { + const palm = { + id: 'palm', + category: 'outdoor', + name: 'Palm', + tags: ['tree', 'plant', 'garden'], + thumbnail: '', + src: 'asset://palm', + dimensions: [1.5, 3, 1.5] as [number, number, number], + } + expect(await search('plant', async () => [palm])).toEqual(['palm']) + expect(await search('sofa', async () => [palm])).toEqual([]) + }) +}) diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index 9b1809bb70..5863c75854 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -2,19 +2,29 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { AGENT_OPERATIONS, type AgentOperation, + achievedChanges, + applyAgentOutcome, type SceneChanges, } from '@pascal-app/core/agent-operations' import { + addLevelTool, + addWallTool, + createRoomTool, + createStairTool, deleteNodeTool, duplicateLevelTool, findByTypeTool, fitStairTool, + furnishRoomTool, getLevelSummaryTool, getNodeTool, getWallsTool, getZonesTool, listLevelsTool, measureStairTool, + placeItemsTool, + ROOM_TOOL_CONTRACTS, + searchAssetsTool, verifySceneTool, } from '@pascal-app/core/agent-tools' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' @@ -26,9 +36,11 @@ import { DESTRUCTIVE_TOOL_ANNOTATIONS, READ_ONLY_TOOL_ANNOTATIONS, } from './annotations' +import { type AssetCatalog, builtInCatalog } from './asset-catalog' import { registerCollectionTools } from './collections' import { refusalResult } from './errors' import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { ROOM_TOOL_ANNOTATIONS, type RoomToolName, structureOutput } from './structure-tools' // Tools the MCP and the hosted chat share whole: one contract, one core operation. The MCP only // applies the operation's changes through its bridge and adds its own facts (scene, persistence). @@ -42,10 +54,24 @@ type SharedTool = { | typeof DESTRUCTIVE_TOOL_ANNOTATIONS outputSchema?: Record envelope?: (bridge: SceneOperations) => Record + /** Reads the host's item library: only these calls wait for it (the hosted one is a query). */ + catalog?: true } const jsonObject = z.record(z.string(), z.unknown()) +/** What the scene holds after a mutating call (core achievedChanges). */ +const achievedOutput = { + achieved: z + .object({ + created: z.record(z.string(), z.number()), + updated: z.number(), + deleted: z.record(z.string(), z.number()), + unchanged: z.literal(true).optional(), + }) + .optional(), +} + const levelRoleOutput = { levelId: z.string(), levelName: z.string().optional(), @@ -138,6 +164,9 @@ const SHARED_TOOLS: SharedTool[] = [ copied: z.record(z.string(), z.number()), skipped: z.record(z.string(), z.number()), newNodeIds: z.array(z.string()), + // A floor copy is hundreds of ids: the result lists 40 and counts the rest. + newNodeIdsOmitted: z.number().optional(), + ...achievedOutput, ...liveSyncOutput, }, }, @@ -160,15 +189,109 @@ const SHARED_TOOLS: SharedTool[] = [ z.object({ type: z.string(), message: z.string(), severity: z.literal('info').optional() }), ), hasIssues: z.boolean(), + authoredObjects: z + .array( + z.object({ + id: z.string(), + name: z.string(), + category: z.string(), + reason: z.string().nullable(), + }), + ) + .optional(), }, envelope: (bridge) => ({ activeSceneId: bridge.getActiveScene()?.id ?? null }), }, + { + contract: addWallTool, + operation: AGENT_OPERATIONS.add_wall, + annotations: ADDITIVE_TOOL_ANNOTATIONS, + }, + { + contract: addLevelTool, + operation: AGENT_OPERATIONS.add_level, + annotations: ADDITIVE_TOOL_ANNOTATIONS, + }, + { + contract: createStairTool, + operation: AGENT_OPERATIONS.create_stair, + annotations: ADDITIVE_TOOL_ANNOTATIONS, + }, + { + contract: placeItemsTool, + operation: AGENT_OPERATIONS.place_items, + annotations: ADDITIVE_TOOL_ANNOTATIONS, + catalog: true, + }, { contract: deleteNodeTool, operation: AGENT_OPERATIONS.delete_node, annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - outputSchema: { deletedIds: z.array(z.string()), ...liveSyncOutput }, + outputSchema: { deletedIds: z.array(z.string()), ...achievedOutput, ...liveSyncOutput }, + }, + { + contract: createRoomTool, + operation: AGENT_OPERATIONS.create_room, + annotations: ADDITIVE_TOOL_ANNOTATIONS, + outputSchema: { + ok: z.literal(true), + zoneId: z.string(), + // The floor plate and the ceiling the host derived; null where it derives none (a terrace). + slabId: z.string().nullable(), + ceilingId: z.string().nullable(), + // One per polygon edge; null where no wall runs along it. + wallIds: z.array(z.string().nullable()), + reusedWalls: z.number(), + areaSqMeters: z.number(), + doorIds: z.array(z.string()), + windowIds: z.array(z.string()), + skippedOpenings: z + .array( + z.object({ + kind: z.enum(['door', 'window']), + index: z.number(), + code: z.string(), + message: z.string(), + }), + ) + .optional(), + message: z.string(), + ...achievedOutput, + ...liveSyncOutput, + }, + }, + { + contract: furnishRoomTool, + operation: AGENT_OPERATIONS.furnish_room, + annotations: ADDITIVE_TOOL_ANNOTATIONS, + catalog: true, + outputSchema: { + ok: z.literal(true), + placed: z.number(), + itemIds: z.array(z.string()), + skipped: z.array(z.string()), + doorWallIndex: z.number(), + doorsDetected: z.number(), + message: z.string(), + ...achievedOutput, + ...liveSyncOutput, + }, + }, + { + contract: searchAssetsTool, + operation: AGENT_OPERATIONS.search_assets, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + catalog: true, }, + ...ROOM_TOOL_CONTRACTS.map((contract) => { + const name = contract.name as RoomToolName + return { + contract, + operation: AGENT_OPERATIONS[name], + annotations: ROOM_TOOL_ANNOTATIONS[name], + outputSchema: { ...structureOutput, ...achievedOutput }, + } + }), ] export function toPatches(changes: SceneChanges): Patch[] { @@ -191,7 +314,11 @@ export function toPatches(changes: SceneChanges): Patch[] { ] } -export function registerSharedTools(server: McpServer, bridge: SceneOperations): void { +export function registerSharedTools( + server: McpServer, + bridge: SceneOperations, + catalog: AssetCatalog = builtInCatalog, +): void { for (const tool of SHARED_TOOLS) { server.registerTool( tool.contract.name, @@ -199,27 +326,53 @@ export function registerSharedTools(server: McpServer, bridge: SceneOperations): title: tool.contract.title, description: tool.contract.description, inputSchema: tool.contract.input, - ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}), + // Loose: a client that listed the tools rejects any field the schema leaves out, and the + // operations in core grow fields (verify_scene's guesses) that this list would miss. + ...(tool.outputSchema ? { outputSchema: z.looseObject(tool.outputSchema) } : {}), annotations: tool.annotations, }, async (input: Record) => { let outcome: ReturnType + // A copy of the map: a host may write its own in place (the hosted bridge does), and a + // "before" that grows with the call reads every creation as unchanged. + const before = { ...(bridge.getNodes() as Record) } + const context = { + activeLevelId: null, + ...(tool.catalog && { catalog: await catalog() }), + } try { - outcome = tool.operation(bridge.getNodes() as Record, input as never, { - activeLevelId: null, - }) + outcome = tool.operation(before, input as never, context) } catch (error) { return refusalResult(error) } const patches = outcome.changes ? toPatches(outcome.changes) : [] + let result = outcome.result let persistence = {} if (patches.length) { - bridge.applyPatch(patches) + result = bridge.runAsSingleHistoryStep(() => + applyAgentOutcome(outcome, { + getNodes: () => bridge.getNodes(), + applyChanges: (changes) => { + const next = toPatches(changes) + if (next.length) bridge.applyPatch(next) + }, + reconcile: () => { + bridge.deriveStructure() + }, + }), + ) persistence = persistencePayload( await publishLiveSceneSnapshot(bridge, tool.contract.name), ) } - const payload = { ...outcome.result, ...(tool.envelope?.(bridge) ?? {}), ...persistence } + // What the scene holds after the call, not only what the call says it built. + const achieved = outcome.changes ? achievedChanges(before, outcome.changes) : null + const payload = { + ...result, + ...(achieved ? { achieved } : {}), + ...(tool.envelope?.(bridge) ?? {}), + ...persistence, + } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], structuredContent: payload, diff --git a/packages/mcp/src/tools/structure-tools.ts b/packages/mcp/src/tools/structure-tools.ts index f199570ca3..82173c8978 100644 --- a/packages/mcp/src/tools/structure-tools.ts +++ b/packages/mcp/src/tools/structure-tools.ts @@ -1,90 +1,33 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { - type AnyNodeId, - applyZoneTransformPlan, - containsPoint, - createMezzanine, - cutFloorOpening, - type DeleteZonePayload, - deleteZone, - divideZone, - duplicateZone, - FloorFoundationPatch, - generateId, - type HostedZoneTransformPlan, - lockOutsideFaces, - mergeZones, - rebaseFloorReference, - removeFloorOpening, - rotateZone, - setFloorFoundation, - setRoomFloorConstruction, - setZoneIntent, - transformZone, - ZoneIntentPatch, -} from '@pascal-app/core' +import type { ROOM_OPERATIONS } from '@pascal-app/core/agent-operations' import { z } from 'zod' -import type { SceneOperations } from '../operations' import { ADDITIVE_TOOL_ANNOTATIONS, DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' -import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' -import { NodeIdSchema } from './schemas' +import { liveSyncOutput } from './live-sync' -export const setZoneIntentInput = { zoneId: NodeIdSchema, patch: ZoneIntentPatch } -export const divideZoneInput = { - zoneId: NodeIdSchema, - cut: z.tuple([z.tuple([z.number(), z.number()]), z.tuple([z.number(), z.number()])]).optional(), - path: z - .array(z.tuple([z.number(), z.number()])) - .min(2) - .optional(), - closed: z.boolean().optional(), - startBoundaryId: NodeIdSchema.optional(), - endBoundaryId: NodeIdSchema.optional(), -} -export const mergeZonesInput = { zoneIds: z.tuple([NodeIdSchema, NodeIdSchema]) } -export const deleteZoneInput = { zoneId: NodeIdSchema, contents: z.enum(['delete', 'keep']) } -const point = z.tuple([z.number().finite(), z.number().finite()]) -export const createMezzanineInput = { - hostZoneId: NodeIdSchema, - polygon: z.array(point).min(3), - elevation: z.number().finite().optional(), - thickness: z.number().finite().min(0.02).optional(), -} -export const cutFloorOpeningInput = { - levelId: NodeIdSchema.optional(), - zoneId: NodeIdSchema.optional(), - levelIds: z.array(NodeIdSchema).min(1).optional(), - polygon: z.array(point).min(3).optional(), - rect: z - .object({ - x: z.number().finite(), - z: z.number().finite(), - width: z.number().finite().positive(), - depth: z.number().finite().positive(), - }) - .optional(), - drawnOn: z.enum(['floor', 'ceiling']).optional(), - cutsPrimary: z.boolean().optional(), - cutsAdjacent: z.boolean().optional(), -} -const rotate = z.object({ angle: z.number().finite(), pivot: point.optional() }).optional() -export const moveZoneInput = { - zoneId: NodeIdSchema, - translate: point.optional(), - rotate, - force: z.boolean().optional(), -} -export const duplicateZoneInput = { ...moveZoneInput, translate: point } -export const rotateZoneInput = { - zoneId: NodeIdSchema, - quarterTurns: z.union([z.literal(1), z.literal(-1)]), - gridStep: z.number().finite().positive().optional(), - force: z.boolean().optional(), -} -export const lockOutsideFacesInput = { - levelId: NodeIdSchema.optional(), - zoneIds: z.array(NodeIdSchema).min(1).optional(), +// The MCP's side of the room and floor-construction tools, whose contracts and operations are +// core's (shared-tools.ts registers them): which ones only add, and what their answers hold. + +export type RoomToolName = keyof typeof ROOM_OPERATIONS + +export const ROOM_TOOL_ANNOTATIONS: Record< + RoomToolName, + typeof ADDITIVE_TOOL_ANNOTATIONS | typeof DESTRUCTIVE_TOOL_ANNOTATIONS +> = { + cut_floor_opening: ADDITIVE_TOOL_ANNOTATIONS, + remove_floor_opening: DESTRUCTIVE_TOOL_ANNOTATIONS, + set_floor_foundation: ADDITIVE_TOOL_ANNOTATIONS, + set_room_floor_construction: ADDITIVE_TOOL_ANNOTATIONS, + rebase_floor_reference: DESTRUCTIVE_TOOL_ANNOTATIONS, + create_mezzanine: ADDITIVE_TOOL_ANNOTATIONS, + move_zone: DESTRUCTIVE_TOOL_ANNOTATIONS, + duplicate_zone: DESTRUCTIVE_TOOL_ANNOTATIONS, + rotate_zone: DESTRUCTIVE_TOOL_ANNOTATIONS, + lock_outside_faces: DESTRUCTIVE_TOOL_ANNOTATIONS, + set_zone_intent: ADDITIVE_TOOL_ANNOTATIONS, + divide_zone: DESTRUCTIVE_TOOL_ANNOTATIONS, + merge_zones: DESTRUCTIVE_TOOL_ANNOTATIONS, + delete_zone: DESTRUCTIVE_TOOL_ANNOTATIONS, } + export const structureOutput = { zoneId: z.string().optional(), openingId: z.string().optional(), @@ -103,7 +46,7 @@ export const structureOutput = { changes: z.number(), separatorId: z.string().optional(), separatorIds: z.array(z.string()).optional(), - zoneIds: z.tuple([z.string(), z.string()]).optional(), + zoneIds: z.array(z.string()).optional(), payload: z .object({ zoneId: z.string(), @@ -125,334 +68,3 @@ export const structureOutput = { .optional(), ...liveSyncOutput, } - -async function apply( - bridge: SceneOperations, - tool: string, - plan: HostedZoneTransformPlan & { - separatorId?: string - separatorIds?: string[] - payload?: DeleteZonePayload - zoneId?: string - openingId?: string - openingIds?: string[] - hints?: Array<{ - code: 'manual-ceiling' - openingId: string - surfaceIds: string[] - message: string - }> - }, - force = false, -) { - let plannedZone = plan.zoneId ? bridge.getNode(plan.zoneId as AnyNodeId) : undefined - for (const change of plan.changes) { - if (change.op === 'create' && change.node.id === plan.zoneId) plannedZone = change.node - else if (change.op === 'update' && change.id === plan.zoneId && plannedZone?.type === 'zone') - plannedZone = { ...plannedZone, ...change.data } as typeof plannedZone - } - if (plan.changes.length && (!plan.conflicts?.length || force)) - bridge.runAsSingleHistoryStep(() => - applyZoneTransformPlan(plan, { - getNodes: () => bridge.getNodes(), - applyChanges: (changes) => - bridge.applyPatch( - changes.map((change) => - change.op === 'create' - ? { ...change, parentId: change.node.parentId as never } - : change, - ), - ), - reconcile: () => { - bridge.deriveStructure() - }, - }), - ) - const zoneIds = - plan.separatorIds?.length && !plan.conflicts?.length - ? Object.values(bridge.getNodes()) - .filter( - (node) => - node.type === 'zone' && - plan.separatorIds!.some((id) => node.boundarySeparatorIds.includes(id)), - ) - .map((node) => node.id) - .sort() - : undefined - let resultZoneId = plan.zoneId - if ( - plan.changes.length && - resultZoneId && - !bridge.getNode(resultZoneId as AnyNodeId) && - plannedZone?.type === 'zone' && - plannedZone.seed - ) { - const { seed, parentId } = plannedZone - resultZoneId = - Object.values(bridge.getNodes()).find( - (node) => - node.type === 'zone' && - node.parentId === parentId && - containsPoint([{ outer: node.polygon, holes: node.holes }], seed), - )?.id ?? resultZoneId - } - const idMap = - plan.idMap && - Object.fromEntries( - Object.entries(plan.idMap).map(([id, targets]) => [ - id, - targets.map((target) => (target === plan.zoneId ? resultZoneId! : target)), - ]), - ) - const payload = { - changes: plan.changes.length, - ...(resultZoneId ? { zoneId: resultZoneId } : {}), - ...(plan.openingId ? { openingId: plan.openingId } : {}), - ...(plan.openingIds ? { openingIds: plan.openingIds } : {}), - ...(plan.hints ? { hints: plan.hints } : {}), - ...(idMap ? { idMap } : {}), - ...(zoneIds ? { zoneIds } : {}), - ...(plan.payload ? { payload: plan.payload } : {}), - ...(plan.separatorId ? { separatorId: plan.separatorId } : {}), - ...(plan.separatorIds ? { separatorIds: plan.separatorIds } : {}), - ...(plan.conflicts ? { conflicts: plan.conflicts } : {}), - ...((plan.conflicts?.length && !force) || !plan.changes.length - ? {} - : persistencePayload(await publishLiveSceneSnapshot(bridge, tool))), - } - return { - content: [{ type: 'text' as const, text: JSON.stringify(payload) }], - structuredContent: payload, - } -} - -export function registerStructureTools(server: McpServer, bridge: SceneOperations) { - server.registerTool( - 'cut_floor_opening', - { - title: 'Cut floor or ceiling opening', - description: - 'Create a persistent opening on a room or level from a polygon or rectangle. A floor cut also opens the automatic ceiling directly below; a ceiling cut can also open the floor above. Use cutsAdjacent:false for a one-surface cut. A mezzanine room cuts only its own plate. levelIds repeats the opening as a shaft on several levels.', - inputSchema: cutFloorOpeningInput, - outputSchema: structureOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async (input) => - apply( - bridge, - 'cut_floor_opening', - cutFloorOpening(bridge.getNodes(), { ...input, mintId: generateId }), - ), - ) - server.registerTool( - 'remove_floor_opening', - { - title: 'Remove floor opening', - description: - 'Remove an authored floor or ceiling opening and restore the derived surfaces it cut.', - inputSchema: { id: NodeIdSchema }, - outputSchema: structureOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async ({ id }) => - apply(bridge, 'remove_floor_opening', removeFloorOpening(bridge.getNodes(), id)), - ) - server.registerTool( - 'set_floor_foundation', - { - title: 'Set floor and foundation', - description: - 'Supply slabId or slabIds; grouped plates receive the same floor-top displacement, while edge slots apply only to the first plate. A ground-contact floor has two inputs: thickness (slab, min 0.01 m, grows upward from the foundation) and foundationHeight (0 = on the ground, no foundation; > 0 = raised on a solid foundation; foundation.type "solid" alone raises it 0.3 m, "none" puts it on the ground). The floor top is derived and never floats: grade + foundationHeight + thickness; everything the floor carries (walls, openings, items, rooms, storeys above) moves with the top. Legacy floorHeight (a target top) maps to foundationHeight = top - grade - thickness, never below 0; null = on the ground. On upper plates the underside stays on the walls below: thickness (or floorHeight as a target top) changes the top; only ground-contact plates accept a foundation. Explicit room floors translate with the floor to preserve steps.', - inputSchema: { - slabId: NodeIdSchema.optional(), - slabIds: z.array(NodeIdSchema).min(1).optional(), - patch: FloorFoundationPatch, - }, - outputSchema: structureOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async (input) => - apply(bridge, 'set_floor_foundation', setFloorFoundation(bridge.getNodes(), input)), - ) - server.registerTool( - 'set_room_floor_construction', - { - title: 'Set room floor construction', - description: - 'Edit the single floor volume associated with a room, with the same patch as set_floor_foundation. On a ground-contact floor: thickness (slab on the foundation, grows upward, min 0.01 m) and foundationHeight (0 = on the ground; > 0 = raised on a solid foundation); the top is derived as grade + foundationHeight + thickness and carries everything on the floor. A drawn slab takes thickness, finishes and a numeric floorHeight (its top) only. Supply slabId when the room spans multiple construction domains; an absorbed legacy slab is edited through its derived base plate.', - inputSchema: { - zoneId: NodeIdSchema, - slabId: NodeIdSchema.optional(), - patch: FloorFoundationPatch, - }, - outputSchema: structureOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async (input) => - apply( - bridge, - 'set_room_floor_construction', - setRoomFloorConstruction(bridge.getNodes(), input), - ), - ) - server.registerTool( - 'rebase_floor_reference', - { - title: 'Rebase floor reference', - description: - 'Change or clear one or more footprint reference datums in one atomic edit, compensating supported storey offsets so existing world geometry stays in place. Supply slabIds together for a shared storey.', - inputSchema: { - slabId: NodeIdSchema.optional(), - slabIds: z.array(NodeIdSchema).min(1).optional(), - referenceFloorElevation: z.number().finite().nullable(), - }, - outputSchema: structureOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async (input) => - apply(bridge, 'rebase_floor_reference', rebaseFloorReference(bridge.getNodes(), input)), - ) - server.registerTool( - 'create_mezzanine', - { - title: 'Create mezzanine', - description: - 'Add an open-below room inside a host reference polygon, with a separate thin plate and railings on open edges. Minimum area 1 m²; elevation defaults to half the storey snapped to 0.05 m, thickness to 0.2 m. Elevation must exceed thickness and stay at least 0.3 m below the resolved footprint plane; overlapping mezzanines are refused. The host floor and walls stay intact.', - inputSchema: createMezzanineInput, - outputSchema: structureOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async (input) => - apply( - bridge, - 'create_mezzanine', - createMezzanine(bridge.getNodes(), { ...input, mintId: generateId }), - ), - ) - server.registerTool( - 'move_zone', - { - title: 'Move or rotate room', - description: - 'Move room intent and contents. Shared walls and openings stay with neighbours; the room takes plain copies. Rotation is radians about Y, before translation, about the room centroid unless a pivot is supplied. Crossings split walls and overlaps re-derive rooms. Force can reposition obstructing openings when they fit; otherwise the edit is refused.', - inputSchema: moveZoneInput, - outputSchema: structureOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async (input) => - apply( - bridge, - 'move_zone', - transformZone(bridge.getNodes(), { ...input, mintId: generateId }), - input.force, - ), - ) - server.registerTool( - 'duplicate_zone', - { - title: 'Duplicate room', - description: - 'Copy room intent and contents with fresh IDs; shared walls are copied without openings. Rotation is radians about Y before translation. Collinear destination walls are merged, preserving their IDs and hosted children. Crossings split walls and rooms re-derive; overlap is allowed. Force can reposition obstructing openings only when they fit.', - inputSchema: duplicateZoneInput, - outputSchema: structureOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async (input) => - apply( - bridge, - 'duplicate_zone', - duplicateZone(bridge.getNodes(), { ...input, mintId: generateId }), - input.force, - ), - ) - server.registerTool( - 'rotate_zone', - { - title: 'Rotate room a quarter turn', - description: - 'Rotate a room by quarterTurns 1 or -1 about its centroid, then align its first reference vertex to gridStep (default 0.5 m). Crossings split walls and collinear overlaps merge. Force can reposition obstructing openings only when they fit.', - inputSchema: rotateZoneInput, - outputSchema: structureOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async (input) => - apply( - bridge, - 'rotate_zone', - rotateZone(bridge.getNodes(), { ...input, mintId: generateId }), - input.force, - ), - ) - server.registerTool( - 'lock_outside_faces', - { - title: 'Keep outside dimensions', - description: - "Keep the building's outer size: put exterior wall faces on their fixed reference lines, moving wall bodies inward so walls thicken inward and later thickness edits preserve outer dimensions. Supply either levelId or zoneIds.", - inputSchema: lockOutsideFacesInput, - outputSchema: structureOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async (input) => { - if (Boolean(input.levelId) === Boolean(input.zoneIds)) - throw Error('Supply either levelId or zoneIds.') - return apply( - bridge, - 'lock_outside_faces', - lockOutsideFaces( - bridge.getNodes(), - input.levelId ? { levelId: input.levelId } : { zoneIds: input.zoneIds! }, - ), - ) - }, - ) - server.registerTool( - 'set_zone_intent', - { - title: 'Set room intent', - description: - 'Update room name, floor finish/elevation/regions, floor.footprint ("new" mints a unique floor key tied to this creator room, an existing key joins that floor on this level, null returns to the shared floor). Touching rooms with the same key share a base plate; disconnected pieces share construction settings. Conversion preserves world geometry in one edit and refuses when infeasible. get_zones exposes floor_choices. A keyed floor uses " floor" while the creator remains on that key; otherwise it uses the largest named room. Existing keys such as "own" without a creator use that fallback. A user-given plate name wins; the shared floor stays "Shared floor". Keyed rooms can have raised or sunken floor.elevation, stored as a level-local walking top. Slab thickness, foundation height and edge finish use set_room_floor_construction, updating every piece of the key. Update ceiling paint regions (ceiling.regions: [x, z] polygons with a finish, later wins), step finishes (floorStepFinish for every step of the room; floorStepOverrides: [{ key, step?, finish }] per doorway, key = the door id the step sits under or the lower room id), wall material and construction opt-outs. Absent fields are unchanged; null clears.', - inputSchema: setZoneIntentInput, - outputSchema: structureOutput, - annotations: ADDITIVE_TOOL_ANNOTATIONS, - }, - async (input) => apply(bridge, 'set_zone_intent', setZoneIntent(bridge.getNodes(), input)), - ) - server.registerTool( - 'divide_zone', - { - title: 'Divide room', - description: - 'Divide a room with an open path (endpoints snap to its boundary; the seed side keeps its id) or a closed island (outer room keeps its id). Supply path or the legacy two-point cut. Islands need 0.25 m² and 5 cm wall clearance.', - inputSchema: divideZoneInput, - outputSchema: structureOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async (input) => - apply(bridge, 'divide_zone', divideZone(bridge.getNodes(), { ...input, mintId: generateId })), - ) - server.registerTool( - 'merge_zones', - { - title: 'Merge rooms', - description: - 'Remove shared separators between two rooms. Walls are preserved; wall-only boundaries return a conflict.', - inputSchema: mergeZonesInput, - outputSchema: structureOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async (input) => apply(bridge, 'merge_zones', mergeZones(bridge.getNodes(), input)), - ) - server.registerTool( - 'delete_zone', - { - title: 'Delete room', - description: - 'Delete a room. A room Divide made (a separator shared with a room on its outline) merges back into that room: the separators go, walls and items stay (mode "merge"). A room whose every boundary is a wall shared with other rooms is refused with a "shared-walls" conflict and nothing changes (mode "blocked"); delete one of its walls instead. Otherwise the room goes with its unshared walls, separators and their openings (mode "delete"), and contents chooses whether its items are kept or deleted. Returns the disposition payload.', - inputSchema: deleteZoneInput, - outputSchema: structureOutput, - annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, - }, - async (input) => apply(bridge, 'delete_zone', deleteZone(bridge.getNodes(), input)), - ) -} diff --git a/packages/mcp/src/tools/undo.ts b/packages/mcp/src/tools/undo.ts index f83f2d05b1..ea4edd7eef 100644 --- a/packages/mcp/src/tools/undo.ts +++ b/packages/mcp/src/tools/undo.ts @@ -2,7 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import type { SceneOperations } from '../operations' import { DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' -import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { + currentLiveSync, + liveSyncOutput, + persistencePayload, + publishLiveSceneSnapshot, +} from './live-sync' export const undoInput = { steps: z.number().int().positive().optional(), @@ -27,7 +32,7 @@ export function registerUndo(server: McpServer, bridge: SceneOperations): void { async ({ steps }) => { const undone = bridge.undo(steps ?? 1) const persistence = - undone > 0 ? await publishLiveSceneSnapshot(bridge, 'undo') : ('published' as const) + undone > 0 ? await publishLiveSceneSnapshot(bridge, 'undo') : currentLiveSync(bridge) const payload = { undone, ...persistencePayload(persistence) } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/units.test.ts b/packages/mcp/src/tools/units.test.ts index e738407048..0e4dff782e 100644 --- a/packages/mcp/src/tools/units.test.ts +++ b/packages/mcp/src/tools/units.test.ts @@ -216,7 +216,11 @@ describe('unit tools', () => { bridge.setActiveScene(meta) const result = await call('set_unit_members', { unitId, memberZoneIds: [zoneId, upperZoneId] }) expect(result.isError).toBeFalsy() - expect(result.structuredContent).toEqual({ unitId, memberCount: 2 }) + expect(result.structuredContent).toEqual({ + unitId, + memberCount: 2, + project: expect.any(String), + }) expect((await store.load(meta.id))?.graph.nodes[unitId]).toMatchObject({ members: [zoneId, upperZoneId], }) diff --git a/packages/mcp/src/tools/view-scene.test.ts b/packages/mcp/src/tools/view-scene.test.ts new file mode 100644 index 0000000000..739dbe380c --- /dev/null +++ b/packages/mcp/src/tools/view-scene.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { refuse } from '@pascal-app/core/agent-tools' +import { WallNode } from '@pascal-app/core/schema' +import { SceneBridge } from '../bridge/scene-bridge' +import { createTestSceneOperations } from './scene-lifecycle/test-utils' +import { registerViewScene, type SceneViewHost } from './view-scene' + +// An agent with no view over the MCP (no editor, export_glb unavailable headless) +// and drew its own elevation from coordinates, without materials. Over the MCP the picture comes +// from an editor tab open on the project, which the host asks. + +type Content = { type: string; text?: string; data?: string; mimeType?: string } + +async function viewWith(host: SceneViewHost | undefined, { project = true } = {}) { + const bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + const level = Object.values(bridge.getNodes()).find((node) => node.type === 'level')! + for (const [start, end] of [ + [ + [0, 0], + [12, 0], + ], + [ + [12, 0], + [12, 8], + ], + ] as [number, number][][]) + bridge.createNode(WallNode.parse({ start, end }), level.id) + const { operations } = createTestSceneOperations({ bridge }) + if (project) + operations.setActiveScene({ + id: 'scene_a', + name: 'Hawkesbury', + projectId: 'project_a', + ownerId: null, + thumbnailUrl: null, + version: 3, + }) + const server = new McpServer({ name: 'view', version: '1' }) + registerViewScene(server, operations, host) + const [a, b] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'view', version: '1' }) + await Promise.all([server.connect(a), client.connect(b)]) + return { + call: async (args: Record) => { + const result = await client.callTool({ name: 'view_scene', arguments: args }) + return { isError: !!result.isError, content: result.content as Content[] } + }, + close: async () => { + await client.close() + await server.close() + }, + } +} + +const json = (content: Content[]) => + JSON.parse(content.find((part) => part.type === 'text')!.text!) as Record + +describe('view_scene over the MCP', () => { + test('an editor tab renders the view the tool sets, and the picture comes back as an image', async () => { + const asked: unknown[] = [] + const view = await viewWith({ + capture: async (request) => { + asked.push(request) + return { + image: new Uint8Array([1, 2, 3]), + mimeType: 'image/webp', + width: request.size.w, + height: request.size.h, + tab: 'tab_1', + capturedAt: '2026-10-05T15:30:00.000Z', + } + }, + }) + try { + const { isError, content } = await view.call({ from: 'south', eyeHeight: 1.7 }) + expect(isError).toBe(false) + expect(asked).toMatchObject([ + { projectId: 'project_a', pose: { projection: 'perspective' }, size: { w: 1280, h: 800 } }, + ]) + expect((asked[0] as { pose: { position: number[] } }).pose.position[1]).toBe(1.7) + expect(content.find((part) => part.type === 'image')).toEqual({ + type: 'image', + data: Buffer.from([1, 2, 3]).toString('base64'), + mimeType: 'image/webp', + }) + expect(json(content)).toMatchObject({ + status: 'viewed', + size: { width: 1280, height: 800 }, + tab: 'tab_1', + capturedAt: '2026-10-05T15:30:00.000Z', + }) + expect(String(json(content).note)).toContain('not a measure') + } finally { + await view.close() + } + }) + + test("the photo's camera renders at the photo's aspect", async () => { + const sizes: unknown[] = [] + const view = await viewWith({ + capture: async (request) => { + sizes.push(request.size) + return { + image: new Uint8Array([0]), + mimeType: 'image/webp', + width: request.size.w, + height: request.size.h, + tab: 'tab_1', + capturedAt: '2026-10-05T15:30:00.000Z', + } + }, + }) + try { + const camera = { position: [6, 1.6, 20], target: [6, 2, 4], fov: 50, aspect: 1.5, shift: 0 } + expect((await view.call({ camera })).isError).toBe(false) + expect(sizes).toEqual([{ w: 1280, h: 853 }]) + } finally { + await view.close() + } + }) + + // An agent cropped the photo 9 times in the shell and compared whole facades only. The photo's + // crop of the element comes back beside the close-up, each image after its label. + test("the photo's crop comes back beside the view, each image labelled", async () => { + const crops: unknown[] = [] + const view = await viewWith({ + capture: async (request) => ({ + image: new Uint8Array([1]), + mimeType: 'image/webp', + width: request.size.w, + height: request.size.h, + tab: 'tab_1', + capturedAt: '2026-10-05T19:55:00.000Z', + }), + crop: async (request) => { + crops.push(request) + return { image: new Uint8Array([9]), mimeType: 'image/png', width: 560, height: 270 } + }, + }) + try { + const photo = { source: 'data:image/png;base64,AAAA', region: [60, 200, 620, 470] } + const { isError, content } = await view.call({ photo }) + expect(isError).toBe(false) + expect(crops).toEqual([photo]) + expect(content.filter((part) => part.type === 'image').map((part) => part.data)).toEqual([ + Buffer.from([1]).toString('base64'), + Buffer.from([9]).toString('base64'), + ]) + const texts = content.filter((part) => part.type === 'text').map((part) => part.text ?? '') + expect(texts.some((text) => text.includes('photo'))).toBe(true) + expect(json(content)).toMatchObject({ photoRegion: [60, 200, 620, 470] }) + } finally { + await view.close() + } + }) + + test('a host that cannot crop a photo says so', async () => { + const view = await viewWith({ + capture: async () => refuse('unexpected', 'not called'), + }) + try { + const { content } = await view.call({ photo: { source: 'x', region: [0, 0, 10, 10] } }) + expect(json(content)).toMatchObject({ code: 'photo_crop_unavailable' }) + } finally { + await view.close() + } + }) + + test('with no editor open on the project, it says so', async () => { + const view = await viewWith({ + capture: async () => + refuse('editor_tab_required', 'No editor tab answered: open the project in the editor.'), + }) + try { + const { isError, content } = await view.call({}) + expect(isError).toBe(true) + expect(json(content)).toMatchObject({ code: 'editor_tab_required' }) + } finally { + await view.close() + } + }) + + test('a server with no editor to ask, or a session with no project, is refused', async () => { + const bare = await viewWith(undefined) + try { + expect(json((await bare.call({})).content)).toMatchObject({ code: 'view_unavailable' }) + } finally { + await bare.close() + } + const unbound = await viewWith( + { capture: async () => refuse('unexpected', 'not called') }, + { project: false }, + ) + try { + expect(json((await unbound.call({})).content)).toMatchObject({ code: 'no_project' }) + } finally { + await unbound.close() + } + }) +}) diff --git a/packages/mcp/src/tools/view-scene.ts b/packages/mcp/src/tools/view-scene.ts new file mode 100644 index 0000000000..d609ff1a2d --- /dev/null +++ b/packages/mcp/src/tools/view-scene.ts @@ -0,0 +1,115 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + type SceneViewCrop, + type SceneViewPose, + sceneViewNote, + sceneViewPlan, +} from '@pascal-app/core/agent-operations' +import { refuse, viewSceneTool } from '@pascal-app/core/agent-tools' +import type { AnyNode } from '@pascal-app/core/schema' +import type { SceneOperations } from '../operations' +import { READ_ONLY_TOOL_ANNOTATIONS } from './annotations' +import { refusalResult } from './errors' + +/** A picture of the scene, rendered by an editor tab open on the project. */ +export type SceneViewCapture = { + image: Uint8Array + mimeType: string + width: number + height: number + /** Which tab rendered it, and when: the scene as that tab showed it then. */ + tab: string + capturedAt: string +} + +/** + * Asks an editor open on the project for a picture, the MCP having no renderer of its own. Refuses + * `editor_tab_required` when none answers. + */ +export type SceneViewHost = { + capture(request: { + projectId: string + pose: SceneViewPose + size: { w: number; h: number } + }): Promise + /** Crops the reference photo to a region; without it a view with a photo is refused. */ + crop?( + request: SceneViewCrop, + ): Promise<{ image: Uint8Array; mimeType: string; width: number; height: number }> +} + +export function registerViewScene( + server: McpServer, + operations: SceneOperations, + views?: SceneViewHost, +) { + server.registerTool( + viewSceneTool.name, + { + title: viewSceneTool.title, + description: viewSceneTool.description, + inputSchema: viewSceneTool.input, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + }, + async (input) => { + try { + if (!views) + refuse( + 'view_unavailable', + 'This Pascal server has no editor to render with: take sizes and counts from the tools.', + ) + const projectId = operations.getActiveScene()?.projectId + if (!projectId) + refuse('no_project', 'This session has no project open to look at: load or create one.') + const { pose, size, crop } = sceneViewPlan( + operations.getNodes() as Record, + input, + ) + if (crop && !views!.crop) + refuse( + 'photo_crop_unavailable', + 'This Pascal server cannot crop a photo: crop the region yourself and lay it beside the view.', + ) + const [shot, cropped] = await Promise.all([ + views!.capture({ projectId: projectId!, pose, size }), + crop ? views!.crop!(crop) : undefined, + ]) + const payload = { + status: 'viewed', + camera: pose, + size: { width: shot.width, height: shot.height }, + tab: shot.tab, + capturedAt: shot.capturedAt, + note: sceneViewNote(), + ...(crop && cropped + ? { + photoRegion: crop.region, + photoCropSize: { width: cropped.width, height: cropped.height }, + } + : {}), + } + const image = (data: Uint8Array, mimeType: string) => ({ + type: 'image' as const, + data: Buffer.from(data).toString('base64'), + mimeType, + }) + return { + content: cropped + ? [ + { type: 'text' as const, text: JSON.stringify(payload) }, + { type: 'text' as const, text: 'The model, from the view:' }, + image(shot.image, shot.mimeType), + { type: 'text' as const, text: 'The photo, cropped to the region:' }, + image(cropped.image, cropped.mimeType), + ] + : [ + image(shot.image, shot.mimeType), + { type: 'text' as const, text: JSON.stringify(payload) }, + ], + } + } catch (error) { + return refusalResult(error) + } + }, + ) +} diff --git a/packages/mcp/src/tools/wall-openings.test.ts b/packages/mcp/src/tools/wall-openings.test.ts index 5d6905a912..fd1fcb7ce9 100644 --- a/packages/mcp/src/tools/wall-openings.test.ts +++ b/packages/mcp/src/tools/wall-openings.test.ts @@ -52,6 +52,7 @@ describe('add_door / add_window over MCP', () => { } expect(node.position[1]).toBeCloseTo(c.expect.centerY, 6) if (c.expect.glassPanels) expect(JSON.stringify(node)).toContain('"glass"') + if (c.expect.node) expect(node).toMatchObject(c.expect.node) }) } }) diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 32e0adc422..5c73aaf2bb 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -15,8 +15,8 @@ import { type WallNode, WallNode as WallNodeSchema, } from '@pascal-app/core' +import { placedDoorFace } from '@pascal-app/core/building' import { - calculateItemRotation, EDITOR_LAYER, getSideFromNormal, isMagneticSnapActive, @@ -481,10 +481,11 @@ const DoorTool: React.FC = () => { } lastWallEvent = event - const faceSide = getSideFromNormal(event.normal) + // An outside wall's door faces out whichever face is hovered, as add_door places it. + const faceSide = placedDoorFace(event.node, getSideFromNormal(event.normal)) const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide const flipOffset = sideFlip ? Math.PI : 0 - const itemRotation = calculateItemRotation(event.normal) + flipOffset + const itemRotation = (faceSide === 'back' ? Math.PI : 0) + flipOffset const cursorRotation = // World yaw of a wall CHILD (-wallAngle + itemRotation, which already // carries the flip) — `calculateCursorRotation` was π off, pointing @@ -513,9 +514,9 @@ const DoorTool: React.FC = () => { return } - const faceSide = getSideFromNormal(event.normal) + const faceSide = placedDoorFace(event.node, getSideFromNormal(event.normal)) const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide - const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0) + const itemRotation = (faceSide === 'back' ? Math.PI : 0) + (sideFlip ? Math.PI : 0) const { clampedX, clampedY, valid } = resolveWallPlacement( event.node, event.localPosition[0], diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 5b4b371910..d7c671a0f9 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -33,6 +33,7 @@ import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' import { GeometrySystem } from '../../systems/geometry/geometry-system' import { PerfActionSettleSystem } from '../../systems/perf-action-settle/perf-action-settle-system' +import { getPendingRoofMergeCount } from '../../systems/roof/roof-system' import { subscribeWallBuildInteractions } from '../../systems/wall/wall-build-lifecycle' import { ImmersiveXRPresentationProvider } from '../../xr/presentation-context' import { SceneRenderer } from '../renderers/scene-renderer' @@ -320,7 +321,8 @@ function hasPendingSceneBuildWork() { export function pendingSceneBuildCount(): number { if (!hasCommittedSceneRoot()) return 1 const state = useScene.getState() - let count = 0 + // A roof's dirty mark clears when its merged shell is queued, frames before it is built. + let count = getPendingRoofMergeCount() for (const id of state.dirtyNodes) { if (isPendingSceneBuild(id, state)) count++ } diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 9a1aaed14e..cb3e9bf20f 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -164,6 +164,19 @@ const warnedMergedRoofNaNIds = new Set() const MAX_ROOFS_PER_FRAME = 1 const MAX_SEGMENTS_PER_FRAME = 3 +/** + * Roofs whose merged shell is still to build: until it is, the roof shows its segments apart (or + * an old shell), and a capture would show that. Only roofs on screen count: one never mounted + * stays queued and would hold every capture. + */ +export function getPendingRoofMergeCount(): number { + let count = 0 + for (const id of pendingRoofUpdates) + if ((sceneRegistry.nodes.get(id) as THREE.Group | undefined)?.getObjectByName('merged-roof')) + count++ + return count +} + function queueSiblingRoofUpdates(roofId: AnyNodeId, nodes: Record) { pendingRoofUpdates.add(roofId) const roof = nodes[roofId]?.type === 'roof' ? getEffectiveNode(nodes[roofId]) : undefined diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index 9bf43e57fe..63119693bc 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -32,6 +32,19 @@ "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." } }, + { + "name": "add_level", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It is not read-only because it creates a new empty level in a building of the connected Pascal scene.", + "destructiveHint": "It goes above the highest level or below the lowest, so no existing level moves and nothing is deleted or replaced.", + "openWorldHint": "It operates on the connected Pascal scene without accessing the public internet." + } + }, { "name": "add_object", "annotations": { @@ -45,6 +58,19 @@ "openWorldHint": "It runs the supplied module in the Pascal server's script host and stores the result with the scene, without accessing the public internet." } }, + { + "name": "add_wall", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It is not read-only because it adds a wall node to a level of the connected Pascal scene.", + "destructiveHint": "Its intended operation adds new modeled state without deleting or replacing existing Pascal scene or project state.", + "openWorldHint": "It operates on the connected Pascal scene without accessing the public internet." + } + }, { "name": "add_window", "annotations": { @@ -136,19 +162,6 @@ "openWorldHint": "It operates on the connected Pascal scene or project service without accessing the public internet." } }, - { - "name": "create_level", - "annotations": { - "readOnlyHint": false, - "destructiveHint": false, - "openWorldHint": false - }, - "justifications": { - "readOnlyHint": "It is not read-only because creates a new level in the connected Pascal scene.", - "destructiveHint": "Its intended operation adds new modeled state without deleting or replacing existing Pascal scene or project state.", - "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." - } - }, { "name": "create_mezzanine", "annotations": { @@ -202,16 +215,16 @@ } }, { - "name": "create_stair_between_levels", + "name": "create_stair", "annotations": { "readOnlyHint": false, - "destructiveHint": true, + "destructiveHint": false, "openWorldHint": false }, "justifications": { - "readOnlyHint": "It creates a stair and an opening intent node that changes derived floor and ceiling geometry.", - "destructiveHint": "The new opening changes existing modeled floor and ceiling geometry.", - "openWorldHint": "It operates on the connected Pascal scene or project service without accessing the public internet." + "readOnlyHint": "It is not read-only because it creates a stair, its flight and the floor openings it owns, and a level above when there is none.", + "destructiveHint": "It adds nodes and opens holes in the floors it passes, as the editor's stair tool does; it deletes nothing.", + "openWorldHint": "It operates on the connected Pascal scene without accessing the public internet." } }, { @@ -240,19 +253,6 @@ "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." } }, - { - "name": "create_wall", - "annotations": { - "readOnlyHint": false, - "destructiveHint": false, - "openWorldHint": false - }, - "justifications": { - "readOnlyHint": "It is not read-only because adds a wall node to the connected Pascal scene.", - "destructiveHint": "Its intended operation adds new modeled state without deleting or replacing existing Pascal scene or project state.", - "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." - } - }, { "name": "cut_floor_opening", "annotations": { @@ -266,19 +266,6 @@ "openWorldHint": "It operates only on the connected Pascal scene." } }, - { - "name": "cut_opening", - "annotations": { - "readOnlyHint": false, - "destructiveHint": false, - "openWorldHint": false - }, - "justifications": { - "readOnlyHint": "It is not read-only because adds an opening definition to existing Pascal scene geometry.", - "destructiveHint": "Its intended operation adds new modeled state without deleting or replacing existing Pascal scene or project state.", - "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." - } - }, { "name": "delete_node", "annotations": { @@ -735,16 +722,16 @@ } }, { - "name": "place_item", + "name": "place_items", "annotations": { "readOnlyHint": false, "destructiveHint": false, "openWorldHint": false }, "justifications": { - "readOnlyHint": "It is not read-only because adds an item at the requested pose in the connected Pascal scene.", + "readOnlyHint": "It is not read-only because it adds catalog items to the floor of a level in the connected Pascal scene.", "destructiveHint": "Its intended operation adds new modeled state without deleting or replacing existing Pascal scene or project state.", - "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." + "openWorldHint": "It operates on the connected Pascal scene and the host's configured item catalog without accessing the public internet." } }, { @@ -954,6 +941,19 @@ "destructiveHint": "It does not create, update, replace, delete, load, or save Pascal scene or project state.", "openWorldHint": "It operates only on data supplied by or already available inside the connected Pascal service and does not access the public internet." } + }, + { + "name": "view_scene", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It renders a picture of the connected Pascal scene in an editor tab open on the project, without changing the scene.", + "destructiveHint": "It does not create, update, replace, delete, load, or save Pascal scene or project state.", + "openWorldHint": "It operates only on the connected Pascal service and the user's own editor tab and does not access the public internet." + } } ] } diff --git a/scripts/openai-tool-annotation-policy.test.ts b/scripts/openai-tool-annotation-policy.test.ts index 4d05ad6fcd..8303d9c99b 100644 --- a/scripts/openai-tool-annotation-policy.test.ts +++ b/scripts/openai-tool-annotation-policy.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test' import packet from '../plugin-evals/tool-annotation-justifications.json' -import { validateOpenAiToolAnnotationPacket } from './openai-tool-annotation-policy' +import { + EXPECTED_OPENAI_TOOL_ANNOTATIONS, + validateOpenAiToolAnnotationPacket, +} from './openai-tool-annotation-policy' const clonePacket = () => structuredClone(packet) @@ -13,7 +16,7 @@ describe('OpenAI tool annotation justification packet', () => { const candidate = clonePacket() candidate.tools.pop() expect(validateOpenAiToolAnnotationPacket(candidate)).toContain( - 'OpenAI tool annotation packet must contain the exact 73-tool inventory', + `OpenAI tool annotation packet must contain the exact ${Object.keys(EXPECTED_OPENAI_TOOL_ANNOTATIONS).length}-tool inventory`, ) }) diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index ec906ecf8b..0dcf39bb48 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -15,6 +15,8 @@ const policy = ( export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { add_door: policy(false, false, false), + add_level: policy(false, false, false), + add_wall: policy(false, false, false), add_window: policy(false, false, false), add_column: policy(false, true, false), analyze_floorplan_image: policy(true, false, true), @@ -29,18 +31,15 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { create_from_template: policy(false, true, false), create_house_from_brief: policy(false, true, false), create_mezzanine: policy(false, false, false), - create_level: policy(false, false, false), create_project: policy(false, false, false), create_roof: policy(false, false, false), create_room: policy(false, false, false), - create_stair_between_levels: policy(false, true, false), + create_stair: policy(false, false, false), fit_stair: policy(false, true, false), measure_stair: policy(true, false, false), create_story_shell: policy(false, false, false), create_unit: policy(false, false, false), - create_wall: policy(false, false, false), cut_floor_opening: policy(false, false, false), - cut_opening: policy(false, false, false), delete_node: policy(false, true, false), delete_scene: policy(false, true, false), delete_zone: policy(false, true, false), @@ -70,7 +69,7 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { move_zone: policy(false, true, false), photo_to_scene: policy(false, true, true), place_design: policy(false, false, false), - place_item: policy(false, false, false), + place_items: policy(false, false, false), rebase_floor_reference: policy(false, true, false), redo: policy(false, true, false), remove_floor_opening: policy(false, true, false), @@ -87,6 +86,7 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { validate_design: policy(true, false, false), validate_scene: policy(true, false, false), verify_scene: policy(true, false, false), + view_scene: policy(true, false, false), } as const satisfies Record const exactKeys = (value: Record, expected: readonly string[]): boolean => { diff --git a/skills/furniture-fit/SKILL.md b/skills/furniture-fit/SKILL.md index c2dc8da318..731b8e9927 100644 --- a/skills/furniture-fit/SKILL.md +++ b/skills/furniture-fit/SKILL.md @@ -84,7 +84,7 @@ Compare every candidate call against the recorded user constraints before execut `verify_scene` checks saved or active scene items, not this prospective candidate. Its clean result cannot pass the candidate's default spacing or door access. Mark those candidate rows `not checked` unless a separate check includes the candidate and the required geometry; identify that evidence explicitly. A candidate collision check at the requested gap supports that gap only. -`place_item` uses catalog dimensions and an unknown catalog ID falls back to a 0.5 m placeholder. That fallback cannot verify a real product. If the connected release lacks the read-only candidate input: +`place_items` uses catalog dimensions and refuses an ID the library lacks (`asset_not_found`), so it cannot place a product of other dimensions. If the connected release lacks the read-only candidate input: - provide a preliminary dimension-and-bounds calculation only when a rectangular measured room and exact intended pose are supplied; - label it `preliminary`, not Pascal-verified; diff --git a/skills/furniture-fit/evals/evals.json b/skills/furniture-fit/evals/evals.json index ef68e9be2c..4fcab70b21 100644 --- a/skills/furniture-fit/evals/evals.json +++ b/skills/furniture-fit/evals/evals.json @@ -100,7 +100,7 @@ "files": [], "expectations": [ "Inspects the connected check_collisions schema before using candidate fields.", - "Passes exact candidate dimensions, target level, position, Y rotation, and minimum clearance without calling place_item.", + "Passes exact candidate dimensions, target level, position, Y rotation, and minimum clearance without calling place_items.", "Confirms the returned candidateItemId and keeps room containment separate from item collision evidence.", "Does not save, checkpoint, or leave a temporary node in the project.", "Falls back to a qualified preliminary report if the connected release lacks candidate support.", diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 924006db5a..19594c2665 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -55,13 +55,13 @@ Record the active project ID, scene ID or version, and graph hash when returned. ### Prefer semantic operations -For construction, prefer tools such as `create_story_shell`, `create_room`, `add_door`, `add_window`, `create_roof`, `furnish_room`, and `place_item`. Use `apply_patch` only when no semantic tool expresses the requested edit and you have inspected the relevant node schema or an existing node of the same type. +For construction, prefer tools such as `create_story_shell`, `create_room`, `add_door`, `add_window`, `create_roof`, `furnish_room`, and `place_items`. Use `apply_patch` only when no semantic tool expresses the requested edit and you have inspected the relevant node schema or an existing node of the same type. For stairs, call `measure_stair` before changing proportions: it reports actual risers, walking-line going, arrival, uniformity, headroom and layout alternatives without editing the scene. Use `fit_stair` only for an explicit sizing or layout change; it applies uniform risers in one reversible edit. `fitRun: true` also fits flight runs or arc sweep. A `straight`, `l` or `u` layout replaces the flight chain; `turningStrategy: "winder"` uses quarter-turn winders with configurable walking-line offset and division. Measure again after fitting. Stair `targets` are design preferences in meters (`maxRiserHeight`, `minimumGoing`, `targetGoing`, `minimumHeadroom`), not a building-code certification. Defaults are 0.18, 0.25, 0.28 and 2 respectively. Headroom checks cover modeled floors, ceilings and stair bodies; missing or unsupported obstacles can still require inspection. Inspect the connected server's contracts before calling these tools. -When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, with the naming conventions its description lists for paint slots, parts, lights, cutouts and clips. Edit it by passing its `nodeId`. Servers without a script host answer `scripts_unavailable`. The hosted server builds scripts in the user's open Pascal editor tab; when no tab has the project open it answers `editor_tab_required` with an `editorUrl`: give the user that link, wait until they say it is open, then repeat the same call. To count or locate things by what they are, including typed parts inside authored objects, use `find_by_type`. To group elements of any kind into a named set the editor lists (all the lights, the windows, the kitchen cabinetry), use `edit_collection`, starting from a template when one fits; `list_collections` reads them back. +When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, with the naming conventions its description lists for paint slots, parts, lights, cutouts and clips. Edit it by passing its `nodeId`. A new object needs a `reason` (why no Pascal tool or catalog item builds it); `verify_scene` lists every authored object with its reason. 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) is built with a hint naming `add_wall`. Servers without a script host answer `scripts_unavailable`. The hosted server builds scripts in the user's open Pascal editor tab; when no tab has the project open it answers `editor_tab_required` with an `editorUrl`: give the user that link, wait until they say it is open, then repeat the same call. To count or locate things by what they are, including typed parts inside authored objects, use `find_by_type`. To group elements of any kind into a named set the editor lists (all the lights, the windows, the kitchen cabinetry), use `edit_collection`, starting from a template when one fits; `list_collections` reads them back. Pascal uses meters. X and Z are floor-plan axes; Y is vertical. Tool fields that accept measurements may also accept strings such as `"6 ft"` or `"180cm"`, but report final spatial values in meters and retain the user's original units when useful. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index f029007623..699d631141 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -62,7 +62,7 @@ Do not mutate just to make a report unless the user authorizes a temporary or sa - `export_json` returns the editable scene graph. - `export_glb` in the open-source headless server currently reports `status: "not_implemented"`; protocol success is not artifact success. - `photo_to_scene` needs host sampling. Without it, expect `sampling_unavailable`. -- `place_item` uses catalog dimensions. If a catalog item is unavailable, its placeholder dimensions are not evidence for a real product. +- `place_items` uses catalog dimensions and refuses an ID the library lacks (`asset_not_found`) rather than placing a placeholder. It places items on a level's floor, or on the host each names (`targetNodeId`): a wall (`y` is the height of the item's bottom; it hangs on the side of the wall the point is on), a ceiling, or an item standing on the floor (on an object built with `add_object`, the real surface under the point; the result names it in `restingOn`). Positions are level coordinates. - `check_collisions` checks rotation-aware scaled item footprints using plan AABBs. Pass `minimumClearance` explicitly: zero reports overlap; a positive measurement also reports pairs closer than that gap. Inspect `status`, `checkedItems`, `skippedItems`, and `unsupportedChecks` before drawing a conclusion. - `verify_scene` adds practical issues, including item separation and rectangular door-access keep-outs. It does not model a door-leaf swing arc or a delivery route. - No tool starts a room scan or clones a scan into a new project. Scans are created only by the Pascal iOS app, and `open_capture_as_project` opens the scan's existing owning project. diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index b42af02f58..294c9247a2 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -20,7 +20,7 @@ A tool has three layers, and only the last one may differ between surfaces: 1. **Contract** — name, description, input schema — one definition in `@pascal-app/core/agent-tools`, registered by the MCP and defined by the chat from the same object. Kept zod-only: the chat declares tools inside a sandbox that rejects Node-dependent packages (`contracts-purity.test.ts`). 2. **Operation** — validation, defaults, clamping, refusals, the nodes to create — one pure function in core (`planWallOpening`, `verifyScene`, `duplicateLevel`…). -3. **Executor** — applying to a store (the chat's live store, the MCP's bridge) and the result envelope (live sync, persistence). Surface context resolves here too: "the active floor" is what the person is viewing in the chat. +3. **Executor** — applying to a store (the chat's live store, the MCP's bridge) and the result envelope (live sync, persistence). Surface context resolves here too: "the active floor" is what the person is viewing in the chat. An edit that reads construction the host derives (re-derived rooms, auto ceilings, floor plates — the room and floor tools) returns `afterReconcile`; both hosts run it through `applyAgentOutcome` with their own reconciler, in one undo step. **The editor is the reference.** What a person can do by hand is what an agent may do: the operation reuses the editor tool's own rules (for openings: `clampDoorToWall`, `clampWindowToWall`, `findWallChildOverlap`, no openings on curved walls, overlap allowed only with force — the editor's Alt). An agent-only guard is the exception, kept only when it is sane and worth giving the editor too. diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 31c5c2b996..d209419b59 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -49,6 +49,8 @@ The compiler also derives upward surfaces (where things rest) and undersides (wh `add_object` (create, or edit by `nodeId`: new code, or params alone to rebuild the stored script), `get_source` (the module and its params, for an edit) and `find_by_type` (nodes and typed parts of one type) are shared contracts in `@pascal-app/core/agent-tools`, one operation each; only the compile step differs per surface. See [agent-surfaces.md](agent-surfaces.md). +A new object carries a `reason`, what it stands in for, kept in `metadata.reason`; `verify_scene` lists every authored object with it (`authoredObjects`), so each names something Pascal has no type for. On a level, a floor object that is a plain box (12 triangles) with a wall's size (≤ 0.45 m thin, ≥ 1 m long, ≥ 2 m high), or a floor plate (≤ 0.35 m thick, ≥ 2 m both ways, on the floor), is refused (`use_walls`, `use_slab`). A detailed object with a wall's size (a bookcase's shelves, a screen's holes) is built with a `hint` naming `add_wall`, as is a name whose head word is something Pascal builds, with that tool. These apply to creation only: an edit, which is also the inspector's rebuild path, is never refused for its shape. + ## Cutter binding A bare `cutout` cuts the surface the object is mounted on. A wall or ceiling parent is the host; a floor item uses its preferred support slab while it overlaps, otherwise the same highest overlapping slab as floor placement. Items resting on other items do not cut a slab beneath them. `cut:wall`, `cut:ceiling` and `cut:slab` require that kind of host; they never select a neighbouring surface.