From 56ed708132ba4d4c3afcb968ae9c902e3b5dd550 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 15:43:51 -0400 Subject: [PATCH 1/6] refactor(core): the wall paint plan lives in core planWallPaint and plannedWallIds move from nodes/src/wall/paint-plan.ts to core/src/systems/wall/wall-paint-plan.ts, unchanged, and core exports them. The editor's wall paint imports them from core; the agent's paint operation (next commit) writes a wall's faces through the same plan instead of a copy. Co-authored-by: Adam NAILI Co-Authored-By: Claude Opus 5.5 --- packages/core/src/index.ts | 1 + .../src/systems/wall/wall-paint-plan.ts} | 13 +++++-------- packages/nodes/src/wall/paint.ts | 3 ++- 3 files changed, 8 insertions(+), 9 deletions(-) rename packages/{nodes/src/wall/paint-plan.ts => core/src/systems/wall/wall-paint-plan.ts} (97%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 916bcf608e..d70dc47f3a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -895,6 +895,7 @@ export { planWallRectangle, wallRectangleCorners, } from './systems/wall/wall-operations' +export { plannedWallIds, planWallPaint } from './systems/wall/wall-paint-plan' export { roomSideFaces } from './systems/wall/wall-room-sides' export { wallDoorStepRuns } from './systems/wall/wall-step-openings' export { diff --git a/packages/nodes/src/wall/paint-plan.ts b/packages/core/src/systems/wall/wall-paint-plan.ts similarity index 97% rename from packages/nodes/src/wall/paint-plan.ts rename to packages/core/src/systems/wall/wall-paint-plan.ts index bcbff31939..d0bda6609f 100644 --- a/packages/nodes/src/wall/paint-plan.ts +++ b/packages/core/src/systems/wall/wall-paint-plan.ts @@ -1,14 +1,12 @@ +import type { AnyNode } from '../../schema' +import type { WallFace, WallFaceRegion, WallNode } from '../../schema/nodes/wall' +import type { ZoneNode } from '../../schema/nodes/zone' import { - type AnyNode, - type WallPaintRole as CoreWallPaintRole, getWallLevelZones, getWallZoneSpans, resolveWallFaceChain, - type WallFace, - type WallFaceRegion, - type WallNode, - type ZoneNode, -} from '@pascal-app/core' + type WallPaintRole, +} from './wall-finish' /** * What one wall paint click changes, as plain node updates — the commit writes @@ -25,7 +23,6 @@ import { * - a room: its wall finish, every override, and on each face turned to it * the face's own paint and the regions over the room's part of the face. */ -export type WallPaintRole = CoreWallPaintRole type Nodes = Readonly> type Span = { wallId: string; face: WallFace; u0: number; u1: number } diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index 211cb283c1..f076337f75 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -13,6 +13,8 @@ import { type PaintPreviewArgs, parseWallPaintRole as parseCoreWallPaintRole, parseMaterialRef, + plannedWallIds, + planWallPaint, resolveWallFaceChain, resolveWallFinish, type SceneMaterialId, @@ -52,7 +54,6 @@ import { resolveSlotPaintMaterialRef, } from '../shared/slot-paint' import { swapPreviewMaterial } from '../shared/swap-preview-material' -import { plannedWallIds, planWallPaint } from './paint-plan' // Roles (see `parseWallPaintRole` in core): face / trim slots write // `node.slots`, `region:` a paint region, `room:/` this face From bc18fd656c761ed6a5e6b8bae80e3b6af5aa9607 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 15:44:35 -0400 Subject: [PATCH 2/6] feat(core,editor): view_scene looks inside a floor view_scene takes interior: { levelId }. The plan checks the level (interior_level_invalid; interior_hidden for a hidden level or ancestor; interior_transform_unsupported for a building tipped off the vertical) and, with no target, eye or camera given, frames the floor from 55 degrees above. The host gets only the level id: the MCP passes it to its SceneViewHost, and the editor's capture works the cutaway out from its own scene with core's sceneViewInterior. For that one render it hides the other floors and this floor's roofs and ceilings, cuts the floor's walls and their shadows at 0.65 of the storey (a ClippingGroup on the level), draws its walls whole whatever the wall mode, and lifts the user's solo and isolation on it (temporarilyShowPresentation in the viewer). Everything is put back after the render, through the capture's existing restore list. The plan now frames buildings where they stand: what is under a building is drawn in its frame, so its position and yaw move and turn the boxes, and an opening is seen from its outside on the site. A roof, or a roof level holding only roofs, frames its segments from its seat to its peak instead of refusing nothing_to_view. An authored snapshot pose passes the walls' finish refs when it swaps their materials, as the wall renderer does, so painted faces keep their finishes. From Adam's pascalorg/editor#1024. This keeps the capture on the snapshot path view_scene already uses (it waits for the scene to build and holds the viewport's last frame) instead of #1024's read-only transaction (per-node commit tracking in NodeRenderer, a frame-loop freeze, control and object state snapshots, asset readiness refusals). Co-authored-by: Adam NAILI Co-Authored-By: Claude Opus 5.5 --- .../src/agent-operations/scene-view.test.ts | 372 +++++++++++++++--- .../core/src/agent-operations/scene-view.ts | 259 ++++++++++-- packages/core/src/agent-tools/view-scene.ts | 8 +- packages/core/src/events/bus.ts | 3 + .../editor/thumbnail-generator.test.ts | 93 ++++- .../components/editor/thumbnail-generator.tsx | 60 ++- packages/mcp/README.md | 2 +- packages/mcp/src/resources/agent-guide.ts | 2 +- packages/mcp/src/tools/view-scene.test.ts | 67 +++- packages/mcp/src/tools/view-scene.ts | 7 +- packages/viewer/src/index.ts | 1 + .../viewer/src/lib/scene-visibility.test.ts | 33 +- packages/viewer/src/lib/scene-visibility.ts | 19 +- skills/pascal-3d/SKILL.md | 2 +- skills/pascal-3d/references/tool-workflows.md | 2 +- wiki/architecture/agent-surfaces.md | 4 + 16 files changed, 828 insertions(+), 106 deletions(-) diff --git a/packages/core/src/agent-operations/scene-view.test.ts b/packages/core/src/agent-operations/scene-view.test.ts index 0af4bd4d6e..2521864f82 100644 --- a/packages/core/src/agent-operations/scene-view.test.ts +++ b/packages/core/src/agent-operations/scene-view.test.ts @@ -11,6 +11,8 @@ import { GuideNode, ItemNode, LevelNode, + RoofNode, + RoofSegmentNode, SlabNode, StairNode, WallNode, @@ -20,6 +22,7 @@ import { photoCropSize, type SceneViewBox, sceneViewBounds, + sceneViewInterior, sceneViewNote, sceneViewPlan, sceneViewPose, @@ -87,6 +90,120 @@ function building(): Record { const box: SceneViewBox = { min: [0, 0, 0], max: [20, 6, 10] } +/** The two-storey house with a ceiling over the ground floor and a roof on the upper one. */ +function finishedBuilding(): Record { + const nodes = building() + const ceiling = CeilingNode.parse({ + id: 'ceiling_0', + parentId: 'level_0', + polygon: [ + [0, 0], + [20, 0], + [20, 10], + [0, 10], + ], + }) + const roof = RoofNode.parse({ id: 'roof_1', parentId: 'level_1' }) + const level = (id: string) => nodes[id] as LevelNode + return { + ...nodes, + level_0: { ...level('level_0'), children: [...level('level_0').children, ceiling.id] }, + level_1: { ...level('level_1'), children: [...level('level_1').children, roof.id] }, + [ceiling.id]: ceiling, + [roof.id]: roof, + } as Record +} + +// Inside a floor the walls, the floors above and the ceilings hide the rooms: an interior view leaves +// the rest of the house out of the picture, for the picture only. +describe('a look inside one floor', () => { + test('frames the floor from above, the other floors and its ceilings left out', () => { + const nodes = finishedBuilding() + const before = structuredClone(nodes) + const inside = sceneViewInterior(nodes, 'level_0') + expect(inside.bounds).toEqual({ min: [0, 0, 0], max: [20, 3, 10] }) + expect(inside.cutHeight).toBeCloseTo(1.95, 6) + expect(inside.hiddenIds.sort()).toEqual(['ceiling_0', 'level_1']) + + const plan = sceneViewPlan(nodes, { interior: { levelId: 'level_0' } }) + expect(plan.interior).toEqual({ levelId: 'level_0' }) + expect(framesBox(plan.pose, inside.bounds)).toBe(true) + // It looks down into the rooms over the cut walls. + const [x, y, z] = plan.pose.position + const [tx, ty, tz] = plan.pose.target + expect(Math.atan2(y - ty, Math.hypot(x - tx, z - tz))).toBeCloseTo((55 * Math.PI) / 180, 2) + expect(nodes).toEqual(before) + }) + + test('the upper floor is cut on its own storey, the ground floor and its roof left out', () => { + const inside = sceneViewInterior(finishedBuilding(), 'level_1') + expect([inside.bounds.min[1], inside.bounds.max[1]]).toEqual([3, 6]) + expect(inside.cutHeight).toBeCloseTo(4.95, 6) + expect(inside.hiddenIds.sort()).toEqual(['level_0', 'roof_1']) + }) + + test('a target in the floor frames it, and an eye given is kept', () => { + const nodes = finishedBuilding() + const wall = sceneViewPlan(nodes, { + interior: { levelId: 'level_0' }, + target: 'wall_level_0_0', + }) + expect(framesBox(wall.pose, sceneViewBounds(nodes, 'wall_level_0_0'))).toBe(true) + const street = sceneViewPlan(nodes, { interior: { levelId: 'level_0' }, eyeHeight: 1.7 }) + expect(street.pose.position[1]).toBe(1.7) + }) + + test("a photo's camera keeps its pose, and the floor is still checked", () => { + const nodes = finishedBuilding() + const camera = { position: [25, 14, 18], target: [10, 1, 5], fov: 53, aspect: 4 / 3 } + const ordinary = sceneViewPlan(nodes, { camera }) + const interior = sceneViewPlan(nodes, { camera, interior: { levelId: 'level_0' } }) + expect(ordinary.interior).toBeUndefined() + expect(interior.pose).toEqual(ordinary.pose) + expect(interior.size).toEqual(ordinary.size) + expect(interior.interior).toEqual({ levelId: 'level_0' }) + for (const levelId of ['level_missing', 'building_main', 'wall_level_0_0']) + expect(refusalOf(() => sceneViewPlan(nodes, { camera, interior: { levelId } }))).toBe( + 'interior_level_invalid', + ) + }) + + test('a hidden floor, or a building tipped off the vertical, has no inside to show', () => { + const nodes = finishedBuilding() + const hidden = { ...nodes, level_0: { ...nodes.level_0!, visible: false } } as typeof nodes + expect(refusalOf(() => sceneViewInterior(hidden, 'level_0'))).toBe('interior_hidden') + const tipped = { + ...nodes, + building_main: { ...nodes.building_main!, rotation: [0.2, 0, 0] }, + } as typeof nodes + expect(refusalOf(() => sceneViewInterior(tipped, 'level_0'))).toBe( + 'interior_transform_unsupported', + ) + }) + + test('a floor of a building away from the origin is cut where it stands', () => { + const nodes = finishedBuilding() + const moved = { + ...nodes, + building_main: { ...nodes.building_main!, position: [5.7, 0.5, 16.5] }, + } as typeof nodes + const inside = sceneViewInterior(moved, 'level_0') + expect(inside.bounds.min.map((v) => Math.round(v * 100) / 100)).toEqual([5.7, 0.5, 16.5]) + expect(inside.bounds.max.map((v) => Math.round(v * 100) / 100)).toEqual([25.7, 3.5, 26.5]) + expect(inside.cutHeight).toBeCloseTo(2.45, 6) + }) +}) + +function refusalOf(run: () => unknown): string | undefined { + try { + run() + } catch (error) { + if (isAgentRefusal(error)) return error.code + throw error + } + return undefined +} + /** 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') @@ -226,62 +343,67 @@ describe('the note a view comes with', () => { // 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. +/** A 10 m wall along x drawn so its outside is the north (-z) side, a door and a window in it. */ +function facadeScene( + outside: 'front' | 'back', + placed: { position?: [number, number, number]; rotation?: [number, number, number] } = {}, +) { + 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], ...placed }) + return Object.fromEntries( + [building, level, wall, door, window, lamp].map((node) => [node.id, node]), + ) as Record +} + 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 - } + const facade = (outside: 'front' | 'back') => facadeScene(outside) test('a door frames its own box, at detail scale', () => { const box = sceneViewBounds(facade('back'), 'door_face') @@ -550,3 +672,143 @@ describe('a close-up of a hosted item', () => { expect(pendant.min[1]).toBeCloseTo(2.1, 2) }) }) + +/** A 10 x 8 m house on a 2.45 m storey under a 22.5 degree hip roof, on a roof level of its own. */ +function hipHouse({ rotation = 0 }: { rotation?: number } = {}): Record { + const corners: [number, number][] = [ + [0, 0], + [10, 0], + [10, 8], + [0, 8], + ] + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: 'level_0', + start, + end: corners[(index + 1) % 4]!, + }), + ) + const segment = RoofSegmentNode.parse({ + id: 'rseg_main', + parentId: 'roof_main', + roofType: 'hip', + width: 10, + depth: 8, + pitch: 22.5, + wallHeight: 0, + }) + const roof = RoofNode.parse({ + id: 'roof_main', + parentId: 'level_roof', + position: [5, 0, 4], + rotation, + children: [segment.id], + }) + const nodes: AnyNode[] = [ + BuildingNode.parse({ id: 'building_main', children: ['level_0', 'level_roof'] }), + LevelNode.parse({ + id: 'level_0', + parentId: 'building_main', + level: 0, + height: 2.45, + children: walls.map((wall) => wall.id), + }), + LevelNode.parse({ id: 'level_roof', parentId: 'building_main', level: 1, children: [roof.id] }), + ...walls, + roof, + segment, + ] + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +// A roof on a roof level of its own holds no wall: a look at it, or at its level, framed nothing. +describe('a close-up of a roof', () => { + const r = (v: number) => Math.round(v * 100) / 100 + + test('a roof, and the level it is on, frame its footprint from its seat to its peak', () => { + for (const target of ['roof_main', 'level_roof']) { + const box = sceneViewBounds(hipHouse(), target) + // 10 × 8 m about (5, 4), seated on the 2.45 m storey; a 22.5° hip over the 4 m half-span. + expect([r(box.min[0]), r(box.max[0]), r(box.min[2]), r(box.max[2])]).toEqual([0, 10, 0, 8]) + expect(r(box.min[1])).toBe(2.45) + expect(box.max[1]).toBeGreaterThan(3.5) + } + }) + + test('a turned roof frames the footprint it covers in the plan', () => { + const box = sceneViewBounds(hipHouse({ rotation: Math.PI / 2 }), 'roof_main') + // A quarter turn about its centre swaps the half-extents: 8 m east-west, 10 m north-south. + expect([r(box.max[0] - box.min[0]), r(box.max[2] - box.min[2])]).toEqual([8, 10]) + }) + + test('the building still frames its walls', () => { + const box = sceneViewBounds(hipHouse(), 'building_main') + expect([r(box.min[1]), r(box.max[1])]).toEqual([0, 2.45]) + }) + + test('the picture is taken from outside the roof, centred on it', () => { + const { pose } = sceneViewPlan(hipHouse(), { + target: 'roof_main', + from: 'south-west', + elevation: 35, + }) + expect(pose.target[0]).toBeCloseTo(5, 1) + expect(pose.target[2]).toBeCloseTo(4, 1) + expect(pose.position[1]).toBeGreaterThan(pose.target[1]) + }) +}) + +// Everything under a building is drawn in that building's frame, and the views were planned in it +// unmoved: a window of a building standing at (5.7, 16.5) as target framed bare ground beside it. +describe('a building away from the origin', () => { + const at: [number, number, number] = [5.7, 0, 16.5] + const placed = () => facadeScene('back', { position: at }) + const r = (v: number) => Math.round(v * 100) / 100 + const rounded = (box: SceneViewBox) => ({ min: box.min.map(r), max: box.max.map(r) }) + const moved = (box: SceneViewBox): SceneViewBox => ({ + min: [box.min[0] + at[0], box.min[1] + at[1], box.min[2] + at[2]], + max: [box.max[0] + at[0], box.max[1] + at[1], box.max[2] + at[2]], + }) + + test('every target frames where it stands: its box at the origin, moved with the building', () => { + for (const target of [ + 'door_face', + 'window_face', + 'wall_face', + 'level_face', + 'building_face', + 'item_lamp', + ]) { + expect(rounded(sceneViewBounds(placed(), target)), target).toEqual( + rounded(moved(sceneViewBounds(facadeScene('back'), target))), + ) + } + expect(rounded(sceneViewBounds(placed()))).toEqual( + rounded(moved(sceneViewBounds(facadeScene('back')))), + ) + }) + + test('the eye looks at the window, and from its outside', () => { + const { pose } = sceneViewPlan(placed(), { target: 'window_face' }) + const window = sceneViewBounds(placed(), 'window_face') + expect(framesBox(pose, window)).toBe(true) + // The wall is drawn so its outside is the north (-z) side, as it was at the origin. + expect(pose.position[2]).toBeLessThan(window.min[2]) + }) + + test('a turned building turns its boxes and the side an opening is seen from', () => { + const turned = facadeScene('back', { position: at, rotation: [0, Math.PI / 2, 0] }) + const local = sceneViewBounds(facadeScene('back'), 'window_face') + const centre = (box: SceneViewBox) => + [0, 1, 2].map((axis) => (box.min[axis]! + box.max[axis]!) / 2) as [number, number, number] + const [lx, ly, lz] = centre(local) + // A quarter turn about the building's origin: local (x, z) lands at (z, -x) on the site. + const box = sceneViewBounds(turned, 'window_face') + expect(centre(box).map(r)).toEqual([at[0] + lz, ly, at[2] - lx].map(r)) + // Its outside was north (-z) in the building's frame: after the turn, west (-x) on the site. + const { pose } = sceneViewPlan(turned, { target: 'window_face' }) + expect(framesBox(pose, box)).toBe(true) + expect(pose.position[0]).toBeLessThan(box.min[0]) + }) +}) diff --git a/packages/core/src/agent-operations/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts index 4f6d3ced9c..b3e13b5d5f 100644 --- a/packages/core/src/agent-operations/scene-view.ts +++ b/packages/core/src/agent-operations/scene-view.ts @@ -6,10 +6,12 @@ import type { ColumnNode, FenceNode, ItemNode, + RoofNode, SlabNode, StairNode, WallNode, } from '../schema' +import { getActiveRoofHeight } from '../schema' import { getLevelElevations } from '../services/storey' import { resolveWallExteriorSide } from '../systems/wall/wall-assembly' @@ -23,6 +25,7 @@ export type SceneViewSide = (typeof VIEW_SIDES)[number] export type SceneViewInput = { target?: string + interior?: SceneViewInterior from?: SceneViewSide position?: number[] elevation?: number @@ -36,6 +39,65 @@ export type SceneViewInput = { /** A region of the reference photo the host crops and returns beside the view. */ export type SceneViewCrop = { source: string; region: [number, number, number, number] } +/** A look inside one floor; the host cuts it from its own scene with sceneViewInterior. */ +export type SceneViewInterior = { levelId: string } + +/** How high up its storey an interior view cuts a floor's walls: low enough to see every room. */ +const INTERIOR_CUT = 0.65 +/** Degrees above the horizon an interior view looks down from, by default. */ +const INTERIOR_ELEVATION = 55 + +/** A node and every node it stands under, nearest first. */ +function ancestry(nodes: Readonly>, node: AnyNode): AnyNode[] { + const chain: AnyNode[] = [] + for (let at: AnyNode | undefined = node; at && !chain.includes(at); at = nodes[at.parentId ?? '']) + chain.push(at) + return chain +} + +/** + * What an interior view of a floor shows, from the scene the host renders: the floor's box (its + * walls' extent, its storey's height), the height its walls are cut at, and what the picture leaves + * out, every other floor and this floor's roofs and ceilings. The live scene is never changed. + */ +export function sceneViewInterior( + nodes: Readonly>, + levelId: string, +): { bounds: SceneViewBox; cutHeight: number; hiddenIds: string[] } { + const level = nodes[levelId] + if (level?.type !== 'level') + refuse('interior_level_invalid', `An interior view needs a level id: ${levelId} is none.`, { + levelId, + }) + for (const node of ancestry(nodes, level)) { + if (node.visible === false) + refuse('interior_hidden', `${node.type} ${node.id} is hidden: show it to look inside.`, { + target: node.id, + }) + // The cut is level: a building tipped off the vertical has no floor to cut across. + if (node.type === 'building' && (node.rotation[0] !== 0 || node.rotation[2] !== 0)) + refuse('interior_transform_unsupported', 'An interior view needs an upright building.', { + target: node.id, + }) + } + const box = sceneViewBounds(nodes, levelId) + const storey = getLevelElevations(nodes as Record).get(levelId) + const baseY = (storey?.baseY ?? 0) + standingOf(nodes, levelId).y + const height = storey?.height ?? 3 + return { + bounds: { min: [box.min[0], baseY, box.min[2]], max: [box.max[0], baseY + height, box.max[2]] }, + cutHeight: baseY + height * INTERIOR_CUT, + hiddenIds: Object.values(nodes) + .filter((node) => + node.type === 'level' + ? node.id !== levelId + : (node.type === 'roof' || node.type === 'ceiling') && + ancestry(nodes, node).some((at) => at.id === levelId), + ) + .map((node) => node.id), + } +} + function cropOf(photo: SceneViewInput['photo']): SceneViewCrop | undefined { if (!photo) return undefined const [left, top, right, bottom] = photo.region.map(Math.round) as [ @@ -273,15 +335,71 @@ function siteBox(node: AnyNode, baseY: number): SceneViewBox | null { return null } +/** + * A roof's box: its segments' footprints in the plan, from the roof's seat to the highest peak. A + * roof holds no wall, so a roof, or the roof level it stands on, framed nothing. + */ +function roofBox( + nodes: Readonly>, + roof: RoofNode, + baseY: number, +): SceneViewBox | null { + const seat = baseY + roof.position[1] + const turn = ([x, z]: Pt, angle: number): Pt => [ + x * Math.cos(angle) - z * Math.sin(angle), + x * Math.sin(angle) + z * Math.cos(angle), + ] + const points: Pt[] = [] + let peak = seat + for (const id of roof.children ?? []) { + const segment = nodes[id] + if (segment?.type !== 'roof-segment') continue + for (const [sx, sz] of [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ] as const) { + // The segment's corner in the roof, then the roof's in the plan: the inverse of the turns + // the roof's geometry reads a plan point with. + const [cx, cz] = turn([(sx * segment.width) / 2, (sz * segment.depth) / 2], -segment.rotation) + const [px, pz] = turn([cx + segment.position[0], cz + segment.position[2]], -roof.rotation) + points.push([px + roof.position[0], pz + roof.position[2]]) + } + peak = Math.max( + peak, + seat + segment.position[1] + segment.wallHeight + getActiveRoofHeight(segment), + ) + } + if (!points.length) return null + return tall( + { + min: [Math.min(...points.map((p) => p[0])), seat, Math.min(...points.map((p) => p[1]))], + max: [Math.max(...points.map((p) => p[0])), peak, Math.max(...points.map((p) => p[1]))], + }, + 0.3, + ) +} + +/** What frames a node standing on a level: a site element, or a roof. */ +function levelChildBox( + nodes: Readonly>, + node: AnyNode, + baseY: number, +): SceneViewBox | null { + return node.type === 'roof' ? roofBox(nodes, node as RoofNode, baseY) : siteBox(node, baseY) +} + /** * 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). + * side it faces. */ -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] +function outsideSide( + opening: AnyNode & { rotation?: number[] }, + wall: WallNode, + yaw: number, +): SceneViewSide { + const [nx, nz] = outwardOf(opening, wall, yaw) 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 @@ -289,6 +407,67 @@ function outsideSide(opening: AnyNode & { rotation?: number[] }, wall: WallNode) ) } +/** + * The way an opening's wall faces outward, a unit vector in the site's plan (x, z), turned with + * its building. The wall's +normal is perp(end - start) = (-dz, dx). + */ +function outwardOf(opening: AnyNode & { rotation?: number[] }, wall: WallNode, yaw: number): Pt { + 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 length = Math.hypot(dx, dz) || 1 + const [ux, uz] = [(-dz * sign) / length, (dx * sign) / length] + // `+ 0` so a vector along an axis reads 0, not -0. + return [ux * Math.cos(yaw) + uz * Math.sin(yaw) + 0, -ux * Math.sin(yaw) + uz * Math.cos(yaw) + 0] +} + +/** + * Where a building stands on its site. What is under a building (levels, walls, openings, roofs) + * is drawn in the building's own frame, which the editor puts at the building's position and yaw: + * a view planned in that frame alone looks at the site's origin, not at the building. + */ +type Standing = { x: number; y: number; z: number; yaw: number } +const AT_ORIGIN: Standing = { x: 0, y: 0, z: 0, yaw: 0 } + +function standingOf(nodes: Readonly>, levelId?: string | null): Standing { + const level = levelId ? nodes[levelId] : undefined + const building = level?.type === 'level' ? nodes[level.parentId ?? ''] : undefined + if (building?.type !== 'building') return AT_ORIGIN + const [x, y, z] = building.position + return { x, y, z, yaw: building.rotation[1] ?? 0 } +} + +/** A plan point of the building's frame, on the site: turned about the building's origin, then moved. */ +const onSite = ([px, pz]: Pt, at: Standing): Pt => [ + at.x + px * Math.cos(at.yaw) + pz * Math.sin(at.yaw), + at.z - px * Math.sin(at.yaw) + pz * Math.cos(at.yaw), +] + +/** A box of the building's frame, on the site: the box of its turned corners. */ +function placedBox(box: SceneViewBox, at: Standing): SceneViewBox { + if (at === AT_ORIGIN) return box + const corners = ( + [ + [box.min[0], box.min[2]], + [box.max[0], box.min[2]], + [box.max[0], box.max[2]], + [box.min[0], box.max[2]], + ] as Pt[] + ).map((corner) => onSite(corner, at)) + return { + min: [ + Math.min(...corners.map((c) => c[0])), + box.min[1] + at.y, + Math.min(...corners.map((c) => c[1])), + ], + max: [ + Math.max(...corners.map((c) => c[0])), + box.max[1] + at.y, + Math.max(...corners.map((c) => c[1])), + ], + } +} + export function sceneViewBounds( nodes: Readonly>, targetId?: string, @@ -301,19 +480,25 @@ export function sceneViewBounds( const elevations = getLevelElevations(nodes as Record) const baseOf = (levelId: string | null | undefined) => (levelId ? elevations.get(levelId)?.baseY : undefined) ?? 0 + const placed = (box: SceneViewBox, levelId: string | null | undefined) => + placedBox(box, standingOf(nodes, levelId)) const opening = openingOf(nodes, target) - if (opening) return openingBox(opening as never, baseOf(opening.wall.parentId)) + if (opening) + return placed( + openingBox(opening as never, baseOf(opening.wall.parentId)), + opening.wall.parentId, + ) if (target?.type === 'item' && nodes[target.parentId ?? '']?.type === 'level') - return itemBox(target, baseOf(target.parentId)) + return placed(itemBox(target, baseOf(target.parentId)), target.parentId) if (target?.type === 'item') { const pose = itemLevelPose(nodes, target, (id) => elevations.get(id)?.height ?? 2.5) - if (pose) return hostedItemBox(target, pose, baseOf(pose.levelId)) + if (pose) return placed(hostedItemBox(target, pose, baseOf(pose.levelId)), pose.levelId) } const site = target && nodes[target.parentId ?? '']?.type === 'level' - ? siteBox(target, baseOf(target.parentId)) + ? levelChildBox(nodes, target, baseOf(target.parentId)) : null - if (site) return site + if (site) return placed(site, target!.parentId) 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 @@ -328,13 +513,21 @@ export function sceneViewBounds( 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 }) + const at = standingOf(nodes, level.id) + if (node.type === 'wall') + outlines.push({ + points: [node.start, node.end].map((point) => onSite(point as Pt, at)), + 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))) + outlines.push({ + points: (node.polygon as Pt[]).map((point) => onSite(point, at)), + levelId: level.id, + }) + else if (node.type === 'item') held.push(placedBox(itemBox(node, baseOf(level.id)), at)) else { - const box = siteBox(node, baseOf(level.id)) - if (box) held.push(box) + const box = levelChildBox(nodes, node, baseOf(level.id)) + if (box) held.push(placedBox(box, at)) } } if (!outlines.length && held.length) @@ -354,8 +547,9 @@ export function sceneViewBounds( 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) + const lift = standingOf(nodes, levelId).y + min[1] = Math.min(min[1], storey.baseY + lift) + max[1] = Math.max(max[1], storey.baseY + storey.height + lift) for (const [x, z] of points) { min[0] = Math.min(min[0], x) max[0] = Math.max(max[0], x) @@ -461,9 +655,16 @@ export function sceneViewNote() { export function sceneViewPlan( nodes: Readonly>, input: SceneViewInput, -): { pose: SceneViewPose; size: { w: number; h: number }; crop?: SceneViewCrop } { +): { + pose: SceneViewPose + size: { w: number; h: number } + crop?: SceneViewCrop + interior?: SceneViewInterior +} { const { camera } = input const crop = cropOf(input.photo) + const inside = input.interior && sceneViewInterior(nodes, input.interior.levelId) + const interior = input.interior && { levelId: input.interior.levelId } if (camera) { const own = ( ['from', 'position', 'elevation', 'eyeHeight', 'fov', 'projection'] as const @@ -483,18 +684,28 @@ export function sceneViewPlan( }, size: { w: VIEW_SIZE.w, h: Math.round(VIEW_SIZE.w / camera.aspect) }, ...(crop ? { crop } : {}), + ...(interior ? { interior } : {}), } } const opening = openingOf(nodes, input.target ? nodes[input.target] : undefined) const from = input.from ?? - (opening && !input.position ? outsideSide(opening.opening, opening.wall) : undefined) + (opening && !input.position + ? outsideSide(opening.opening, opening.wall, standingOf(nodes, opening.wall.parentId).yaw) + : undefined) + // Inside a floor, the eye looks down into it over the cut walls. + const lookDown = inside && input.elevation === undefined && input.eyeHeight === undefined return { - pose: sceneViewPose(sceneViewBounds(nodes, input.target), { - ...input, - ...(from ? { from } : {}), - }), + pose: sceneViewPose( + input.target || !inside ? sceneViewBounds(nodes, input.target) : inside.bounds, + { + ...input, + ...(lookDown ? { elevation: INTERIOR_ELEVATION } : {}), + ...(from ? { from } : {}), + }, + ), size: { ...VIEW_SIZE }, ...(crop ? { crop } : {}), + ...(interior ? { interior } : {}), } } diff --git a/packages/core/src/agent-tools/view-scene.ts b/packages/core/src/agent-tools/view-scene.ts index 047534723b..2f7b66eefa 100644 --- a/packages/core/src/agent-tools/view-scene.ts +++ b/packages/core/src/agent-tools/view-scene.ts @@ -18,7 +18,7 @@ export const viewSceneTool = { name: 'view_scene', title: 'Look at the scene', description: - "Look at the building in 3D from a viewpoint you pick and get the picture back. Use it to compare what you built with a reference and say what differs before you fix it: the facade from the photo's own camera (camera: its position, aim, field of view and aspect), rendered at the photo's aspect to lay beside it; or from the photo's side at street height (from, eyeHeight 1.7); one face square on (projection orthographic); the massing from above. North is the plan's top edge (z grows south, x east). The view frames the target (a building, a level, a wall or a zone; the whole building by default) from outside, unless you place the eye yourself with position or camera. A door, a window or an item on a floor frames at detail scale, an opening seen from its outside face: a close-up to lay beside the photo's crop of the same element, to see what differs. Over the MCP the user's editor tab open on the project renders the picture: with none open it is refused (editor_tab_required), and a tab in the background must be brought to the front (editor_tab_hidden). A picture is not a measure: take sizes and counts from the tools.", + "Look at the building in 3D from a viewpoint you pick and get the picture back. Use it to compare what you built with a reference and say what differs before you fix it: the facade from the photo's own camera (camera: its position, aim, field of view and aspect), rendered at the photo's aspect to lay beside it; or from the photo's side at street height (from, eyeHeight 1.7); one face square on (projection orthographic); the massing from above. North is the plan's top edge (z grows south, x east). The view frames the target (a building, a level, a wall or a zone; the whole building by default) from outside, unless you place the eye yourself with position or camera. To see inside a floor, give interior {levelId}: the other floors and that floor's roofs and ceilings are left out of the picture and its walls cut at two thirds of the storey; with no eye given it looks down into the floor, or into the room or zone you target. The live view is not changed. A door, a window or an item on a floor frames at detail scale, an opening seen from its outside face: a close-up to lay beside the photo's crop of the same element, to see what differs. Over the MCP the user's editor tab open on the project renders the picture: with none open it is refused (editor_tab_required), and a tab in the background must be brought to the front (editor_tab_hidden). A picture is not a measure: take sizes and counts from the tools.", input: { target: z .string() @@ -26,6 +26,12 @@ export const viewSceneTool = { .describe( 'A building, level, wall, zone, door, window or floor item id to frame. Default: every wall in the scene.', ), + interior: z + .object({ levelId: z.string().min(1) }) + .optional() + .describe( + 'Look inside this floor (a level id): the other floors and its roofs and ceilings are left out of the picture, its walls cut at two thirds of the storey. Default framing looks down into it; camera or position still wins.', + ), from: z .enum(VIEW_SIDES) .optional() diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index c0f8d9aa32..abcf85c7d3 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,7 @@ import type { ThreeEvent } from '@react-three/fiber' import mitt from 'mitt' import type { Object3D } from 'three' +import type { SceneViewInterior } from '../agent-operations/scene-view' import type { BlockNode, BoxVentNode, @@ -278,6 +279,8 @@ export interface ThumbnailGenerateEvent { ephemeral?: boolean /** World clipping planes for this frame (a section's cut). */ clip?: readonly { normal: [number, number, number]; constant: number }[] + /** Look inside this floor for the frame: the rest of the house left out, its walls cut (view_scene). */ + interior?: SceneViewInterior /** Render the canvas at this multiple of its size for the frame (print-scale pictures). */ supersample?: number } diff --git a/packages/editor/src/components/editor/thumbnail-generator.test.ts b/packages/editor/src/components/editor/thumbnail-generator.test.ts index 059b0375e6..82cfa678da 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.test.ts +++ b/packages/editor/src/components/editor/thumbnail-generator.test.ts @@ -1,8 +1,16 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { useViewer } from '@pascal-app/viewer' -import { Vector2 } from 'three' -import { CanvasTarget, type WebGPURenderer } from 'three/webgpu' -import { atCaptureSize, presentFinished, pumpFrames, tileGrid } from './thumbnail-generator' +import { type AnyNode, sceneRegistry, useScene } from '@pascal-app/core' +import { BuildingNode, CeilingNode, LevelNode, WallNode } from '@pascal-app/core/schema' +import { hideFromScene, SCENE_LAYER, useViewer } from '@pascal-app/viewer' +import { Group, Layers, Mesh, Vector2 } from 'three' +import { CanvasTarget, ClippingGroup, type WebGPURenderer } from 'three/webgpu' +import { + atCaptureSize, + interiorCutaway, + presentFinished, + pumpFrames, + tileGrid, +} from './thumbnail-generator' type FakeCanvas = { width: number; height: number; style: Record } @@ -202,3 +210,80 @@ describe('a print-size picture past one render pass', () => { expect(right.inner.x - right.x).toBeGreaterThan(0) }) }) + +describe('an interior view of a floor', () => { + /** A two-storey house as the editor holds it: the scene graph, and each node's object. */ + function house() { + const ground = LevelNode.parse({ id: 'level_ground', parentId: 'building_h', level: 0 }) + const upper = LevelNode.parse({ id: 'level_upper', parentId: 'building_h', level: 1 }) + const building = BuildingNode.parse({ id: 'building_h', children: [ground.id, upper.id] }) + const wall = WallNode.parse({ + id: 'wall_ground', + parentId: ground.id, + start: [0, 0], + end: [6, 0], + }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_ground', + parentId: ground.id, + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + }) + ground.children = [wall.id, ceiling.id] + const nodes = Object.fromEntries( + [building, ground, upper, wall, ceiling].map((node) => [node.id, node]), + ) as Record + useScene.setState({ nodes } as never) + const objects = Object.fromEntries( + [ground, upper, ceiling].map((node) => [node.id, new Group()]), + ) as Record + const wallMesh = new Mesh() + wallMesh.layers.set(SCENE_LAYER) + objects.level_ground!.add(wallMesh, objects.ceiling_ground!) + for (const [id, object] of Object.entries({ ...objects, wall_ground: wallMesh })) + sceneRegistry.nodes.set(id, object) + return { objects, wallMesh } + } + + afterEach(() => { + for (const id of ['level_ground', 'level_upper', 'ceiling_ground', 'wall_ground']) + sceneRegistry.nodes.delete(id) + }) + + test('leaves the rest of the house out and cuts the floor, for the one render', () => { + const { objects, wallMesh } = house() + hideFromScene(wallMesh, 'isolated') + const restorers: (() => void)[] = [] + interiorCutaway('level_ground', (restore) => restorers.push(restore)) + + expect(objects.level_upper!.visible).toBe(false) + expect(objects.ceiling_ground!.visible).toBe(false) + const [cut] = objects.level_ground!.children as ClippingGroup[] + expect(objects.level_ground!.children).toHaveLength(1) + expect(cut).toBeInstanceOf(ClippingGroup) + expect(cut!.clipShadows).toBe(true) + // Walls stand below the plane: y = 0.65 × a 2.5 m storey. + const [plane] = cut!.clippingPlanes + expect(plane!.normal.toArray()).toEqual([0, -1, 0]) + expect(plane!.constant).toBeCloseTo(1.625, 6) + expect(cut!.children).toContain(wallMesh) + // The user isolated something else: the floor still shows whole. + expect(wallMesh.layers.test(new Layers())).toBe(true) + + for (const restore of restorers.reverse()) restore() + expect(objects.level_upper!.visible).toBe(true) + expect(objects.ceiling_ground!.visible).toBe(true) + expect(objects.level_ground!.children).toEqual([wallMesh, objects.ceiling_ground!]) + expect(wallMesh.layers.test(new Layers())).toBe(false) + }) + + test('refuses a floor the editor has not built', () => { + house() + sceneRegistry.nodes.delete('level_ground') + expect(() => interiorCutaway('level_ground', () => {})).toThrow('not built yet') + }) +}) diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index ed72c6c731..292d53386d 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -7,11 +7,13 @@ import { type ThumbnailGenerateEvent, useScene, } from '@pascal-app/core' +import { sceneViewInterior } from '@pascal-app/core/agent-operations' import { computeHeroFraming, createSnapshotPipeline, GRID_LAYER, getVisibleWallMaterials, + getWallFinishRefs, heroCameraPose, holdLiveFrame, pendingSceneBuildCount, @@ -24,6 +26,7 @@ import { THUMBNAIL_HEIGHT, THUMBNAIL_WIDTH, temporarilyHideNodeTypes, + temporarilyShowPresentation, temporarilyShowShadowOnly, useSceneAtmosphere, useViewer, @@ -461,15 +464,17 @@ function macrotask(): Promise { } /** - * The scene's children gathered under one ClippingGroup carrying `planes` - * (world space) for the length of a render; the returned function puts - * them back on the scene. The WebGPU renderer clips only through such - * groups (there is no renderer-level plane list), and a group added and - * removed inside one synchronous render never reaches the React tree. + * The children of `root` (the scene, or one floor) gathered under one + * ClippingGroup carrying `planes` (world space) for the length of a render; + * the returned function puts them back. The WebGPU renderer clips only + * through such groups (there is no renderer-level plane list), and a group + * added and removed inside one synchronous render never reaches the React + * tree. `clipShadows` cuts the shadows too, so light reaches past the cut. */ function clipSceneFor( - scene: THREE.Scene, + root: THREE.Object3D, planes: readonly { normal: [number, number, number]; constant: number }[], + clipShadows = false, ): () => void { const group = new ClippingGroup() group.clippingPlanes = planes.map( @@ -480,15 +485,39 @@ function clipSceneFor( ), ) group.enabled = true - const children = [...scene.children] + group.clipShadows = clipShadows + const children = [...root.children] for (const child of children) group.add(child) - scene.add(group) + root.add(group) return () => { - for (const child of children) scene.add(child) - scene.remove(group) + for (const child of children) root.add(child) + root.remove(group) } } +/** + * An interior view of one floor, for one render (view_scene): the other + * floors and this floor's roofs and ceilings out of the picture, its walls + * cut where core says (sceneViewInterior, from the scene as this editor has + * it) and their shadows with them, and whatever the user's solo or isolation + * holds back on it shown. + */ +export function interiorCutaway(levelId: string, restore: (callback: () => void) => void) { + const { cutHeight, hiddenIds } = sceneViewInterior(useScene.getState().nodes, levelId) + const floor = sceneRegistry.nodes.get(levelId) + if (!floor) throw new Error('The floor to look inside is not built yet. Try again.') + for (const id of hiddenIds) { + const object = sceneRegistry.nodes.get(id) + if (!object?.visible) continue + object.visible = false + restore(() => { + object.visible = true + }) + } + restore(temporarilyShowPresentation(floor)) + restore(clipSceneFor(floor, [{ normal: [0, -1, 0], constant: cutHeight }], true)) +} + export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => { const gl = useThree((state) => state.gl) const scene = useThree((state) => state.scene) @@ -582,7 +611,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const generate = useCallback( async (event: ThumbnailGenerateEvent) => { - const { captureMode, cropRegion, standardSize, cameraPose, requestId } = event + const { captureMode, cropRegion, standardSize, cameraPose, requestId, interior } = event const snapLevels = event.snapLevels === true const transparent = event.transparent === true // A SHEET's capture asks for more than a @@ -862,6 +891,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro if (cameraPose) { restore(snapLevelsToTruePositions()) restore(temporarilyShowShadowOnly(scene)) + } + // Walls whole, as built, whatever the user's wall mode: an + // authored pose is not the user's view, an interior is cut below. + if (cameraPose || interior) { const wallMaterials = new Map() restore(() => { for (const [mesh, material] of wallMaterials) mesh.material = material @@ -880,6 +913,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro viewer.colorPreset, viewer.sceneTheme, state.materials, + getWallFinishRefs(mesh.geometry), ) } } @@ -889,6 +923,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // those meshes before either snapshot render path submits them, // and restore fog afterwards so the atmosphere never leaks in. restore(refreshIsolation(scene)) + if (interior) { + interiorCutaway(interior.levelId, restore) + restore(lightInterior(scene, thumbnailCamera)) + } } // The scene pass is a FRAME-updated node: it renders once per node // frame, and only the animation loop advances that frame. With the diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ab1d2c6bf9..68ab405672 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -339,7 +339,7 @@ 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' }` | -| `view_scene` | Look at the building from a viewpoint the agent picks (a side, a street-height eye, a photo's camera, square-on, from above) and get the picture back, with the photo's crop beside it when a region is given. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `photo_crop_unavailable`; on the hosted server `editor_tab_required`, `editor_tab_hidden` | +| `view_scene` | Look at the building from a viewpoint the agent picks (a side, a street-height eye, a photo's camera, square-on, from above) and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, its walls cut at two thirds of the storey, for the picture only. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`; on the hosted server `editor_tab_required`, `editor_tab_hidden` | | `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` | 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`, `slab_not_found` / `ceiling_not_found`, `slab_not_on_level` / `ceiling_not_on_level` | diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index dc7f36d8ff..49721d9272 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -53,7 +53,7 @@ export const AGENT_GUIDE = [ '', '## Looking and Starting Over', '', - "- Call `view_scene` to see what you built from a viewpoint you pick (a compass side, a height, a street-level eye, an orthographic face, a photo's own camera) and compare it with the reference before fixing anything. It is a picture to compare, not a measurement. On a hosted server the person's open editor tab renders it; when the tab is hidden or closed the call is refused with the editor URL, so ask the person to bring it forward.", + "- Call `view_scene` to see what you built from a viewpoint you pick (a compass side, a height, a street-level eye, an orthographic face, a photo's own camera) and compare it with the reference before fixing anything. It is a picture to compare, not a measurement. To see the rooms of one floor, pass `interior: { levelId }`: the other floors, its roof and its ceilings are left out of the picture and its walls cut low. On a hosted server the person's open editor tab renders it; when the tab is hidden or closed the call is refused with the editor URL, so ask the person to bring it forward.", '- Call `clear_scene` only when the person asked to start over: it empties the project on purpose, keeping the site, the building and one level. Any other write that would leave the project empty is refused as `scene_wipe_blocked`, and nothing changes.', '', '## Custom Designs', diff --git a/packages/mcp/src/tools/view-scene.test.ts b/packages/mcp/src/tools/view-scene.test.ts index 739dbe380c..d389e84f27 100644 --- a/packages/mcp/src/tools/view-scene.test.ts +++ b/packages/mcp/src/tools/view-scene.test.ts @@ -14,11 +14,16 @@ import { registerViewScene, type SceneViewHost } from './view-scene' type Content = { type: string; text?: string; data?: string; mimeType?: string } -async function viewWith(host: SceneViewHost | undefined, { project = true } = {}) { +async function viewWith( + host: SceneViewHost | undefined, + { project = true, at }: { project?: boolean; at?: [number, number, number] } = {}, +) { const bridge = new SceneBridge() bridge.setScene({}, []) bridge.loadDefault() const level = Object.values(bridge.getNodes()).find((node) => node.type === 'level')! + const building = Object.values(bridge.getNodes()).find((node) => node.type === 'building')! + if (at) bridge.updateNode(building.id, { position: at }) for (const [start, end] of [ [ [0, 0], @@ -46,6 +51,7 @@ async function viewWith(host: SceneViewHost | undefined, { project = true } = {} const client = new Client({ name: 'view', version: '1' }) await Promise.all([server.connect(a), client.connect(b)]) return { + levelId: level.id, call: async (args: Record) => { const result = await client.callTool({ name: 'view_scene', arguments: args }) return { isError: !!result.isError, content: result.content as Content[] } @@ -100,6 +106,65 @@ describe('view_scene over the MCP', () => { } }) + test('a look inside a floor reaches the editor tab as the floor to cut', async () => { + const asked: Parameters[0][] = [] + const view = await viewWith({ + capture: async (request) => { + asked.push(request) + return { + image: new Uint8Array([1]), + mimeType: 'image/webp', + width: request.size.w, + height: request.size.h, + tab: 'tab_1', + capturedAt: '2026-10-12T09:00:00.000Z', + } + }, + }) + try { + expect((await view.call({ interior: { levelId: view.levelId } })).isError).toBe(false) + expect(asked).toMatchObject([{ interior: { levelId: view.levelId } }]) + expect(asked[0]!.pose.position[1]).toBeGreaterThan(asked[0]!.pose.target[1]) + expect((await view.call({ from: 'south' })).isError).toBe(false) + expect(asked[1]).not.toHaveProperty('interior') + const wrong = await view.call({ interior: { levelId: 'level_missing' } }) + expect(wrong.isError).toBe(true) + expect(json(wrong.content)).toMatchObject({ code: 'interior_level_invalid' }) + expect(asked).toHaveLength(2) + } finally { + await view.close() + } + }) + + // A building standing away from the site's origin was looked for at the origin. + test('a building away from the origin is looked at where it stands', async () => { + const asked: Parameters[0][] = [] + const view = await viewWith( + { + capture: async (request) => { + asked.push(request) + return { + image: new Uint8Array([1]), + mimeType: 'image/webp', + width: request.size.w, + height: request.size.h, + tab: 'tab_1', + capturedAt: '2026-10-12T09:00:00.000Z', + } + }, + }, + { at: [5.7, 0, 16.5] }, + ) + try { + expect((await view.call({ target: view.levelId })).isError).toBe(false) + // Walls from (0, 0) to (12, 0) and (12, 8): centre (6, 4) in the building's frame. + const [x, , z] = asked[0]!.pose.target + expect([Math.round(x * 100) / 100, Math.round(z * 100) / 100]).toEqual([11.7, 20.5]) + } finally { + await view.close() + } + }) + test("the photo's camera renders at the photo's aspect", async () => { const sizes: unknown[] = [] const view = await viewWith({ diff --git a/packages/mcp/src/tools/view-scene.ts b/packages/mcp/src/tools/view-scene.ts index d609ff1a2d..5f6e987313 100644 --- a/packages/mcp/src/tools/view-scene.ts +++ b/packages/mcp/src/tools/view-scene.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { type SceneViewCrop, + type SceneViewInterior, type SceneViewPose, sceneViewNote, sceneViewPlan, @@ -31,6 +32,8 @@ export type SceneViewHost = { projectId: string pose: SceneViewPose size: { w: number; h: number } + /** A look inside this floor: the editor leaves the rest out and cuts its walls. */ + interior?: SceneViewInterior }): Promise /** Crops the reference photo to a region; without it a view with a photo is refused. */ crop?( @@ -61,7 +64,7 @@ export function registerViewScene( 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( + const { pose, size, crop, interior } = sceneViewPlan( operations.getNodes() as Record, input, ) @@ -71,7 +74,7 @@ export function registerViewScene( '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 }), + views!.capture({ projectId: projectId!, pose, size, ...(interior ? { interior } : {}) }), crop ? views!.crop!(crop) : undefined, ]) const payload = { diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index e8e264bd13..9d36ca5cee 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -197,6 +197,7 @@ export { type HiddenReason, hideFromScene, showInScene, + temporarilyShowPresentation, temporarilyShowShadowOnly, } from './lib/scene-visibility' export { SCRIPTED_MODEL_FLAG } from './lib/scripted-opening' diff --git a/packages/viewer/src/lib/scene-visibility.test.ts b/packages/viewer/src/lib/scene-visibility.test.ts index 272a6e58d3..7d45a92f73 100644 --- a/packages/viewer/src/lib/scene-visibility.test.ts +++ b/packages/viewer/src/lib/scene-visibility.test.ts @@ -9,7 +9,12 @@ import { SHADOW_ONLY_LAYER, setSurfaceRaycastLayers, } from './layers' -import { hideFromScene, showInScene, temporarilyShowShadowOnly } from './scene-visibility' +import { + hideFromScene, + showInScene, + temporarilyShowPresentation, + temporarilyShowShadowOnly, +} from './scene-visibility' function sceneObject(): THREE.Object3D { const obj = new THREE.Object3D() @@ -48,6 +53,32 @@ describe('scene visibility', () => { expect(isolated.layers.isEnabled(SCENE_LAYER)).toBe(false) }) + test('an interior capture shows the isolated and soloed parts of its floor, never batched sources', () => { + const root = new THREE.Group() + const isolated = sceneObject() + const soloed = sceneObject() + const batched = sceneObject() + const elsewhere = sceneObject() + root.add(isolated, soloed, batched) + hideFromScene(isolated, 'isolated') + hideFromScene(soloed, 'shadow-only') + hideFromScene(batched, 'isolated') + hideFromScene(batched, 'wall-batched') + hideFromScene(elsewhere, 'isolated') + const original = [isolated, soloed, batched, elsewhere].map((obj) => obj.layers.mask) + const captureLayers = new THREE.Layers() + + const restore = temporarilyShowPresentation(root) + expect(isolated.layers.test(captureLayers)).toBe(true) + expect(soloed.layers.test(captureLayers)).toBe(true) + expect(batched.layers.test(captureLayers)).toBe(false) + expect(batched.layers.isEnabled(BATCHED_LAYER)).toBe(true) + expect(elsewhere.layers.test(captureLayers)).toBe(false) + restore() + + expect([isolated, soloed, batched, elsewhere].map((obj) => obj.layers.mask)).toEqual(original) + }) + test('one reason hides and gives the exact mask back', () => { const obj = sceneObject() obj.layers.enable(OVERLAY_LAYER) diff --git a/packages/viewer/src/lib/scene-visibility.ts b/packages/viewer/src/lib/scene-visibility.ts index 388eba3993..e52ecbc48b 100644 --- a/packages/viewer/src/lib/scene-visibility.ts +++ b/packages/viewer/src/lib/scene-visibility.ts @@ -53,15 +53,28 @@ export function showInScene(obj: Object3D, reason: HiddenReason): void { } export function temporarilyShowShadowOnly(root: Object3D): () => void { + return temporarilyShowHeld(root, ['shadow-only']) +} + +/** + * Lifts what the user's view holds back under `root` — solo's shadow-only + * levels and isolation — for one capture: an interior view shows its floor + * whole, whatever the user isolated. + */ +export function temporarilyShowPresentation(root: Object3D): () => void { + return temporarilyShowHeld(root, ['shadow-only', 'isolated']) +} + +function temporarilyShowHeld(root: Object3D, lifted: readonly HiddenReason[]): () => void { const masks = new Map() root.traverse((obj) => { const hold = (obj as Holder)[HOLD] - if (!hold?.reasons.has('shadow-only')) return + if (!hold || !lifted.some((reason) => hold.reasons.has(reason))) return masks.set(obj, obj.layers.mask) const reasons = new Set(hold.reasons) - reasons.delete('shadow-only') + for (const reason of lifted) reasons.delete(reason) // A capture needs the original scene geometry, while editor overlays and - // objects hidden by isolation or batching must retain their own masks. + // the reasons not lifted (batching) must retain their own masks. if (reasons.size === 0) obj.layers.mask = hold.original else applyHold(obj, { original: hold.original, reasons }) }) diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 6ea0124a2d..66abafb431 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -69,7 +69,7 @@ When those tools and the catalog cannot reproduce something faithfully (custom c 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. -To compare what you built with a reference, call `view_scene`: the building from a side, a street-height eye (`eyeHeight: 1.7`), a photo's own camera, square-on (`projection: "orthographic"`) or from above, and, with a photo region, the photo's crop beside the view. It returns a picture, not measures: take sizes and counts from the tools. On the hosted server the user's open Pascal editor tab renders the picture: `editor_tab_required` means no tab has the project open (give the user `editorUrl`, wait until they say it is open, then repeat the call), and `editor_tab_hidden` means the tab is in the background (ask them to bring it to the front, then repeat). A server with no renderer, such as the local CLI, answers `view_unavailable`. +To compare what you built with a reference, call `view_scene`: the building from a side, a street-height eye (`eyeHeight: 1.7`), a photo's own camera, square-on (`projection: "orthographic"`) or from above, and, with a photo region, the photo's crop beside the view. To see the rooms of one floor, pass `interior: { levelId }`: the other floors, its roof and its ceilings are left out of the picture and its walls cut low. It returns a picture, not measures: take sizes and counts from the tools. On the hosted server the user's open Pascal editor tab renders the picture: `editor_tab_required` means no tab has the project open (give the user `editorUrl`, wait until they say it is open, then repeat the call), and `editor_tab_hidden` means the tab is in the background (ask them to bring it to the front, then repeat). A server with no renderer, such as the local CLI, answers `view_unavailable`. Never empty a project by deleting everything. A write that would leave it empty (deleting its only room, a patch removing every node) is refused with `scene_wipe_blocked` and nothing changes, in the project or the session. When the person asks to start over, call `clear_scene` with their words in `reason`: it resets to a site, a building and one level, and keeps the project's installed plugins. To replace the only room, build what replaces it first, then remove it. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 9412e50217..85d8771871 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -55,7 +55,7 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra ## Look at the result -`view_scene` returns a picture of the building from the viewpoint you pick, to compare with a reference before fixing. It is read-only and creates nothing. On the hosted server the user's open editor tab renders it: `editor_tab_required` comes with `editorUrl` (show it, wait until the tab is open, repeat the call) and `editor_tab_hidden` means the tab is in the background (ask the user to bring it forward, repeat). A server with no renderer answers `view_unavailable`. A picture is not a measure. +`view_scene` returns a picture of the building from the viewpoint you pick, to compare with a reference before fixing. It is read-only and creates nothing. `interior: { levelId }` looks inside one floor, the other floors, its roof and its ceilings left out and its walls cut low; the live view is not changed. On the hosted server the user's open editor tab renders it: `editor_tab_required` comes with `editorUrl` (show it, wait until the tab is open, repeat the call) and `editor_tab_hidden` means the tab is in the background (ask the user to bring it forward, repeat). A server with no renderer answers `view_unavailable`. A picture is not a measure. ## Start over diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 0d7fbc26de..64d27946c4 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -98,6 +98,10 @@ One contract (`viewSceneTool`) and one plan (`sceneViewPlan` in `core/agent-oper The answer is the picture plus the pose it was taken from, and which tab rendered it and when. A picture is not a measure: sizes and counts come from the tools. +Views are planned on the site: a building's position and yaw move and turn the boxes of what stands under it, so a building away from the origin is looked at where it stands. A roof, or a roof level holding only roofs, frames its segments from its seat to its peak. + +**Inside a floor.** `interior: { levelId }` looks into one floor. The plan checks the level (`interior_level_invalid`, `interior_hidden` for a hidden level or ancestor, `interior_transform_unsupported` for a tipped building), frames the floor from 55° above unless a target, an eye or a camera is given, and hands the host only the level id. The editor that renders it works out the rest from its own scene with `sceneViewInterior`: for that one render the other floors and this floor's roofs and ceilings are hidden, its walls (and their shadows) are cut at 0.65 of the storey height, its walls are drawn whole whatever the user's wall mode, and the user's solo and isolation are lifted on it. Everything is put back right after the render, so the live view does not change. + ## Emptying a project: `clear_scene` A write that would leave a populated project empty is far more often an accident than an intent, so a store may refuse it: the save options carry `allowSceneWipe`, and without it a guarding store throws `SceneWipeBlockedError` (a `SceneInvalidError`). Live sync answers that refusal as `scene_wipe_blocked` with `mutationApplied: false` and loads the stored scene back into the session, so the agent's next write builds on the project as stored; when the stored scene cannot be read back, it says to call `load_scene` first. From 2075b4fd64ac1f58e14b0349c7eb1e2a283e5998 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 15:44:49 -0400 Subject: [PATCH 3/6] feat(core,mcp): paint and search_materials, with native scene materials paint gives walls, columns, slabs, ceilings, fences, doors, windows and roofs a finish in one call, as the editor's Paint does: the slots its slot paint writes, a wall's faces through the shared wall paint plan, a roof's or a segment's top, edge or wall role field (refused shadowed_field when the roof's segments have their own). exterior and interior resolve to a wall's faces from the rooms on its sides. The finish is a colour (the nearest library colour, said with how near), a library or scene ref, a native material, or erase. Any unknown target, role or material refuses the whole call. A native material is kept as a scene material, reused when the scene holds one the material schema reads as equal. The operation returns it in SceneChanges.materials and needs the scene's materials in AgentContext.materials; a host without them refuses a native material (materials_unavailable) and still paints refs and colours. Scene refs are checked against those materials, and an unknown library material's refusal points to search_materials. get_node returns the scene materials a node's finishes use. search_materials finds library refs by words, as the library writes them (timber is wood, render is stucco), by shade and by surface, and names the words no material answers. On the MCP both are shared tools. SceneOperations gains supportsMaterialUpserts and getMaterials: the bridge lends its materials as the context and applies SceneChanges.materials as upsert_material patches, written before the nodes in the same history step. upsert_material is not part of apply_patch's schema. From Adam's pascalorg/editor#1024, without the run_batch and plan-reference hooks it was written with. Co-authored-by: Adam NAILI Co-Authored-By: Claude Opus 5.5 --- .../src/agent-operations/apply-changes.ts | 9 +- .../core/src/agent-operations/create-stair.ts | 7 +- .../core/src/agent-operations/get-node.ts | 24 +- packages/core/src/agent-operations/index.ts | 7 + .../src/agent-operations/material-refs.ts | 32 +- .../core/src/agent-operations/paint.test.ts | 419 ++++++++++++++++++ packages/core/src/agent-operations/paint.ts | 265 +++++++++++ .../src/agent-operations/scene-materials.ts | 73 +++ .../agent-operations/search-materials.test.ts | 54 +++ .../src/agent-operations/search-materials.ts | 120 +++++ packages/core/src/agent-operations/types.ts | 7 +- packages/core/src/agent-tools/index.ts | 6 + packages/core/src/agent-tools/nodes.ts | 3 +- packages/core/src/agent-tools/paint.ts | 35 ++ .../core/src/agent-tools/search-materials.ts | 35 ++ packages/mcp/README.md | 4 +- packages/mcp/src/bridge/scene-bridge.ts | 27 +- .../mcp/src/operations/scene-operations.ts | 16 + packages/mcp/src/resources/agent-guide.ts | 1 + packages/mcp/src/tools/honest-patch-guard.ts | 2 +- packages/mcp/src/tools/paint.test.ts | 135 ++++++ .../src/tools/read-tool-annotations.test.ts | 2 + packages/mcp/src/tools/shared-tools.ts | 39 +- .../tool-annotation-justifications.json | 26 ++ scripts/openai-tool-annotation-policy.ts | 2 + skills/pascal-3d/SKILL.md | 2 + wiki/architecture/agent-surfaces.md | 6 + 27 files changed, 1333 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/agent-operations/paint.test.ts create mode 100644 packages/core/src/agent-operations/paint.ts create mode 100644 packages/core/src/agent-operations/scene-materials.ts create mode 100644 packages/core/src/agent-operations/search-materials.test.ts create mode 100644 packages/core/src/agent-operations/search-materials.ts create mode 100644 packages/core/src/agent-tools/paint.ts create mode 100644 packages/core/src/agent-tools/search-materials.ts create mode 100644 packages/mcp/src/tools/paint.test.ts diff --git a/packages/core/src/agent-operations/apply-changes.ts b/packages/core/src/agent-operations/apply-changes.ts index edea0941d6..e38716cb74 100644 --- a/packages/core/src/agent-operations/apply-changes.ts +++ b/packages/core/src/agent-operations/apply-changes.ts @@ -1,4 +1,4 @@ -import type { AnyNode, Collection } from '../schema' +import type { AnyNode, Collection, SceneMaterial } from '../schema' import type { SceneChanges, SceneNodes } from './types' const childrenOf = (node: AnyNode | undefined): string[] | null => @@ -54,11 +54,14 @@ export function applySceneChanges( * 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 { +export function mergeSceneChanges( + sets: readonly SceneChanges[], +): Required> & Pick { const created = new Map() const updated = new Map>() const deleted = new Set() const collections: Record = {} + const materials = new Map() const dropCreated = (id: string) => { created.delete(id) for (const [childId, entry] of [...created]) @@ -66,6 +69,7 @@ export function mergeSceneChanges(sets: readonly SceneChanges[]): Required ({ id, data })), delete: [...deleted], diff --git a/packages/core/src/agent-operations/create-stair.ts b/packages/core/src/agent-operations/create-stair.ts index a3c355d6e4..438f371cf0 100644 --- a/packages/core/src/agent-operations/create-stair.ts +++ b/packages/core/src/agent-operations/create-stair.ts @@ -121,7 +121,12 @@ export const createStair: AgentOperation = (nodes, input, cont (level) => level.parentId === building.id || building.children.includes(level.id), ) - const changes: Required = { create: [], update: [], delete: [], collections: {} } + const changes: Required> = { + create: [], + update: [], + delete: [], + collections: {}, + } let upper = input.toLevelId ? requireLevel(nodes, input.toLevelId) : floors.find((level) => level.level > from.level) diff --git a/packages/core/src/agent-operations/get-node.ts b/packages/core/src/agent-operations/get-node.ts index bb2d19dc4e..a25f80357c 100644 --- a/packages/core/src/agent-operations/get-node.ts +++ b/packages/core/src/agent-operations/get-node.ts @@ -1,10 +1,14 @@ import { refuse } from '../agent-tools/refusal' import { isFloorAnchoredOpening } from '../lib/floor-opening-footprints' import { getOpeningFloorDatum } from '../lib/opening-floor-datum' +import { referencedSceneMaterialIds } from './scene-materials' import type { AgentOperation } from './types' -/** `get_node`: a node, whole; a door or window also says where its floor is and what it hangs from. */ -export const getNode: AgentOperation<{ id: string }> = (nodes, { id }) => { +/** + * `get_node`: a node, whole; a door or window also says where its floor is and what it hangs from; + * with the scene's materials lent, the ones its finishes use. + */ +export const getNode: AgentOperation<{ id: string }> = (nodes, { id }, { materials }) => { const node = nodes[id] if (!node) refuse('node_not_found', `Node not found: ${id}.`, { id }) const wall = node.parentId ? nodes[node.parentId] : undefined @@ -15,5 +19,19 @@ export const getNode: AgentOperation<{ id: string }> = (nodes, { id }) => { anchor: isFloorAnchoredOpening(node) ? 'floor' : 'wall', } : undefined - return { result: { node: resolvedOpening ? { ...node, resolvedOpening } : node } } + const used = referencedSceneMaterialIds(node) + return { + result: { + node: resolvedOpening ? { ...node, resolvedOpening } : node, + ...(materials + ? { + materials: Object.fromEntries( + Object.values(materials) + .filter((material) => used.has(material.id)) + .map((material) => [material.id, material]), + ), + } + : {}), + }, + } } diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index f9afea8bb9..d2622d9124 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -9,9 +9,11 @@ import { furnishRoom } from './furnish-room' import { getNode } from './get-node' import { getLevelSummary, getWalls, getZones } from './level-reads' import { listLevels } from './list-levels' +import { paint } from './paint' import { placeItems } from './place-items' import { ROOM_OPERATIONS } from './room-structure' import { searchAssets } from './search-assets' +import { searchMaterials } from './search-materials' import { fitStair, measureStairOperation } from './stairs' import type { AgentOperations } from './types' import { verifyScene } from './verify-scene' @@ -40,13 +42,16 @@ export * from './list-levels' export * from './material-preset' export * from './material-refs' export * from './node-patch' +export * from './paint' export * from './place-items' export * from './plan-geometry' export * from './room-structure' +export * from './scene-materials' export * from './scene-measure' export * from './scene-queries' export * from './scene-view' export * from './search-assets' +export * from './search-materials' export * from './stairs' export * from './types' export * from './verify-scene' @@ -72,5 +77,7 @@ export const AGENT_OPERATIONS = { create_room: createRoom, furnish_room: furnishRoom, search_assets: searchAssets, + paint, + search_materials: searchMaterials, ...ROOM_OPERATIONS, } as const satisfies AgentOperations diff --git a/packages/core/src/agent-operations/material-refs.ts b/packages/core/src/agent-operations/material-refs.ts index b5c2b73469..2391f06e80 100644 --- a/packages/core/src/agent-operations/material-refs.ts +++ b/packages/core/src/agent-operations/material-refs.ts @@ -5,6 +5,7 @@ import { type MaterialSurface, parseMaterialRef, } from '../material-library' +import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' const SURFACE_WORDS: Record = { roof: 'roofing', @@ -74,20 +75,35 @@ export function nearestLibraryMaterials( /** * 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). + * a scene material (`scene:`, checked against the scene's materials when the host lends them). + * 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 } = {}, + { + paint = false, + materials, + }: { + /** Whether to point to `paint` for a colour (paint passes it); else a flat library colour. */ + paint?: boolean + /** The scene's materials, when the host has them. */ + materials?: Readonly> + } = {}, ): string { const parsed = parseMaterialRef(asked.includes(':') ? asked : `library:${asked}`) - if (parsed?.kind === 'scene') return asked + if (parsed?.kind === 'scene') { + if (materials && !Object.hasOwn(materials, parsed.id)) + refuse('unknown_material', `${asked} is not one of this scene's materials.`, { + material: asked, + ...(field ? { field } : {}), + }) + 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}` @@ -108,7 +124,7 @@ export function requireMaterialRef( 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, { + return refuse('unknown_material', `${message} search_materials finds one by words.`, { material: asked, ...(field ? { field } : {}), nearest, diff --git a/packages/core/src/agent-operations/paint.test.ts b/packages/core/src/agent-operations/paint.test.ts new file mode 100644 index 0000000000..9aaec1bdc1 --- /dev/null +++ b/packages/core/src/agent-operations/paint.test.ts @@ -0,0 +1,419 @@ +import { describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { AGENT_TOOL_CONTRACTS, isAgentRefusal } from '../agent-tools' +import { slotPaintMaterial } from '../lib/slots' +import { + type AnyNode, + BuildingNode, + CeilingNode, + ColumnNode, + DoorNode, + LevelNode, + RoofNode, + RoofSegmentNode, + type SceneMaterial, + SiteNode, + SlabNode, + WallNode, + WindowNode, +} from '../schema' +import { achievedChanges } from './achieved' +import { applySceneChanges } from './apply-changes' +import { AGENT_OPERATIONS } from './index' +import type { AgentContext, SceneChanges, SceneNodes } from './types' + +// `paint` as both surfaces run it: the contract parses the input, the operation plans the change, +// and the scene and its materials after the change are what a host saves. + +const TIMBER_NAME = 'Mountain house, warm timber' +const TIMBER = { preset: 'custom', properties: { color: '#71503a', roughness: 0.86, metalness: 0 } } +/** The timber as the material schema keeps it. */ +const KEPT_TIMBER = { + preset: 'custom', + properties: { + color: '#71503a', + roughness: 0.86, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, +} +const DRYWALL = 'library:concrete-drywall' + +/** A 6 × 4 m room of four walls; the south one runs east, so its face a is in, b out. */ +function room() { + const south = WallNode.parse({ + id: 'wall_south', + parentId: 'level_paint', + start: [0, 0], + end: [6, 0], + slots: { a: DRYWALL, b: 'library:concrete-raw' }, + faceRegions: [{ id: 'stripe', face: 'a', u0: 0, u1: 2, v0: 0, v1: 1, finish: '#fffafa' }], + children: ['window_south', 'door_south'], + }) + const walls = [ + south, + WallNode.parse({ id: 'wall_east', parentId: 'level_paint', start: [6, 0], end: [6, 4] }), + WallNode.parse({ id: 'wall_north', parentId: 'level_paint', start: [6, 4], end: [0, 4] }), + WallNode.parse({ id: 'wall_west', parentId: 'level_paint', start: [0, 4], end: [0, 0] }), + ] + const ceiling = CeilingNode.parse({ + id: 'ceiling_paint', + parentId: 'level_paint', + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + regions: [ + { + id: 'accent', + polygon: [ + [0, 0], + [1, 0], + [1, 1], + ], + finish: DRYWALL, + }, + ], + slots: { surface: 'library:preset-nearblack', retained: DRYWALL }, + materialPreset: 'white', + material: { preset: 'custom', properties: { color: '#123456' } }, + }) + const slab = SlabNode.parse({ + id: 'slab_paint', + parentId: 'level_paint', + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + }) + const nodes: AnyNode[] = [ + SiteNode.parse({ id: 'site_paint', children: ['building_paint'] }), + BuildingNode.parse({ id: 'building_paint', parentId: 'site_paint', children: ['level_paint'] }), + LevelNode.parse({ + id: 'level_paint', + parentId: 'building_paint', + level: 0, + children: [...walls.map((wall) => wall.id), ceiling.id, slab.id], + }), + ...walls, + WindowNode.parse({ id: 'window_south', parentId: south.id, position: [4, 1.5, 0] }), + DoorNode.parse({ id: 'door_south', parentId: south.id, position: [1, 1, 0] }), + ceiling, + slab, + ] + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +const parse = (input: unknown) => + z.object(AGENT_TOOL_CONTRACTS.find((tool) => tool.name === 'paint')!.input).parse(input) + +function paint( + nodes: SceneNodes, + input: Record, + materials?: Readonly>, +) { + const context: AgentContext = { + activeLevelId: 'level_paint', + ...(materials ? { materials } : {}), + } + return AGENT_OPERATIONS.paint(nodes, parse(input) as never, context) +} + +/** The scene and its materials once a host has applied the change. */ +function applied( + nodes: SceneNodes, + materials: Readonly>, + changes: SceneChanges | undefined, +) { + return { + nodes: applySceneChanges(nodes, changes) as Record, + materials: { + ...materials, + ...Object.fromEntries((changes?.materials ?? []).map((material) => [material.id, material])), + }, + } +} + +function refusal(run: () => unknown): string { + try { + run() + } catch (error) { + if (isAgentRefusal(error)) return error.code + throw error + } + throw new Error('expected a refusal') +} + +const timberOutside = (targets = ['wall_south']) => ({ + targets, + role: 'exterior', + material: TIMBER, + materialName: TIMBER_NAME, +}) + +describe('paint with a native material', () => { + test("keeps it as a scene material, on the wall's outside face only", () => { + const nodes = room() + const before = structuredClone(nodes) + const outcome = paint(nodes, timberOutside(), {}) + const after = applied(nodes, {}, outcome.changes) + const wall = after.nodes.wall_south as WallNode + const ref = wall.slots?.b + expect(ref).toMatch(/^scene:/) + const kept = after.materials[ref!.slice('scene:'.length)]! + expect(kept).toMatchObject({ name: TIMBER_NAME, material: KEPT_TIMBER }) + expect(outcome.result).toMatchObject({ ok: true, finish: ref, materialId: kept.id }) + // The inside face, its paint regions and the openings are untouched. + expect(wall.slots?.a).toBe(DRYWALL) + expect(wall.faceRegions).toEqual((before.wall_south as WallNode).faceRegions) + expect(after.nodes.window_south).toEqual(before.window_south) + expect(after.nodes.door_south).toEqual(before.door_south) + expect(nodes).toEqual(before) + // What the editor's eyedropper reads back is the exact material. + expect(slotPaintMaterial(ref, after.materials)).toEqual({ + material: KEPT_TIMBER as never, + materialPreset: undefined, + }) + }) + + test('reuses the equal material already in the scene, whatever the order of its keys', () => { + const first = paint(room(), timberOutside(), {}) + const once = applied(room(), {}, first.changes) + const second = paint( + once.nodes, + { + ...timberOutside(['wall_east']), + material: { + properties: { metalness: 0, roughness: 0.86, color: '#71503a' }, + preset: 'custom', + }, + }, + once.materials, + ) + expect(second.changes?.materials).toBeUndefined() + const twice = applied(once.nodes, once.materials, second.changes) + expect((twice.nodes.wall_east as WallNode).slots?.b).toBe( + (twice.nodes.wall_south as WallNode).slots?.b, + ) + expect(Object.keys(twice.materials)).toHaveLength(1) + }) + + test('a different texture is a different material', () => { + const texture = { url: '/material/wood/existing-timber.jpg', repeat: [2, 3], scale: 0.5 } + const first = paint(room(), { ...timberOutside(), material: { ...TIMBER, texture } }, {}) + expect(first.changes?.materials?.[0]?.material).toEqual({ ...KEPT_TIMBER, texture } as never) + const once = applied(room(), {}, first.changes) + const second = paint( + once.nodes, + { + ...timberOutside(['wall_east']), + material: { ...TIMBER, texture: { ...texture, repeat: [1, 1] } }, + }, + once.materials, + ) + expect(second.changes?.materials).toHaveLength(1) + }) + + test('a scene ref paints as it is, and one the scene lacks is refused', () => { + const once = applied(room(), {}, paint(room(), timberOutside(), {}).changes) + const ref = (once.nodes.wall_south as WallNode).slots!.b! + const again = paint( + once.nodes, + { targets: ['wall_east'], role: 'b', material: ref }, + once.materials, + ) + expect(again.changes?.materials).toBeUndefined() + expect(applied(once.nodes, once.materials, again.changes).nodes.wall_east).toMatchObject({ + slots: { b: ref }, + }) + expect( + refusal(() => + paint(room(), { targets: ['wall_south'], role: 'b', material: 'scene:mat_missing' }, {}), + ), + ).toBe('unknown_material') + }) + + test('a host that cannot keep scene materials refuses a native one, and paints the rest', () => { + expect(refusal(() => paint(room(), timberOutside()))).toBe('materials_unavailable') + const library = paint(room(), { targets: ['wall_south'], role: 'b', material: 'concrete-raw' }) + expect(library.result.finish).toBe('library:concrete-raw') + const colour = paint(room(), { targets: ['wall_south'], role: 'b', color: '#71503a' }) + expect(colour.result.finish).toMatch(/^library:/) + expect(String(colour.result.note)).toMatch(/nearest/) + }) + + test('the contract bounds a material and its name before the operation runs', () => { + expect(() => parse({ ...timberOutside(), materialName: 'M'.repeat(120) })).not.toThrow() + for (const bad of [ + { ...timberOutside(), materialName: 'M'.repeat(121) }, + { ...timberOutside(), materialName: ' ' }, + { ...timberOutside(), material: { properties: { roughness: 1.01 } } }, + { ...timberOutside(), material: { properties: { metalness: -0.01 } } }, + { ...timberOutside(), material: { properties: { roughness: '0.86' } } }, + { ...timberOutside(), material: null }, + ]) + expect(() => parse(bad)).toThrow() + }) +}) + +describe('paint refuses the whole call', () => { + for (const [label, input, code] of [ + ['a target not in the scene', timberOutside(['wall_south', 'wall_gone']), 'target_not_found'], + [ + 'a role a later target lacks', + { ...timberOutside(['wall_south', 'door_south']), role: 'a' }, + 'unknown_role', + ], + [ + 'a material the library lacks', + { targets: ['wall_south'], material: 'library:corrugated' }, + 'unknown_material', + ], + ['two finishes', { targets: ['wall_south'], color: '#ffffff', erase: true }, 'finish_required'], + ['a kind with no finish', { targets: ['level_paint'], color: '#ffffff' }, 'not_paintable'], + ] as const) { + test(label, () => { + const nodes = room() + const before = structuredClone(nodes) + expect(refusal(() => paint(nodes, input, {}))).toBe(code) + expect(nodes).toEqual(before) + }) + } + + test('a scripted column with no shaft names the surfaces it has', () => { + const column = ColumnNode.parse({ + id: 'column_script', + parentId: 'level_paint', + source: { + kind: 'script', + script: 'a'.repeat(64), + artifact: 'b'.repeat(64), + manifest: { + bounds: { min: [-0.2, 0, -0.2], max: [0.2, 3, 0.2] }, + triangles: 12, + slots: [ + { id: 'stone', color: '#dddddd' }, + { id: 'band', color: '#555555' }, + ], + }, + }, + }) + const nodes = { ...room(), [column.id]: column } + expect(refusal(() => paint(nodes, { targets: [column.id], material: TIMBER }, {}))).toBe( + 'unknown_role', + ) + const stone = paint(nodes, { targets: [column.id], role: 'stone', material: DRYWALL }, {}) + expect(applied(nodes, {}, stone.changes).nodes[column.id]).toMatchObject({ + slots: { stone: DRYWALL }, + }) + }) +}) + +describe('a ceiling', () => { + test('takes its finish on its surface; regions, other slots and other nodes untouched', () => { + const nodes = room() + const before = structuredClone(nodes) + for (const role of [undefined, 'surface']) { + const outcome = paint(nodes, { + targets: ['ceiling_paint'], + material: 'library:concrete-raw', + ...(role ? { role } : {}), + }) + expect(outcome.result).toMatchObject({ + painted: [{ id: 'ceiling_paint', roles: ['surface'] }], + }) + const after = applied(nodes, {}, outcome.changes).nodes + expect(after.ceiling_paint).toEqual({ + ...before.ceiling_paint!, + slots: { surface: 'library:concrete-raw', retained: DRYWALL }, + } as AnyNode) + for (const id of Object.keys(before).filter((id) => id !== 'ceiling_paint')) + expect(after[id]).toBe(nodes[id]!) + } + }) + + test('erasing removes the slot as the editor does, keeping its legacy material', () => { + const nodes = room() + const after = applied( + nodes, + {}, + paint(nodes, { targets: ['ceiling_paint'], erase: true }).changes, + ) + const ceiling = after.nodes.ceiling_paint as CeilingNode + expect(ceiling.slots).toEqual({ retained: DRYWALL }) + expect(ceiling.material).toEqual((nodes.ceiling_paint as CeilingNode).material) + expect(ceiling.materialPreset).toBe('white') + }) +}) + +describe('what paint reports', () => { + test('a finish already there is no change', () => { + const nodes = room() + const outcome = paint(nodes, { targets: ['wall_south'], role: 'a', material: DRYWALL }) + expect(outcome.changes?.update).toEqual([]) + expect(achievedChanges(nodes, outcome.changes!)).toMatchObject({ updated: 0, unchanged: true }) + }) + + test('of two targets, only the one that changed counts', () => { + const nodes = room() + const outcome = paint(nodes, { + targets: ['wall_south', 'wall_east'], + role: 'a', + material: DRYWALL, + }) + expect(outcome.changes?.update?.map(({ id }) => id)).toEqual(['wall_east']) + expect(achievedChanges(nodes, outcome.changes!)).toEqual({ + created: {}, + updated: 1, + deleted: {}, + }) + }) +}) + +describe('a roof', () => { + function roofed(segment: Record = {}) { + const nodes = room() + const roof = RoofNode.parse({ + id: 'roof_paint', + parentId: 'level_paint', + topMaterial: { preset: 'custom', properties: { color: '#000000' } }, + children: ['rseg_paint'], + }) + const rseg = RoofSegmentNode.parse({ id: 'rseg_paint', parentId: roof.id, ...segment }) + return { ...nodes, [roof.id]: roof, [rseg.id]: rseg } as Record + } + + test('paints its top by default, in place of the inline material it had', () => { + const nodes = roofed() + const after = applied( + nodes, + {}, + paint(nodes, { targets: ['roof_paint'], material: 'library:roof-claytiles' }).changes, + ) + const roof = after.nodes.roof_paint as RoofNode + expect(roof.topMaterialPreset).toBe('library:roof-claytiles') + expect(roof.topMaterial).toBeUndefined() + }) + + test("is refused when a segment's own finish would hide it", () => { + for (const own of [ + { topMaterialPreset: 'library:roof-claytiles' }, + { materialPreset: 'library:roof-claytiles' }, + ]) + expect(refusal(() => paint(roofed(own), { targets: ['roof_paint'], color: '#336699' }))).toBe( + 'shadowed_field', + ) + const edge = paint(roofed({ topMaterialPreset: 'library:roof-claytiles' }), { + targets: ['roof_paint'], + role: 'edge', + color: '#336699', + }) + expect(edge.result).toMatchObject({ painted: [{ id: 'roof_paint', roles: ['edge'] }] }) + }) +}) diff --git a/packages/core/src/agent-operations/paint.ts b/packages/core/src/agent-operations/paint.ts new file mode 100644 index 0000000000..44bfe8af21 --- /dev/null +++ b/packages/core/src/agent-operations/paint.ts @@ -0,0 +1,265 @@ +import { refuse } from '../agent-tools/refusal' +import type { MaterialSurface } from '../material-library' +import { + ceilingSlots, + columnSlots, + doorSlots, + fenceSlots, + slabSlots, + wallSlots, + windowSlots, +} from '../node-slots' +import { nearestLibraryColorRef } from '../procedural-items/library-colors' +import type { SlotDeclaration } from '../registry/types' +import { + type AnyNode, + type ColumnNode, + type FenceNode, + getEffectiveSegmentSurfaceMaterial, + type MaterialSchema, + type RoofSegmentNode, + type SceneMaterial, + type WallNode, +} from '../schema' +import { planWallPaint } from '../systems/wall/wall-paint-plan' +import { roomSideFaces } from '../systems/wall/wall-room-sides' +import { finishSurface, requireMaterialRef } from './material-refs' +import { resolveInlinePaintMaterial } from './scene-materials' +import type { AgentContext, AgentOperation, SceneNodes } from './types' + +/** + * `paint`: a finish in one call, as the editor's Paint gives it. Through patches an agent once took + * five tries to paint a pier grey: `material: {color}` was stored as `{}`, `materialPreset: null` + * was refused and an unknown preset fell back to grey. A colour is the nearest of the library's + * flat colours, said with how near; a native material is kept as a scene material. Slots are + * written as the editor's slot paint writes them, a wall's faces through its paint plan, a roof's + * role through its role field. + */ + +type PaintInput = { + targets: string[] + role?: string + color?: string + material?: string | MaterialSchema + materialName?: string + erase?: true +} + +const ROOF_ROLES = ['top', 'edge', 'wall'] as const +type RoofRole = (typeof ROOF_ROLES)[number] + +/** The surfaces painted when no role is named: what "paint the column" means. */ +const DEFAULT_ROLES: Record = { + wall: ['a', 'b', 'curtain-frame', 'curtain-solid'], + column: ['shaft'], + slab: ['surface'], + ceiling: ['surface'], + fence: ['posts', 'infill', 'base', 'rail'], + door: ['panel', 'frame'], + window: ['frame'], +} + +function slotsOf(node: AnyNode): SlotDeclaration[] | null { + switch (node.type) { + case 'wall': + return wallSlots(node as WallNode) + case 'column': + return columnSlots(node as ColumnNode) + case 'slab': + return slabSlots() + case 'ceiling': + return ceilingSlots() + case 'fence': + return fenceSlots(node as FenceNode) + case 'door': + return doorSlots() + case 'window': + return windowSlots() + default: + return null + } +} + +type Finish = { + /** The ref written, or undefined to erase. */ + ref: string | undefined + note?: string + sceneMaterial?: SceneMaterial + created?: boolean +} + +/** The finish asked for, as the ref it writes; refused when it is unknown. */ +function finishOf( + input: PaintInput, + surface: MaterialSurface | undefined, + context: AgentContext, +): Finish { + const given = [input.color, input.material, input.erase].filter((value) => value !== undefined) + if (given.length !== 1) + refuse( + 'finish_required', + 'Give one finish: color (#rrggbb), material (a library or scene ref, or a native material), or erase: true.', + ) + if (input.materialName !== undefined && typeof input.material !== 'object') + refuse('invalid_material_name', 'materialName names a native material only.') + if (input.erase) return { ref: undefined } + if (input.color) { + const match = nearestLibraryColorRef(input.color) + if (!match) refuse('invalid_color', `${input.color} is no colour: give #rrggbb.`) + return { + ref: match.ref, + note: `${input.color}: nearest library colour ${match.name} (${match.color}), ΔE ${Math.round(match.distance)}.`, + } + } + if (typeof input.material === 'object') + return resolveInlinePaintMaterial(context.materials, input.material, input.materialName) + const ref = requireMaterialRef(input.material!, undefined, surface, { + paint: true, + materials: context.materials, + }) + const sceneMaterial = context.materials + ? Object.values(context.materials).find((entry) => `scene:${entry.id}` === ref) + : undefined + return { ref, ...(sceneMaterial ? { sceneMaterial } : {}) } +} + +/** A node's data for an update: what changed, and `undefined` for the fields it lost. */ +function changedData(before: AnyNode, after: AnyNode): Partial { + const data: Record = {} + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + const [a, b] = [ + (before as Record)[key], + (after as Record)[key], + ] + if (JSON.stringify(a) !== JSON.stringify(b)) data[key] = b + } + return data as Partial +} + +/** A roof's or a segment's role written, its inline material for that role gone with it. */ +function paintRoofRole(node: AnyNode, role: RoofRole, ref: string | undefined): AnyNode { + const next = { ...node } as Record + delete next[`${role}Material`] + if (ref) next[`${role}MaterialPreset`] = ref + else delete next[`${role}MaterialPreset`] + return next as AnyNode +} + +export const paint: AgentOperation = (nodes, input, context) => { + // A refused material's nearest are the first target's kind: a roof is offered roofing. + const first = input.targets[0] ? nodes[input.targets[0]] : undefined + const finish = finishOf(input, first && finishSurface(first.type, input.role), context) + const { ref } = finish + const written: Record = {} + const current = (id: string) => (written[id] ?? nodes[id]) as AnyNode | undefined + const scene = () => ({ ...nodes, ...written }) as SceneNodes + const painted: { id: string; roles: string[] }[] = [] + + for (const id of input.targets) { + const node = current(id) + if (!node) + refuse( + 'target_not_found', + `Nothing to paint: ${id} is not in the scene (a room edit may have replaced its walls: read the scene again). Nothing was painted.`, + { target: id }, + ) + + if (node.type === 'roof' || node.type === 'roof-segment') { + const role = (input.role ?? 'top') as RoofRole + if (!ROOF_ROLES.includes(role)) + refuse('unknown_role', `A roof's roles are ${ROOF_ROLES.join(', ')}.`, { + target: id, + roles: [...ROOF_ROLES], + }) + if (node.type === 'roof') { + // A segment's own finish for the role, or its catch-all, shows over its roof's. + const shadowing = node.children.filter((childId) => { + const segment = current(childId) + if (segment?.type !== 'roof-segment') return false + const own = getEffectiveSegmentSurfaceMaterial(segment as RoofSegmentNode, role) + return own.material !== undefined || own.materialPreset !== undefined + }) + if (shadowing.length) + refuse( + 'shadowed_field', + `The roof's ${role} would not show: segments ${shadowing.join(', ')} have their own. Paint the segments instead.`, + { target: id, segments: shadowing }, + ) + } + written[id] = paintRoofRole(node, role, ref) + painted.push({ id, roles: [role] }) + continue + } + + const slots = slotsOf(node) + if (!slots) + refuse( + 'not_paintable', + `A ${node.type} has no finish paint gives: paint walls, columns, slabs, ceilings, fences, doors, windows or roofs.`, + { target: id, type: node.type }, + ) + const ids = slots.map((slot) => slot.slotId) + let roles: string[] + if (input.role === 'exterior' || input.role === 'interior') { + if (node.type !== 'wall') + refuse( + 'unknown_role', + `Only a wall has an ${input.role} face: a ${node.type}'s roles are ${ids.join(', ')}.`, + { target: id, roles: ids }, + ) + const sides = roomSideFaces(scene() as never, id) + const face = input.role === 'interior' ? sides.inside : sides.outside + if (!face) + refuse( + 'side_unknown', + `Wall ${id} has rooms on both sides or none: name its face, a or b.`, + { target: id }, + ) + roles = [face] + } else if (input.role) { + if (!ids.includes(input.role)) + refuse('unknown_role', `A ${node.type}'s roles are ${ids.join(', ')}.`, { + target: id, + roles: ids, + }) + roles = [input.role] + } else roles = (DEFAULT_ROLES[node.type] ?? []).filter((role) => ids.includes(role)) + if (!roles.length) + refuse('unknown_role', `Name the ${node.type}'s surface: its roles are ${ids.join(', ')}.`, { + target: id, + roles: ids, + }) + + for (const role of roles) { + const target = current(id)! + if (target.type === 'wall') { + Object.assign(written, planWallPaint(scene(), target, { kind: 'slot', slotId: role }, ref)) + continue + } + const next = { ...((target as AnyNode & { slots?: Record }).slots ?? {}) } + if (ref) next[role] = ref + else delete next[role] + written[id] = { ...target, slots: next } as AnyNode + } + painted.push({ id, roles }) + } + + const update = Object.entries(written).flatMap(([id, node]) => { + const data = changedData(nodes[id]!, node) + return Object.keys(data).length ? [{ id, data }] : [] + }) + const createdMaterial = finish.created ? finish.sceneMaterial : undefined + return { + result: { + ok: true, + painted, + finish: ref ?? 'default', + ...(finish.sceneMaterial + ? { materialId: finish.sceneMaterial.id, material: finish.sceneMaterial.material } + : {}), + ...(createdMaterial ? { createdMaterial } : {}), + ...(finish.note ? { note: finish.note } : {}), + }, + changes: { update, ...(createdMaterial ? { materials: [createdMaterial] } : {}) }, + } +} diff --git a/packages/core/src/agent-operations/scene-materials.ts b/packages/core/src/agent-operations/scene-materials.ts new file mode 100644 index 0000000000..7c7b5a7204 --- /dev/null +++ b/packages/core/src/agent-operations/scene-materials.ts @@ -0,0 +1,73 @@ +import { refuse } from '../agent-tools/refusal' +import { parseMaterialRef, toSceneMaterialRef } from '../material-library' +import { MaterialSchema } from '../schema/material' +import { + generateSceneMaterialId, + type SceneMaterial, + type SceneMaterialId, +} from '../schema/scene-material' + +/** Whether two parsed values are the same, whatever their keys' order. */ +function equal(a: unknown, b: unknown): boolean { + if (a === b) return true + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false + if (Array.isArray(a) || Array.isArray(b)) + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((value, index) => equal(value, b[index])) + ) + const left = a as Record + const right = b as Record + return ( + Object.keys(left).length === Object.keys(right).length && + Object.keys(left).every((key) => Object.hasOwn(right, key) && equal(left[key], right[key])) + ) +} + +/** + * A native material as the editor's Paint keeps a one-off: the scene's equal material when it has + * one (compared as the schema reads both), else a new scene material. Refused where the host + * cannot store scene materials, so no node points at a material nothing keeps. + */ +export function resolveInlinePaintMaterial( + materials: Readonly> | undefined, + input: MaterialSchema, + name?: string, +): { ref: string; sceneMaterial: SceneMaterial; created: boolean } { + if (!materials) + refuse( + 'materials_unavailable', + 'This host cannot keep native materials: give a library material or a colour. Nothing was painted.', + ) + const parsed = MaterialSchema.safeParse(input) + if (!parsed.success) refuse('invalid_material', `Not a native material: ${parsed.error.message}`) + for (const existing of Object.values(materials)) { + const normalized = MaterialSchema.safeParse(existing.material) + if (normalized.success && equal(normalized.data, parsed.data)) + return { ref: toSceneMaterialRef(existing.id), sceneMaterial: existing, created: false } + } + let id = generateSceneMaterialId() + while (materials[id]) id = generateSceneMaterialId() + const sceneMaterial = { + id, + name: name ?? `Material ${Object.keys(materials).length + 1}`, + material: parsed.data, + } + return { ref: toSceneMaterialRef(id), sceneMaterial, created: true } +} + +/** The scene materials a node points at, in its slots and its role fields alike. */ +export function referencedSceneMaterialIds(value: unknown): Set { + const ids = new Set() + const visit = (entry: unknown) => { + if (typeof entry === 'string') { + const parsed = parseMaterialRef(entry) + if (parsed?.kind === 'scene') ids.add(parsed.id) + } else if (entry && typeof entry === 'object') + for (const child of Object.values(entry)) visit(child) + } + visit(value) + return ids +} diff --git a/packages/core/src/agent-operations/search-materials.test.ts b/packages/core/src/agent-operations/search-materials.test.ts new file mode 100644 index 0000000000..2f28e60a51 --- /dev/null +++ b/packages/core/src/agent-operations/search-materials.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test' +import { getCatalogMaterialById } from '../material-library' +import { AGENT_OPERATIONS } from './index' + +type Group = { + query: string + results: { ref: string; label: string; kind: string; surfaces: string[]; color?: string }[] + missing?: string[] +} + +function search(...queries: { query: string; surface?: 'roof' | 'wall' | 'floor' | 'ceiling' }[]) { + const { result } = AGENT_OPERATIONS.search_materials({}, { queries } as never, { + activeLevelId: null, + }) + return (result as { groups: Group[] }).groups +} + +const refs = (group: Group) => group.results.map((result) => result.ref) + +describe('search_materials', () => { + test('reads building words as the library writes them', () => { + const [render, weatherboard, timber] = search( + { query: 'white render' }, + { query: 'weatherboard' }, + { query: 'timber floor' }, + ) + expect(refs(render!)).toContain('library:concrete-stucco') + expect(render!.missing).toBeUndefined() + expect(refs(weatherboard!).some((ref) => ref.startsWith('library:siding-lap'))).toBe(true) + expect(refs(timber!)).toContain('library:wood-finewood27') + }) + + test('keeps to the surface asked, every result a library ref', () => { + const [roofing] = search({ query: 'tiles', surface: 'roof' }) + expect(refs(roofing!)).toContain('library:roof-claytiles') + for (const { ref, surfaces } of roofing!.results) { + expect(surfaces).toContain('roof') + expect(getCatalogMaterialById(ref.slice('library:'.length))).toBeDefined() + } + }) + + test('says which words no material answers, rather than guess', () => { + const [group] = search({ query: 'corrugated polycarbonate' }) + expect(group!.missing).toEqual(['corrugated', 'polycarbonate']) + expect(group!.results).toEqual([]) + }) + + test('ranks a material answering every word above one answering some', () => { + // The white sidings answer "white" only. + const [group] = search({ query: 'white stucco' }) + expect(refs(group!)[0]).toBe('library:concrete-stucco') + expect(refs(group!)).toContain('library:siding-lap-white') + }) +}) diff --git a/packages/core/src/agent-operations/search-materials.ts b/packages/core/src/agent-operations/search-materials.ts new file mode 100644 index 0000000000..caef5d90ab --- /dev/null +++ b/packages/core/src/agent-operations/search-materials.ts @@ -0,0 +1,120 @@ +import { + MATERIAL_CATALOG, + type MaterialCatalogItem, + type MaterialSurface, +} from '../material-library' +import type { AgentOperation } from './types' + +type SearchMaterialsInput = { queries: { query: string; surface?: MaterialSurface }[] } + +/** + * Building words as the library writes them: a plan or a brief says timber, weatherboard, render + * where the library says wood, siding, stucco. + */ +const SAME: Record = { + timber: ['wood'], + weatherboard: ['siding', 'clapboard'], + cladding: ['siding', 'clapboard', 'board', 'batten'], + render: ['stucco', 'plaster'], + rendered: ['stucco', 'plaster'], + sheeting: ['metal', 'seam'], + sheet: ['metal', 'seam'], + decking: ['wood', 'plank', 'board'], + paving: ['stone', 'tile', 'concrete', 'ground'], + pavers: ['stone', 'tile', 'concrete'], + gray: ['grey'], + grey: ['gray'], +} + +/** Surface words: a material suiting the surface answers them. */ +const SURFACE_WORDS: Record = { + roof: 'roof', + roofing: 'roof', + wall: 'wall', + facade: 'wall', + floor: 'floor', + flooring: 'floor', + ceiling: 'ceiling', + soffit: 'ceiling', + outdoor: 'outdoor', + garden: 'outdoor', +} + +type Shade = { luminance: number; saturation: number } + +/** Shade words, by the material's colour: relative luminance (0 black, 1 white) and saturation. */ +const SHADES: Record boolean> = { + dark: ({ luminance }) => luminance < 0.25, + black: ({ luminance }) => luminance < 0.08, + light: ({ luminance }) => luminance > 0.6, + pale: ({ luminance }) => luminance > 0.6, + white: ({ luminance }) => luminance > 0.75, + grey: ({ luminance, saturation }) => luminance > 0.04 && luminance < 0.75 && saturation < 0.2, + gray: ({ luminance, saturation }) => luminance > 0.04 && luminance < 0.75 && saturation < 0.2, +} + +function shadeOf(hex: string | undefined): Shade | null { + const match = /^#?([0-9a-f]{6})$/i.exec(hex ?? '') + if (!match) return null + const srgb = [0, 2, 4].map((i) => Number.parseInt(match[1]!.slice(i, i + 2), 16) / 255) + const [r, g, b] = srgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)) + const max = Math.max(...srgb) + return { + luminance: 0.2126 * r! + 0.7152 * g! + 0.0722 * b!, + saturation: max ? (max - Math.min(...srgb)) / max : 0, + } +} + +const stem = (word: string) => (word.length > 4 && word.endsWith('s') ? word.slice(0, -1) : word) + +/** Whether a material answers a query word: by its text, the trade vocabulary, shade or surface. */ +function answers(material: MaterialCatalogItem, text: string, word: string): boolean { + const forms = [word, stem(word), ...(SAME[word] ?? []), ...(SAME[stem(word)] ?? [])] + if (forms.some((form) => text.includes(form))) return true + const shade = SHADES[word] + const colour = shadeOf(material.previewColor) + if (shade && colour && shade(colour)) return true + const surface = SURFACE_WORDS[word] ?? SURFACE_WORDS[stem(word)] + return !!surface && !!material.surfaces?.includes(surface) +} + +/** `search_materials`: the library's materials by words, best first, and the words none answers. */ +export const searchMaterials: AgentOperation = (_nodes, { queries }) => ({ + result: { + groups: queries.map(({ query, surface }) => { + const words = query + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + const scored = MATERIAL_CATALOG.filter( + (material) => !surface || material.surfaces?.includes(surface), + ).map((material) => { + const text = + `${material.id} ${material.label} ${material.category} ${material.description ?? ''}`.toLowerCase() + return { material, hits: words.filter((word) => answers(material, text, word)) } + }) + const missing = words.filter((word) => !scored.some(({ hits }) => hits.includes(word))) + const best = scored + .filter(({ hits }) => hits.length) + .sort( + (a, b) => + b.hits.length - a.hits.length || a.material.label.localeCompare(b.material.label), + ) + .slice(0, 12) + return { + query, + ...(surface ? { surface } : {}), + results: best.map(({ material }) => ({ + ref: `library:${material.id}`, + label: material.label, + kind: material.category, + surfaces: material.surfaces ?? [], + ...(material.previewColor ? { color: material.previewColor } : {}), + })), + ...(missing.length ? { missing } : {}), + } + }), + message: + 'Materials best first, as library: refs for paint. The words no material answers are under missing: the library lacks them.', + }, +}) diff --git a/packages/core/src/agent-operations/types.ts b/packages/core/src/agent-operations/types.ts index 705c84b99b..bce8b99a06 100644 --- a/packages/core/src/agent-operations/types.ts +++ b/packages/core/src/agent-operations/types.ts @@ -1,4 +1,4 @@ -import type { AnyNode, AssetInput, Collection } from '../schema' +import type { AnyNode, AssetInput, Collection, SceneMaterial, SceneMaterialId } from '../schema' export type SceneNodes = Readonly> @@ -7,16 +7,21 @@ export type SceneNodes = Readonly> * keeps checkpoints passes the one asked for, and verify_scene compares the scene with it. A host * with an item library passes it as `catalog` (the chat and the hosted MCP: the published library; * the standalone MCP: its built-in list), resolved before the call, as the operations are sync. + * A host that can store scene materials with the nodes that use them passes the scene's + * `materials`: paint then checks scene refs against them and keeps a native material as one. */ 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[] + materials?: Readonly> } /** Edits an operation asks for; each surface applies them its own way, in one undo step. */ export type SceneChanges = { + /** Scene materials to add, written before the nodes that use them. */ + materials?: readonly SceneMaterial[] create?: { node: AnyNode; parentId?: string }[] update?: { id: string; data: Partial }[] /** Ids to remove; as with the editor's Delete, each goes with everything under it. */ diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 5656eebd04..8d3b6dec9d 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -16,8 +16,10 @@ import { listLevelsTool, } from './levels' import { deleteNodeTool, getNodeTool } from './nodes' +import { paintTool } from './paint' import { placeItemsTool } from './place-items' import { ROOM_TOOL_CONTRACTS } from './room-structure' +import { searchMaterialsTool } from './search-materials' import { createStairTool, fitStairTool, measureStairTool } from './stairs' import { verifySceneTool } from './verify-scene' import { viewSceneTool } from './view-scene' @@ -39,9 +41,11 @@ export * from './levels' export * from './measurement' export { NodeId } from './node-id' export * from './nodes' +export * from './paint' export * from './place-items' export * from './refusal' export * from './room-structure' +export * from './search-materials' export * from './stairs' export * from './verify-scene' export * from './view-scene' @@ -82,5 +86,7 @@ export const AGENT_TOOL_CONTRACTS = [ createRoomTool, furnishRoomTool, searchAssetsTool, + paintTool, + searchMaterialsTool, ...ROOM_TOOL_CONTRACTS, ] as const satisfies readonly AgentToolContract[] diff --git a/packages/core/src/agent-tools/nodes.ts b/packages/core/src/agent-tools/nodes.ts index 44b7bb1a22..e5a8a9dcc6 100644 --- a/packages/core/src/agent-tools/nodes.ts +++ b/packages/core/src/agent-tools/nodes.ts @@ -3,7 +3,8 @@ import { NodeId } from './node-id' export const getNodeTool = { name: 'get_node', title: 'Get node', - description: 'Get the full data of any node by its id.', + description: + 'Get the full data of any node by its id, and the scene materials (scene:) its finishes use.', input: { id: NodeId.describe('The node id.') }, } diff --git a/packages/core/src/agent-tools/paint.ts b/packages/core/src/agent-tools/paint.ts new file mode 100644 index 0000000000..5cc93cb7d7 --- /dev/null +++ b/packages/core/src/agent-tools/paint.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { MaterialSchema } from '../schema/material' +import { NodeId } from './node-id' + +export const paintTool = { + name: 'paint', + title: 'Paint', + description: + "Give walls, columns, slabs, ceilings, fences, doors, windows or roofs a finish in one call, as the editor's Paint does. Give one finish: color (#rrggbb, painted with the nearest of the library's flat colours, said with how near); material, a library: (find it with search_materials) or scene: ref, or a native material {properties: {color, roughness, metalness}} for an exact finish, kept as a scene material (materialName names a new one) and reused when the scene holds an equal one; or erase: true, back to the surface's default. role picks the surface: a wall's face a or b (or exterior, interior: the face turned out or into its room), its trims aSkirting, bSkirting, aCrown, bCrown, aChairRail, bChairRail, a curtain wall's curtain-frame, curtain-glass, curtain-solid; a column's shaft, base, capital or frame; a slab's surface, side, edge, riser, underside or foundation; a ceiling's surface; a fence's posts, infill, base or rail; a door's panel, frame, glass or hardware; a window's frame or glass; a roof's top, edge or wall. Without a role: both faces of a wall, a column's shaft, a slab's or a ceiling's surface, a whole fence, a door's panel and frame, a window's frame, a roof's top. A ceiling shows its finish's colour, not its texture. The result says the finish written, and the scene material when there is one. A target that is not in the scene, a role it lacks or an unknown material refuses the whole call: nothing is painted.", + input: { + targets: z.array(NodeId).min(1).max(200).describe('The nodes to paint.'), + role: z.string().min(1).optional().describe('The surface: see the description.'), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .optional() + .describe('A colour, #rrggbb: painted with the nearest library colour.'), + material: z + .union([z.string().min(1), MaterialSchema]) + .optional() + .describe( + 'A library: or scene: ref, or a native material (properties color, roughness, metalness, and an existing texture) kept as a scene material.', + ), + materialName: z + .string() + .trim() + .min(1) + .max(120) + .optional() + .describe( + 'The name of a new native material; an equal one already in the scene keeps its own.', + ), + erase: z.literal(true).optional().describe("Back to the surface's default."), + }, +} diff --git a/packages/core/src/agent-tools/search-materials.ts b/packages/core/src/agent-tools/search-materials.ts new file mode 100644 index 0000000000..021b68f65c --- /dev/null +++ b/packages/core/src/agent-tools/search-materials.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import type { MaterialSurface } from '../material-library' + +// The library's surfaces, named here so the contract stays free of the catalog it searches. +const SURFACES = [ + 'floor', + 'wall', + 'ceiling', + 'roof', + 'furniture', + 'outdoor', +] as const satisfies readonly MaterialSurface[] + +export const searchMaterialsTool = { + name: 'search_materials', + title: 'Search materials', + description: + "Search the material library (the finishes paint gives) by words, several queries in one call: find a library material's id here rather than guessing it. A query matches a material's id, name and kind, building words as the library writes them (timber is wood, weatherboard is siding, render is stucco), colour words by its shade (dark, light, white, grey) and surface words (roof, wall, floor, ceiling) by what it suits. Returns one group per query: each material's ref (library:), name, kind, the surfaces it suits and its colour, best first, and the words no material answers (missing), so a finish the library lacks is said, not guessed.", + input: { + queries: z + .array( + z.object({ + query: z + .string() + .trim() + .min(1) + .describe('Search words, e.g. "white render", "grey metal roofing", "oak floor".'), + surface: z.enum(SURFACES).optional().describe('Only materials that suit this surface.'), + }), + ) + .min(1) + .max(12) + .describe('One or more searches to run in one call.'), + }, +} diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 68ab405672..daeb56b1df 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -331,7 +331,7 @@ captured by Zundo's temporal middleware as a single undoable step. | Name | Purpose | Key input | Output | | --- | --- | --- | --- | | `get_scene` | Return the full scene graph. | — | `{ nodes, rootNodeIds, collections }` | -| `get_node` | Fetch a node by id, whole. | `{ id }` | `{ node }`, or refusal `node_not_found` | +| `get_node` | Fetch a node by id, whole, with the scene materials its finishes use. | `{ id }` | `{ node, materials? }`, or refusal `node_not_found` | | `describe_node` | Node summary with ancestry, children count and properties. | `{ id }` | `{ id, type, parentId, ancestry[], childrenCount, properties, description }` | | `find_nodes` | Filter nodes by type (any node kind) / parent / zone / level, or by import source id (`provenance.refs[].id` or legacy `metadata.sourceIds`, exact or prefix). | `{ type?, parentId?, zoneId?, levelId?, sourceId?, sourceIdPrefix? }` | `{ nodes: AnyNode[] }` | | `list_levels` | List every building's levels in floor order with their role (storey, roof-only, support). | — | `{ activeSceneId, activeLevelId, levelCount, occupiedStoryCount, supportLevelCount, roofLevelIds, levels[] }` | @@ -341,6 +341,8 @@ captured by Zundo's temporal middleware as a single undoable step. | `measure` | Distance between two nodes' world-space reference points (hosted doors, windows and items resolved through their host; every node kind); area when applicable. | `{ fromId, toId }` | `{ distanceMeters, fromPoint?, toPoint?, areaSqMeters?, units: 'meters' }` | | `view_scene` | Look at the building from a viewpoint the agent picks (a side, a street-height eye, a photo's camera, square-on, from above) and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, its walls cut at two thirds of the storey, for the picture only. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`; on the hosted server `editor_tab_required`, `editor_tab_hidden` | | `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` | +| `search_materials` | Search the material library by words, several queries in one call: building words as the library writes them (timber is wood, render is stucco), shades and surfaces. Read-only. | `{ queries: [{ query, surface? }] }` | `{ groups: [{ query, results: [{ ref, label, kind, surfaces, color? }], missing? }] }` | +| `paint` | Give walls, columns, slabs, ceilings, fences, doors, windows or roofs a finish in one call, as the editor's Paint does: a colour (the nearest library colour), a `library:` or `scene:` ref, a native material kept as a scene material (reused when an equal one exists), or erase. `role` picks the surface (a wall's `exterior`/`interior` face, a slot, a roof's `top`/`edge`/`wall`). | `{ targets[], role?, color?, material?, materialName?, erase? }` (one finish) | `{ ok, painted[], finish, materialId?, material?, createdMaterial?, note?, achieved }`; refusals `target_not_found`, `unknown_role`, `unknown_material`, `materials_unavailable`, `shadowed_field`, `not_paintable` | | `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` | 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`, `slab_not_found` / `ceiling_not_found`, `slab_not_on_level` / `ceiling_not_on_level` | | `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 }` | diff --git a/packages/mcp/src/bridge/scene-bridge.ts b/packages/mcp/src/bridge/scene-bridge.ts index ae1ae67866..3b12c85388 100644 --- a/packages/mcp/src/bridge/scene-bridge.ts +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -21,13 +21,14 @@ import { structureChangeBatch, } from '@pascal-app/core' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' -import type { AnyNode, Collection, CollectionId } from '@pascal-app/core/schema' +import type { AnyNode, Collection, CollectionId, SceneMaterialId } from '@pascal-app/core/schema' import { type AnyNodeId, AnyNode as AnyNodeSchema, type AnyNodeType, generateId, parseNode, + SceneMaterial, } from '@pascal-app/core/schema' // Per PLAN §0.6: `useScene` is the DEFAULT export from `@pascal-app/core/store`. import useScene from '@pascal-app/core/store' @@ -44,7 +45,9 @@ export type ValidationResult = { export type CreatePatch = { op: 'create'; node: AnyNode; parentId?: AnyNodeId } export type UpdatePatch = { op: 'update'; id: AnyNodeId; data: Partial } export type DeletePatch = { op: 'delete'; id: AnyNodeId; cascade?: boolean } -export type Patch = CreatePatch | UpdatePatch | DeletePatch +/** Adds or replaces a scene material, written before the nodes of the same patch. */ +export type MaterialUpsertPatch = { op: 'upsert_material'; material: SceneMaterial } +export type Patch = CreatePatch | UpdatePatch | DeletePatch | MaterialUpsertPatch export type ActiveSceneMeta = Pick< SceneMeta, 'id' | 'name' | 'projectId' | 'ownerId' | 'thumbnailUrl' | 'version' | 'graphHash' @@ -61,6 +64,8 @@ type SetSceneExtra = Parameters['setScene'] * `flushDirty()` for observability. */ export class SceneBridge { + /** `applyPatch` takes `upsert_material` patches, in the patch's one history step. */ + readonly supportsMaterialUpserts = true private activeScene: ActiveSceneMeta | null = null /** @@ -194,6 +199,10 @@ export class SceneBridge { return useScene.getState().collections } + getMaterials(): Record { + return useScene.getState().materials + } + setCollections(collections: Record): void { if (useScene.getState().readOnly) return useScene.setState({ collections }) @@ -408,11 +417,19 @@ export class SceneBridge { // Zod-normalised copy (which has a generated id if the caller omitted one) // instead of the unparsed input. const parsedCreateNodes = new Map() + const materialUpserts = new Map() for (let i = 0; i < patches.length; i++) { const p = patches[i] if (!p) throw new Error(`invalid patch: patches[${i}] is undefined`) - if (p.op === 'create') { + if (p.op === 'upsert_material') { + const parsed = SceneMaterial.safeParse(p.material) + if (!parsed.success) + throw new Error( + `invalid patch: patches[${i}] material failed schema: ${parsed.error.message}`, + ) + materialUpserts.set(parsed.data.id, parsed.data) + } else if (p.op === 'create') { const res = parseNode(p.node) if (!res.success) { throw new Error( @@ -483,7 +500,7 @@ export class SceneBridge { if (patch.data.parentId !== undefined) created.parentId = patch.data.parentId as AnyNodeId } else updateOps.set(patch.id, { ...updateOps.get(patch.id), ...patch.data } as Partial) - } else { + } else if (patch.op === 'delete') { deleteIds.add(patch.id) createOps.delete(patch.id) updateOps.delete(patch.id) @@ -512,6 +529,8 @@ export class SceneBridge { runAsSingleSceneHistoryStep(useScene, () => { pauseSpaceDetection() try { + for (const material of materialUpserts.values()) + useScene.getState().addSceneMaterial(material) useScene.getState().createNodes([...createOps.values(), ...planned.create]) useScene.getState().updateNodes([...updateOps].map(([id, data]) => ({ id, data }))) useScene.getState().deleteNodes(planned.delete) diff --git a/packages/mcp/src/operations/scene-operations.ts b/packages/mcp/src/operations/scene-operations.ts index 0d581431f0..46f560bf61 100644 --- a/packages/mcp/src/operations/scene-operations.ts +++ b/packages/mcp/src/operations/scene-operations.ts @@ -6,6 +6,8 @@ import type { AnyNodeType, Collection, CollectionId, + SceneMaterial, + SceneMaterialId, } from '@pascal-app/core/schema' import type { ActiveSceneMeta, Patch, SceneBridge, ValidationResult } from '../bridge/scene-bridge' import type { @@ -37,6 +39,11 @@ export interface SceneOperations { readonly canCreateProject: boolean readonly canGetProjectStatus: boolean readonly storeBackend: SceneStore['backend'] | null + /** + * Whether `applyPatch` takes `upsert_material` patches with the nodes that use them, in one + * history step. A host that does lends `getMaterials`, and paint keeps native materials there. + */ + readonly supportsMaterialUpserts?: boolean setActiveScene(meta: ActiveSceneMeta): void getActiveScene(): ActiveSceneMeta | null @@ -49,6 +56,7 @@ export interface SceneOperations { getNode(id: AnyNodeId): AnyNode | null getNodes(): Record getCollections(): Record + getMaterials?(): Readonly> /** Replace the scene's collections (one undo step, like any edit). */ setCollections(collections: Record): void getRootNodeIds(): AnyNodeId[] @@ -118,6 +126,10 @@ class SceneOperationsFacade implements SceneOperations { return this.#bridge !== undefined } + get supportsMaterialUpserts(): boolean { + return this.#bridge?.supportsMaterialUpserts === true + } + get hasStore(): boolean { return this.#store !== undefined } @@ -199,6 +211,10 @@ class SceneOperationsFacade implements SceneOperations { return this.requireBridge().getCollections() } + getMaterials(): Readonly> { + return this.requireBridge().getMaterials() + } + setCollections(collections: Record): void { this.requireBridge().setCollections(collections) } diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index 49721d9272..a1b163ddc4 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -36,6 +36,7 @@ export const AGENT_GUIDE = [ '- Do not hand-write node graphs unless no semantic tool exists.', '- When you do use `apply_patch` for bulk graph edits, batch-first is the default: one call containing all create/update/delete ops for the phase, in stable order so later ops can reference ids created earlier. A single call is atomic (all or nothing); do not loop one-op `apply_patch` calls.', '- For rooms, use `create_room` -> `add_door` -> `add_window` -> `furnish_room`.', + "- Give finishes with `paint`, as the editor's Paint does: a library material found with `search_materials`, a colour, or a native material for an exact finish. Do not write `slots` or material presets through `apply_patch`.", '- Floors and ceilings are DERIVED from rooms, never authored. State intent — walls, separators and a zone with `spaceRole: "room"` — and the structure reconciler builds the floor plate and the ceiling for you. Creating a slab or ceiling carrying the derived markers (`boundary` / `autoFromWalls`), or reshaping or relinking one, is refused; deleting one records that opt-out on the zone instead. A room without a floor or ceiling is expressed on its zone (`hasFloor: false`, `hasCeiling: false`), and its floor height with `floor.elevation`.', '- Manual slabs remain valid for things that are not rooms: pools, platforms, stair decks, terraces at their own elevation.', '- `furnish_room` skips or nudges poses that block door clear zones or overlap other items; `verify_scene` and `check_collisions` report remaining issues.', diff --git a/packages/mcp/src/tools/honest-patch-guard.ts b/packages/mcp/src/tools/honest-patch-guard.ts index 5fbb28046f..85123f2a57 100644 --- a/packages/mcp/src/tools/honest-patch-guard.ts +++ b/packages/mcp/src/tools/honest-patch-guard.ts @@ -14,7 +14,7 @@ function honestUpdates(patches: Patch[], scene: Readonly 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 { + else if (patch.op === 'update') { const current = nodes[patch.id] if (!current) continue try { diff --git a/packages/mcp/src/tools/paint.test.ts b/packages/mcp/src/tools/paint.test.ts new file mode 100644 index 0000000000..fe5a6881a9 --- /dev/null +++ b/packages/mcp/src/tools/paint.test.ts @@ -0,0 +1,135 @@ +import { afterEach, 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 AnyNode, + type AnyNodeId, + BuildingNode, + LevelNode, + type SceneMaterial, + SiteNode, + WallNode, +} from '@pascal-app/core/schema' +import { SceneBridge } from '../bridge/scene-bridge' +import { createSceneOperations } from '../operations' +import { registerSharedTools } from './shared-tools' + +// paint through a real client: the operation's scene material reaches the bridge with the nodes +// that use it, in one history step, and is saved with the scene. + +type Result = { isError?: boolean; content: Array<{ type: string; text?: string }> } + +const TIMBER = { preset: 'custom', properties: { color: '#71503a', roughness: 0.86, metalness: 0 } } + +/** Two walls of a room, on one level. */ +function scene() { + const walls = [ + WallNode.parse({ id: 'wall_south', parentId: 'level_m', start: [0, 0], end: [6, 0] }), + WallNode.parse({ id: 'wall_east', parentId: 'level_m', start: [6, 0], end: [6, 4] }), + ] + const nodes: AnyNode[] = [ + SiteNode.parse({ id: 'site_m', children: ['building_m'] }), + BuildingNode.parse({ id: 'building_m', parentId: 'site_m', children: ['level_m'] }), + LevelNode.parse({ + id: 'level_m', + parentId: 'building_m', + level: 0, + children: walls.map((wall) => wall.id), + }), + ...walls, + ] + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +let bridge: SceneBridge +let client: Client +let server: McpServer + +const materials = () => bridge.getMaterials() as Record +const slotsOf = (id: string) => (bridge.getNodes()[id as AnyNodeId] as WallNode).slots ?? {} + +async function call(name: string, args: Record) { + const result = (await client.callTool({ name, arguments: args })) as Result + const text = result.content.find((entry) => entry.type === 'text')?.text ?? '' + return { isError: !!result.isError, payload: result.isError ? { text } : JSON.parse(text) } +} + +beforeEach(async () => { + bridge = new SceneBridge() + bridge.setScene(scene(), ['site_m' as AnyNodeId], { materials: {} }) + bridge.clearHistory() + server = new McpServer({ name: 'paint', version: '1' }) + registerSharedTools(server, createSceneOperations({ bridge })) + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'paint', version: '1' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) +}) + +afterEach(async () => { + await client.close() + await server.close() +}) + +describe('paint over the MCP', () => { + test('a native material is kept with the wall that uses it, in one undo step', async () => { + const before = bridge.exportJSON() + const painted = await call('paint', { + targets: ['wall_south'], + role: 'b', + material: TIMBER, + materialName: 'Warm timber', + }) + expect(painted.isError).toBe(false) + const ref = slotsOf('wall_south').b! + expect(painted.payload).toMatchObject({ ok: true, finish: ref, achieved: { updated: 1 } }) + const id = ref.slice('scene:'.length) + expect(materials()[id]).toMatchObject({ name: 'Warm timber', material: TIMBER }) + + const read = await call('get_node', { id: 'wall_south' }) + expect(read.payload).toMatchObject({ + node: { slots: { b: ref } }, + materials: { [id]: { material: TIMBER } }, + }) + + const after = bridge.exportJSON() + expect(bridge.getHistory()).toEqual({ pastCount: 1, futureCount: 0 }) + expect(bridge.undo()).toBe(1) + expect(bridge.exportJSON()).toEqual(before) + expect(bridge.redo()).toBe(1) + expect(bridge.exportJSON()).toEqual(after) + + bridge.setScene({}, [], { materials: {} }) + bridge.loadJSON(JSON.stringify(after)) + expect(slotsOf('wall_south').b).toBe(ref) + expect(materials()[id]).toMatchObject({ material: TIMBER }) + }) + + test('the next call reuses the material rather than keep a second', async () => { + await call('paint', { targets: ['wall_south'], role: 'b', material: TIMBER }) + await call('paint', { targets: ['wall_east'], role: 'b', material: TIMBER }) + expect(slotsOf('wall_east').b).toBe(slotsOf('wall_south').b) + expect(Object.keys(materials())).toHaveLength(1) + }) + + test('a refused call leaves the scene, its materials and its history as they were', async () => { + const before = bridge.exportJSON() + for (const args of [ + { targets: ['wall_south', 'wall_gone'], role: 'b', material: TIMBER }, + { targets: ['wall_south'], role: 'b', material: 'scene:mat_missing' }, + { targets: ['wall_south'], role: 'b', material: { properties: { roughness: 2 } } }, + ]) { + expect((await call('paint', args)).isError).toBe(true) + expect(bridge.exportJSON()).toEqual(before) + expect(bridge.getHistory()).toEqual({ pastCount: 0, futureCount: 0 }) + } + }) + + test('search_materials finds the library ref paint takes', async () => { + const found = await call('search_materials', { queries: [{ query: 'white render' }] }) + const ref = found.payload.groups[0].results[0].ref as string + expect(ref).toMatch(/^library:/) + expect((await call('paint', { targets: ['wall_south'], material: ref })).isError).toBe(false) + expect(slotsOf('wall_south')).toEqual({ a: ref, b: ref }) + }) +}) diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index f0edfdb5b6..fefb2a7ef0 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -37,6 +37,7 @@ const TOOL_POLICIES = [ 'get_source', 'list_collections', 'search_assets', + 'search_materials', 'validate_design', 'validate_scene', 'verify_scene', @@ -107,6 +108,7 @@ const TOOL_POLICIES = [ 'rotate_zone', 'lock_outside_faces', 'merge_zones', + 'paint', 'delete_scene', 'get_project_status', 'load_scene', diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index 4442b63c54..b2b86b7da7 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { AGENT_OPERATIONS, + type AgentContext, type AgentOperation, achievedChanges, applyAgentOutcome, @@ -25,12 +26,19 @@ import { getZonesTool, listLevelsTool, measureStairTool, + paintTool, placeItemsTool, ROOM_TOOL_CONTRACTS, searchAssetsTool, + searchMaterialsTool, verifySceneTool, } from '@pascal-app/core/agent-tools' -import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' +import { + type AnyNode, + type AnyNodeId, + MaterialSchema, + SceneMaterial, +} from '@pascal-app/core/schema' import { z } from 'zod' import type { Patch } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations' @@ -133,7 +141,7 @@ const SHARED_TOOLS: SharedTool[] = [ contract: getNodeTool, operation: AGENT_OPERATIONS.get_node, annotations: READ_ONLY_TOOL_ANNOTATIONS, - outputSchema: { node: jsonObject }, + outputSchema: { node: jsonObject, materials: z.record(z.string(), SceneMaterial).optional() }, }, { contract: getLevelSummaryTool, @@ -305,6 +313,27 @@ const SHARED_TOOLS: SharedTool[] = [ annotations: READ_ONLY_TOOL_ANNOTATIONS, catalog: true, }, + { + contract: paintTool, + operation: AGENT_OPERATIONS.paint, + annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, + outputSchema: { + ok: z.literal(true), + painted: z.array(z.object({ id: z.string(), roles: z.array(z.string()) })), + finish: z.string(), + materialId: z.string().optional(), + material: MaterialSchema.optional(), + createdMaterial: SceneMaterial.optional(), + note: z.string().optional(), + ...achievedOutput, + ...liveSyncOutput, + }, + }, + { + contract: searchMaterialsTool, + operation: AGENT_OPERATIONS.search_materials, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + }, ...ROOM_TOOL_CONTRACTS.map((contract) => { const name = contract.name as RoomToolName return { @@ -318,6 +347,7 @@ const SHARED_TOOLS: SharedTool[] = [ export function toPatches(changes: SceneChanges): Patch[] { return [ + ...(changes.materials ?? []).map((material) => ({ op: 'upsert_material' as const, material })), ...(changes.create ?? []).map(({ node, parentId }) => ({ op: 'create' as const, node, @@ -379,9 +409,12 @@ export function registerOperationTool( // 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 = { + const context: AgentContext = { activeLevelId: null, ...(tool.catalog && { catalog: await catalog() }), + ...(bridge.supportsMaterialUpserts && bridge.getMaterials + ? { materials: bridge.getMaterials() } + : {}), } try { outcome = tool.operation(before, input as never, context) diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index b2e124b743..65c69d2b2b 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -708,6 +708,19 @@ "openWorldHint": "Uses only the connected scene; no public internet access." } }, + { + "name": "paint", + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It is not read-only because it writes finishes onto walls, slabs, ceilings, openings and roofs of the connected Pascal scene, and may add a scene material.", + "destructiveHint": "It replaces the finish a surface had, and erase removes a surface's own finish.", + "openWorldHint": "It operates on the connected Pascal scene and its material library without accessing the public internet." + } + }, { "name": "photo_to_scene", "annotations": { @@ -838,6 +851,19 @@ "openWorldHint": "It operates only on data supplied by or already available inside the connected Pascal service and does not access the public internet." } }, + { + "name": "search_materials", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It searches the Pascal material library without painting anything or changing a scene.", + "destructiveHint": "It does not create, update, replace, delete, load, or save Pascal scene or project state.", + "openWorldHint": "It reads only the material library built into Pascal and does not access the public internet." + } + }, { "name": "set_floor_foundation", "annotations": { diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index 693854965b..57f0cfdb0f 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -68,6 +68,7 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { measure: policy(true, false, false), merge_zones: policy(false, true, false), move_zone: policy(false, true, false), + paint: policy(false, true, false), photo_to_scene: policy(false, true, true), place_design: policy(false, false, false), place_items: policy(false, false, false), @@ -78,6 +79,7 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { rotate_zone: policy(false, true, false), save_scene: policy(false, true, false), search_assets: policy(true, false, false), + search_materials: policy(true, false, false), set_floor_foundation: policy(false, false, false), set_room_floor_construction: policy(false, false, false), set_unit_members: policy(false, true, false), diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 66abafb431..da2d7132a3 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -57,6 +57,8 @@ Record the active project ID, scene ID or version, and graph hash when returned. 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. +To give a surface a finish, call `paint` as the editor's Paint does: the targets, a `role` when it is not the default surface (a wall's `exterior` or `interior` face, a slot, a roof's `top`, `edge` or `wall`), and one finish: a library material whose `library:` you found with `search_materials`, a `color` (painted with the nearest library colour), or a native material for an exact finish, kept as a scene material and reused when an equal one exists. An unknown target, role or material refuses the whole call and nothing is painted. + A window `style` (double-hung, grid, transom…) shapes a Fixed window's panes. For a casement, sliding, hung, awning, louvered, bay or bow window, pass its `windowType` without a style: those types draw their own sashes, and a style there is refused (`style_needs_fixed_window`). `picture` is an alias of `single` (one pane): both are accepted and draw the same. To add a stair, call `create_stair` on the floor it rises from: `(x, z)` is the back-centre of the bottom step and `rotation` the climb direction. It sizes the flight as the editor's stair tool does (about 18 cm risers, the run from the design targets) unless you give `length` or `steps`, creates the level above when there is none, and cuts the floor it arrives through and the ceiling it leaves. It refuses a flight to or from a roof level (`roof_level`), a `toLevelId` that is not above (`not_above`), and a given slab or ceiling that is not on the floor it arrives on or leaves (`slab_not_on_level`, `ceiling_not_on_level`). diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 64d27946c4..7362c191fa 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -102,6 +102,12 @@ Views are planned on the site: a building's position and yaw move and turn the b **Inside a floor.** `interior: { levelId }` looks into one floor. The plan checks the level (`interior_level_invalid`, `interior_hidden` for a hidden level or ancestor, `interior_transform_unsupported` for a tipped building), frames the floor from 55° above unless a target, an eye or a camera is given, and hands the host only the level id. The editor that renders it works out the rest from its own scene with `sceneViewInterior`: for that one render the other floors and this floor's roofs and ceilings are hidden, its walls (and their shadows) are cut at 0.65 of the storey height, its walls are drawn whole whatever the user's wall mode, and the user's solo and isolation are lifted on it. Everything is put back right after the render, so the live view does not change. +## Finishes: `paint` and `search_materials` + +`paint` gives surfaces a finish as the editor's Paint does, in one call and one undo step: the slots the editor's slot paint writes, a wall's faces through the same paint plan (`planWallPaint` in core, shared with the editor's wall paint), a roof's or a segment's `top`, `edge` or `wall` role field. `exterior` and `interior` resolve to a wall's faces from the rooms on its sides. A colour paints with the nearest library colour, and the result says how near. `search_materials` finds `library:` refs by words, shade and surface, and names the words the library does not answer. + +A native material (an exact PBR finish) is kept as a scene material, reused when the scene holds one the material schema reads as equal. The operation returns it in `SceneChanges.materials`; a host writes it before the nodes that use it, in the same step. It needs the scene's materials in `AgentContext.materials`: the MCP lends them when its `SceneOperations` sets `supportsMaterialUpserts` and has `getMaterials`, and applies them as `upsert_material` patches (an internal op, not part of `apply_patch`'s schema). A host without them refuses a native material (`materials_unavailable`) and still paints library refs and colours. `get_node` returns the scene materials a node's finishes use when the host lends them. + ## Emptying a project: `clear_scene` A write that would leave a populated project empty is far more often an accident than an intent, so a store may refuse it: the save options carry `allowSceneWipe`, and without it a guarding store throws `SceneWipeBlockedError` (a `SceneInvalidError`). Live sync answers that refusal as `scene_wipe_blocked` with `mutationApplied: false` and loads the stored scene back into the session, so the agent's next write builds on the project as stored; when the stored scene cannot be read back, it says to call `load_scene` first. From 45c5a148a28a314cf580dae01a5ca5ed12cdf216 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 15:48:17 -0400 Subject: [PATCH 4/6] fix(core): an interior view refuses a target on another floor Every other floor is left out of an interior view, so a target on one framed nothing the picture shows. view_scene with interior and a target off that floor is refused interior_target_elsewhere, naming both. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/agent-operations/scene-view.test.ts | 6 ++++++ packages/core/src/agent-operations/scene-view.ts | 10 +++++++++- packages/mcp/README.md | 2 +- wiki/architecture/agent-surfaces.md | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agent-operations/scene-view.test.ts b/packages/core/src/agent-operations/scene-view.test.ts index 2521864f82..c4aa28b8a5 100644 --- a/packages/core/src/agent-operations/scene-view.test.ts +++ b/packages/core/src/agent-operations/scene-view.test.ts @@ -151,6 +151,12 @@ describe('a look inside one floor', () => { expect(framesBox(wall.pose, sceneViewBounds(nodes, 'wall_level_0_0'))).toBe(true) const street = sceneViewPlan(nodes, { interior: { levelId: 'level_0' }, eyeHeight: 1.7 }) expect(street.pose.position[1]).toBe(1.7) + // The upper floor is left out of the picture: its wall would frame nothing shown. + expect( + refusalOf(() => + sceneViewPlan(nodes, { interior: { levelId: 'level_0' }, target: 'wall_level_1_0' }), + ), + ).toBe('interior_target_elsewhere') }) test("a photo's camera keeps its pose, and the floor is still checked", () => { diff --git a/packages/core/src/agent-operations/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts index b3e13b5d5f..38be548849 100644 --- a/packages/core/src/agent-operations/scene-view.ts +++ b/packages/core/src/agent-operations/scene-view.ts @@ -665,6 +665,14 @@ export function sceneViewPlan( const crop = cropOf(input.photo) const inside = input.interior && sceneViewInterior(nodes, input.interior.levelId) const interior = input.interior && { levelId: input.interior.levelId } + const target = input.target ? nodes[input.target] : undefined + // Every other floor is left out of an interior view: a target on one would frame nothing shown. + if (interior && target && !ancestry(nodes, target).some((node) => node.id === interior.levelId)) + refuse( + 'interior_target_elsewhere', + `${target.type} ${target.id} is not on ${interior.levelId}: target something on the floor you look inside.`, + { target: target.id, levelId: interior.levelId }, + ) if (camera) { const own = ( ['from', 'position', 'elevation', 'eyeHeight', 'fov', 'projection'] as const @@ -687,7 +695,7 @@ export function sceneViewPlan( ...(interior ? { interior } : {}), } } - const opening = openingOf(nodes, input.target ? nodes[input.target] : undefined) + const opening = openingOf(nodes, target) const from = input.from ?? (opening && !input.position diff --git a/packages/mcp/README.md b/packages/mcp/README.md index daeb56b1df..f8fd143ccd 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -339,7 +339,7 @@ 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' }` | -| `view_scene` | Look at the building from a viewpoint the agent picks (a side, a street-height eye, a photo's camera, square-on, from above) and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, its walls cut at two thirds of the storey, for the picture only. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`; on the hosted server `editor_tab_required`, `editor_tab_hidden` | +| `view_scene` | Look at the building from a viewpoint the agent picks (a side, a street-height eye, a photo's camera, square-on, from above) and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, its walls cut at two thirds of the storey, for the picture only. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`, `interior_transform_unsupported`, `interior_target_elsewhere`; on the hosted server `editor_tab_required`, `editor_tab_hidden` | | `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` | | `search_materials` | Search the material library by words, several queries in one call: building words as the library writes them (timber is wood, render is stucco), shades and surfaces. Read-only. | `{ queries: [{ query, surface? }] }` | `{ groups: [{ query, results: [{ ref, label, kind, surfaces, color? }], missing? }] }` | | `paint` | Give walls, columns, slabs, ceilings, fences, doors, windows or roofs a finish in one call, as the editor's Paint does: a colour (the nearest library colour), a `library:` or `scene:` ref, a native material kept as a scene material (reused when an equal one exists), or erase. `role` picks the surface (a wall's `exterior`/`interior` face, a slot, a roof's `top`/`edge`/`wall`). | `{ targets[], role?, color?, material?, materialName?, erase? }` (one finish) | `{ ok, painted[], finish, materialId?, material?, createdMaterial?, note?, achieved }`; refusals `target_not_found`, `unknown_role`, `unknown_material`, `materials_unavailable`, `shadowed_field`, `not_paintable` | diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 7362c191fa..800d91775b 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -100,7 +100,7 @@ The answer is the picture plus the pose it was taken from, and which tab rendere Views are planned on the site: a building's position and yaw move and turn the boxes of what stands under it, so a building away from the origin is looked at where it stands. A roof, or a roof level holding only roofs, frames its segments from its seat to its peak. -**Inside a floor.** `interior: { levelId }` looks into one floor. The plan checks the level (`interior_level_invalid`, `interior_hidden` for a hidden level or ancestor, `interior_transform_unsupported` for a tipped building), frames the floor from 55° above unless a target, an eye or a camera is given, and hands the host only the level id. The editor that renders it works out the rest from its own scene with `sceneViewInterior`: for that one render the other floors and this floor's roofs and ceilings are hidden, its walls (and their shadows) are cut at 0.65 of the storey height, its walls are drawn whole whatever the user's wall mode, and the user's solo and isolation are lifted on it. Everything is put back right after the render, so the live view does not change. +**Inside a floor.** `interior: { levelId }` looks into one floor. The plan checks the level (`interior_level_invalid`, `interior_hidden` for a hidden level or ancestor, `interior_transform_unsupported` for a tipped building) and that a target is on it (`interior_target_elsewhere`), frames the floor from 55° above unless a target, an eye or a camera is given, and hands the host only the level id. The editor that renders it works out the rest from its own scene with `sceneViewInterior`: for that one render the other floors and this floor's roofs and ceilings are hidden, its walls (and their shadows) are cut at 0.65 of the storey height, its walls are drawn whole whatever the user's wall mode, and the user's solo and isolation are lifted on it. Everything is put back right after the render, so the live view does not change. ## Finishes: `paint` and `search_materials` From 62481dada32eccbcd0f66f43b69cd0739438bba5 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 17:21:39 -0400 Subject: [PATCH 5/6] fix(core): paint shows over a room's finish, search matches whole words, interiors refuse what they leave out - paint: a wall face is painted whole, its slot and an override in each room whose wall finish shows over it, as the editor's Paint writes a click on that room's face; painted parts keep theirs and the note names them. A curtain wall's exterior or interior is refused with its roles. - search_materials: query words match whole words of a material (or begin one, from four letters), short and common words are dropped, and a flat colour suits every surface filter. - view_scene interior: a level holding only roofs and ceilings is refused, a target the picture leaves out (a roof, a ceiling) is interior_target_elsewhere, and a hidden site no longer blocks the floors on it. The contract says what is cut: everything on the floor at 0.65 of the storey height. Co-Authored-By: Claude Opus 5.5 --- .../core/src/agent-operations/paint.test.ts | 93 +++++++++++++++++++ packages/core/src/agent-operations/paint.ts | 51 +++++++++- .../src/agent-operations/scene-view.test.ts | 32 +++++++ .../core/src/agent-operations/scene-view.ts | 49 ++++++---- .../agent-operations/search-materials.test.ts | 14 +++ .../src/agent-operations/search-materials.ts | 38 ++++++-- packages/core/src/agent-tools/paint.ts | 2 +- .../core/src/agent-tools/search-materials.ts | 7 +- packages/core/src/agent-tools/view-scene.ts | 4 +- packages/mcp/README.md | 4 +- packages/mcp/src/resources/agent-guide.ts | 2 +- packages/mcp/src/tools/view-scene.ts | 2 +- skills/pascal-3d/SKILL.md | 2 +- skills/pascal-3d/references/tool-workflows.md | 2 +- wiki/architecture/agent-surfaces.md | 4 +- 15 files changed, 262 insertions(+), 44 deletions(-) diff --git a/packages/core/src/agent-operations/paint.test.ts b/packages/core/src/agent-operations/paint.test.ts index 9aaec1bdc1..18d0493293 100644 --- a/packages/core/src/agent-operations/paint.test.ts +++ b/packages/core/src/agent-operations/paint.test.ts @@ -16,7 +16,13 @@ import { SlabNode, WallNode, WindowNode, + ZoneNode, } from '../schema' +import { + buildWallFinishLayout, + getWallLevelZones, + resolveWallFinish, +} from '../systems/wall/wall-finish' import { achievedChanges } from './achieved' import { applySceneChanges } from './apply-changes' import { AGENT_OPERATIONS } from './index' @@ -262,6 +268,83 @@ describe('paint with a native material', () => { }) }) +describe('a wall in a room with a wall finish', () => { + const WHITE = 'library:preset-white' + + /** The room, its walls given one finish as the editor's "paint this room's walls" leaves them. */ + function finished() { + const nodes = room() + const zone = ZoneNode.parse({ + id: 'zone_paint', + parentId: 'level_paint', + name: 'Living room', + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + boundaryWallIds: ['wall_south', 'wall_east', 'wall_north', 'wall_west'], + wallMaterial: WHITE, + }) + const level = nodes.level_paint as LevelNode + return { + ...nodes, + level_paint: { ...level, children: [...level.children, zone.id] }, + [zone.id]: zone, + } as Record + } + + /** The finish the renderer draws at station `u`, 2 m up face `face` of `wallId`. */ + function shownAt(nodes: Record, wallId: string, face: 'a' | 'b', u: number) { + const wall = nodes[wallId] as WallNode + return resolveWallFinish( + buildWallFinishLayout(wall, getWallLevelZones(wall, nodes)), + face, + u, + 2, + ) + } + + test("its inside face shows the paint over the room's finish; the room's other walls keep it", () => { + const nodes = finished() + expect(shownAt(nodes, 'wall_south', 'a', 3)).toMatchObject({ source: 'zone', ref: WHITE }) + const outcome = paint(nodes, { + targets: ['wall_south'], + role: 'interior', + material: 'library:concrete-raw', + }) + expect(outcome.result).toMatchObject({ painted: [{ id: 'wall_south', roles: ['a'] }] }) + const after = applied(nodes, {}, outcome.changes).nodes + for (const u of [0.5, 3, 5.5]) + expect(shownAt(after, 'wall_south', 'a', u)).toMatchObject({ ref: 'library:concrete-raw' }) + expect(after.zone_paint).toMatchObject({ + wallMaterial: WHITE, + wallOverrides: [{ wallId: 'wall_south', face: 'a', finish: 'library:concrete-raw' }], + }) + // The painted stripe on that face keeps its own finish, and the result says so. + expect(String(outcome.result.note)).toContain('wall_south a: stripe') + }) + + test("erasing it shows the face's own default, not the room's finish", () => { + const nodes = finished() + const once = applied( + nodes, + {}, + paint(nodes, { targets: ['wall_south'], role: 'a', material: 'library:concrete-raw' }) + .changes, + ).nodes + const erased = applied( + once, + {}, + paint(once, { targets: ['wall_south'], role: 'a', erase: true }).changes, + ).nodes + expect((erased.wall_south as WallNode).slots?.a).toBeUndefined() + expect(shownAt(erased, 'wall_south', 'a', 3).source).not.toBe('zone') + expect(shownAt(erased, 'wall_east', 'a', 2)).toEqual(shownAt(nodes, 'wall_east', 'a', 2)) + }) +}) + describe('paint refuses the whole call', () => { for (const [label, input, code] of [ ['a target not in the scene', timberOutside(['wall_south', 'wall_gone']), 'target_not_found'], @@ -286,6 +369,16 @@ describe('paint refuses the whole call', () => { }) } + test("a curtain wall's exterior: it has frame, glass and panels, no faces", () => { + const nodes = room() + nodes.wall_east = { ...(nodes.wall_east as WallNode), wallType: 'curtain' } + expect( + refusal(() => + paint(nodes, { targets: ['wall_south', 'wall_east'], role: 'exterior', color: '#ffffff' }), + ), + ).toBe('unknown_role') + }) + test('a scripted column with no shaft names the surfaces it has', () => { const column = ColumnNode.parse({ id: 'column_script', diff --git a/packages/core/src/agent-operations/paint.ts b/packages/core/src/agent-operations/paint.ts index 44bfe8af21..8d93b7b389 100644 --- a/packages/core/src/agent-operations/paint.ts +++ b/packages/core/src/agent-operations/paint.ts @@ -19,8 +19,10 @@ import { type MaterialSchema, type RoofSegmentNode, type SceneMaterial, + type WallFace, type WallNode, } from '../schema' +import { buildWallFinishLayout, getWallLevelZones } from '../systems/wall/wall-finish' import { planWallPaint } from '../systems/wall/wall-paint-plan' import { roomSideFaces } from '../systems/wall/wall-room-sides' import { finishSurface, requireMaterialRef } from './material-refs' @@ -145,6 +147,29 @@ function paintRoofRole(node: AnyNode, role: RoofRole, ref: string | undefined): return next as AnyNode } +/** + * A wall face painted whole, as the editor's Paint paints each part of it: the face's own finish, + * and the finish of each room it is turned to, which shows over the face's own. Painted parts + * (regions) stay on top, as they do under a room's paint. + */ +function paintWallFace( + nodes: SceneNodes, + wall: WallNode, + face: WallFace, + ref: string | undefined, +): Record { + const plan = planWallPaint(nodes, wall, { kind: 'slot', slotId: face }, ref) + const rooms = buildWallFinishLayout(wall, getWallLevelZones(wall, nodes)).spans[face] + for (const zoneId of new Set(rooms.map((span) => span.zoneId))) { + const scene = { ...nodes, ...plan } + Object.assign( + plan, + planWallPaint(scene, scene[wall.id] as WallNode, { kind: 'room-face', zoneId, face }, ref), + ) + } + return plan +} + export const paint: AgentOperation = (nodes, input, context) => { // A refused material's nearest are the first target's kind: a roof is offered roofing. const first = input.targets[0] ? nodes[input.targets[0]] : undefined @@ -154,6 +179,7 @@ export const paint: AgentOperation = (nodes, input, context) => { const current = (id: string) => (written[id] ?? nodes[id]) as AnyNode | undefined const scene = () => ({ ...nodes, ...written }) as SceneNodes const painted: { id: string; roles: string[] }[] = [] + const keptParts: string[] = [] for (const id of input.targets) { const node = current(id) @@ -201,10 +227,11 @@ export const paint: AgentOperation = (nodes, input, context) => { const ids = slots.map((slot) => slot.slotId) let roles: string[] if (input.role === 'exterior' || input.role === 'interior') { - if (node.type !== 'wall') + // A curtain wall has frame, glass and panels where a wall has faces. + if (node.type !== 'wall' || !ids.includes('a')) refuse( 'unknown_role', - `Only a wall has an ${input.role} face: a ${node.type}'s roles are ${ids.join(', ')}.`, + `A ${node.type === 'wall' ? 'curtain wall' : node.type} has no ${input.role} face: its roles are ${ids.join(', ')}.`, { target: id, roles: ids }, ) const sides = roomSideFaces(scene() as never, id) @@ -233,7 +260,17 @@ export const paint: AgentOperation = (nodes, input, context) => { for (const role of roles) { const target = current(id)! if (target.type === 'wall') { - Object.assign(written, planWallPaint(scene(), target, { kind: 'slot', slotId: role }, ref)) + const face = role === 'a' || role === 'b' ? role : null + Object.assign( + written, + face + ? paintWallFace(scene(), target, face, ref) + : planWallPaint(scene(), target, { kind: 'slot', slotId: role }, ref), + ) + const parts = + face && ref ? target.faceRegions?.filter((region) => region.face === face) : [] + if (parts?.length) + keptParts.push(`${id} ${face}: ${parts.map((region) => region.id).join(', ')}`) continue } const next = { ...((target as AnyNode & { slots?: Record }).slots ?? {}) } @@ -249,6 +286,12 @@ export const paint: AgentOperation = (nodes, input, context) => { return Object.keys(data).length ? [{ id, data }] : [] }) const createdMaterial = finish.created ? finish.sceneMaterial : undefined + const note = [ + finish.note, + keptParts.length && `Painted parts keep their own finish (${keptParts.join('; ')}).`, + ] + .filter(Boolean) + .join(' ') return { result: { ok: true, @@ -258,7 +301,7 @@ export const paint: AgentOperation = (nodes, input, context) => { ? { materialId: finish.sceneMaterial.id, material: finish.sceneMaterial.material } : {}), ...(createdMaterial ? { createdMaterial } : {}), - ...(finish.note ? { note: finish.note } : {}), + ...(note ? { note } : {}), }, changes: { update, ...(createdMaterial ? { materials: [createdMaterial] } : {}) }, } diff --git a/packages/core/src/agent-operations/scene-view.test.ts b/packages/core/src/agent-operations/scene-view.test.ts index c4aa28b8a5..54133836f0 100644 --- a/packages/core/src/agent-operations/scene-view.test.ts +++ b/packages/core/src/agent-operations/scene-view.test.ts @@ -13,6 +13,7 @@ import { LevelNode, RoofNode, RoofSegmentNode, + SiteNode, SlabNode, StairNode, WallNode, @@ -159,6 +160,24 @@ describe('a look inside one floor', () => { ).toBe('interior_target_elsewhere') }) + test("the floor's roofs and ceilings are no target: the picture leaves them out", () => { + const nodes = finishedBuilding() + for (const [levelId, target] of [ + ['level_1', 'roof_1'], + ['level_0', 'ceiling_0'], + ] as const) + expect(refusalOf(() => sceneViewPlan(nodes, { interior: { levelId }, target }))).toBe( + 'interior_target_elsewhere', + ) + }) + + test('a roof level has no inside, its roofs left out of the picture: the floor below has', () => { + expect(refusalOf(() => sceneViewInterior(hipHouse(), 'level_roof'))).toBe( + 'interior_level_invalid', + ) + expect(sceneViewInterior(hipHouse(), 'level_0').hiddenIds).toEqual(['level_roof']) + }) + test("a photo's camera keeps its pose, and the floor is still checked", () => { const nodes = finishedBuilding() const camera = { position: [25, 14, 18], target: [10, 1, 5], fov: 53, aspect: 4 / 3 } @@ -187,6 +206,19 @@ describe('a look inside one floor', () => { ) }) + test('a hidden site hides its ground only: the floors on it are still shown', () => { + const nodes = finishedBuilding() + const site = SiteNode.parse({ id: 'site_main', visible: false, children: ['building_main'] }) + const onHiddenSite = { + ...nodes, + [site.id]: site, + building_main: { ...nodes.building_main!, parentId: site.id }, + } as typeof nodes + expect(sceneViewInterior(onHiddenSite, 'level_0').bounds).toEqual( + sceneViewInterior(nodes, 'level_0').bounds, + ) + }) + test('a floor of a building away from the origin is cut where it stands', () => { const nodes = finishedBuilding() const moved = { diff --git a/packages/core/src/agent-operations/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts index 38be548849..75d634cd3f 100644 --- a/packages/core/src/agent-operations/scene-view.ts +++ b/packages/core/src/agent-operations/scene-view.ts @@ -42,7 +42,7 @@ export type SceneViewCrop = { source: string; region: [number, number, number, n /** A look inside one floor; the host cuts it from its own scene with sceneViewInterior. */ export type SceneViewInterior = { levelId: string } -/** How high up its storey an interior view cuts a floor's walls: low enough to see every room. */ +/** How high up its storey an interior view cuts a floor: low enough to see every room. */ const INTERIOR_CUT = 0.65 /** Degrees above the horizon an interior view looks down from, by default. */ const INTERIOR_ELEVATION = 55 @@ -70,7 +70,8 @@ export function sceneViewInterior( levelId, }) for (const node of ancestry(nodes, level)) { - if (node.visible === false) + // A hidden site drops only its own ground and lot line: what stands on it still shows. + if (node.visible === false && node.type !== 'site') refuse('interior_hidden', `${node.type} ${node.id} is hidden: show it to look inside.`, { target: node.id, }) @@ -80,6 +81,20 @@ export function sceneViewInterior( target: node.id, }) } + const hiddenIds = Object.values(nodes) + .filter((node) => + node.type === 'level' + ? node.id !== levelId + : (node.type === 'roof' || node.type === 'ceiling') && + ancestry(nodes, node).some((at) => at.id === levelId), + ) + .map((node) => node.id) + if (level.children.length && level.children.every((id) => hiddenIds.includes(id))) + refuse( + 'interior_level_invalid', + `${levelId} holds only roofs and ceilings, which an interior view leaves out: look inside the floor below it.`, + { levelId }, + ) const box = sceneViewBounds(nodes, levelId) const storey = getLevelElevations(nodes as Record).get(levelId) const baseY = (storey?.baseY ?? 0) + standingOf(nodes, levelId).y @@ -87,14 +102,7 @@ export function sceneViewInterior( return { bounds: { min: [box.min[0], baseY, box.min[2]], max: [box.max[0], baseY + height, box.max[2]] }, cutHeight: baseY + height * INTERIOR_CUT, - hiddenIds: Object.values(nodes) - .filter((node) => - node.type === 'level' - ? node.id !== levelId - : (node.type === 'roof' || node.type === 'ceiling') && - ancestry(nodes, node).some((at) => at.id === levelId), - ) - .map((node) => node.id), + hiddenIds, } } @@ -666,13 +674,20 @@ export function sceneViewPlan( const inside = input.interior && sceneViewInterior(nodes, input.interior.levelId) const interior = input.interior && { levelId: input.interior.levelId } const target = input.target ? nodes[input.target] : undefined - // Every other floor is left out of an interior view: a target on one would frame nothing shown. - if (interior && target && !ancestry(nodes, target).some((node) => node.id === interior.levelId)) - refuse( - 'interior_target_elsewhere', - `${target.type} ${target.id} is not on ${interior.levelId}: target something on the floor you look inside.`, - { target: target.id, levelId: interior.levelId }, - ) + // An interior view shows this floor alone, without its roofs and ceilings: a target left out of + // the picture would frame nothing shown. + if (interior && inside && target) { + const chain = ancestry(nodes, target) + const left = chain.find((node) => inside.hiddenIds.includes(node.id)) + if (left || !chain.some((node) => node.id === interior.levelId)) + refuse( + 'interior_target_elsewhere', + left && left.type !== 'level' + ? `${target.type} ${target.id} is left out of an interior view, as the floor's roofs and ceilings are: target something on the floor you look inside.` + : `${target.type} ${target.id} is not on ${interior.levelId}: target something on the floor you look inside.`, + { target: target.id, levelId: interior.levelId }, + ) + } if (camera) { const own = ( ['from', 'position', 'elevation', 'eyeHeight', 'fov', 'projection'] as const diff --git a/packages/core/src/agent-operations/search-materials.test.ts b/packages/core/src/agent-operations/search-materials.test.ts index 2f28e60a51..94cdd17118 100644 --- a/packages/core/src/agent-operations/search-materials.test.ts +++ b/packages/core/src/agent-operations/search-materials.test.ts @@ -45,6 +45,20 @@ describe('search_materials', () => { expect(group!.results).toEqual([]) }) + test('matches whole words: a short or common word answers nothing by being inside another', () => { + const [walls, brick] = search({ query: 'white render for the walls' }, { query: 'a red brick' }) + // "for" is in forest, "red" in prepared, "a" in nearly every material. + expect(refs(walls!)[0]).toBe('library:concrete-stucco') + expect(refs(walls!)).not.toContain('library:siding-lap-forest') + expect(refs(brick!)).not.toContain('library:concrete-drywall') + expect(refs(brick!).every((ref) => /brick|red|tomato/.test(ref))).toBe(true) + }) + + test('a flat colour suits every surface asked', () => { + const [ceiling] = search({ query: 'white', surface: 'ceiling' }) + expect(refs(ceiling!)).toContain('library:preset-white') + }) + test('ranks a material answering every word above one answering some', () => { // The white sidings answer "white" only. const [group] = search({ query: 'white stucco' }) diff --git a/packages/core/src/agent-operations/search-materials.ts b/packages/core/src/agent-operations/search-materials.ts index caef5d90ab..04f70cdfd7 100644 --- a/packages/core/src/agent-operations/search-materials.ts +++ b/packages/core/src/agent-operations/search-materials.ts @@ -67,13 +67,33 @@ function shadeOf(hex: string | undefined): Shade | null { const stem = (word: string) => (word.length > 4 && word.endsWith('s') ? word.slice(0, -1) : word) -/** Whether a material answers a query word: by its text, the trade vocabulary, shade or surface. */ -function answers(material: MaterialCatalogItem, text: string, word: string): boolean { +/** Words that tell no material from another: "white render for the walls" asks for three things. */ +const STOP_WORDS = new Set(['and', 'the', 'for', 'with', 'from', 'into', 'some', 'like', 'its']) + +const wordsOf = (text: string) => + text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + +/** Whether a material can go on a surface: one that names no surface goes anywhere (flat colours). */ +const suits = (material: MaterialCatalogItem, surface: MaterialSurface) => + !material.surfaces || material.surfaces.includes(surface) + +/** + * Whether a material answers a query word: by a word of its text (whole, or begun by a form of four + * letters or more: tile answers tiles, for never answers forest), the trade vocabulary, shade or + * the surface it is made for. + */ +function answers(material: MaterialCatalogItem, text: readonly string[], word: string): boolean { const forms = [word, stem(word), ...(SAME[word] ?? []), ...(SAME[stem(word)] ?? [])] - if (forms.some((form) => text.includes(form))) return true + const said = (token: string, form: string) => + token === form || stem(token) === form || (form.length >= 4 && token.startsWith(form)) + if (forms.some((form) => text.some((token) => said(token, form)))) return true const shade = SHADES[word] const colour = shadeOf(material.previewColor) if (shade && colour && shade(colour)) return true + // A surface word asks for a material made for it (floor is flooring), not one that can go there. const surface = SURFACE_WORDS[word] ?? SURFACE_WORDS[stem(word)] return !!surface && !!material.surfaces?.includes(surface) } @@ -82,15 +102,13 @@ function answers(material: MaterialCatalogItem, text: string, word: string): boo export const searchMaterials: AgentOperation = (_nodes, { queries }) => ({ result: { groups: queries.map(({ query, surface }) => { - const words = query - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter(Boolean) + const words = wordsOf(query).filter((word) => word.length > 2 && !STOP_WORDS.has(word)) const scored = MATERIAL_CATALOG.filter( - (material) => !surface || material.surfaces?.includes(surface), + (material) => !surface || suits(material, surface), ).map((material) => { - const text = - `${material.id} ${material.label} ${material.category} ${material.description ?? ''}`.toLowerCase() + const text = wordsOf( + `${material.id} ${material.label} ${material.category} ${material.description ?? ''}`, + ) return { material, hits: words.filter((word) => answers(material, text, word)) } }) const missing = words.filter((word) => !scored.some(({ hits }) => hits.includes(word))) diff --git a/packages/core/src/agent-tools/paint.ts b/packages/core/src/agent-tools/paint.ts index 5cc93cb7d7..13ad90a5f4 100644 --- a/packages/core/src/agent-tools/paint.ts +++ b/packages/core/src/agent-tools/paint.ts @@ -6,7 +6,7 @@ export const paintTool = { name: 'paint', title: 'Paint', description: - "Give walls, columns, slabs, ceilings, fences, doors, windows or roofs a finish in one call, as the editor's Paint does. Give one finish: color (#rrggbb, painted with the nearest of the library's flat colours, said with how near); material, a library: (find it with search_materials) or scene: ref, or a native material {properties: {color, roughness, metalness}} for an exact finish, kept as a scene material (materialName names a new one) and reused when the scene holds an equal one; or erase: true, back to the surface's default. role picks the surface: a wall's face a or b (or exterior, interior: the face turned out or into its room), its trims aSkirting, bSkirting, aCrown, bCrown, aChairRail, bChairRail, a curtain wall's curtain-frame, curtain-glass, curtain-solid; a column's shaft, base, capital or frame; a slab's surface, side, edge, riser, underside or foundation; a ceiling's surface; a fence's posts, infill, base or rail; a door's panel, frame, glass or hardware; a window's frame or glass; a roof's top, edge or wall. Without a role: both faces of a wall, a column's shaft, a slab's or a ceiling's surface, a whole fence, a door's panel and frame, a window's frame, a roof's top. A ceiling shows its finish's colour, not its texture. The result says the finish written, and the scene material when there is one. A target that is not in the scene, a role it lacks or an unknown material refuses the whole call: nothing is painted.", + "Give walls, columns, slabs, ceilings, fences, doors, windows or roofs a finish in one call, as the editor's Paint does. Give one finish: color (#rrggbb, painted with the nearest of the library's flat colours, said with how near); material, a library: (find it with search_materials) or scene: ref, or a native material {properties: {color, roughness, metalness}} for an exact finish, kept as a scene material (materialName names a new one) and reused when the scene holds an equal one; or erase: true, back to the surface's default. role picks the surface: a wall's face a or b (or exterior, interior: the face turned out or into its room), its trims aSkirting, bSkirting, aCrown, bCrown, aChairRail, bChairRail, a curtain wall's curtain-frame, curtain-glass, curtain-solid; a column's shaft, base, capital or frame; a slab's surface, side, edge, riser, underside or foundation; a ceiling's surface; a fence's posts, infill, base or rail; a door's panel, frame, glass or hardware; a window's frame or glass; a roof's top, edge or wall. Without a role: both faces of a wall, a column's shaft, a slab's or a ceiling's surface, a whole fence, a door's panel and frame, a window's frame, a roof's top. A wall face is painted whole, over the wall finish of the room it faces; its painted parts keep their own, named in the note. A ceiling shows its finish's colour, not its texture. The result says the finish written, and the scene material when there is one. A target that is not in the scene, a role it lacks or an unknown material refuses the whole call: nothing is painted.", input: { targets: z.array(NodeId).min(1).max(200).describe('The nodes to paint.'), role: z.string().min(1).optional().describe('The surface: see the description.'), diff --git a/packages/core/src/agent-tools/search-materials.ts b/packages/core/src/agent-tools/search-materials.ts index 021b68f65c..fbad1efce5 100644 --- a/packages/core/src/agent-tools/search-materials.ts +++ b/packages/core/src/agent-tools/search-materials.ts @@ -15,7 +15,7 @@ export const searchMaterialsTool = { name: 'search_materials', title: 'Search materials', description: - "Search the material library (the finishes paint gives) by words, several queries in one call: find a library material's id here rather than guessing it. A query matches a material's id, name and kind, building words as the library writes them (timber is wood, weatherboard is siding, render is stucco), colour words by its shade (dark, light, white, grey) and surface words (roof, wall, floor, ceiling) by what it suits. Returns one group per query: each material's ref (library:), name, kind, the surfaces it suits and its colour, best first, and the words no material answers (missing), so a finish the library lacks is said, not guessed.", + "Search the material library (the finishes paint gives) by words, several queries in one call: find a library material's id here rather than guessing it. A query's words match the words of a material's id, name and kind, building words as the library writes them (timber is wood, weatherboard is siding, render is stucco), colour words by its shade (dark, light, white, grey) and surface words (roof, wall, floor, ceiling) by what it suits. Returns one group per query: each material's ref (library:), name, kind, the surfaces it suits and its colour, best first, and the words no material answers (missing), so a finish the library lacks is said, not guessed.", input: { queries: z .array( @@ -25,7 +25,10 @@ export const searchMaterialsTool = { .trim() .min(1) .describe('Search words, e.g. "white render", "grey metal roofing", "oak floor".'), - surface: z.enum(SURFACES).optional().describe('Only materials that suit this surface.'), + surface: z + .enum(SURFACES) + .optional() + .describe('Only materials that suit this surface; flat colours suit every one.'), }), ) .min(1) diff --git a/packages/core/src/agent-tools/view-scene.ts b/packages/core/src/agent-tools/view-scene.ts index 2f7b66eefa..51c664e0f7 100644 --- a/packages/core/src/agent-tools/view-scene.ts +++ b/packages/core/src/agent-tools/view-scene.ts @@ -18,7 +18,7 @@ export const viewSceneTool = { name: 'view_scene', title: 'Look at the scene', description: - "Look at the building in 3D from a viewpoint you pick and get the picture back. Use it to compare what you built with a reference and say what differs before you fix it: the facade from the photo's own camera (camera: its position, aim, field of view and aspect), rendered at the photo's aspect to lay beside it; or from the photo's side at street height (from, eyeHeight 1.7); one face square on (projection orthographic); the massing from above. North is the plan's top edge (z grows south, x east). The view frames the target (a building, a level, a wall or a zone; the whole building by default) from outside, unless you place the eye yourself with position or camera. To see inside a floor, give interior {levelId}: the other floors and that floor's roofs and ceilings are left out of the picture and its walls cut at two thirds of the storey; with no eye given it looks down into the floor, or into the room or zone you target. The live view is not changed. A door, a window or an item on a floor frames at detail scale, an opening seen from its outside face: a close-up to lay beside the photo's crop of the same element, to see what differs. Over the MCP the user's editor tab open on the project renders the picture: with none open it is refused (editor_tab_required), and a tab in the background must be brought to the front (editor_tab_hidden). A picture is not a measure: take sizes and counts from the tools.", + "Look at the building in 3D from a viewpoint you pick and get the picture back. Use it to compare what you built with a reference and say what differs before you fix it: the facade from the photo's own camera (camera: its position, aim, field of view and aspect), rendered at the photo's aspect to lay beside it; or from the photo's side at street height (from, eyeHeight 1.7); one face square on (projection orthographic); the massing from above. North is the plan's top edge (z grows south, x east). The view frames the target (a building, a level, a wall or a zone; the whole building by default) from outside, unless you place the eye yourself with position or camera. To see inside a floor, give interior {levelId}: the other floors and that floor's roofs and ceilings are left out of the picture, and everything on it, walls, openings and furniture alike, is cut at 0.65 of the storey's height (about two thirds), so a tall wardrobe or a door head is cut, not short; a roof level has no inside; with no eye given it looks down into the floor, or into the room or zone you target. The live view is not changed. A door, a window or an item on a floor frames at detail scale, an opening seen from its outside face: a close-up to lay beside the photo's crop of the same element, to see what differs. Over the MCP the user's editor tab open on the project renders the picture: with none open it is refused (editor_tab_required), 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() @@ -30,7 +30,7 @@ export const viewSceneTool = { .object({ levelId: z.string().min(1) }) .optional() .describe( - 'Look inside this floor (a level id): the other floors and its roofs and ceilings are left out of the picture, its walls cut at two thirds of the storey. Default framing looks down into it; camera or position still wins.', + 'Look inside this floor (a level id): the other floors and its roofs and ceilings are left out of the picture, everything on it (walls, openings, furniture) cut at 0.65 of the storey height. Default framing looks down into it; camera or position still wins.', ), from: z .enum(VIEW_SIDES) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index f8fd143ccd..4a573706d3 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -339,10 +339,10 @@ 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' }` | -| `view_scene` | Look at the building from a viewpoint the agent picks (a side, a street-height eye, a photo's camera, square-on, from above) and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, its walls cut at two thirds of the storey, for the picture only. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`, `interior_transform_unsupported`, `interior_target_elsewhere`; on the hosted server `editor_tab_required`, `editor_tab_hidden` | +| `view_scene` | Look at the building from a viewpoint the agent picks (a side, a street-height eye, a photo's camera, square-on, from above) and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, everything on it (walls, openings, furniture) cut at 0.65 of the storey's height, for the picture only. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`, `interior_transform_unsupported`, `interior_target_elsewhere`; on the hosted server `editor_tab_required`, `editor_tab_hidden` | | `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` | | `search_materials` | Search the material library by words, several queries in one call: building words as the library writes them (timber is wood, render is stucco), shades and surfaces. Read-only. | `{ queries: [{ query, surface? }] }` | `{ groups: [{ query, results: [{ ref, label, kind, surfaces, color? }], missing? }] }` | -| `paint` | Give walls, columns, slabs, ceilings, fences, doors, windows or roofs a finish in one call, as the editor's Paint does: a colour (the nearest library colour), a `library:` or `scene:` ref, a native material kept as a scene material (reused when an equal one exists), or erase. `role` picks the surface (a wall's `exterior`/`interior` face, a slot, a roof's `top`/`edge`/`wall`). | `{ targets[], role?, color?, material?, materialName?, erase? }` (one finish) | `{ ok, painted[], finish, materialId?, material?, createdMaterial?, note?, achieved }`; refusals `target_not_found`, `unknown_role`, `unknown_material`, `materials_unavailable`, `shadowed_field`, `not_paintable` | +| `paint` | Give walls, columns, slabs, ceilings, fences, doors, windows or roofs a finish in one call, as the editor's Paint does: a colour (the nearest library colour), a `library:` or `scene:` ref, a native material kept as a scene material (reused when an equal one exists), or erase. `role` picks the surface (a wall's `exterior`/`interior` face, a slot, a roof's `top`/`edge`/`wall`); a wall face is painted whole, over its room's wall finish, its painted parts keeping theirs (named in `note`). | `{ targets[], role?, color?, material?, materialName?, erase? }` (one finish) | `{ ok, painted[], finish, materialId?, material?, createdMaterial?, note?, achieved }`; refusals `target_not_found`, `unknown_role`, `unknown_material`, `materials_unavailable`, `shadowed_field`, `not_paintable` | | `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` | 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`, `slab_not_found` / `ceiling_not_found`, `slab_not_on_level` / `ceiling_not_on_level` | | `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 }` | diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index a1b163ddc4..4301fa0be9 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -54,7 +54,7 @@ export const AGENT_GUIDE = [ '', '## Looking and Starting Over', '', - "- Call `view_scene` to see what you built from a viewpoint you pick (a compass side, a height, a street-level eye, an orthographic face, a photo's own camera) and compare it with the reference before fixing anything. It is a picture to compare, not a measurement. To see the rooms of one floor, pass `interior: { levelId }`: the other floors, its roof and its ceilings are left out of the picture and its walls cut low. On a hosted server the person's open editor tab renders it; when the tab is hidden or closed the call is refused with the editor URL, so ask the person to bring it forward.", + "- Call `view_scene` to see what you built from a viewpoint you pick (a compass side, a height, a street-level eye, an orthographic face, a photo's own camera) and compare it with the reference before fixing anything. It is a picture to compare, not a measurement. To see the rooms of one floor, pass `interior: { levelId }`: the other floors, its roof and its ceilings are left out of the picture and everything on it, walls, openings and furniture alike, is cut at 0.65 of the storey's height. On a hosted server the person's open editor tab renders it; when the tab is hidden or closed the call is refused with the editor URL, so ask the person to bring it forward.", '- Call `clear_scene` only when the person asked to start over: it empties the project on purpose, keeping the site, the building and one level. Any other write that would leave the project empty is refused as `scene_wipe_blocked`, and nothing changes.', '', '## Custom Designs', diff --git a/packages/mcp/src/tools/view-scene.ts b/packages/mcp/src/tools/view-scene.ts index 5f6e987313..8499727a7d 100644 --- a/packages/mcp/src/tools/view-scene.ts +++ b/packages/mcp/src/tools/view-scene.ts @@ -32,7 +32,7 @@ export type SceneViewHost = { projectId: string pose: SceneViewPose size: { w: number; h: number } - /** A look inside this floor: the editor leaves the rest out and cuts its walls. */ + /** A look inside this floor: the editor leaves the rest out and cuts everything on it. */ interior?: SceneViewInterior }): Promise /** Crops the reference photo to a region; without it a view with a photo is refused. */ diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index da2d7132a3..d14f008140 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -71,7 +71,7 @@ When those tools and the catalog cannot reproduce something faithfully (custom c 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. -To compare what you built with a reference, call `view_scene`: the building from a side, a street-height eye (`eyeHeight: 1.7`), a photo's own camera, square-on (`projection: "orthographic"`) or from above, and, with a photo region, the photo's crop beside the view. To see the rooms of one floor, pass `interior: { levelId }`: the other floors, its roof and its ceilings are left out of the picture and its walls cut low. It returns a picture, not measures: take sizes and counts from the tools. On the hosted server the user's open Pascal editor tab renders the picture: `editor_tab_required` means no tab has the project open (give the user `editorUrl`, wait until they say it is open, then repeat the call), and `editor_tab_hidden` means the tab is in the background (ask them to bring it to the front, then repeat). A server with no renderer, such as the local CLI, answers `view_unavailable`. +To compare what you built with a reference, call `view_scene`: the building from a side, a street-height eye (`eyeHeight: 1.7`), a photo's own camera, square-on (`projection: "orthographic"`) or from above, and, with a photo region, the photo's crop beside the view. To see the rooms of one floor, pass `interior: { levelId }`: the other floors, its roof and its ceilings are left out of the picture and everything on it, walls, openings and furniture alike, is cut at 0.65 of the storey's height. It returns a picture, not measures: take sizes and counts from the tools. On the hosted server the user's open Pascal editor tab renders the picture: `editor_tab_required` means no tab has the project open (give the user `editorUrl`, wait until they say it is open, then repeat the call), and `editor_tab_hidden` means the tab is in the background (ask them to bring it to the front, then repeat). A server with no renderer, such as the local CLI, answers `view_unavailable`. Never empty a project by deleting everything. A write that would leave it empty (deleting its only room, a patch removing every node) is refused with `scene_wipe_blocked` and nothing changes, in the project or the session. When the person asks to start over, call `clear_scene` with their words in `reason`: it resets to a site, a building and one level, and keeps the project's installed plugins. To replace the only room, build what replaces it first, then remove it. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 85d8771871..f2c40ec1b1 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -55,7 +55,7 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra ## Look at the result -`view_scene` returns a picture of the building from the viewpoint you pick, to compare with a reference before fixing. It is read-only and creates nothing. `interior: { levelId }` looks inside one floor, the other floors, its roof and its ceilings left out and its walls cut low; the live view is not changed. On the hosted server the user's open editor tab renders it: `editor_tab_required` comes with `editorUrl` (show it, wait until the tab is open, repeat the call) and `editor_tab_hidden` means the tab is in the background (ask the user to bring it forward, repeat). A server with no renderer answers `view_unavailable`. A picture is not a measure. +`view_scene` returns a picture of the building from the viewpoint you pick, to compare with a reference before fixing. It is read-only and creates nothing. `interior: { levelId }` looks inside one floor, the other floors, its roof and its ceilings left out and everything on it, walls, openings and furniture alike, cut at 0.65 of the storey's height; the live view is not changed. On the hosted server the user's open editor tab renders it: `editor_tab_required` comes with `editorUrl` (show it, wait until the tab is open, repeat the call) and `editor_tab_hidden` means the tab is in the background (ask the user to bring it forward, repeat). A server with no renderer answers `view_unavailable`. A picture is not a measure. ## Start over diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 800d91775b..f57752c3df 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -100,11 +100,11 @@ The answer is the picture plus the pose it was taken from, and which tab rendere Views are planned on the site: a building's position and yaw move and turn the boxes of what stands under it, so a building away from the origin is looked at where it stands. A roof, or a roof level holding only roofs, frames its segments from its seat to its peak. -**Inside a floor.** `interior: { levelId }` looks into one floor. The plan checks the level (`interior_level_invalid`, `interior_hidden` for a hidden level or ancestor, `interior_transform_unsupported` for a tipped building) and that a target is on it (`interior_target_elsewhere`), frames the floor from 55° above unless a target, an eye or a camera is given, and hands the host only the level id. The editor that renders it works out the rest from its own scene with `sceneViewInterior`: for that one render the other floors and this floor's roofs and ceilings are hidden, its walls (and their shadows) are cut at 0.65 of the storey height, its walls are drawn whole whatever the user's wall mode, and the user's solo and isolation are lifted on it. Everything is put back right after the render, so the live view does not change. +**Inside a floor.** `interior: { levelId }` looks into one floor. The plan checks the level (`interior_level_invalid`, also for a level holding only roofs and ceilings; `interior_hidden` for a hidden level or ancestor, a hidden site excepted, since it hides only its ground; `interior_transform_unsupported` for a tipped building) and that a target is on it and in the picture (`interior_target_elsewhere`, also for its roofs and ceilings), frames the floor from 55° above unless a target, an eye or a camera is given, and hands the host only the level id. The editor that renders it works out the rest from its own scene with `sceneViewInterior`: for that one render the other floors and this floor's roofs and ceilings are hidden, everything on the floor (walls, openings, furniture) and its shadows are cut at 0.65 of the storey height, its walls are drawn whole whatever the user's wall mode, and the user's solo and isolation are lifted on it. Everything is put back right after the render, so the live view does not change. ## Finishes: `paint` and `search_materials` -`paint` gives surfaces a finish as the editor's Paint does, in one call and one undo step: the slots the editor's slot paint writes, a wall's faces through the same paint plan (`planWallPaint` in core, shared with the editor's wall paint), a roof's or a segment's `top`, `edge` or `wall` role field. `exterior` and `interior` resolve to a wall's faces from the rooms on its sides. A colour paints with the nearest library colour, and the result says how near. `search_materials` finds `library:` refs by words, shade and surface, and names the words the library does not answer. +`paint` gives surfaces a finish as the editor's Paint does, in one call and one undo step: the slots the editor's slot paint writes, a wall's faces through the same paint plan (`planWallPaint` in core, shared with the editor's wall paint), a roof's or a segment's `top`, `edge` or `wall` role field. `exterior` and `interior` resolve to a wall's faces from the rooms on its sides. A face is painted whole: its own slot, and an override in each room whose wall finish would otherwise show over it, as the editor's Paint writes a click on that room's face; painted parts (regions) keep their own finish and the result's `note` names them. A colour paints with the nearest library colour, and the result says how near. `search_materials` finds `library:` refs by whole words (a short or common word is dropped), shade and surface, and names the words the library does not answer; a flat colour suits every `surface`. A native material (an exact PBR finish) is kept as a scene material, reused when the scene holds one the material schema reads as equal. The operation returns it in `SceneChanges.materials`; a host writes it before the nodes that use it, in the same step. It needs the scene's materials in `AgentContext.materials`: the MCP lends them when its `SceneOperations` sets `supportsMaterialUpserts` and has `getMaterials`, and applies them as `upsert_material` patches (an internal op, not part of `apply_patch`'s schema). A host without them refuses a native material (`materials_unavailable`) and still paints library refs and colours. `get_node` returns the scene materials a node's finishes use when the host lends them. From b5663692a33da437a5c1730e3344318fd930525a Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 17:21:39 -0400 Subject: [PATCH 6/6] fix(viewer,editor): a capture draws walls with the renderer's palette and keeps the perspective it asks for - An authored pose or an interior view asks for whole walls on thumbnail:before-capture ({ wholeWalls: true }), and WallCutout swaps them with the resolver the renderer mounted it with (wallsForCapture). The capture's own swap used the plain wall palette, so a curtain wall's glass came out opaque. - A capture with a perspective view of its own (view_scene's) stays perspective when the user's camera is orthographic (capturesOrthographically); it took the user's orthographic view while the result reported the planned camera. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/events/bus.ts | 3 +- .../editor/thumbnail-generator.test.ts | 21 ++++- .../components/editor/thumbnail-generator.tsx | 71 ++++++--------- .../systems/wall/wall-cutout-cache.test.ts | 44 +++++++++- .../viewer/src/systems/wall/wall-cutout.tsx | 86 +++++++++++-------- 5 files changed, 141 insertions(+), 84 deletions(-) diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index abcf85c7d3..5cecc7af74 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -359,7 +359,8 @@ type WindowAnimationEvents = { } type ThumbnailEvents = { - 'thumbnail:before-capture': undefined + /** `wholeWalls`: every wall drawn whole for the frame, whatever the wall mode (an authored pose, an interior view). */ + 'thumbnail:before-capture': { wholeWalls?: boolean } | undefined 'thumbnail:after-capture': undefined } diff --git a/packages/editor/src/components/editor/thumbnail-generator.test.ts b/packages/editor/src/components/editor/thumbnail-generator.test.ts index 82cfa678da..226611cbcd 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.test.ts +++ b/packages/editor/src/components/editor/thumbnail-generator.test.ts @@ -2,10 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, sceneRegistry, useScene } from '@pascal-app/core' import { BuildingNode, CeilingNode, LevelNode, WallNode } from '@pascal-app/core/schema' import { hideFromScene, SCENE_LAYER, useViewer } from '@pascal-app/viewer' -import { Group, Layers, Mesh, Vector2 } from 'three' +import { Group, Layers, Mesh, OrthographicCamera, PerspectiveCamera, Vector2 } from 'three' import { CanvasTarget, ClippingGroup, type WebGPURenderer } from 'three/webgpu' import { atCaptureSize, + capturesOrthographically, interiorCutaway, presentFinished, pumpFrames, @@ -287,3 +288,21 @@ describe('an interior view of a floor', () => { expect(() => interiorCutaway('level_ground', () => {})).toThrow('not built yet') }) }) + +describe("a capture's projection", () => { + const user = { ortho: new OrthographicCamera(), perspective: new PerspectiveCamera() } + const view = { position: [8, 6, 8], target: [0, 1, 0] } as never + + test("a perspective view asked for stays perspective under the user's orthographic camera", () => { + // Else view_scene's planned camera, a look into a floor from 55° or a photo's, would give + // way to the user's own orthographic view while the result reports the planned one. + expect(capturesOrthographically({ perspective: view }, user.ortho)).toBe(false) + expect(capturesOrthographically({ ortho: view }, user.perspective)).toBe(true) + }) + + test("with no view of its own, a capture takes the user's projection", () => { + expect(capturesOrthographically({}, user.ortho)).toBe(true) + expect(capturesOrthographically({}, user.perspective)).toBe(false) + expect(capturesOrthographically({ snapLevels: true }, user.ortho)).toBe(false) + }) +}) diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 292d53386d..bcec196c38 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -1,19 +1,11 @@ 'use client' -import { - type AnyNodeId, - emitter, - sceneRegistry, - type ThumbnailGenerateEvent, - useScene, -} from '@pascal-app/core' +import { emitter, sceneRegistry, type ThumbnailGenerateEvent, useScene } from '@pascal-app/core' import { sceneViewInterior } from '@pascal-app/core/agent-operations' import { computeHeroFraming, createSnapshotPipeline, GRID_LAYER, - getVisibleWallMaterials, - getWallFinishRefs, heroCameraPose, holdLiveFrame, pendingSceneBuildCount, @@ -497,9 +489,9 @@ function clipSceneFor( /** * An interior view of one floor, for one render (view_scene): the other - * floors and this floor's roofs and ceilings out of the picture, its walls - * cut where core says (sceneViewInterior, from the scene as this editor has - * it) and their shadows with them, and whatever the user's solo or isolation + * floors and this floor's roofs and ceilings out of the picture, everything + * on it cut where core says (sceneViewInterior, from the scene as this editor + * has it) and its shadows with it, and whatever the user's solo or isolation * holds back on it shown. */ export function interiorCutaway(levelId: string, restore: (callback: () => void) => void) { @@ -518,6 +510,21 @@ export function interiorCutaway(levelId: string, restore: (callback: () => void) restore(clipSceneFor(floor, [{ normal: [0, -1, 0], constant: cutHeight }], true)) } +/** + * A caller's own orthographic view, or with no view of its own an + * orthographic main camera, captures orthographically; a caller's perspective + * view (view_scene's), the auto-save hero shot and an authored snapshot pose + * stay perspective. + */ +export function capturesOrthographically( + event: Pick, + mainCamera: THREE.Camera, +): boolean { + if (event.snapLevels || event.cameraPose) return false + if (event.ortho) return true + return !event.perspective && mainCamera instanceof THREE.OrthographicCamera +} + export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => { const gl = useThree((state) => state.gl) const scene = useThree((state) => state.scene) @@ -688,13 +695,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro try { const edges = edgesOverride ?? (transparent ? 'off' : useViewer.getState().edges) - // A caller's own orthographic view, or an orthographic main camera, - // captures orthographically; the auto-save hero shot and an authored - // snapshot pose stay perspective. - const wantOrtho = - !snapLevels && - !cameraPose && - (ortho !== undefined || mainCamera instanceof THREE.OrthographicCamera) + const wantOrtho = capturesOrthographically(event, mainCamera) let thumbnailCamera: THREE.PerspectiveCamera | THREE.OrthographicCamera = perspectiveCamera let pipeline: SnapshotPipeline | null = pipelineRef.current @@ -887,36 +888,16 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro } restore(() => emitter.emit('thumbnail:after-capture', undefined)) - emitter.emit('thumbnail:before-capture', undefined) + // Walls whole, as built, whatever the user's wall mode: an + // authored pose is not the user's view, an interior is cut below. + emitter.emit( + 'thumbnail:before-capture', + cameraPose || interior ? { wholeWalls: true } : undefined, + ) if (cameraPose) { restore(snapLevelsToTruePositions()) restore(temporarilyShowShadowOnly(scene)) } - // Walls whole, as built, whatever the user's wall mode: an - // authored pose is not the user's view, an interior is cut below. - if (cameraPose || interior) { - const wallMaterials = new Map() - restore(() => { - for (const [mesh, material] of wallMaterials) mesh.material = material - }) - const state = useScene.getState() - const viewer = useViewer.getState() - for (const id of sceneRegistry.byType.wall ?? []) { - const node = state.nodes[id as AnyNodeId] - const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh | undefined - if (node?.type !== 'wall' || !mesh?.isMesh) continue - wallMaterials.set(mesh, mesh.material) - mesh.material = getVisibleWallMaterials( - node, - viewer.shading, - viewer.textures, - viewer.colorPreset, - viewer.sceneTheme, - state.materials, - getWallFinishRefs(mesh.geometry), - ) - } - } // Geometry and presentation systems may have mounted new meshes // since the viewport's last frame. Apply the same isolation to diff --git a/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts b/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts index 6fff1e6d0c..806b1c52b6 100644 --- a/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts +++ b/packages/viewer/src/systems/wall/wall-cutout-cache.test.ts @@ -25,7 +25,12 @@ import { } from 'three' import { createStore, type StoreApi } from 'zustand/vanilla' import { applyMaterialPresetToMaterials } from '../../lib/materials' -import { getWallHideState, runWallCutoutFrame, WALL_CUTOUT_FRAME_PRIORITY } from './wall-cutout' +import { + getWallHideState, + runWallCutoutFrame, + WALL_CUTOUT_FRAME_PRIORITY, + wallsForCapture, +} from './wall-cutout' import { sameMaterialArray, WALL_FACING_HYSTERESIS, @@ -33,7 +38,7 @@ import { type WallCutoutViewerState, wallFacingNegative, } from './wall-cutout-cache' -import { getMaterialsForWall } from './wall-materials' +import { getMaterialsForWall, type WallMaterialsResolver } from './wall-materials' import { drainRebuiltWalls, notifyWallRebuilt, @@ -716,3 +721,38 @@ test('legacy wall extensions preserve the viewer-to-nodes dependency boundary', expect(source.split('\n')[1]).toContain('viewer-owned') } }) + +describe('walls for a capture', () => { + // A curtain wall draws its frame, glass and panels from the resolver its renderer mounts the + // cutout with: a capture that drew it with the plain wall palette turned its glass opaque. + const glass = new MeshBasicMaterial({ transparent: true, opacity: 0.3 }) + const curtainAware: WallMaterialsResolver = (wall, ...rest) => + getMaterialsForWall(wall, rest[0], rest[1], rest[2], rest[3], rest[4], rest[5], { + hash: 'curtain', + create: () => ({ visible: [glass, glass, glass] }), + }) + + test("draws every wall whole with the renderer's own palette, then puts the view back", () => { + viewerStore.setState({ textures: true }) + const { node, mesh } = addWall() + cache.update(camera, 1) + const cutaway = mesh.material + expect(mesh.userData.wallHidden).toBe(true) + + const restore = wallsForCapture(curtainAware, viewerStore, true) + expect(mesh.material).toBe(curtainAware(node, 'solid', true, 'clay', 'studio', {}, []).visible) + expect((mesh.material as Material[])[1]).toBe(glass) + restore() + expect(mesh.material).toBe(cutaway) + }) + + test('leaves the wall mode alone for a capture that does not ask for whole walls', () => { + const { mesh } = addWall() + cache.update(camera, 1) + const cutaway = mesh.material + const restore = wallsForCapture(curtainAware, viewerStore, false) + expect(mesh.material).toBe(cutaway) + restore() + expect(mesh.material).toBe(cutaway) + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 1fd2d77153..7e6050fa03 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -49,6 +49,52 @@ export function getWallHideState( return wallHiddenFromFacing(wallNode, wallMode, v.dot(cameraDir) < 0) } +/** + * The walls as a capture draws them, until the returned function puts them back: a delete or a + * selection highlight taken off and, with `wholeWalls`, every wall whole whatever the wall mode. + * The palettes come from the renderer's own resolver, so a curtain wall keeps its glass. + */ +export function wallsForCapture( + materialResolver: WallMaterialsResolver, + viewerStore: WallCutoutViewerStore, + wholeWalls: boolean, +): () => void { + const snapshot = new Map() + const viewer = viewerStore.getState() + sceneRegistry.byType.wall!.forEach((wallId) => { + const wallMesh = sceneRegistry.nodes.get(wallId) as Mesh | undefined + if (!wallMesh) return + const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined + if (wallNode?.type !== 'wall') return + const mats = materialResolver( + wallNode, + viewer.shading, + viewer.textures, + viewer.colorPreset, + viewer.sceneTheme, + useScene.getState().materials, + getWallFinishRefs(wallMesh.geometry), + ) + const current = wallMesh.material as Material | Material[] + snapshot.set(wallMesh, current) + if (wholeWalls || current === mats.deleteVisible) { + wallMesh.material = mats.visible + } else if (current === mats.deleteInvisible) { + wallMesh.material = mats.invisible + } else if ( + current === mats.deleteTranslucent || + sameMaterialArray(current, getSelectionHighlightMaterials(mats.translucent)) + ) { + wallMesh.material = mats.translucent + } + }) + return () => { + snapshot.forEach((material, mesh) => { + mesh.material = material + }) + } +} + export const WallCutout = ({ viewerStore = useViewer, materialResolver = getMaterialsForWall, @@ -70,43 +116,13 @@ export const WallCutout = ({ useFrame((state) => runWallCutoutFrame(cache, state), WALL_CUTOUT_FRAME_PRIORITY) useEffect(() => { - const snapshot = new Map() - - const restoreForCapture = () => { - sceneRegistry.byType.wall!.forEach((wallId) => { - const wallMesh = sceneRegistry.nodes.get(wallId) as Mesh | undefined - if (!wallMesh) return - const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined - if (wallNode?.type !== 'wall') return - const mats = materialResolver( - wallNode, - viewerStore.getState().shading, - viewerStore.getState().textures, - viewerStore.getState().colorPreset, - viewerStore.getState().sceneTheme, - useScene.getState().materials, - getWallFinishRefs(wallMesh.geometry), - ) - const current = wallMesh.material as Material | Material[] - snapshot.set(wallMesh, current) - if (current === mats.deleteVisible) { - wallMesh.material = mats.visible - } else if (current === mats.deleteInvisible) { - wallMesh.material = mats.invisible - } else if ( - current === mats.deleteTranslucent || - sameMaterialArray(current, getSelectionHighlightMaterials(mats.translucent)) - ) { - wallMesh.material = mats.translucent - } - }) + let restore: (() => void) | null = null + const restoreForCapture = (event?: { wholeWalls?: boolean }) => { + restore = wallsForCapture(materialResolver, viewerStore, event?.wholeWalls === true) } - const reapplyAfterCapture = () => { - snapshot.forEach((mat, mesh) => { - mesh.material = mat - }) - snapshot.clear() + restore?.() + restore = null } emitter.on('thumbnail:before-capture', restoreForCapture)