From 61036ed663e973910615de679c5ec5c388bb61cd Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 16 Oct 2026 16:28:58 -0400 Subject: [PATCH 01/26] =?UTF-8?q?core:=20author=5Fscene=20=E2=80=94=20a=20?= =?UTF-8?q?whole=20three.js=20design=20handed=20over=20as=20a=20payload,?= =?UTF-8?q?=20written=20as=20native=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../__fixtures__/notation-cases.ts | 2 + .../src/agent-operations/author-scene.test.ts | 221 ++++++++ .../core/src/agent-operations/author-scene.ts | 527 ++++++++++++++++++ packages/core/src/agent-operations/index.ts | 1 + packages/core/src/agent-tools/author-scene.ts | 201 +++++++ packages/core/src/agent-tools/index.ts | 3 + 6 files changed, 955 insertions(+) create mode 100644 packages/core/src/agent-operations/author-scene.test.ts create mode 100644 packages/core/src/agent-operations/author-scene.ts create mode 100644 packages/core/src/agent-tools/author-scene.ts diff --git a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts index 224ba09a49..971bdcb14e 100644 --- a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts @@ -328,6 +328,8 @@ export const NOTATION_CASES: NotationCase[] = [ */ export const NOT_LENGTHS = new Set([ // Coordinates, in level metres. + // A light's relative brightness, 1 for an ordinary bulb. + 'author_scene.payload.levels[].lights[].intensity', 'add_column.x', 'add_column.y', 'add_column.z', diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts new file mode 100644 index 0000000000..08bd34f31a --- /dev/null +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from 'bun:test' +import type { AuthorScenePayload } from '../agent-tools/author-scene' +import { authorScenePayload } from '../agent-tools/author-scene' +import type { AnyNode, CompiledGeometryScript } from '../schema' +import { authorScene } from './author-scene' + +/** A site, a building and the empty level a new project starts with. */ +function scaffold(): Record { + return { + site_1: { + id: 'site_1', + object: 'node', + type: 'site', + name: 'Site', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + children: ['building_1'], + } as unknown as AnyNode, + building_1: { + id: 'building_1', + object: 'node', + type: 'building', + name: 'Building', + parentId: 'site_1', + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + children: ['level_0'], + } as unknown as AnyNode, + level_0: { + id: 'level_0', + object: 'node', + type: 'level', + name: 'Level 0', + parentId: 'building_1', + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + level: 0, + children: [], + } as unknown as AnyNode, + } +} + +/** What a host's compile of a 1.6 × 0.5 × 2 m box returns. */ +const bed: CompiledGeometryScript = { + sha256: 'a'.repeat(64), + script: 'b'.repeat(64), + language: 'three', + mount: 'floor', + params: {}, + manifest: { + bounds: { min: [-0.8, 0, -1], max: [0.8, 0.5, 1] }, + parts: [], + slots: [{ id: 'wood', color: '#8a6a4a' }], + lights: [], + animations: [], + params: [], + triangles: 12, + cutout: false, + surfaces: [], + } as unknown as CompiledGeometryScript['manifest'], +} + +const bedroom: AuthorScenePayload = authorScenePayload.parse({ + levels: [ + { + name: 'Ground floor', + walls: [ + { + id: 'north', + start: [-2, -2.5], + end: [2, -2.5], + height: 2.7, + thickness: 0.12, + color: '#f1ece4', + }, + { + id: 'east', + start: [2, -2.5], + end: [2, 2.5], + height: 2.7, + thickness: 0.12, + openings: [{ kind: 'window', along: 2.5, width: 1.4, height: 1.3, sill: 0.9 }], + }, + { + id: 'south', + start: [2, 2.5], + end: [-2, 2.5], + height: 2.7, + thickness: 0.12, + openings: [ + { kind: 'door', along: 1.2, width: 0.9, height: 2.1, hinge: 'start', swing: 'front' }, + ], + }, + // Ends 10 cm short of the north wall: the join rule closes the room. + { id: 'west', start: [-2, 2.5], end: [-2, -2.4], height: 2.7, thickness: 0.12 }, + ], + rooms: [{ name: 'Bedroom', seed: [0, 0], floor: 'library:wood-floorplank1' }], + items: [ + { + id: 'bed', + name: 'Bed', + category: 'bed', + code: 'export default function build() {}', + position: [0, 0, -1], + }, + { + id: 'lamp', + name: 'Lamp', + category: 'lamp', + code: 'export default function build() {}', + position: [1.2, 0.5, -2], + host: 'bed', + }, + ], + lights: [{ position: [1.2, 1.1, -2], fixture: 'lamp' }, { position: [0, 2.5, 1] }], + }, + ], + notes: ['The rug was left out.'], +}) + +describe('author_scene', () => { + test('writes the design as native nodes and reports what it did', () => { + const outcome = authorScene( + scaffold(), + { payload: bedroom, compiled: { bed, lamp: bed } }, + { activeLevelId: 'level_0' }, + ) + const r = outcome.result + expect(r.levels).toBe(1) + expect(r.walls).toBe(4) + expect(r.doors).toBe(1) + expect(r.windows).toBe(1) + expect(r.rooms).toBe(1) + expect(r.roomsNamed).toBe(1) + expect(r.items).toBe(2) + expect(r.lights).toBe(2) + expect(r.defects).toEqual([]) + expect(r.snapped).toEqual([{ id: 'west', end: 'end', movedM: 0.1 }]) + expect(r.notes).toContain('The rug was left out.') + expect(outcome.changes?.delete).toEqual(['level_0']) + const created = outcome.changes!.create!.map((c) => c.node) + const byType = (t: string) => created.filter((n) => n.type === t) + expect(byType('level')).toHaveLength(1) + expect(byType('wall')).toHaveLength(4) + expect(byType('zone')).toHaveLength(1) + const zone = byType('zone')[0] as AnyNode & { name: string; floor?: { finish?: string } } + expect(zone.name).toBe('Bedroom') + expect(zone.floor?.finish).toBe('library:wood-floorplank1') + // The room got a floor plate from the structure reconcile. + expect(byType('slab').length).toBeGreaterThan(0) + const lamp = byType('item').find((n) => (n as { name?: string }).name === 'Lamp') as AnyNode & { + parentId: string + interactive?: { effects: unknown[] } + } + const bedNode = byType('item').find((n) => (n as { name?: string }).name === 'Bed')! + expect(lamp.parentId).toBe(bedNode.id) + expect(lamp.interactive?.effects).toHaveLength(1) + const painted = byType('wall').find( + (w) => + (w as { metadata: { authorScene: { id: string } } }).metadata.authorScene.id === 'north', + ) as AnyNode & { slots?: Record } + expect(Object.keys(painted.slots ?? {}).length).toBeGreaterThan(0) + }) + + test('the same payload writes the same ids', () => { + const a = authorScene( + scaffold(), + { payload: bedroom, compiled: { bed, lamp: bed } }, + { activeLevelId: 'level_0' }, + ) + const b = authorScene( + scaffold(), + { payload: bedroom, compiled: { bed, lamp: bed } }, + { activeLevelId: 'level_0' }, + ) + expect(a.changes!.create!.map((c) => c.node.id)).toEqual( + b.changes!.create!.map((c) => c.node.id), + ) + }) + + test('a failed compile and a missing host are reported, not dropped silently', () => { + const outcome = authorScene( + scaffold(), + { payload: bedroom, compiled: { bed }, compileErrors: { lamp: 'Unsupported import' } }, + { activeLevelId: 'level_0' }, + ) + expect(outcome.result.items).toBe(1) + expect(outcome.result.defects.map((d) => d.code)).toEqual(['compile_failed']) + }) + + test('refuses to write over a conversion unless told to', () => { + const first = authorScene( + scaffold(), + { payload: bedroom, compiled: { bed, lamp: bed } }, + { activeLevelId: 'level_0' }, + ) + const scene = scaffold() + delete scene.level_0 + for (const { node } of first.changes!.create!) scene[node.id] = node + expect(() => + authorScene( + scene, + { payload: bedroom, compiled: { bed, lamp: bed } }, + { activeLevelId: null }, + ), + ).toThrow() + const again = authorScene( + scene, + { payload: bedroom, compiled: { bed, lamp: bed }, replace: true }, + { activeLevelId: null }, + ) + expect(again.result.walls).toBe(4) + }) +}) diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts new file mode 100644 index 0000000000..42c7a19872 --- /dev/null +++ b/packages/core/src/agent-operations/author-scene.ts @@ -0,0 +1,527 @@ +import type { AuthorScenePayload } from '../agent-tools/author-scene' +import { refuse } from '../agent-tools/refusal' +import { reconcileSceneStructure } from '../lib/structure-reconcile' +import { nearestLibraryColorRef } from '../procedural-items/library-colors' +import { + type AnyNode, + ColumnNode, + type CompiledGeometryScript, + DoorNode, + LevelNode, + SlabNode, + WallNode, + WindowNode, + ZoneNode, +} from '../schema' +import { migrateRoomZones } from '../utils/room-zone-migration' +import { addObject } from './add-object' +import { paint } from './paint' +import type { AgentContext, AgentOperationOutcome, NodeWrite, SceneNodes } from './types' + +/** + * `author_scene`: a whole design, read by its author from the three.js module it wrote, handed + * over as a payload and written as native Pascal nodes in one step. Not in AGENT_OPERATIONS: the + * host compiles every item's module first (the MCP on its server, the chat in its worker) and + * passes the results as `compiled`, keyed by the payload's item ids; an item whose compile failed + * is absent there and comes back in the report with the host's reason. + * + * The policy, decided with the owner: accept and report. Everything valid is written; every + * defect comes back by the payload's own id with a reason; the one correction Pascal makes + * itself is its join rule (a wall end within reach of another wall's centerline snaps onto it), + * reported per wall. Ids are derived from the payload, so the same payload writes the same scene. + */ +export type AuthorSceneInput = { + payload: AuthorScenePayload + compiled: Record + /** Why an item's module did not compile, by the payload's item id, for the report. */ + compileErrors?: Record + replace?: boolean +} + +export type AuthorSceneDefect = { id: string; code: string; message: string } + +export type AuthorSceneReport = { + levels: number + walls: number + doors: number + windows: number + rooms: number + roomsNamed: number + slabs: number + columns: number + items: number + lights: number + /** The join rule applied: wall ends moved onto a centerline, with how far. */ + snapped: { id: string; end: 'start' | 'end'; movedM: number }[] + defects: AuthorSceneDefect[] + notes: string[] +} + +const JOIN_REACH = 0.3 + +/** Small, stable ids: the payload's own hash and a counter per kind, so a replay writes the same scene. */ +function idFactory(payload: AuthorScenePayload) { + let hash = 0x811c9dc5 + for (const ch of JSON.stringify(payload)) + hash = Math.imul(hash ^ ch.charCodeAt(0), 0x01000193) >>> 0 + const base = hash.toString(16).padStart(8, '0') + const counts = new Map() + return (kind: string) => { + const n = (counts.get(kind) ?? 0) + 1 + counts.set(kind, n) + return `${kind}_as${base}${n.toString(36).padStart(4, '0')}` + } +} + +const dist = (a: readonly number[], b: readonly number[]) => + Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!) + +/** The point on segment ab nearest to p, and how far p is from it. */ +/** + * Where a wall end meets another wall's centerline: the projection onto that line, as long as it + * lands on the wall or within reach past its ends. The line, not the segment, so a partition + * stopping short of a wall extends to it, and the wall it meets does not slide to the partition. + */ +function nearestOnSegment(p: readonly number[], a: readonly number[], b: readonly number[]) { + const len = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!) || 1 + const t = ((p[0]! - a[0]!) * (b[0]! - a[0]!) + (p[1]! - a[1]!) * (b[1]! - a[1]!)) / (len * len) + if (t * len < -JOIN_REACH || (t - 1) * len > JOIN_REACH) + return { point: [p[0]!, p[1]!] as [number, number], distance: Number.POSITIVE_INFINITY } + const q: [number, number] = [a[0]! + t * (b[0]! - a[0]!), a[1]! + t * (b[1]! - a[1]!)] + return { point: q, distance: dist(p, q) } +} + +function finishRef(value: string | undefined): string | undefined { + if (!value) return undefined + if (value.startsWith('library:') || value.startsWith('scene:')) return value + return nearestLibraryColorRef(value)?.ref +} + +function pointInPolygon(point: readonly number[], polygon: readonly (readonly number[])[]) { + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, zi] = [polygon[i]![0]!, polygon[i]![1]!] + const [xj, zj] = [polygon[j]![0]!, polygon[j]![1]!] + if ( + zi > point[1]! !== zj > point[1]! && + point[0]! < ((xj - xi) * (point[1]! - zi)) / (zj - zi) + xi + ) + inside = !inside + } + return inside +} + +export function authorScene( + scene: SceneNodes, + input: AuthorSceneInput, + context: AgentContext, +): AgentOperationOutcome & { result: AuthorSceneReport } { + const { payload } = input + const building = Object.values(scene).find((n) => n.type === 'building') + if (!building) refuse('no_building', 'The scene has no building to put the levels in.') + const existingLevels = Object.values(scene).filter((n) => n.type === 'level') + const converted = existingLevels.some( + (l) => (l.metadata as { authorScene?: unknown })?.authorScene, + ) + if (converted && !input.replace) + refuse( + 'conversion_exists', + 'This scene already holds a conversion. Pass replace: true to write over it.', + ) + + const mint = idFactory(payload) + const report: AuthorSceneReport = { + levels: 0, + walls: 0, + doors: 0, + windows: 0, + rooms: 0, + roomsNamed: 0, + slabs: 0, + columns: 0, + items: 0, + lights: 0, + snapped: [], + defects: [], + notes: [...payload.notes], + } + const defect = (id: string, code: string, message: string) => + report.defects.push({ id, code, message }) + + // The draft: the scene without its levels, then everything the payload describes. + const draft: Record = {} + for (const [id, node] of Object.entries(scene)) { + if (node.type === 'level') continue + const levelIds = new Set(existingLevels.map((l) => l.id)) + if (node.parentId && levelIds.has(node.parentId)) continue + draft[id] = node + } + const underLevel = new Set() + for (const node of Object.values(scene)) { + let at = node.parentId + while (at) { + if (existingLevels.some((l) => l.id === at)) { + underLevel.add(node.id) + break + } + at = scene[at]?.parentId ?? null + } + } + for (const id of underLevel) delete draft[id] + const deleted = existingLevels.map((l) => l.id) + + const levelNodes: ReturnType[] = [] + const itemNodeIds = new Map() + const itemPositions = new Map() + + payload.levels.forEach((level, index) => { + const levelNode = LevelNode.parse({ + object: 'node', + type: 'level', + id: mint('level'), + parentId: building!.id, + visible: true, + metadata: { authorScene: { elevation: level.elevation } }, + name: level.name, + level: index, + children: [], + }) + draft[levelNode.id] = levelNode + levelNodes.push(levelNode) + const place = (node: AnyNode) => { + draft[node.id] = node + levelNode.children.push(node.id as never) + } + + // ---------- walls, joined ---------- + const walls = (level.walls ?? []).map((w) => ({ + spec: w, + start: [...w.start] as [number, number], + end: [...w.end] as [number, number], + })) + for (const w of walls) { + for (const end of ['start', 'end'] as const) { + const pt = w[end] + let best: { point: [number, number]; distance: number } | null = null + for (const o of walls) { + if (o === w) continue + const near = nearestOnSegment(pt, o.start, o.end) + if (near.distance < JOIN_REACH && (!best || near.distance < best.distance)) best = near + } + if (best && best.distance > 0.001) { + report.snapped.push({ + id: w.spec.id, + end, + movedM: Math.round(best.distance * 1000) / 1000, + }) + w[end] = best.point + } + } + } + const wallNodes = new Map>() + for (const w of walls) { + if (dist(w.start, w.end) < 0.05) { + defect(w.spec.id, 'wall_too_short', 'The wall is shorter than 5 cm after joining.') + continue + } + const node = WallNode.parse({ + object: 'node', + type: 'wall', + id: mint('wall'), + parentId: levelNode.id, + visible: true, + metadata: { authorScene: { id: w.spec.id } }, + children: [], + start: w.start, + end: w.end, + height: w.spec.height, + thickness: w.spec.thickness, + frontSide: 'interior', + backSide: 'exterior', + }) + place(node) + wallNodes.set(w.spec.id, node) + report.walls++ + // ---------- its openings ---------- + const length = dist(w.start, w.end) + ;(w.spec.openings ?? []).forEach((o, i) => { + const id = `${w.spec.id}/${i}` + if (o.width > length + 0.01) { + defect( + id, + 'opening_wider_than_wall', + `${o.kind} ${o.width} m wide on a ${length.toFixed(2)} m wall.`, + ) + return + } + const x = Math.min(Math.max(o.along, o.width / 2), length - o.width / 2) + if (Math.abs(x - o.along) > 0.01) + report.notes.push( + `${id}: ${o.kind} moved ${Math.abs(x - o.along).toFixed(2)} m along the wall to fit inside it.`, + ) + const common = { + object: 'node', + id: mint(o.kind), + parentId: node.id, + visible: true, + metadata: { authorScene: { id, color: o.color ?? null } }, + wallId: node.id, + width: o.width, + height: o.height, + rotation: [0, 0, 0], + ...(o.name ? { name: o.name } : {}), + } + const opening = + o.kind === 'door' + ? DoorNode.parse({ + ...common, + type: 'door', + position: [x, o.height / 2, 0], + ...(o.hinge ? { hingesSide: o.hinge === 'start' ? 'left' : 'right' } : {}), + ...(o.swing ? { swingDirection: o.swing === 'back' ? 'outward' : 'inward' } : {}), + }) + : WindowNode.parse({ + ...common, + type: 'window', + position: [x, o.sill + o.height / 2, 0], + }) + draft[opening.id] = opening + node.children.push(opening.id as never) + if (o.kind === 'door') report.doors++ + else report.windows++ + }) + } + + // ---------- slabs and columns ---------- + for (const s of level.slabs ?? []) { + const node = SlabNode.parse({ + object: 'node', + type: 'slab', + id: mint('slab'), + parentId: levelNode.id, + visible: true, + metadata: { authorScene: { finish: s.finish ?? null } }, + ...(s.name ? { name: s.name } : {}), + polygon: s.polygon, + elevation: s.top, + thickness: s.thickness, + ...(s.railing?.length + ? { railing: s.railing.map((r) => ({ start: r.start, end: r.end })) } + : {}), + }) + place(node) + report.slabs++ + } + for (const c of level.columns ?? []) { + const node = ColumnNode.parse({ + object: 'node', + type: 'column', + id: mint('column'), + parentId: levelNode.id, + visible: true, + metadata: { authorScene: { color: c.color ?? null } }, + ...(c.name ? { name: c.name } : {}), + position: [c.position[0], 0, c.position[1]], + shape: 'square', + width: c.width, + depth: c.depth, + height: c.height, + baseHeight: 0, + capitalHeight: 0, + }) + place(node) + report.columns++ + } + + // ---------- items, hosts first ---------- + const pending = [...(level.items ?? [])] + let progress = true + while (pending.length && progress) { + progress = false + for (const item of [...pending]) { + const hostId = item.host ? itemNodeIds.get(item.host) : undefined + if (item.host && !hostId) continue + pending.splice(pending.indexOf(item), 1) + progress = true + const compiled = input.compiled[item.id] + if (!compiled) { + defect( + item.id, + 'compile_failed', + input.compileErrors?.[item.id] ?? 'The module did not compile.', + ) + continue + } + const hostPosition = hostId ? itemPositions.get(item.host!) : undefined + const position: [number, number, number] = hostPosition + ? [ + item.position[0]! - hostPosition[0], + item.position[1]! - hostPosition[1], + item.position[2]! - hostPosition[2], + ] + : [item.position[0]!, item.position[1]!, item.position[2]!] + try { + const outcome = addObject( + draft, + { + compiled: { ...compiled, nodeId: mint('item') }, + name: item.name, + category: item.category, + reason: `author_scene: ${item.name}`, + position, + rotation: item.rotation, + parentId: hostId ?? levelNode.id, + }, + { ...context, activeLevelId: levelNode.id }, + ) + for (const { node, parentId } of outcome.changes?.create ?? []) { + draft[node.id] = node + const parent = draft[parentId ?? levelNode.id] as AnyNode & { children?: string[] } + parent.children = [...(parent.children ?? []), node.id] + itemNodeIds.set(item.id, node.id) + itemPositions.set(item.id, [item.position[0]!, item.position[1]!, item.position[2]!]) + report.items++ + } + } catch (error) { + const e = error as { code?: string; message?: string } + defect(item.id, e.code ?? 'item_refused', e.message ?? String(error)) + } + } + } + for (const item of pending) + defect(item.id, 'host_missing', `Its host ${item.host} was not placed.`) + + // ---------- lights: on their fixture, else on the nearest item ---------- + for (const [i, light] of level.lights.entries()) { + const id = `light/${index}/${i}` + let hostKey = light.fixture && itemNodeIds.has(light.fixture) ? light.fixture : undefined + if (!hostKey) { + let best: { key: string; d: number } | null = null + for (const [key, pos] of itemPositions) { + const d = Math.hypot(pos[0] - light.position[0]!, pos[2] - light.position[2]!) + if (!best || d < best.d) best = { key, d } + } + if (!best) { + defect( + id, + 'light_unhosted', + 'No item on this level to hold the light; Pascal keeps lights inside objects.', + ) + continue + } + hostKey = best.key + report.notes.push( + `${id}: no fixture given, attached to ${hostKey} ${best.d.toFixed(2)} m away; moving that item moves the light.`, + ) + } + const hostNode = draft[itemNodeIds.get(hostKey)!] as AnyNode & { + interactive?: { controls?: unknown[]; effects?: unknown[] } + } + const hostPos = itemPositions.get(hostKey)! + const effects = [ + ...(hostNode.interactive?.effects ?? []), + { + kind: 'light', + color: light.color, + intensityRange: [0, light.intensity], + offset: [ + light.position[0]! - hostPos[0], + light.position[1]! - hostPos[1], + light.position[2]! - hostPos[2], + ], + }, + ] + draft[hostNode.id] = Object.assign({}, hostNode, { + interactive: { controls: hostNode.interactive?.controls ?? [], effects }, + }) as unknown as AnyNode + report.lights++ + } + + // ---------- wall paint, through the editor's paint ---------- + for (const w of walls) { + const node = wallNodes.get(w.spec.id) + if (!node || !w.spec.color) continue + try { + const { changes } = paint( + draft, + { targets: [node.id], color: w.spec.color }, + { ...context, activeLevelId: levelNode.id }, + ) + for (const u of changes?.update ?? []) + draft[u.id] = { ...draft[u.id], ...u.data } as AnyNode + } catch (error) { + defect(w.spec.id, 'paint_refused', (error as Error).message) + } + } + }) + report.levels = levelNodes.length + + // ---------- rooms: Pascal's own derivation, then the payload's names and floors ---------- + const { nodes: withZones, createdZoneIds } = migrateRoomZones(draft) + const zones = createdZoneIds.map((id) => ZoneNode.parse(withZones[id])) + for (const zone of zones) { + draft[zone.id] = zone + const level = draft[zone.parentId!] as AnyNode & { children: string[] } + draft[zone.parentId!] = { ...level, children: [...level.children, zone.id] } as AnyNode + report.rooms++ + } + payload.levels.forEach((level, index) => { + const levelNode = levelNodes[index]! + for (const [i, room] of level.rooms.entries()) { + const zone = zones.find( + (z) => z.parentId === levelNode.id && pointInPolygon(room.seed, z.polygon), + ) + if (!zone) { + defect( + `room/${index}/${i}`, + 'room_not_enclosed', + `No closed room around (${room.seed[0]}, ${room.seed[1]}) "${room.name}": its walls do not meet.`, + ) + continue + } + const finish = finishRef(room.floor) + const regions = room.floorRegions.map((r, k) => ({ + id: `region_${k}`, + polygon: r.polygon, + finish: finishRef(r.finish) ?? r.finish, + })) + draft[zone.id] = ZoneNode.parse({ + ...zone, + name: room.name, + spaceRole: 'room', + ...(finish || regions.length + ? { + floor: { + ...zone.floor, + ...(finish ? { finish } : {}), + ...(regions.length ? { regions } : {}), + }, + } + : {}), + metadata: { ...zone.metadata, authorScene: { seed: room.seed } }, + }) + report.roomsNamed++ + } + }) + + // ---------- floors and ceilings, as the editor derives them on load ---------- + const structure = reconcileSceneStructure({ + nodes: draft, + levelIds: levelNodes.map((l) => l.id as `level_${string}`), + mintId: (kind) => mint(kind), + }) + + const create: NodeWrite[] = Object.values(structure.nodes) + .filter((node) => !scene[node.id]) + .map((node) => ({ op: 'create', node, parentId: node.parentId ?? undefined })) + return { + result: report, + changes: { + delete: deleted, + create: create.map((c) => ({ + node: (c as { node: AnyNode }).node, + parentId: (c as { parentId?: string }).parentId, + })), + }, + } +} diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index c7297883e3..ba3ad569b9 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -41,6 +41,7 @@ export * from './add-object' export * from './add-wall' export * from './apply-changes' export * from './apply-outcome' +export * from './author-scene' export * from './collections' export * from './corner-window' export * from './create-roof' diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts new file mode 100644 index 0000000000..0f93df0cd6 --- /dev/null +++ b/packages/core/src/agent-tools/author-scene.ts @@ -0,0 +1,201 @@ +import { z } from 'zod' +import { measurement } from './measurement' +import { NodeId } from './node-id' + +const DESCRIPTION = `Turn a whole design you wrote as free three.js into an editable Pascal scene, in one call. You read your own module however you like (its source, running it and reading the objects, both) and hand over a conversion payload: levels, walls with their openings, rooms, slabs, columns, railings, every piece of furniture as an authored object with its own module, and lights. Pascal does the rest server-side: compiles and stores each item, places it, derives the rooms and their floors, paints, runs its checks, saves, and answers with a gate report you iterate against. Nothing you hand over is silently dropped: what cannot be placed comes back by name with a reason, and what Pascal corrected (a wall end snapped onto the centerline it meets) is listed. + +Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre. An item's position is where its bottom centre stands; its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is). Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. + +Call again with the same scene and a corrected payload to replace the previous conversion (replace: true); the default refuses to overwrite a scene that already holds a conversion.` + +const plan = (what: string) => + z.array(z.number()).length(2).describe(`[x, z] ${what} in plan metres.`) +const xyz = (what: string) => + z.array(z.number()).length(3).describe(`[x, y, z] ${what} in metres, y above the level's floor.`) +const length = (description: string, opts: { positive?: boolean; default?: number } = {}) => + measurement('length', 'm', { description, ...opts }) + +export const authorSceneOpening = z.object({ + kind: z.enum(['door', 'window']), + along: length("From the wall's start to the opening's centre, along the centerline."), + width: length('Opening width.', { positive: true }), + height: length('Opening height.', { positive: true }), + sill: length( + 'Bottom of the opening above the floor; 0 for a door or floor-to-ceiling glazing (default 0).', + { default: 0 }, + ), + name: z.string().max(80).optional(), + color: z + .string() + .optional() + .describe("The leaf's or frame's colour as #rrggbb, when the design has one."), + hinge: z + .enum(['start', 'end']) + .optional() + .describe("Which end of the wall the door's hinge is nearer."), + swing: z + .enum(['front', 'back']) + .optional() + .describe("The side the door opens towards: the wall's front or back face."), +}) + +export const authorSceneWall = z.object({ + id: z.string().min(1).max(64).describe('Your own id, for the report.'), + start: plan('start'), + end: plan('end'), + height: length('Wall height.', { positive: true }), + thickness: length('Wall thickness.', { positive: true }), + color: z + .string() + .optional() + .describe('Paint for both faces as #rrggbb; Pascal picks the nearest library colour.'), + openings: z.array(authorSceneOpening).optional(), +}) + +export const authorSceneRoom = z.object({ + name: z + .string() + .min(1) + .max(80) + .describe( + "The room's name as a person would say it (Bedroom 2, Salle de bains, Living + kitchen).", + ), + seed: plan('a point inside the room'), + floor: z + .string() + .optional() + .describe( + "The floor's material: a library ref (library:) from search_materials, or #rrggbb for a flat colour.", + ), + floorRegions: z + .array(z.object({ polygon: z.array(plan('vertex')).min(3), finish: z.string() })) + .default([]) + .describe('Parts of the floor in another finish (the kitchen tiles inside an open-plan room).'), +}) + +export const authorSceneSlab = z.object({ + name: z.string().max(80).optional(), + polygon: z.array(plan('vertex')).min(3), + thickness: length('Slab thickness (default 0.15 m).', { default: 0.15, positive: true }), + top: length( + "The slab's top above the level's floor; a balcony at 0, a plinth below 0 (default 0).", + { + default: 0, + }, + ), + finish: z.string().optional().describe('library: or #rrggbb.'), + railing: z + .array( + z.object({ + start: plan('start'), + end: plan('end'), + height: length('Railing height (default 1.1 m).', { default: 1.1, positive: true }), + }), + ) + .optional() + .describe('Railing runs on this slab (a balcony).'), +}) + +export const authorSceneColumn = z.object({ + name: z.string().max(80).optional(), + position: plan('centre'), + width: length('Column width.', { positive: true }), + depth: length('Column depth.', { positive: true }), + height: length('Column height.', { positive: true }), + color: z.string().optional(), +}) + +export const authorSceneItem = z.object({ + id: z.string().min(1).max(64).describe('Your own id, for the report and for hosting.'), + name: z.string().min(1).max(80), + category: z + .string() + .min(1) + .max(40) + .describe('One word a catalog would use: bed, chair, sofa, plant, lamp, sink.'), + code: z + .string() + .min(1) + .max(48_000) + .describe("The piece's own three.js module (see the description)."), + position: xyz('bottom centre'), + rotation: measurement('angle', 'deg', { + default: 0, + description: 'Turn about the vertical axis (default 0).', + }), + host: z + .string() + .optional() + .describe( + 'The id of the item this one rests on or hangs from (a lamp on a nightstand), so moving the host moves it.', + ), +}) + +export const authorSceneLight = z.object({ + position: xyz('the bulb'), + color: z.string().default('#ffffff').describe('#rrggbb (default white).'), + intensity: z + .number() + .positive() + .default(1) + .describe('Relative brightness, 1 for an ordinary bulb (default 1).'), + fixture: z + .string() + .optional() + .describe( + 'The id of the item holding this light (a pendant, a lamp). Without one, the light is attached to the nearest item.', + ), +}) + +export const authorSceneLevel = z.object({ + name: z.string().min(1).max(80), + elevation: length("The level's floor height above the ground floor's (default 0).", { + default: 0, + }), + walls: z.array(authorSceneWall).optional(), + rooms: z.array(authorSceneRoom).default([]), + slabs: z.array(authorSceneSlab).optional(), + columns: z.array(authorSceneColumn).optional(), + items: z.array(authorSceneItem).optional(), + lights: z.array(authorSceneLight).default([]), +}) + +export const authorScenePayload = z.object({ + levels: z.array(authorSceneLevel).min(1), + notes: z + .array(z.string().max(400)) + .default([]) + .describe( + 'What you could not convert, or converted with a change, one sentence each. Kept with the scene.', + ), +}) +export type AuthorScenePayload = z.infer + +export const authorSceneTool = { + name: 'author_scene', + title: 'Author scene', + description: DESCRIPTION, + input: { + payload: authorScenePayload, + module: z + .string() + .max(400_000) + .optional() + .describe( + 'The three.js module the payload was read from. Stored with the scene as its source.', + ), + adapter: z + .string() + .max(400_000) + .optional() + .describe( + 'The program that produced the payload from the module. Stored with the scene, so a regenerated design starts from it.', + ), + replace: z + .boolean() + .default(false) + .describe('Replace the conversion this scene already holds (default false).'), + sceneId: NodeId.optional().describe('The scene to write; the active one when omitted.'), + }, + annotations: { destructiveHint: true }, +} diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 6759a47c1f..cb524f5c84 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,5 +1,6 @@ import { addObjectTool, getSourceTool } from './add-object' import { searchAssetsTool, viewAssetsTool } from './assets' +import { authorSceneTool } from './author-scene' import { runBatchTool } from './batch' import { setCheckpointTool } from './checkpoints' import { clearSceneTool } from './clear-scene' @@ -39,6 +40,7 @@ import { addWallTool } from './walls' export * from './achieved' export * from './add-object' export * from './assets' +export * from './author-scene' export * from './clear-scene' export * from './collections' export * from './columns' @@ -106,6 +108,7 @@ export const AGENT_TOOL_CONTRACTS = [ moveRoofsToRoofLevelTool, deleteNodeTool, addObjectTool, + authorSceneTool, getSourceTool, findByTypeTool, editCollectionTool, From 0c5daead505bb625bdbf99f7a2a3e08dccf9022d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 16 Oct 2026 16:32:35 -0400 Subject: [PATCH 02/26] =?UTF-8?q?mcp:=20author=5Fscene=20=E2=80=94=20compi?= =?UTF-8?q?le=20and=20store=20the=20items,=20write=20the=20conversion,=20k?= =?UTF-8?q?eep=20its=20provenance,=20answer=20the=20gate=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- packages/mcp/src/tools/author-scene.test.ts | 149 +++++++++++++++ packages/mcp/src/tools/author-scene.ts | 178 ++++++++++++++++++ packages/mcp/src/tools/index.ts | 2 + .../src/tools/read-tool-annotations.test.ts | 1 + .../tool-annotation-justifications.json | 13 ++ scripts/openai-tool-annotation-policy.ts | 1 + 6 files changed, 344 insertions(+) create mode 100644 packages/mcp/src/tools/author-scene.test.ts create mode 100644 packages/mcp/src/tools/author-scene.ts diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts new file mode 100644 index 0000000000..6aab5dece6 --- /dev/null +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { compileGeometryScript } from '@pascal-app/geometry-script' +import { SceneBridge } from '../bridge/scene-bridge' +import { createSceneOperations } from '../operations' +import { createPascalMcpServer } from '../server' +import type { GeometryScriptHost } from './add-object' + +// Layer 2 of 3: author_scene through a real client, with the real compiler behind the host. + +const BOX = (w: number, h: number, d: number, color: string) => `import * as THREE from 'three' +export const mount = 'floor' +export default function build({ THREE }) { + const g = new THREE.Group() + const m = new THREE.MeshStandardMaterial({ color: '${color}' }); m.name = 'slot_body' + const b = new THREE.Mesh(new THREE.BoxGeometry(${w}, ${h}, ${d}), m); b.position.y = ${h / 2}; g.add(b) + return g +}` + +const payload = { + levels: [ + { + name: 'Ground floor', + walls: [ + { + id: 'n', + start: [-2, -2.5], + end: [2, -2.5], + height: 2.7, + thickness: 0.12, + color: '#f1ece4', + }, + { + id: 'e', + start: [2, -2.5], + end: [2, 2.5], + height: 2.7, + thickness: 0.12, + openings: [{ kind: 'window', along: 2.5, width: 1.4, height: 1.3, sill: 0.9 }], + }, + { + id: 's', + start: [2, 2.5], + end: [-2, 2.5], + height: 2.7, + thickness: 0.12, + openings: [{ kind: 'door', along: 1.2, width: 0.9, height: 2.1 }], + }, + { id: 'w', start: [-2, 2.5], end: [-2, -2.4], height: 2.7, thickness: 0.12 }, + ], + rooms: [{ name: 'Bedroom', seed: [0, 0], floor: 'library:wood-floorplank1' }], + items: [ + { + id: 'bed', + name: 'Bed', + category: 'bed', + code: BOX(1.6, 0.5, 2, '#8a6a4a'), + position: [0, 0, -1], + }, + { + id: 'lamp', + name: 'Lamp', + category: 'lamp', + code: BOX(0.2, 0.4, 0.2, '#eeeeee'), + position: [1.2, 0.5, -2], + host: 'bed', + }, + { + id: 'bad', + name: 'Broken', + category: 'plant', + code: 'import x from "nowhere"', + position: [1, 0, 1], + }, + ], + lights: [{ position: [1.2, 1, -2], fixture: 'lamp' }], + }, + ], + notes: ['The rug was left out.'], +} + +describe('author_scene (MCP)', () => { + let client: Client + const stored: string[] = [] + const host: GeometryScriptHost = { + compile: async ({ code }) => { + const { glb, ...compiled } = await compileGeometryScript({ code }) + return { ...compiled, glb: new Uint8Array(glb) } + }, + storeArtifact: async ({ sha256, mimeType }) => { + stored.push(`${mimeType} ${sha256.slice(0, 8)}`) + return sha256 + }, + readArtifact: async () => null, + } + + beforeEach(async () => { + stored.length = 0 + const bridge = new SceneBridge() + const operations = createSceneOperations({ bridge }) + operations.loadDefault() + bridge.setActiveScene({ id: 'scene_test', name: 'Test' } as never) + const server = createPascalMcpServer({ operations, geometryScripts: host }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'test', version: '0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + }) + + test('writes the design, keeps its provenance, and reports the gate', async () => { + const result = await client.callTool({ + name: 'author_scene', + arguments: { payload, module: '// the module', adapter: '// the adapter' }, + }) + const report = result.structuredContent as Record & { + defects: { id: string; code: string }[] + verify: { issues: number } + } + expect(report.walls).toBe(4) + expect(report.doors).toBe(1) + expect(report.windows).toBe(1) + expect(report.rooms).toBe(1) + expect(report.items).toBe(2) + expect(report.lights).toBe(1) + expect(report.itemsCompiled).toBe(2) + expect(report.defects).toEqual([ + { id: 'bad', code: 'compile_failed', message: expect.stringContaining('Unsupported import') }, + ]) + expect(report.green).toBe(false) + expect(report.snapped).toEqual([{ id: 'w', end: 'end', movedM: 0.1 }]) + expect(report.notes).toContain('The rug was left out.') + // Two artifacts per item (GLB + code), plus the module and the adapter. + expect(stored.filter((s) => s.startsWith('model/gltf-binary'))).toHaveLength(2) + expect(stored.filter((s) => s.startsWith('text/javascript'))).toHaveLength(4) + const walls = await client.callTool({ name: 'get_walls', arguments: {} }) + expect(JSON.stringify(walls.structuredContent)).toContain('"height":2.7') + }) + + test('refuses to write over a conversion unless told to', async () => { + await client.callTool({ name: 'author_scene', arguments: { payload } }) + const again = await client.callTool({ name: 'author_scene', arguments: { payload } }) + expect(JSON.stringify(again.structuredContent ?? again.content)).toContain('conversion_exists') + const replaced = await client.callTool({ + name: 'author_scene', + arguments: { payload, replace: true }, + }) + expect((replaced.structuredContent as { walls: number }).walls).toBe(4) + }) +}) diff --git a/packages/mcp/src/tools/author-scene.ts b/packages/mcp/src/tools/author-scene.ts new file mode 100644 index 0000000000..c009097df6 --- /dev/null +++ b/packages/mcp/src/tools/author-scene.ts @@ -0,0 +1,178 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + type AuthorSceneReport, + achievedChanges, + authorScene, + type SceneNodes, + verifyScene, +} from '@pascal-app/core/agent-operations' +import { + type AuthorScenePayload, + authorSceneTool, + isAgentRefusal, +} from '@pascal-app/core/agent-tools' +import { + type AnyNode, + type CompiledGeometryScript, + GEOMETRY_SCRIPT_MIME_TYPES, +} from '@pascal-app/core/schema' +import type { SceneOperations } from '../operations' +import { compileAndStore, type GeometryScriptHost } from './add-object' +import { DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' +import { refusalResult, toolError } from './errors' +import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { toPatches } from './shared-tools' + +type Input = { + payload: AuthorScenePayload + module?: string + adapter?: string + replace?: boolean + sceneId?: string +} + +/** + * `author_scene` on the MCP: every item's module compiled and stored through the host, the + * module and the adapter kept as the scene's provenance, the operation applied, Pascal's checks + * run, and the gate report answered. Replay is the same payload through the same operation. + */ +export function registerAuthorScene( + server: McpServer, + bridge: SceneOperations, + host: GeometryScriptHost | undefined, +): void { + server.registerTool( + authorSceneTool.name, + { + title: authorSceneTool.title, + description: authorSceneTool.description, + inputSchema: authorSceneTool.input, + annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, + }, + async (input: Record) => { + if (!host) { + return toolError('This Pascal server cannot run geometry scripts.', { + code: 'scripts_unavailable', + }) + } + const args = input as Input + const scene = bridge.getActiveScene() + if (!scene) { + return toolError('Open or save a scene first: a conversion is stored with a scene.', { + code: 'no_active_scene', + }) + } + // The items' modules, compiled and stored one by one; a failure is a defect, not a stop. + const compiled: Record = {} + const compileErrors: Record = {} + for (const level of args.payload.levels) { + for (const item of level.items ?? []) { + try { + compiled[item.id] = await compileAndStore( + host, + scene.id, + { code: item.code, language: 'three', params: undefined }, + 'object', + ) + } catch (error) { + compileErrors[item.id] = error instanceof Error ? error.message : String(error) + } + } + } + // Provenance: the design's module and the program that read it, kept as scripts of the scene. + const provenance: Record = {} + for (const [kind, text] of [ + ['module', args.module], + ['adapter', args.adapter], + ] as const) { + if (!text) continue + provenance[kind] = await host.storeArtifact({ + sceneId: scene.id, + sha256: await sha256(text), + bytes: new TextEncoder().encode(text), + mimeType: GEOMETRY_SCRIPT_MIME_TYPES.three, + metadata: { kind: 'item' }, + }) + } + + const before = { ...(bridge.getNodes() as Record) } + let outcome: ReturnType + try { + outcome = authorScene( + before, + { payload: args.payload, compiled, compileErrors, replace: args.replace }, + { activeLevelId: bridge.activeLevelId?.() ?? null }, + ) + } catch (error) { + if (isAgentRefusal(error)) return refusalResult(error) + return toolError(error instanceof Error ? error.message : String(error), { + code: 'conversion_failed', + }) + } + const patches = outcome.changes ? toPatches(outcome.changes) : [] + if (patches.length) bridge.applyPatch(patches) + const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building') + if (building) { + bridge.updateNode(building.id, { + metadata: { + ...building.metadata, + authorScene: { + ...provenance, + payload: withoutCode(args.payload), + report: outcome.result, + at: new Date().toISOString(), + }, + }, + } as Partial) + } + const checks = verifyScene(bridge.getNodes() as SceneNodes, undefined, { + activeLevelId: null, + }).result as { valid: boolean; issues: { type?: string; message?: string }[] } + const byType = new Map() + for (const issue of checks.issues) + byType.set(issue.type ?? 'unknown', (byType.get(issue.type ?? 'unknown') ?? 0) + 1) + const payload = { + ...gateReport(outcome.result, compiled), + verify: { + valid: checks.valid, + issues: checks.issues.length, + byType: Object.fromEntries(byType), + first: checks.issues.slice(0, 40).map((i) => i.message), + }, + achieved: achievedChanges(before as SceneNodes, outcome.changes ?? {}), + ...persistencePayload(await publishLiveSceneSnapshot(bridge, authorSceneTool.name)), + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} + +/** The payload as kept with the scene: every item's code is already its own artifact. */ +function withoutCode(payload: AuthorScenePayload) { + return { + ...payload, + levels: payload.levels.map((level) => ({ + ...level, + items: (level.items ?? []).map(({ code: _, ...item }) => item), + })), + } +} + +/** What the agent iterates against: the operation's report plus what the items hold. */ +function gateReport(report: AuthorSceneReport, compiled: Record) { + const items = Object.values(compiled) + return { + ...report, + itemTriangles: items.reduce((n, c) => n + c.manifest.triangles, 0), + itemsCompiled: items.length, + green: report.defects.length === 0, + } +} + +async function sha256(text: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)) + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index e9194c274f..ac05caca37 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -9,6 +9,7 @@ import { } from './add-object' import { registerApplyPatch } from './apply-patch' import type { AssetCatalog } from './asset-catalog' +import { registerAuthorScene } from './author-scene' import { registerCheckCollisions } from './check-collisions' import { registerClearScene } from './clear-scene' import { registerConstructionTools } from './construction-tools' @@ -57,6 +58,7 @@ export function registerTools( }) registerAddColumn(server, operations, geometryScripts) registerAddObject(server, operations, geometryScripts) + registerAuthorScene(server, operations, geometryScripts) registerGetSource(server, operations, geometryScripts) registerMeasure(server, operations) registerViewScene(server, operations, sceneViews) diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index dc6325a8e9..83abdb0db5 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -97,6 +97,7 @@ const TOOL_POLICIES = [ 'apply_patch', 'clear_scene', 'add_object', + 'author_scene', 'add_column', 'edit_collection', 'create_from_template', diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index 95d537fa45..18309dfcbd 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -149,6 +149,19 @@ "openWorldHint": "It operates on the connected Pascal scene or project service without accessing the public internet." } }, + { + "name": "author_scene", + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "Writes the whole conversion: levels, walls, openings, rooms, slabs, columns, items and lights replace what the scene held.", + "destructiveHint": "Replaces the scene's levels with the payload's; with replace it writes over a previous conversion.", + "openWorldHint": "Everything it touches is the scene and the project's own artifact store." + } + }, { "name": "check_collisions", "annotations": { diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index 5ae12a10e4..75a97c5497 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -27,6 +27,7 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { find_by_type: policy(true, false, false), get_source: policy(true, false, false), add_object: policy(false, true, false), + author_scene: policy(false, true, false), edit_collection: policy(false, true, false), list_collections: policy(true, false, false), check_collisions: policy(true, false, false), From 934ab39d81fbc8bd30ea6b89d4a8fe53666ec7a9 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 16 Oct 2026 21:59:08 -0400 Subject: [PATCH 03/26] core: verify_scene reads hosted items through their host, lets stacked things stack, and keeps a door's keep-out to its swing side Co-Authored-By: Claude Fable 5.1 --- .../src/agent-operations/door-clearance.ts | 32 +++- .../src/agent-operations/item-space.test.ts | 141 ++++++++++++++++++ .../core/src/agent-operations/item-space.ts | 73 +++++++++ .../src/agent-operations/layout-clearance.ts | 58 +++++-- 4 files changed, 286 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/agent-operations/item-space.test.ts create mode 100644 packages/core/src/agent-operations/item-space.ts diff --git a/packages/core/src/agent-operations/door-clearance.ts b/packages/core/src/agent-operations/door-clearance.ts index 437447ce1f..8beb54d3be 100644 --- a/packages/core/src/agent-operations/door-clearance.ts +++ b/packages/core/src/agent-operations/door-clearance.ts @@ -10,6 +10,8 @@ import type { Vec3 } from '../procedural-items/recipe' import { boxCorners, rotateVector } from '../procedural-items/spatial' import { type AnyNode, getScaledDimensions } from '../schema' +import { isFloorUnderlay } from './furniture-kind' +import { itemLevelHeightRange, itemPlanAabbInLevel } from './item-space' import { type Vec2, wallLength } from './plan-geometry' export type PlanAabb = { @@ -44,10 +46,12 @@ export type DoorKeepout = { doorId: string wallId: string levelId: string | null - /** World-space AABB on both sides of the wall opening. */ + /** World-space AABB: the full clear depth on the swing side, a walkway on the other. */ aabb: PlanAabb width: number localX: number + /** The door's height: an item whose bottom is above it does not block the door. */ + height: number } /** @@ -67,12 +71,18 @@ export type DoorOpeningLike = { id: string position?: readonly number[] width?: number + height?: number + /** Which face the leaf swings towards: 'inward' is the wall's +normal side, as the door plan draws it. */ + swingDirection?: 'inward' | 'outward' } /** Plan depth (m) cleared on each side of the wall face through the opening. */ export const DEFAULT_DOOR_CLEAR_DEPTH = 0.65 /** Extra half-width (m) beyond the door leaf along the wall. */ export const DEFAULT_DOOR_SIDE_PAD = 0.05 +/** Plan depth (m) kept free on the side a door does not swing to: room to walk through. */ +export const DOOR_WALKWAY_DEPTH = 0.35 +const DEFAULT_DOOR_HEIGHT = 2.1 /** * True when A and B come closer than `gap` meters (including penetration). @@ -306,11 +316,20 @@ export function doorKeepoutFromWall( const nz = dx const half = width / 2 + sidePad + // The leaf needs the full depth where it swings; the other side only needs a walkway. Without a + // known swing, both sides get the full depth. + const walkway = Math.min(clearDepth, DOOR_WALKWAY_DEPTH) + const [back, front] = + door.swingDirection === 'inward' + ? [-walkway, clearDepth] + : door.swingDirection === 'outward' + ? [-clearDepth, walkway] + : [-clearDepth, clearDepth] const corners: Vec2[] = [] for (const along of [localX - half, localX + half]) { const cx = sx + dx * along const cz = sz + dz * along - for (const side of [-clearDepth, clearDepth]) { + for (const side of [back, front]) { corners.push([cx + nx * side, cz + nz * side]) } } @@ -323,6 +342,7 @@ export function doorKeepoutFromWall( levelId: options?.levelId ?? null, width, localX, + height: typeof door.height === 'number' && door.height > 0 ? door.height : DEFAULT_DOOR_HEIGHT, aabb: { minX: Math.min(...xs), maxX: Math.max(...xs), @@ -392,15 +412,21 @@ export function findBlockedDoors(args: { if (node.type !== 'item') continue const itemLevel = resolveNodeLevelId(node.id, byId) if (args.levelId && itemLevel !== args.levelId) continue + // A rug is walked over; a thing on a wall is read by the wall observations. + if (isFloorUnderlay(node)) continue + if (node.parentId && byId.get(node.parentId as never)?.type === 'wall') continue - const aabb = itemNodePlanAabb(node) + const aabb = itemPlanAabbInLevel(node, byId) if (!aabb) continue + const heights = itemLevelHeightRange(node, byId) for (const keepout of keepouts) { // Same-level only (multi-story safety). if (keepout.levelId && itemLevel && keepout.levelId !== itemLevel) continue if (args.levelId && keepout.levelId && keepout.levelId !== args.levelId) continue if (!itemBlocksDoorKeepout(aabb, keepout)) continue + // A pendant over the doorway is above the leaf. + if (heights && heights.min >= keepout.height - 0.05) continue const itemName = node.name ?? node.asset?.name ?? node.id issues.push({ doorId: keepout.doorId, diff --git a/packages/core/src/agent-operations/item-space.test.ts b/packages/core/src/agent-operations/item-space.test.ts new file mode 100644 index 0000000000..504ec25060 --- /dev/null +++ b/packages/core/src/agent-operations/item-space.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, DoorNode, ItemNode, LevelNode, WallNode } from '../schema' +import { findBlockedDoors } from './door-clearance' +import { findItemItemCollisions } from './layout-clearance' + +// What a converted, furnished flat showed verify_scene getting wrong: a lamp on a nightstand +// reported as overlapping it, a rug and a pendant "blocking" a door, a wall's cap trims +// colliding across the room, a door's keep-out on the side it does not swing to. + +const level = LevelNode.parse({ + object: 'node', + type: 'level', + id: 'level_t', + parentId: null, + visible: true, + metadata: {}, + name: 'G', + level: 0, + children: [], +}) +const item = ( + id: string, + name: string, + position: [number, number, number], + dims: [number, number, number], + parentId = level.id, + extra: Record = {}, +) => + ItemNode.parse({ + object: 'node', + type: 'item', + id, + parentId, + visible: true, + metadata: {}, + name, + position, + rotation: [0, 0, 0], + scale: [1, 1, 1], + asset: { + id: `asset_${id}`, + name, + src: 'https://example.com/x.glb', + thumbnail: 'https://example.com/x.webp', + dimensions: dims, + category: name.toLowerCase(), + }, + ...extra, + }) +const scene = (...nodes: AnyNode[]) => [level, ...nodes] + +describe('verify_scene layout checks on a furnished room', () => { + test('a lamp hosted on its nightstand is stacked, not colliding, and its footprint is read through the host', () => { + const nightstand = item('item_ns', 'Nightstand', [1, 0, 1], [0.5, 0.6, 0.4]) + const lamp = item('item_lamp', 'Lamp', [0, 0.6, 0], [0.2, 0.4, 0.2], nightstand.id) + const chair = item('item_chair', 'Chair', [1.02, 0, 1.45], [0.4, 0.9, 0.4]) + const collisions = findItemItemCollisions({ + nodes: scene(nightstand, lamp, chair), + levelId: level.id, + }) + expect(collisions.map((c) => [c.aId, c.bId].sort().join('+'))).toEqual(['item_chair+item_ns']) + }) + + test('a lamp standing on a table top is above it, not inside it', () => { + const table = item('item_table', 'Table', [0, 0, 0], [1.2, 0.75, 0.8]) + const lamp = item('item_lamp2', 'Lamp', [0.2, 0.75, 0.1], [0.2, 0.4, 0.2]) + expect(findItemItemCollisions({ nodes: scene(table, lamp), levelId: level.id })).toEqual([]) + }) + + test('a wall-hosted cap trim never joins the floor collisions', () => { + const wall = WallNode.parse({ + object: 'node', + type: 'wall', + id: 'wall_t', + parentId: level.id, + visible: true, + metadata: {}, + children: ['item_cap'], + start: [-2, 0], + end: [2, 0], + height: 2.6, + thickness: 0.12, + frontSide: 'interior', + backSide: 'exterior', + }) + const cap = item('item_cap', 'Cap', [2, 2.6, 0], [4, 0.012, 0.13], wall.id, { + asset: { + id: 'asset_cap', + name: 'Cap', + src: 'https://example.com/x.glb', + thumbnail: 'https://example.com/x.webp', + dimensions: [4, 0.012, 0.13], + category: 'trim', + attachTo: 'wall', + }, + }) + const bed = item('item_bed', 'Bed', [0, 0, 1.2], [1.6, 0.5, 2]) + expect(findItemItemCollisions({ nodes: scene(wall, cap, bed), levelId: level.id })).toEqual([]) + }) + + test('a rug under the door and a pendant above it do not block it; a chest on the swing side does, one past the walkway on the other side does not', () => { + const wall = WallNode.parse({ + object: 'node', + type: 'wall', + id: 'wall_d', + parentId: level.id, + visible: true, + metadata: {}, + children: ['door_t'], + start: [-2, 0], + end: [2, 0], + height: 2.6, + thickness: 0.12, + frontSide: 'interior', + backSide: 'exterior', + }) + const door = DoorNode.parse({ + object: 'node', + type: 'door', + id: 'door_t', + parentId: wall.id, + visible: true, + metadata: {}, + wallId: wall.id, + width: 0.9, + height: 2.1, + position: [2, 1.05, 0], + rotation: [0, 0, 0], + swingDirection: 'inward', + }) + const rug = item('item_rug', 'Doormat rug', [0, 0, 0.4], [0.8, 0.01, 0.5]) + const pendant = item('item_pendant', 'Pendant', [0, 2.3, 0.3], [0.3, 0.25, 0.3]) + const chestSwingSide = item('item_chest', 'Chest', [0, 0, 0.45], [0.8, 0.5, 0.4]) + const chestOtherSide = item('item_chest2', 'Chest', [0, 0, -0.65], [0.8, 0.5, 0.4]) + const blocked = findBlockedDoors({ + nodes: scene(wall, door, rug, pendant, chestSwingSide, chestOtherSide), + levelId: level.id, + }) + expect(blocked.map((b) => b.itemId)).toEqual(['item_chest']) + }) +}) diff --git a/packages/core/src/agent-operations/item-space.ts b/packages/core/src/agent-operations/item-space.ts new file mode 100644 index 0000000000..f9e89eed6c --- /dev/null +++ b/packages/core/src/agent-operations/item-space.ts @@ -0,0 +1,73 @@ +import { type AnyNode, getScaledDimensions, type ItemNode } from '../schema' +import { itemNodePlanAabb, type PlanAabb } from './door-clearance' + +/** + * Where an item stands in its level's frame when it is hosted by another item (a lamp on a + * nightstand): its position is local to the host, so the plan checks read it through the host + * chain, each host's yaw turning the offset. An item hosted by a wall keeps wall-local + * coordinates and is read by the wall observations instead, so this answers nothing for it. + */ +export function itemLevelPose( + node: ItemNode, + byId: Map, +): { position: [number, number, number]; yawDeg: number } | null { + let [x, y, z] = node.position + let yaw = node.rotation[1] + let parent = node.parentId ? byId.get(node.parentId) : undefined + while (parent && parent.type !== 'level') { + // A wall's child is wall-local: not this frame. A ceiling's or slab's child is already in plan metres. + if (parent.type === 'wall') return null + if (parent.type !== 'item') break + const turn = parent.rotation[1] + const [px, py, pz] = parent.position + const [rx, rz] = [ + x * Math.cos(turn) + z * Math.sin(turn), + -x * Math.sin(turn) + z * Math.cos(turn), + ] + x = px + rx + y = py + y + z = pz + rz + yaw += turn + parent = parent.parentId ? byId.get(parent.parentId) : undefined + } + return { position: [x, y, z], yawDeg: yaw } +} + +/** The item's plan footprint in its level's frame, or null when it is wall-hosted or malformed. */ +export function itemPlanAabbInLevel(node: ItemNode, byId: Map): PlanAabb | null { + const pose = itemLevelPose(node, byId) + if (!pose) return null + return itemNodePlanAabb({ + ...node, + position: pose.position, + rotation: [node.rotation[0], pose.yawDeg, node.rotation[2]], + }) +} + +/** The item's bottom and top above its level's floor, from its pose and scaled height. */ +export function itemLevelHeightRange( + node: ItemNode, + byId: Map, +): { min: number; max: number } | null { + // Only a plainly placed item answers: yaw-only, positive scales, a real height. Anything else + // keeps the conservative rule that unknown geometry cannot waive a collision. + if (node.rotation[0] !== 0 || node.rotation[2] !== 0) return null + if (node.scale.some((v) => !(v > 0)) || (node.asset.scale ?? [1, 1, 1]).some((v) => !(v > 0))) + return null + const pose = itemLevelPose(node, byId) + if (!pose) return null + const height = getScaledDimensions(node)[1] + if (!(height > 0) || !Number.isFinite(height) || !Number.isFinite(pose.position[1])) return null + return { min: pose.position[1], max: pose.position[1] + height } +} + +/** Every item above this one in its host chain: a thing and what it rests on are stacked by design, not colliding. */ +export function itemHosts(node: AnyNode, byId: Map): Set { + const hosts = new Set() + let parent = node.parentId ? byId.get(node.parentId) : undefined + while (parent?.type === 'item') { + hosts.add(parent.id) + parent = parent.parentId ? byId.get(parent.parentId) : undefined + } + return hosts +} diff --git a/packages/core/src/agent-operations/layout-clearance.ts b/packages/core/src/agent-operations/layout-clearance.ts index 19b9162c26..b3d7905070 100644 --- a/packages/core/src/agent-operations/layout-clearance.ts +++ b/packages/core/src/agent-operations/layout-clearance.ts @@ -32,6 +32,7 @@ import { resolveNodeLevelId, } from './door-clearance' import { furnitureKind, isFloorUnderlay } from './furniture-kind' +import { itemHosts, itemLevelHeightRange, itemPlanAabbInLevel } from './item-space' import { pointInPolygon, type Vec2 } from './plan-geometry' export { @@ -90,13 +91,13 @@ export function collectOccupiedFootprints( const levelId = resolveNodeLevelId(node.id, byId) if (options?.levelId && levelId !== options.levelId) continue if (levelId && node.parentId === levelId && isFloorUnderlay(node)) continue - // Floor packing: prefer level-parented items (not wall-hosted children). + // A wall's children keep wall-local coordinates and are read by the wall observations. + if (node.parentId && byId.get(node.parentId)?.type === 'wall') continue + // Floor packing: prefer level-parented items (not ceiling-hosted children). if (options?.floorOnly && node.parentId && levelId && node.parentId !== levelId) { if (attach === 'wall' || attach === 'wall-side' || attach === 'ceiling') continue - // Wall-parented items without attachTo still skipped for floor packing. - if (byId.get(node.parentId)?.type === 'wall') continue } - const aabb = nodeItemAabb(node) + const aabb = itemPlanAabbInLevel(node, byId) if (!aabb) continue const name = node.name ?? node.asset?.name out.push({ @@ -265,29 +266,56 @@ export function findItemItemCollisions(args: { const footprints = collectOccupiedFootprints(list, { levelId: args.levelId }) // Drawing fallbacks on invalid support heights cannot prove collision separation. const finiteContext = list.every((node) => node.type === 'item' || finiteNumbers(node)) + const byId = new Map(list.map((node) => [node.id, node])) + // The measured interval when the model's bounds allow it, else the node's own height above its + // floor. A measured contact stays a collision (roundoff); a lamp standing on a table top by its + // own height is stacked. const intervals = new Map( - footprints.map(({ id }) => [ - id, - finiteContext ? itemVerticalInterval(nodes[id], nodes) : undefined, - ]), + footprints.map(({ id }) => { + const node = nodes[id] as ItemNode + const measured = finiteContext ? itemVerticalInterval(node, nodes) : undefined + if (measured) return [id, { ...measured, measured: true }] + const range = itemLevelHeightRange(node, byId) + return [ + id, + range + ? { + levelId: resolveNodeLevelId(id, byId) ?? '', + min: range.min, + max: range.max, + measured: false, + } + : undefined, + ] + }), ) + const hosts = new Map(footprints.map(({ id }) => [id, itemHosts(nodes[id]!, byId)])) const collisions: ItemCollision[] = [] for (let i = 0; i < footprints.length; i++) { for (let j = i + 1; j < footprints.length; j++) { const a = footprints[i]! const b = footprints[j]! if (a.levelId && b.levelId && a.levelId !== b.levelId) continue + // A thing resting on top of its host is stacked by design: its bottom at the host's top. A + // child sunk into its host, or one whose height is unknown, is still a collision. + const [child, host] = hosts.get(a.id)?.has(b.id) + ? [a, b] + : hosts.get(b.id)?.has(a.id) + ? [b, a] + : [] + if (child && host) { + const cY = intervals.get(child.id) + const hY = intervals.get(host.id) + if (cY && hY && cY.min >= hY.max - 0.02) continue + } if (!aabbsOverlap(a.aabb, b.aabb, gap)) continue const aY = intervals.get(a.id) const bY = intervals.get(b.id) // Contact and sub-micrometre separation remain conservative against roundoff. - if ( - aY && - bY && - aY.levelId === bY.levelId && - (bY.min - aY.max > 1e-6 || aY.min - bY.max > 1e-6) - ) - continue + if (aY && bY && aY.levelId === bY.levelId) { + const touch = aY.measured && bY.measured ? 1e-6 : -1e-6 + if (bY.min - aY.max > touch || aY.min - bY.max > touch) continue + } collisions.push({ aId: a.id, bId: b.id, From ff17a77ddac65aebdb04fbcaaf0b973343f0a6df Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 16 Oct 2026 22:00:42 -0400 Subject: [PATCH 04/26] =?UTF-8?q?skill=20+=20wiki:=20author=5Fscene=20?= =?UTF-8?q?=E2=80=94=20convert=20a=20design=20written=20as=20three.js=20in?= =?UTF-8?q?=20one=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- skills/pascal-3d/SKILL.md | 2 ++ skills/pascal-3d/references/tool-workflows.md | 9 +++++++++ wiki/architecture/agent-surfaces.md | 9 +++++++++ 3 files changed, 20 insertions(+) diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 69e6bd1fe2..d31b5f2539 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -73,6 +73,8 @@ Stair `targets` are design preferences in meters (`maxRiserHeight`, `minimumGoin When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, or with `language: 'bpy'` a Blender module when the shape needs Blender's modelling (bevels, smooth organic forms, booleans, curves), with the naming conventions its description lists for paint slots, parts, lights, cutouts and clips. Edit it by passing its `nodeId`. A new object needs a `reason` (why no Pascal tool or catalog item builds it); `verify_scene` lists every authored object with its reason. On a floor, a plain box with a wall's size or a floor plate is refused with the tool to use (`use_walls`, `use_slab`); a detailed object with a wall's size (a bookcase, a screen) is built with a hint naming `add_wall`. Servers without a script host answer `scripts_unavailable`. The hosted server builds three.js scripts in the user's open Pascal editor tab (Blender modules run in its Blender service and need no tab); when no tab has the project open it answers `editor_tab_required` with an `editorUrl`: give the user that link, wait until they say it is open, then repeat the same call. `editor_tab_busy` means a tab is open but busy or still loading: wait a few seconds and repeat the same call; the user need not be asked. Whether you may open the link yourself is the person's setting ("Agents may open the editor for me"; Ask first: ask the user to open the link (or bring the tab forward), or ask their permission before opening it yourself; Allow: you may open the link yourself, in a tab in front; Never: ask the user, and do not open or switch windows yourself); the refusal's words say which, so follow them. It applies to `editor_tab_required` and `editor_tab_hidden`; `editor_tab_busy` carries no link, and an autonomous agent account, which has no editor login, is refused with `editor_host_identity_required` whatever the setting says. To count or locate things by what they are, including typed parts inside authored objects, use `find_by_type`. To group elements of any kind into a named set the editor lists (all the lights, the windows, the kitchen cabinetry), use `edit_collection`, starting from a template when one fits; `list_collections` reads them back. +When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their openings, rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module, and lights. Pascal compiles and stores the items, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it could not place and why, what it corrected (a wall end snapped onto the centerline it meets). Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and make drawers, doors and lids animation clips: named pieces convert with exact names and moving parts keep moving. + 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; pass the photo as a `data:image/...;base64` URL. 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), `editor_tab_hidden` means the tab is in the background (ask them to bring it to the front, then repeat), and `editor_tab_busy` means the tab is open but busy or still loading (wait a few seconds and repeat; do not ask the user). A server with no renderer, such as the local CLI, answers `view_unavailable`. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 4783ba6bf7..309d6e790a 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -51,6 +51,15 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra `add_object`, and `add_window`, `add_door` or `add_column` with `code` or new params on a scripted node, compile in the user's open Pascal editor tab of the project (a `bpy` module builds in Pascal's Blender service instead, with no tab). When none is open the call answers `editor_tab_required` with `editorUrl` and `mutationApplied: false`; nothing changed. Show the user the link, wait until they confirm the tab is open, and repeat the same call. `editor_tab_busy` means a tab is open but busy or still loading: wait a few seconds and repeat the same call, without asking the user. Whether you may open the link yourself is the person's setting ("Agents may open the editor for me"; Ask first: ask the user to open the link (or bring the tab forward), or ask their permission before opening it yourself; Allow: you may open the link yourself, in a tab in front; Never: ask the user, and do not open or switch windows yourself); the refusal's words say which, so follow them. `editor_tab_timeout` means the tab stopped answering; ask whether it is still open, then retry. `script_failed` is the module's own error: fix the code. +## Convert a design written as three.js + +`author_scene` turns a whole design you wrote as one three.js module into native, editable Pascal nodes in one call. The reading is yours; the writing is Pascal's. + +1. Read your module: its source for intent (helper names, declared opening types), the executed graph for truth (world transforms, deleted objects gone). Most designs make walls with a helper such as `wall(x1, z1, x2, z2, height, thickness, openings)`: those arguments are the payload's walls. +2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color`, `openings` with `kind`, `along` from the wall's start, `width`, `height`, `sill`), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`), `slabs`, `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand), `lights` (`position`, `fixture`). End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. +3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `use_slab`…), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. +4. Fix the program, not the payload by hand, and call again with `replace: true`. Stop when `green` is true and the scene looks like the design. Report what you could not convert in `notes`. + ## Wrap a window around a corner Where a photo's glass meets at a corner with no wall between the panes, build one corner window rather than two windows near the corner (which leave it solid). diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 0ced1d61f8..5b6bd0e707 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -133,3 +133,12 @@ A native material (an exact PBR finish) is kept as a scene material, reused when 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. `clear_scene` is the only way to empty a project on purpose: it resets the scene to the host's default scaffold (a site, a building, one level), keeps the project's installed plugins, clears the undo history, and saves with `allowSceneWipe`. Its `reason` says what the person asked. The by-hand counterpart is the hosted editor's **Clear the project**, offered on the notice when a save is refused as a wipe. A store with no wipe guard (the local SQLite store) saves either way. + +## Converting a whole design: `author_scene` + +A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`) takes a conversion payload, levels with walls and their openings, rooms by seed with a floor material, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall, zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, items through `addObject`, lights as light effects on their fixture (or the nearest item: Pascal keeps lights inside objects), paint through `paint`. Ids derive from the payload's hash, so the same payload writes the same scene. + +The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and the one correction Pascal makes itself is its join rule (a wall end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. + +What the converting agents asked of `verify_scene` is in the same PR: an item hosted by another item is read through its host (`item-space.ts`), a thing resting on its host is stacked, not colliding, a rug or a pendant does not block a door, and a door's keep-out is the full clear depth on its swing side and a walkway on the other. + From 4c76a4c1470845412955feb2efb9c508fcf0910b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 16 Oct 2026 22:02:37 -0400 Subject: [PATCH 05/26] core: a preview colour for every textured material; author_scene's metadata classified Co-Authored-By: Claude Fable 5.1 --- .../core/src/contracts/reference-inventory.ts | 18 +++++ packages/core/src/material-library.ts | 66 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 60f8c18879..0b0dbf52d1 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -919,6 +919,24 @@ const described = (reason: string, paths: readonly string[]) => /** Metadata keys found in editor sources that are not references. */ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[] = [ { path: 'metadata.roomBoundary', reason: 'Boolean opt-out from room boundary detection.' }, + ...described( + "author_scene's provenance: the payload's own ids on walls and openings, a room's seed, a level's elevation, a slab's finish, a wall's colour, and on the building the stored payload, the report and when it was written; none of it names a node.", + [ + 'authorScene', + 'authorScene.at', + 'authorScene.color', + 'authorScene.elevation', + 'authorScene.finish', + 'authorScene.id', + 'authorScene.payload', + 'authorScene.report', + 'authorScene.seed', + ], + ), + ...described( + "An artifact's kind as the MCP host stores it (item, window, door, column): artifact metadata, not a node's.", + ['kind'], + ), ...described('Flag, enum, number or tag; names nothing.', [ 'annotationObstacle', 'annotationRole', diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index eb2472a282..77e7e7975c 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -79,6 +79,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/finewood_27/finewood_27_thumb.webp', + previewColor: '#a7753f', preset: { maps: { albedoMap: '/material/wood/finewood_27/finewood_27_basecolor_512.ktx2', @@ -117,6 +118,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Wood plank finish', previewThumbnailUrl: '/material/wood/floor_plank_1/floor_plank_1_thumb.webp', + previewColor: '#bf8552', preset: { maps: { albedoMap: '/material/wood/floor_plank_1/floor_plank_1_basecolor_512.ktx2', @@ -154,6 +156,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_thumb.webp', + previewColor: '#a87d50', preset: { maps: { albedoMap: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_basecolor_512.ktx2', @@ -191,6 +194,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_thumb.webp', + previewColor: '#673021', preset: { maps: { albedoMap: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_basecolor_512.ktx2', @@ -228,6 +232,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/square_parquet_21/square_parquet_21_thumb.webp', + previewColor: '#3f220f', preset: { maps: { albedoMap: '/material/wood/square_parquet_21/square_parquet_21_basecolor_512.ktx2', @@ -265,6 +270,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/square_wood_parquet_23/square_wood_parquet_23_thumb.webp', + previewColor: '#7e4108', preset: { maps: { albedoMap: @@ -304,6 +310,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine/wood_fine_thumb.webp', + previewColor: '#ba9a80', preset: { maps: { albedoMap: '/material/wood/wood_fine/wood_fine_basecolor_512.ktx2', @@ -341,6 +348,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_11/wood_fine_11_thumb.webp', + previewColor: '#f59d52', preset: { maps: { albedoMap: '/material/wood/wood_fine_11/wood_fine_11_basecolor_512.ktx2', @@ -378,6 +386,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_13/wood_fine_13_thumb.webp', + previewColor: '#e5a76f', preset: { maps: { albedoMap: '/material/wood/wood_fine_13/wood_fine_13_basecolor_512.ktx2', @@ -415,6 +424,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_2/wood_fine_2_thumb.webp', + previewColor: '#dcb791', preset: { maps: { albedoMap: '/material/wood/wood_fine_2/wood_fine_2_basecolor_512.ktx2', @@ -452,6 +462,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_22/wood_fine_22_thumb.webp', + previewColor: '#bfa67e', preset: { maps: { albedoMap: '/material/wood/wood_fine_22/wood_fine_22_basecolor_512.ktx2', @@ -489,6 +500,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_24/wood_fine_24_thumb.webp', + previewColor: '#a43c28', preset: { maps: { albedoMap: '/material/wood/wood_fine_24/wood_fine_24_basecolor_512.ktx2', @@ -526,6 +538,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/wood_parquet_14/wood_parquet_14_thumb.webp', + previewColor: '#e3d4c2', preset: { maps: { albedoMap: '/material/wood/wood_parquet_14/wood_parquet_14_basecolor_512.ktx2', @@ -565,6 +578,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/wooden_parquet_11/wooden_parquet_11_thumb.webp', + previewColor: '#59392d', preset: { maps: { albedoMap: '/material/wood/wooden_parquet_11/wooden_parquet_11_basecolor_512.ktx2', @@ -602,6 +616,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_121/woodparquet_121_thumb.webp', + previewColor: '#8f7247', preset: { maps: { albedoMap: '/material/wood/woodparquet_121/woodparquet_121_basecolor_512.ktx2', @@ -640,6 +655,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_56/woodparquet_56_thumb.webp', + previewColor: '#ba8b5d', preset: { maps: { albedoMap: '/material/wood/woodparquet_56/woodparquet_56_basecolor_512.ktx2', @@ -679,6 +695,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_65/woodparquet_65_thumb.webp', + previewColor: '#9f7841', preset: { maps: { albedoMap: '/material/wood/woodparquet_65/woodparquet_65_basecolor_512.ktx2', @@ -718,6 +735,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_99/woodparquet_99_thumb.webp', + previewColor: '#b69573', preset: { maps: { albedoMap: '/material/wood/woodparquet_99/woodparquet_99_basecolor_512.ktx2', @@ -757,6 +775,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Wood plank finish', previewThumbnailUrl: '/material/wood/woodplank_19/woodplank_19_thumb.webp', + previewColor: '#8f7b5f', preset: { maps: { albedoMap: '/material/wood/woodplank_19/woodplank_19_basecolor_512.ktx2', @@ -795,6 +814,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Wood plank finish', previewThumbnailUrl: '/material/wood/woodplank_48/woodplank_48_thumb.webp', + previewColor: '#89664d', preset: { maps: { albedoMap: '/material/wood/woodplank_48/woodplank_48_basecolor_512.ktx2', @@ -833,6 +853,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_quarry/tile_quarry_thumb.webp', + previewColor: '#484946', preset: { maps: { albedoMap: '/material/flooring/tile_quarry/tile_quarry_basecolor_512.ktx2', @@ -871,6 +892,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'floor', 'outdoor'], description: 'Brick finish', previewThumbnailUrl: '/material/flooring/brick_wall_rustic/brick_wall_rustic_thumb.webp', + previewColor: '#9b6f57', preset: { maps: { albedoMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor_512.ktx2', @@ -910,6 +932,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'floor', 'outdoor'], description: 'Brick finish', previewThumbnailUrl: '/material/flooring/brick_wall_aged/brick_wall_aged_thumb.webp', + previewColor: '#be9877', preset: { maps: { albedoMap: '/material/flooring/brick_wall_aged/brick_wall_aged_basecolor_512.ktx2', @@ -949,6 +972,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'floor', 'outdoor'], description: 'Brick finish', previewThumbnailUrl: '/material/flooring/brick_wall_weathered/brick_wall_weathered_thumb.webp', + previewColor: '#86513c', preset: { maps: { albedoMap: @@ -989,6 +1013,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'furniture'], description: 'Panel finish', previewThumbnailUrl: '/material/flooring/garage_panel/garage_panel_thumb.webp', + previewColor: '#d7dad7', preset: { maps: { albedoMap: '/material/flooring/garage_panel/garage_panel_basecolor_512.ktx2', @@ -1026,6 +1051,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Stone flooring finish', previewThumbnailUrl: '/material/flooring/green_labradorite/green_labradorite_thumb.webp', + previewColor: '#313828', preset: { maps: { albedoMap: '/material/flooring/green_labradorite/green_labradorite_basecolor_512.ktx2', @@ -1063,6 +1089,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['outdoor', 'floor'], description: 'Ground surface finish', previewThumbnailUrl: '/material/flooring/ground_earth/ground_earth_thumb.webp', + previewColor: '#716c62', preset: { maps: { albedoMap: '/material/flooring/ground_earth/ground_earth_basecolor_512.ktx2', @@ -1102,6 +1129,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'outdoor'], description: 'Pool tile finish', previewThumbnailUrl: '/material/flooring/pool_tiles/pool_tiles_thumb.webp', + previewColor: '#79b4ce', preset: { maps: { albedoMap: '/material/flooring/pool_tiles/pool_tiles_basecolor_512.ktx2', @@ -1139,6 +1167,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Tile flooring finish', previewThumbnailUrl: '/material/flooring/tiles_checker/tiles_checker_thumb.webp', + previewColor: '#bab4b0', preset: { maps: { albedoMap: '/material/flooring/tiles_checker/tiles_checker_basecolor_512.ktx2', @@ -1176,6 +1205,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Tile flooring finish', previewThumbnailUrl: '/material/flooring/tiles_grid/tiles_grid_thumb.webp', + previewColor: '#828282', preset: { maps: { albedoMap: '/material/flooring/tiles_grid/tiles_grid_basecolor_512.ktx2', @@ -1213,6 +1243,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'floor', 'outdoor'], description: 'Stone finish', previewThumbnailUrl: '/material/flooring/stone_wall/stone_wall_thumb.webp', + previewColor: '#b2845c', preset: { maps: { albedoMap: '/material/flooring/stone_wall/stone_wall_basecolor_512.ktx2', @@ -1250,6 +1281,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Wood-look ceramic flooring finish', previewThumbnailUrl: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_thumb.webp', + previewColor: '#977459', preset: { maps: { albedoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_basecolor_512.ktx2', @@ -1287,6 +1319,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Ceramic flooring finish', previewThumbnailUrl: '/material/flooring/ceramic_mosaic/ceramic_mosaic_thumb.webp', + previewColor: '#b9b6ae', preset: { maps: { albedoMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor_512.ktx2', @@ -1326,6 +1359,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Terrazzo flooring finish', previewThumbnailUrl: '/material/flooring/terrazzo/terrazzo_thumb.webp', + previewColor: '#939e9f', preset: { maps: { albedoMap: '/material/flooring/terrazzo/terrazzo_basecolor_512.ktx2', @@ -1364,6 +1398,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_stone/tile_stone_thumb.webp', + previewColor: '#d1d0c9', preset: { maps: { albedoMap: '/material/flooring/tile_stone/tile_stone_basecolor_512.ktx2', @@ -1402,6 +1437,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_terracotta/tile_terracotta_thumb.webp', + previewColor: '#6e6b65', preset: { maps: { albedoMap: '/material/flooring/tile_terracotta/tile_terracotta_basecolor_512.ktx2', @@ -1441,6 +1477,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ description: 'Green quartzite flooring finish', previewThumbnailUrl: '/material/flooring/green_glass_quartzite/green_glass_quartzite_thumb.webp', + previewColor: '#5d6957', preset: { maps: { albedoMap: @@ -1479,6 +1516,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Dark ceramic flooring finish', previewThumbnailUrl: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_thumb.webp', + previewColor: '#535351', preset: { maps: { albedoMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor_512.ktx2', @@ -1520,6 +1558,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Light ceramic flooring finish', previewThumbnailUrl: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_thumb.webp', + previewColor: '#9c9b95', preset: { maps: { albedoMap: @@ -1562,6 +1601,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'White marble flooring finish', previewThumbnailUrl: '/material/flooring/statuaretto/statuaretto_thumb.webp', + previewColor: '#bcbec2', preset: { maps: { albedoMap: '/material/flooring/statuaretto/statuaretto_basecolor_512.ktx2', @@ -1599,6 +1639,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_mosaic/tile_mosaic_thumb.webp', + previewColor: '#d1ccbe', preset: { maps: { albedoMap: '/material/flooring/tile_mosaic/tile_mosaic_basecolor_512.ktx2', @@ -1638,6 +1679,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_pattern/tile_pattern_thumb.webp', + previewColor: '#999287', preset: { maps: { albedoMap: '/material/flooring/tile_pattern/tile_pattern_basecolor_512.ktx2', @@ -1677,6 +1719,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Wood-look ceramic flooring finish', previewThumbnailUrl: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_thumb.webp', + previewColor: '#786250', preset: { maps: { albedoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_basecolor_512.ktx2', @@ -1714,6 +1757,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor'], description: 'Wood parquet flooring finish', previewThumbnailUrl: '/material/flooring/woodparquet/woodparquet_thumb.webp', + previewColor: '#bea27e', preset: { maps: { albedoMap: '/material/flooring/woodparquet/woodparquet_basecolor_512.ktx2', @@ -1753,6 +1797,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['roof'], description: 'Classic roof shingle finish', previewThumbnailUrl: '/material/roofing/roof_shingles_classic/roof_shingles_classic_thumb.webp', + previewColor: '#9e5b46', preset: { maps: { albedoMap: @@ -1795,6 +1840,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['roof'], description: 'Clay roof tile finish', previewThumbnailUrl: '/material/roofing/roof_tiles_clay/roof_tiles_clay_thumb.webp', + previewColor: '#765241', preset: { maps: { albedoMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor_512.ktx2', @@ -1834,6 +1880,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['roof'], description: 'Terracotta roof tile finish', previewThumbnailUrl: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_thumb.webp', + previewColor: '#494b4d', preset: { maps: { albedoMap: @@ -1876,6 +1923,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['roof'], description: 'Grey composition shingle finish', previewThumbnailUrl: '/material/roofing/roof_shingles_grey/roof_shingles_grey_thumb.webp', + previewColor: '#505050', preset: { maps: { albedoMap: '/material/roofing/roof_shingles_grey/roof_shingles_grey_basecolor_512.webp', @@ -1918,6 +1966,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ description: 'Weathered roof shingle finish', previewThumbnailUrl: '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_thumb.webp', + previewColor: '#a15e49', preset: { maps: { albedoMap: @@ -3470,6 +3519,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture', 'wall'], description: 'Natural linen weave', previewThumbnailUrl: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_thumb.webp', + previewColor: '#aaa49f', preset: { maps: { albedoMap: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_basecolor_512.ktx2', @@ -3506,6 +3556,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture', 'wall'], description: 'Plain cotton weave', previewThumbnailUrl: '/material/fabric/blue_cotton/blue_cotton_thumb.webp', + previewColor: '#528bc1', preset: { maps: { albedoMap: '/material/fabric/blue_cotton/blue_cotton_basecolor_512.ktx2', @@ -3544,6 +3595,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture'], description: 'Velvet with a soft sheen', previewThumbnailUrl: '/material/fabric/red_velvet/red_velvet_thumb.webp', + previewColor: '#781113', preset: { maps: { albedoMap: '/material/fabric/red_velvet/red_velvet_basecolor_512.ktx2', @@ -3582,6 +3634,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture'], description: 'Matte wool felt', previewThumbnailUrl: '/material/fabric/white_wool/white_wool_thumb.webp', + previewColor: '#d2d2d2', preset: { maps: { albedoMap: '/material/fabric/white_wool/white_wool_basecolor_512.ktx2', @@ -3620,6 +3673,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture'], description: 'Matte suede nap', previewThumbnailUrl: '/material/fabric/suede/suede_thumb.webp', + previewColor: '#c8be98', preset: { maps: { albedoMap: '/material/fabric/suede/suede_basecolor_512.ktx2', @@ -3658,6 +3712,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture'], description: 'Looped bouclé upholstery', previewThumbnailUrl: '/material/fabric/wool_boucle/wool_boucle_thumb.webp', + previewColor: '#776d60', preset: { maps: { albedoMap: '/material/fabric/wool_boucle/wool_boucle_basecolor_512.ktx2', @@ -3694,6 +3749,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture'], description: 'Smooth black leather', previewThumbnailUrl: '/material/leather/black_leather/black_leather_thumb.webp', + previewColor: '#2b2b2b', preset: { maps: { albedoMap: '/material/leather/black_leather/black_leather_basecolor_512.ktx2', @@ -3731,6 +3787,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture'], description: 'Pebbled calf leather', previewThumbnailUrl: '/material/leather/calf_leather/calf_leather_thumb.webp', + previewColor: '#78411a', preset: { maps: { albedoMap: '/material/leather/calf_leather/calf_leather_basecolor_512.ktx2', @@ -3769,6 +3826,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'ceiling'], description: 'Smooth painted plaster wall', previewThumbnailUrl: '/material/concrete/plaster_painted/plaster_painted_thumb.webp', + previewColor: '#c8c5bd', preset: { maps: { albedoMap: '/material/concrete/plaster_painted/plaster_painted_basecolor_512.ktx2', @@ -3807,6 +3865,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Polished concrete floor', previewThumbnailUrl: '/material/concrete/concrete_polished/concrete_polished_thumb.webp', + previewColor: '#6d6b69', preset: { maps: { albedoMap: '/material/concrete/concrete_polished/concrete_polished_basecolor_512.ktx2', @@ -3844,6 +3903,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'floor', 'outdoor'], description: 'Board-formed raw concrete', previewThumbnailUrl: '/material/concrete/concrete_raw/concrete_raw_thumb.webp', + previewColor: '#a5a5a5', preset: { maps: { albedoMap: '/material/concrete/concrete_raw/concrete_raw_basecolor_512.ktx2', @@ -3882,6 +3942,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'floor'], description: 'Smooth cast concrete plate', previewThumbnailUrl: '/material/concrete/concrete_plate/concrete_plate_thumb.webp', + previewColor: '#ababab', preset: { maps: { albedoMap: '/material/concrete/concrete_plate/concrete_plate_basecolor_512.ktx2', @@ -3920,6 +3981,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'outdoor'], description: 'Exterior stucco render', previewThumbnailUrl: '/material/concrete/white_stucco/white_stucco_thumb.webp', + previewColor: '#cccccc', preset: { maps: { albedoMap: '/material/concrete/white_stucco/white_stucco_basecolor_512.ktx2', @@ -3958,6 +4020,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['wall', 'ceiling'], description: 'Smooth prepared drywall ready for paint', previewThumbnailUrl: '/material/concrete/prepared_drywall/prepared_drywall_thumb.webp', + previewColor: '#b1b1b1', preset: { maps: { albedoMap: '/material/concrete/prepared_drywall/prepared_drywall_basecolor_512.ktx2', @@ -3996,6 +4059,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture', 'wall'], description: 'Warm copper', previewThumbnailUrl: '/material/metal/copper_metal/copper_metal_thumb.webp', + previewColor: '#cb845d', preset: { maps: { albedoMap: '/material/metal/copper_metal/copper_metal_basecolor_512.ktx2', @@ -4034,6 +4098,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['furniture', 'wall'], description: 'Polished stainless steel', previewThumbnailUrl: '/material/metal/polished_metal/polished_metal_thumb.webp', + previewColor: '#f2f4f3', preset: { maps: { albedoMap: '/material/metal/polished_metal/polished_metal_basecolor_512.ktx2', @@ -4074,6 +4139,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ description: 'Brushed stainless steel', previewThumbnailUrl: '/material/metal/stainless_steel_brushed/stainless_steel_brushed_thumb.webp', + previewColor: '#636363', preset: { maps: { albedoMap: From 8033010927c59f741a48bf6f2f13f9d49db173d0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 16 Oct 2026 22:05:09 -0400 Subject: [PATCH 06/26] core: author_scene is MCP-first, outside the shared contract list until the chat can run it Co-Authored-By: Claude Fable 5.1 --- .../core/src/agent-operations/__fixtures__/notation-cases.ts | 2 -- packages/core/src/agent-tools/index.ts | 2 -- wiki/architecture/agent-surfaces.md | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts index 971bdcb14e..224ba09a49 100644 --- a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts @@ -328,8 +328,6 @@ export const NOTATION_CASES: NotationCase[] = [ */ export const NOT_LENGTHS = new Set([ // Coordinates, in level metres. - // A light's relative brightness, 1 for an ordinary bulb. - 'author_scene.payload.levels[].lights[].intensity', 'add_column.x', 'add_column.y', 'add_column.z', diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index cb524f5c84..75d704a1e6 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,6 +1,5 @@ import { addObjectTool, getSourceTool } from './add-object' import { searchAssetsTool, viewAssetsTool } from './assets' -import { authorSceneTool } from './author-scene' import { runBatchTool } from './batch' import { setCheckpointTool } from './checkpoints' import { clearSceneTool } from './clear-scene' @@ -108,7 +107,6 @@ export const AGENT_TOOL_CONTRACTS = [ moveRoofsToRoofLevelTool, deleteNodeTool, addObjectTool, - authorSceneTool, getSourceTool, findByTypeTool, editCollectionTool, diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 5b6bd0e707..3edde02b55 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -136,7 +136,7 @@ A write that would leave a populated project empty is far more often an accident ## Converting a whole design: `author_scene` -A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`) takes a conversion payload, levels with walls and their openings, rooms by seed with a floor material, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall, zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, items through `addObject`, lights as light effects on their fixture (or the nearest item: Pascal keeps lights inside objects), paint through `paint`. Ids derive from the payload's hash, so the same payload writes the same scene. +A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`; registered on the MCP first and not yet in `AGENT_TOOL_CONTRACTS`, since the hosted chat's agent cannot run its own program until its sandbox can emit the payload: a reviewed `gap` in the parity test) takes a conversion payload, levels with walls and their openings, rooms by seed with a floor material, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall, zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, items through `addObject`, lights as light effects on their fixture (or the nearest item: Pascal keeps lights inside objects), paint through `paint`. Ids derive from the payload's hash, so the same payload writes the same scene. The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and the one correction Pascal makes itself is its join rule (a wall end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. From 75abaca420e30120c04ff66422df6947f13191d3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 16 Oct 2026 22:09:18 -0400 Subject: [PATCH 07/26] core: author_scene salts a replacement's ids so a payload written over itself is not wiped Co-Authored-By: Claude Fable 5.1 --- .../src/agent-operations/author-scene.test.ts | 3 +++ .../core/src/agent-operations/author-scene.ts | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 08bd34f31a..6af459bd52 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -217,5 +217,8 @@ describe('author_scene', () => { { activeLevelId: null }, ) expect(again.result.walls).toBe(4) + // Written over itself, the same payload gets fresh ids, so nothing is created and then deleted. + const firstIds = new Set(first.changes!.create!.map((c) => c.node.id)) + expect(again.changes!.create!.some((c) => firstIds.has(c.node.id))).toBe(false) }) }) diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 42c7a19872..ab8478ed7f 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -59,10 +59,15 @@ export type AuthorSceneReport = { const JOIN_REACH = 0.3 -/** Small, stable ids: the payload's own hash and a counter per kind, so a replay writes the same scene. */ -function idFactory(payload: AuthorScenePayload) { +/** + * Small, stable ids: the payload's own hash and a counter per kind, so a replay writes the same + * scene. A revision salt keeps a replacement's ids apart from the conversion it replaces: the + * same payload written over itself would otherwise create nodes that already exist and then + * delete them with the old levels. + */ +function idFactory(payload: AuthorScenePayload, revision = 0) { let hash = 0x811c9dc5 - for (const ch of JSON.stringify(payload)) + for (const ch of `${revision}:${JSON.stringify(payload)}`) hash = Math.imul(hash ^ ch.charCodeAt(0), 0x01000193) >>> 0 const base = hash.toString(16).padStart(8, '0') const counts = new Map() @@ -129,7 +134,10 @@ export function authorScene( 'This scene already holds a conversion. Pass replace: true to write over it.', ) - const mint = idFactory(payload) + // The first revision whose ids are free in this scene. + let revision = 0 + while (revision < 32 && scene[idFactory(payload, revision)('probe')]) revision++ + const mint = idFactory(payload, revision) const report: AuthorSceneReport = { levels: 0, walls: 0, From a430fa87dd811214b5d9be7aa7293333165f913a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 16 Oct 2026 22:26:47 -0400 Subject: [PATCH 08/26] =?UTF-8?q?core:=20author=5Fscene=20after=20the=20ac?= =?UTF-8?q?ceptance=20runs=20=E2=80=94=20lights=20on=20the=20asset,=20an?= =?UTF-8?q?=20id=20map,=20square=20columns,=20slab=20finishes=20as=20slots?= =?UTF-8?q?,=20rugs=20allowed,=20shared=20enclosures=20reported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../core/src/agent-operations/add-object.ts | 5 +++ .../src/agent-operations/author-scene.test.ts | 6 +++- .../core/src/agent-operations/author-scene.ts | 32 ++++++++++++++++--- packages/core/src/agent-tools/author-scene.ts | 2 +- packages/mcp/src/tools/author-scene.test.ts | 3 ++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index adebb221b9..a952b668de 100644 --- a/packages/core/src/agent-operations/add-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -201,10 +201,15 @@ function refuseWallOrSlabShape( { label }, ) const base = input.position?.[1] ?? 0 + // A rug, carpet or mat is a thin wide box on the floor by nature, not a floor plate. + const covering = /\b(rug|carpet|mat|runner)\b/i.test( + `${input.name ?? ''} ${input.category ?? ''}`, + ) if ( height <= 0.35 && short >= 2 && base <= 0.05 && + !covering && shareOverFloor(nodes, levelId, compiled, input) < FLOOR_SHARE ) { // Its extent along x and z as it stands, so the numbers match the outline the agent drew. diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 6af459bd52..0171142ea7 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -161,7 +161,11 @@ describe('author_scene', () => { } const bedNode = byType('item').find((n) => (n as { name?: string }).name === 'Bed')! expect(lamp.parentId).toBe(bedNode.id) - expect(lamp.interactive?.effects).toHaveLength(1) + expect( + (lamp as unknown as { asset: { interactive?: { effects: unknown[] } } }).asset.interactive + ?.effects, + ).toHaveLength(1) + expect(r.ids.bed).toBe(bedNode.id) const painted = byType('wall').find( (w) => (w as { metadata: { authorScene: { id: string } } }).metadata.authorScene.id === 'north', diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index ab8478ed7f..63f3bb3d63 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -55,6 +55,8 @@ export type AuthorSceneReport = { snapped: { id: string; end: 'start' | 'end'; movedM: number }[] defects: AuthorSceneDefect[] notes: string[] + /** The payload's own ids (walls, `wall/i` openings, `room/level/i`, items) to the nodes written. */ + ids: Record } const JOIN_REACH = 0.3 @@ -152,6 +154,7 @@ export function authorScene( snapped: [], defects: [], notes: [...payload.notes], + ids: {}, } const defect = (id: string, code: string, message: string) => report.defects.push({ id, code, message }) @@ -249,6 +252,7 @@ export function authorScene( }) place(node) wallNodes.set(w.spec.id, node) + report.ids[w.spec.id] = node.id report.walls++ // ---------- its openings ---------- const length = dist(w.start, w.end) @@ -294,6 +298,7 @@ export function authorScene( position: [x, o.sill + o.height / 2, 0], }) draft[opening.id] = opening + report.ids[id] = opening.id node.children.push(opening.id as never) if (o.kind === 'door') report.doors++ else report.windows++ @@ -313,6 +318,7 @@ export function authorScene( polygon: s.polygon, elevation: s.top, thickness: s.thickness, + ...(finishRef(s.finish) ? { slots: { surface: finishRef(s.finish) } } : {}), ...(s.railing?.length ? { railing: s.railing.map((r) => ({ start: r.start, end: r.end })) } : {}), @@ -330,7 +336,7 @@ export function authorScene( metadata: { authorScene: { color: c.color ?? null } }, ...(c.name ? { name: c.name } : {}), position: [c.position[0], 0, c.position[1]], - shape: 'square', + crossSection: 'square', width: c.width, depth: c.depth, height: c.height, @@ -387,6 +393,7 @@ export function authorScene( const parent = draft[parentId ?? levelNode.id] as AnyNode & { children?: string[] } parent.children = [...(parent.children ?? []), node.id] itemNodeIds.set(item.id, node.id) + report.ids[item.id] = node.id itemPositions.set(item.id, [item.position[0]!, item.position[1]!, item.position[2]!]) report.items++ } @@ -423,11 +430,16 @@ export function authorScene( ) } const hostNode = draft[itemNodeIds.get(hostKey)!] as AnyNode & { - interactive?: { controls?: unknown[]; effects?: unknown[] } + asset: { interactive?: { controls: unknown[]; effects: unknown[] } } } const hostPos = itemPositions.get(hostKey)! + // Where add_object keeps an object's lights: the asset's interactive block, with a toggle. + const interactive = hostNode.asset.interactive ?? { controls: [], effects: [] } + const controls = interactive.controls.some((c) => (c as { kind?: string }).kind === 'toggle') + ? interactive.controls + : [...interactive.controls, { kind: 'toggle', label: 'Lights', default: true }] const effects = [ - ...(hostNode.interactive?.effects ?? []), + ...interactive.effects, { kind: 'light', color: light.color, @@ -440,7 +452,7 @@ export function authorScene( }, ] draft[hostNode.id] = Object.assign({}, hostNode, { - interactive: { controls: hostNode.interactive?.controls ?? [], effects }, + asset: { ...hostNode.asset, interactive: { controls, effects } }, }) as unknown as AnyNode report.lights++ } @@ -473,12 +485,22 @@ export function authorScene( draft[zone.parentId!] = { ...level, children: [...level.children, zone.id] } as AnyNode report.rooms++ } + const claimed = new Map() payload.levels.forEach((level, index) => { const levelNode = levelNodes[index]! for (const [i, room] of level.rooms.entries()) { const zone = zones.find( (z) => z.parentId === levelNode.id && pointInPolygon(room.seed, z.polygon), ) + // Two seeds in one enclosure: the walls do not separate the rooms the payload names. + if (zone && claimed.has(zone.id)) { + defect( + `room/${index}/${i}`, + 'room_shares_enclosure', + `"${room.name}" and "${claimed.get(zone.id)}" are one enclosure: no wall separates them.`, + ) + continue + } if (!zone) { defect( `room/${index}/${i}`, @@ -508,6 +530,8 @@ export function authorScene( : {}), metadata: { ...zone.metadata, authorScene: { seed: room.seed } }, }) + claimed.set(zone.id, room.name) + report.ids[`room/${index}/${i}`] = zone.id report.roomsNamed++ } }) diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index 0f93df0cd6..4a102dc67e 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -163,7 +163,7 @@ export const authorSceneLevel = z.object({ export const authorScenePayload = z.object({ levels: z.array(authorSceneLevel).min(1), notes: z - .array(z.string().max(400)) + .array(z.string().max(1200)) .default([]) .describe( 'What you could not convert, or converted with a change, one sentence each. Kept with the scene.', diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts index 6aab5dece6..877082de41 100644 --- a/packages/mcp/src/tools/author-scene.test.ts +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -132,6 +132,9 @@ describe('author_scene (MCP)', () => { // Two artifacts per item (GLB + code), plus the module and the adapter. expect(stored.filter((s) => s.startsWith('model/gltf-binary'))).toHaveLength(2) expect(stored.filter((s) => s.startsWith('text/javascript'))).toHaveLength(4) + // The light rides on its fixture through the bridge, not only in the operation's draft. + const lamp = await client.callTool({ name: 'find_by_type', arguments: { type: 'item' } }) + expect(JSON.stringify(lamp.structuredContent ?? lamp.content)).toContain('Lamp') const walls = await client.callTool({ name: 'get_walls', arguments: {} }) expect(JSON.stringify(walls.structuredContent)).toContain('"height":2.7') }) From 0123c8e2cde397e63ae37f037c7c21cc9d0a1cab Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 08:09:18 -0400 Subject: [PATCH 09/26] core: author_scene takes opening types, styles and modules, exact colours per wall face, walls as hosts, and reports defaults and warnings Four fresh agents converted real designs with author_scene and the payload could not say what they knew: every window came out fixed, every door plain and hinged with its handle on the hinge edge, sage became mid grey through the nearest library colour, one colour painted both faces, baseboards and casings had no wall to follow, and 43 twelve-millimetre "wall cap" slabs lifted the walls onto them with nothing in the report to say so. An opening now takes `type` (DoorType / WindowType), `style` (the door and window style presets, applied as add_door/add_window apply them; a style on an operable window is dropped and said), `leaves` and `code` (a module compiled and stored as an item's, keyed `/`, written as the scripted door or window). A door's handle is set opposite its hinge. A hex colour is one exact scene material per distinct colour (roughness 0.9, metalness 0, named by the hex) reused across walls, floors, regions and slabs; `colorLeft` / `colorRight` paint one face (left = front = role a). `items[].host` accepts a wall id: the item is written as a wall-side item on the face nearest its point, facing the room, as place_items does. Hosted things and lights are written in their host's frame, so a rotated or wall-hung host keeps them where they were. The report gains `defaults` (one sentence per thing Pascal filled in or dropped), `warnings` (`thin_slab`, `many_slabs`, `many_regions`: written, but doubtful) and the defect `slab_over_wall`, which refuses a slab over a wall's footprint with its top above half the wall's height. Co-Authored-By: Claude Fable 5.1 --- .../src/agent-operations/author-scene.test.ts | 366 +++++++++++- .../core/src/agent-operations/author-scene.ts | 528 ++++++++++++++---- packages/core/src/agent-tools/author-scene.ts | 65 ++- 3 files changed, 852 insertions(+), 107 deletions(-) diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 0171142ea7..2d10a6bc99 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' import type { AuthorScenePayload } from '../agent-tools/author-scene' import { authorScenePayload } from '../agent-tools/author-scene' -import type { AnyNode, CompiledGeometryScript } from '../schema' +import type { AnyNode, CompiledGeometryScript, SceneMaterial } from '../schema' import { authorScene } from './author-scene' /** A site, a building and the empty level a new project starts with. */ @@ -67,6 +67,43 @@ const bed: CompiledGeometryScript = { } as unknown as CompiledGeometryScript['manifest'], } +/** A host's compile of a 1 × 2.1 m door module, mount 'wall'. */ +const arch: CompiledGeometryScript = { + ...bed, + mount: 'wall', + manifest: { + ...bed.manifest, + bounds: { min: [-0.5, 0, -0.1], max: [0.5, 2.1, 0.1] }, + slots: [{ id: 'wood', color: '#8a6a4a' }], + cutout: true, + } as unknown as CompiledGeometryScript['manifest'], +} + +/** A host's compile of a baseboard, 2 m long, 12 cm high, 1.5 cm deep. */ +const baseboard: CompiledGeometryScript = { + ...bed, + manifest: { + ...bed.manifest, + bounds: { min: [-1, 0, -0.0075], max: [1, 0.12, 0.0075] }, + } as unknown as CompiledGeometryScript['manifest'], +} + +const run = ( + payload: AuthorScenePayload, + compiled: Record, + materials?: Record, +) => + authorScene( + scaffold(), + { payload, compiled }, + { activeLevelId: 'level_0', ...(materials ? { materials } : {}) }, + ) + +const nodesOf = (outcome: ReturnType) => + outcome.changes!.create!.map((c) => c.node) +const byAuthorId = (outcome: ReturnType, id: string) => + nodesOf(outcome).find((n) => n.id === outcome.result.ids[id]) as AnyNode & Record + const bedroom: AuthorScenePayload = authorScenePayload.parse({ levels: [ { @@ -225,4 +262,331 @@ describe('author_scene', () => { const firstIds = new Set(first.changes!.create!.map((c) => c.node.id)) expect(again.changes!.create!.some((c) => firstIds.has(c.node.id))).toBe(false) }) + + test('an opening takes its type, style, leaves and a handle opposite the hinge', () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + walls: bedroom.levels[0]!.walls!.map((w) => + w.id === 'south' + ? { + ...w, + openings: [ + { + kind: 'door', + along: 1.2, + width: 1.5, + height: 2.1, + type: 'french', + style: 'modern', + hinge: 'end', + }, + { kind: 'door', along: 3, width: 0.9, height: 2.1, leaves: 2 }, + ], + } + : w.id === 'east' + ? { + ...w, + openings: [ + { + kind: 'window', + along: 1.2, + width: 1, + height: 1.2, + sill: 0.9, + type: 'casement', + style: 'grid', + }, + { + kind: 'window', + along: 3.5, + width: 1, + height: 1.2, + sill: 0.9, + style: 'grid', + }, + ], + } + : w, + ), + }, + ], + }) + const outcome = run(payload, {}) + const r = outcome.result + expect(r.defects).toEqual([]) + const french = byAuthorId(outcome, 'south/0') + expect(french.doorType).toBe('french') + expect(french.leafCount).toBe(2) + expect(french.hingesSide).toBe('right') + expect(french.handleSide).toBe('left') + // The style's leaf over the type's: a modern French door is one flush slab. + expect(french.segments).toHaveLength(1) + expect(byAuthorId(outcome, 'south/1').leafCount).toBe(2) + expect(byAuthorId(outcome, 'south/1').handleSide).toBe('right') + const casement = byAuthorId(outcome, 'east/0') + expect(casement.windowType).toBe('casement') + expect(casement.columnRatios).toEqual([1]) + const grid = byAuthorId(outcome, 'east/1') + expect(grid.windowType).toBe('fixed') + expect(grid.columnRatios).toEqual([0.5, 0.5]) + expect(r.defaults).toContain('door south/0: handle set opposite the hinge (left)') + expect(r.defaults).toContain( + 'window east/0: style grid dropped, a casement window draws its own sashes', + ) + expect(r.defaults).toContain('1 window has no type: written fixed (cannot open); give type') + }) + + test('an opening with code is the scripted door or window add_door writes, by its own id', () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + walls: bedroom.levels[0]!.walls!.map((w) => + w.id === 'south' + ? { + ...w, + openings: [ + { kind: 'door', along: 1, width: 0.9, height: 2.1, code: '// arch' }, + { kind: 'window', along: 3, width: 0.9, height: 1, code: '// round' }, + ], + } + : w, + ), + }, + ], + }) + const outcome = authorScene( + scaffold(), + { payload, compiled: { 'south/0': arch }, compileErrors: { 'south/1': 'Syntax error' } }, + { activeLevelId: 'level_0' }, + ) + const door = byAuthorId(outcome, 'south/0') + expect(door.source.kind).toBe('script') + expect(door.source.artifact).toBe(arch.sha256) + expect(door.slots.wood).toStartWith('library:wood-') + expect([door.width, door.height]).toEqual([1, 2.1]) + expect(outcome.result.defects).toEqual([ + { id: 'south/1', code: 'compile_failed', message: 'Syntax error' }, + ]) + expect(outcome.result.windows).toBe(1) + }) + + test('a hex colour is one exact scene material per colour, a wall face each its own', () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + walls: bedroom.levels[0]!.walls!.map((w) => + w.id === 'north' + ? { ...w, color: '#8f9e8a' } + : w.id === 'east' + ? { ...w, colorLeft: '#8f9e8a', colorRight: 'library:preset-white' } + : w, + ), + rooms: [ + { + name: 'Bedroom', + seed: [0, 0], + floor: '#8f9e8a', + floorRegions: [ + { + polygon: [ + [-1, -1], + [1, -1], + [1, 1], + ], + finish: '#223344', + }, + ], + }, + ], + slabs: [ + { + name: 'Terrace', + polygon: [ + [3, -2], + [6, -2], + [6, 2], + [3, 2], + ], + finish: '#8f9e8a', + }, + ], + }, + ], + }) + const outcome = run(payload, {}, {}) + const materials = outcome.changes!.materials! + expect(materials.map((m) => m.name).sort()).toEqual(['#223344', '#8f9e8a']) + const sage = `scene:${materials.find((m) => m.name === '#8f9e8a')!.id}` + expect(materials[0]!.material.properties).toMatchObject({ roughness: 0.9, metalness: 0 }) + const north = byAuthorId(outcome, 'north') + expect(north.slots.a).toBe(sage) + expect(north.slots.b).toBe(sage) + const east = byAuthorId(outcome, 'east') + expect(east.slots.a).toBe(sage) + expect(east.slots.b).toBe('library:preset-white') + expect(byAuthorId(outcome, 'room/0/0').floor.finish).toBe(sage) + expect(byAuthorId(outcome, 'room/0/0').floor.regions[0].finish).toBe( + `scene:${materials.find((m) => m.name === '#223344')!.id}`, + ) + expect(byAuthorId(outcome, 'slab/0/0').slots.surface).toBe(sage) + expect(outcome.result.defects).toEqual([]) + // A host that keeps no scene materials gets the nearest library colour, and is told. + const plain = run(payload, {}) + expect(plain.changes!.materials).toBeUndefined() + expect(byAuthorId(plain, 'north').slots.a).toStartWith('library:') + expect(plain.result.defaults).toContain( + 'this host keeps no scene materials: colours written as the nearest library colour', + ) + }) + + test('an item hosted by a wall is fixed to the face nearest its point and faces the room', () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + lights: [], + items: [ + { + id: 'skirting', + name: 'Baseboard', + category: 'trim', + code: '// baseboard', + position: [0, 0, -2.4], + host: 'north', + }, + ], + }, + ], + }) + const outcome = run(payload, { skirting: baseboard }) + expect(outcome.result.defects).toEqual([]) + const trim = byAuthorId(outcome, 'skirting') + const wall = byAuthorId(outcome, 'north') + expect(trim.parentId).toBe(wall.id) + expect(trim.wallId).toBe(wall.id) + // The north wall runs +x at z = -2.5; a point at z = -2.4 is on its left, its front (+z face). + expect(trim.side).toBe('front') + expect(trim.position[0]).toBeCloseTo(2, 6) + expect(trim.position[1]).toBe(0) + expect(trim.position[2]).toBeCloseTo(0.06, 6) + expect(trim.wallT).toBeCloseTo(0.5, 6) + expect(trim.rotation[1]).toBe(0) + expect(trim.asset.attachTo).toBe('wall-side') + expect(wall.children).toContain(trim.id) + }) + + test('warns about thin and many slabs and many regions, refuses a slab that would cap a wall', () => { + const slab = (top: number, thickness: number, polygon: number[][], name: string) => ({ + name, + top, + thickness, + polygon, + }) + const across = [ + [-1, -2.7], + [1, -2.7], + [1, -2.3], + [-1, -2.3], + ] + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + rooms: [ + { + name: 'Bedroom', + seed: [0, 0], + floorRegions: Array.from({ length: 25 }, (_, i) => ({ + polygon: [ + [-1 + i * 0.05, -1], + [-0.95 + i * 0.05, -1], + [-0.95 + i * 0.05, 1], + ], + finish: '#ffffff', + })), + }, + ], + slabs: [ + // A cap on the north wall: refused. + slab(2.662, 0.012, across, 'Wall cap'), + // A terrace under the same wall's footprint at floor level: a slab. + slab(0, 0.15, across, 'Terrace'), + // A platform flush against the wall's inside face: not over it. + slab( + 2.0, + 0.2, + [ + [-1, -2.44], + [1, -2.44], + [1, -1], + [-1, -1], + ], + 'Shelf', + ), + slab( + 0.3, + 0.01, + [ + [3, 3], + [4, 3], + [4, 4], + ], + 'Film', + ), + ...Array.from({ length: 18 }, (_, i) => + slab( + 0, + 0.15, + [ + [5 + i, 5], + [5.5 + i, 5], + [5.5 + i, 5.5], + ], + `Step ${i}`, + ), + ), + ], + }, + ], + }) + const outcome = run(payload, {}) + const r = outcome.result + expect(r.defects).toEqual([ + { + id: 'slab/0/0', + code: 'slab_over_wall', + message: expect.stringContaining('"Wall cap" overlaps wall north with its top at 2.66 m'), + }, + ]) + expect(r.ids['slab/0/0']).toBeUndefined() + expect(r.ids['slab/0/1']).toBeDefined() + expect(r.ids['slab/0/2']).toBeDefined() + expect(r.slabs).toBe(21) + expect(r.warnings).toEqual([ + { id: 'level/0', code: 'many_slabs', message: expect.stringContaining('22 slabs') }, + { id: 'slab/0/3', code: 'thin_slab', message: expect.stringContaining('10 mm') }, + { + id: 'room/0/0', + code: 'many_regions', + message: expect.stringContaining('25 floor regions'), + }, + ]) + }) }) diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 63f3bb3d63..6eaa5c3a5b 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -1,5 +1,21 @@ import type { AuthorScenePayload } from '../agent-tools/author-scene' import { refuse } from '../agent-tools/refusal' +import { doorTypeFields } from '../building/door-types' +import { + DOOR_STYLES, + type DoorStyle, + doorStyleLook, + getWindowStyleOverrides, + WINDOW_STYLES, + type WindowStyle, +} from '../building/opening-style-presets' +import { windowTakesStyle, windowTypeFields } from '../building/window-types' +import { + matchScriptSlotsToLibrary, + requireMeasured, + scriptedSize, + scriptSource, +} from '../lib/geometry-script-node' import { reconcileSceneStructure } from '../lib/structure-reconcile' import { nearestLibraryColorRef } from '../procedural-items/library-colors' import { @@ -8,32 +24,41 @@ import { type CompiledGeometryScript, DoorNode, LevelNode, + type SceneMaterial, + type SceneMaterialId, SlabNode, WallNode, WindowNode, ZoneNode, } from '../schema' +import { DoorType, WindowType } from '../schema/nodes/opening-types' +import { getWallLocalFaceZ } from '../systems/wall/wall-frame' import { migrateRoomZones } from '../utils/room-zone-migration' import { addObject } from './add-object' import { paint } from './paint' +import { pointInPolygon, projectWorldPointToWallLocalX, type Vec2 } from './plan-geometry' +import { resolveInlinePaintMaterial } from './scene-materials' import type { AgentContext, AgentOperationOutcome, NodeWrite, SceneNodes } from './types' /** * `author_scene`: a whole design, read by its author from the three.js module it wrote, handed * over as a payload and written as native Pascal nodes in one step. Not in AGENT_OPERATIONS: the - * host compiles every item's module first (the MCP on its server, the chat in its worker) and - * passes the results as `compiled`, keyed by the payload's item ids; an item whose compile failed - * is absent there and comes back in the report with the host's reason. + * host compiles every module first (the MCP on its server, the chat in its worker) and passes the + * results as `compiled`, keyed by the payload's item ids and, for an opening with code, by + * `/`; a module whose compile failed is absent there and comes back in the report + * with the host's reason. * * The policy, decided with the owner: accept and report. Everything valid is written; every - * defect comes back by the payload's own id with a reason; the one correction Pascal makes - * itself is its join rule (a wall end within reach of another wall's centerline snaps onto it), - * reported per wall. Ids are derived from the payload, so the same payload writes the same scene. + * defect comes back by the payload's own id with a reason; what Pascal fills in or drops is a + * sentence in `defaults`; what it writes but doubts is a `warning`. The one correction Pascal + * makes itself is its join rule (a wall end within reach of another wall's centerline snaps onto + * it), reported per wall. Ids are derived from the payload, so the same payload writes the same + * scene. */ export type AuthorSceneInput = { payload: AuthorScenePayload compiled: Record - /** Why an item's module did not compile, by the payload's item id, for the report. */ + /** Why a module did not compile, by the payload's item id or `/`, for the report. */ compileErrors?: Record replace?: boolean } @@ -53,6 +78,10 @@ export type AuthorSceneReport = { lights: number /** The join rule applied: wall ends moved onto a centerline, with how far. */ snapped: { id: string; end: 'start' | 'end'; movedM: number }[] + /** What Pascal filled in or dropped, one sentence per fact. */ + defaults: string[] + /** Written, but doubtful: by the payload's id, with a code. */ + warnings: AuthorSceneDefect[] defects: AuthorSceneDefect[] notes: string[] /** The payload's own ids (walls, `wall/i` openings, `room/level/i`, items) to the nodes written. */ @@ -60,6 +89,12 @@ export type AuthorSceneReport = { } const JOIN_REACH = 0.3 +const THIN_SLAB = 0.02 +const MANY_SLABS = 20 +const MANY_REGIONS = 24 +const HEX = /^#[0-9a-f]{6}$/i + +type Vec3 = [number, number, number] /** * Small, stable ids: the payload's own hash and a counter per kind, so a replay writes the same @@ -83,7 +118,8 @@ function idFactory(payload: AuthorScenePayload, revision = 0) { const dist = (a: readonly number[], b: readonly number[]) => Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!) -/** The point on segment ab nearest to p, and how far p is from it. */ +const metres = (value: number) => `${value.toFixed(2)} m` + /** * Where a wall end meets another wall's centerline: the projection onto that line, as long as it * lands on the wall or within reach past its ends. The line, not the segment, so a partition @@ -98,26 +134,113 @@ function nearestOnSegment(p: readonly number[], a: readonly number[], b: readonl return { point: q, distance: dist(p, q) } } -function finishRef(value: string | undefined): string | undefined { - if (!value) return undefined - if (value.startsWith('library:') || value.startsWith('scene:')) return value - return nearestLibraryColorRef(value)?.ref +const cross = (o: Vec2, a: Vec2, b: Vec2) => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + +/** Whether two segments cross each other's interior (touching ends and shared edges do not). */ +function segmentsCross(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean { + const [d1, d2, d3, d4] = [cross(c, d, a), cross(c, d, b), cross(a, b, c), cross(a, b, d)] + return d1 * d2 < 0 && d3 * d4 < 0 } -function pointInPolygon(point: readonly number[], polygon: readonly (readonly number[])[]) { - let inside = false - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const [xi, zi] = [polygon[i]![0]!, polygon[i]![1]!] - const [xj, zj] = [polygon[j]![0]!, polygon[j]![1]!] - if ( - zi > point[1]! !== zj > point[1]! && - point[0]! < ((xj - xi) * (point[1]! - zi)) / (zj - zi) + xi - ) - inside = !inside +/** Whether two polygons share interior area: a slab flush against a wall's face does not. */ +function polygonsOverlap(a: Vec2[], b: Vec2[]): boolean { + if (a.some((p) => pointInPolygon(p, b, false)) || b.some((p) => pointInPolygon(p, a, false))) + return true + for (let i = 0; i < a.length; i++) + for (let j = 0; j < b.length; j++) + if (segmentsCross(a[i]!, a[(i + 1) % a.length]!, b[j]!, b[(j + 1) % b.length]!)) return true + return false +} + +/** A wall's footprint, pulled in by a few millimetres so a slab meeting its face is not on it. */ +function wallFootprint(wall: { start: Vec2; end: Vec2; thickness: number }): Vec2[] { + const inset = 0.005 + const [dx, dz] = [wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]] + const len = Math.hypot(dx, dz) || 1 + const [ux, uz] = [dx / len, dz / len] + const half = Math.max(wall.thickness / 2 - inset, 0.001) + const s: Vec2 = [wall.start[0] + ux * inset, wall.start[1] + uz * inset] + const e: Vec2 = [wall.end[0] - ux * inset, wall.end[1] - uz * inset] + return [ + [s[0] - uz * half, s[1] + ux * half], + [e[0] - uz * half, e[1] + ux * half], + [e[0] + uz * half, e[1] - ux * half], + [s[0] + uz * half, s[1] - ux * half], + ] +} + +/** + * Where an item stands and which way it faces, so what rests on it (another item, a light) is + * written in its frame: Pascal keeps a hosted thing's position relative to its host. + */ +type Frame = { position: Vec3; yaw: number } + +function toLocal(frame: Frame, world: Vec3): Vec3 { + const [dx, dy, dz] = [ + world[0] - frame.position[0], + world[1] - frame.position[1], + world[2] - frame.position[2], + ] + const [c, s] = [Math.cos(frame.yaw), Math.sin(frame.yaw)] + return [c * dx - s * dz, dy, s * dx + c * dz] +} + +/** + * The finish a payload value writes. A library or scene ref passes through; a hex colour is kept + * as an exact native material, one scene material per distinct colour across the payload, named + * by the colour. A host that keeps no scene materials gets the nearest library colour, said once. + */ +function finishPool(context: AgentContext, say: (sentence: string) => void) { + const materials: Record | undefined = context.materials + ? { ...context.materials } + : undefined + const created: SceneMaterial[] = [] + let snapped = false + return { + materials, + created, + ref(value: string): { ref: string } | { error: string } { + if (value.startsWith('library:') || value.startsWith('scene:')) return { ref: value } + if (!HEX.test(value)) return { error: `${value} is no colour: give #rrggbb or library:.` } + const hex = value.toLowerCase() + if (!materials) { + if (!snapped) + say('this host keeps no scene materials: colours written as the nearest library colour') + snapped = true + return { ref: nearestLibraryColorRef(hex)!.ref } + } + const resolved = resolveInlinePaintMaterial( + materials, + { + properties: { + color: hex, + roughness: 0.9, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + }, + hex, + ) + if (resolved.created) { + materials[resolved.sceneMaterial.id as SceneMaterialId] = resolved.sceneMaterial + created.push(resolved.sceneMaterial) + } + return { ref: resolved.ref } + }, } - return inside } +const isDoorType = (type: string): type is DoorType => DoorType.options.includes(type as DoorType) +const isWindowType = (type: string): type is WindowType => + WindowType.options.includes(type as WindowType) +const isDoorStyle = (style: string): style is DoorStyle => + (DOOR_STYLES as readonly string[]).includes(style) +const isWindowStyle = (style: string): style is WindowStyle => + (WINDOW_STYLES as readonly string[]).includes(style) + export function authorScene( scene: SceneNodes, input: AuthorSceneInput, @@ -152,12 +275,32 @@ export function authorScene( items: 0, lights: 0, snapped: [], + defaults: [], + warnings: [], defects: [], notes: [...payload.notes], ids: {}, } const defect = (id: string, code: string, message: string) => report.defects.push({ id, code, message }) + const warn = (id: string, code: string, message: string) => + report.warnings.push({ id, code, message }) + const say = (sentence: string) => report.defaults.push(sentence) + const finishes = finishPool(context, say) + const finishRef = (value: string | undefined, id: string): string | undefined => { + if (!value) return undefined + const got = finishes.ref(value) + if ('error' in got) { + defect(id, 'invalid_color', got.error) + return undefined + } + return got.ref + } + const paintContext = (levelId: string): AgentContext => ({ + ...context, + activeLevelId: levelId, + ...(finishes.materials ? { materials: finishes.materials } : {}), + }) // The draft: the scene without its levels, then everything the payload describes. const draft: Record = {} @@ -183,7 +326,8 @@ export function authorScene( const levelNodes: ReturnType[] = [] const itemNodeIds = new Map() - const itemPositions = new Map() + const itemFrames = new Map() + let untypedWindows = 0 payload.levels.forEach((level, index) => { const levelNode = LevelNode.parse({ @@ -229,7 +373,7 @@ export function authorScene( } } } - const wallNodes = new Map>() + const wallNodes = new Map() for (const w of walls) { if (dist(w.start, w.end) < 0.05) { defect(w.spec.id, 'wall_too_short', 'The wall is shorter than 5 cm after joining.') @@ -251,25 +395,52 @@ export function authorScene( backSide: 'exterior', }) place(node) - wallNodes.set(w.spec.id, node) + wallNodes.set(w.spec.id, { ...w, node }) report.ids[w.spec.id] = node.id report.walls++ // ---------- its openings ---------- const length = dist(w.start, w.end) ;(w.spec.openings ?? []).forEach((o, i) => { const id = `${w.spec.id}/${i}` - if (o.width > length + 0.01) { + // A module builds the opening: its bounds are its size, its type and style do not apply. + let compiled: CompiledGeometryScript | undefined + if (o.code) { + compiled = input.compiled[id] + if (!compiled) { + defect(id, 'compile_failed', input.compileErrors?.[id] ?? 'The module did not compile.') + return + } + if (compiled.mount !== 'wall') { + defect( + id, + 'wrong_mount', + `A ${o.kind}'s module uses mount 'wall', not '${compiled.mount}'.`, + ) + return + } + try { + requireMeasured(compiled, { label: o.name ?? id, tool: 'author_scene' }) + } catch (error) { + const e = error as { code?: string; message?: string } + defect(id, e.code ?? 'unmeasurable_object', e.message ?? String(error)) + return + } + if (o.type || o.style) + say(`${o.kind} ${id}: type and style ignored, its module builds it`) + } + const [width, height] = compiled ? scriptedSize(compiled.manifest) : [o.width, o.height] + if (width > length + 0.01) { defect( id, 'opening_wider_than_wall', - `${o.kind} ${o.width} m wide on a ${length.toFixed(2)} m wall.`, + `${o.kind} ${width.toFixed(2)} m wide on a ${length.toFixed(2)} m wall.`, ) return } - const x = Math.min(Math.max(o.along, o.width / 2), length - o.width / 2) + const x = Math.min(Math.max(o.along, width / 2), length - width / 2) if (Math.abs(x - o.along) > 0.01) - report.notes.push( - `${id}: ${o.kind} moved ${Math.abs(x - o.along).toFixed(2)} m along the wall to fit inside it.`, + say( + `${o.kind} ${id}: moved ${Math.abs(x - o.along).toFixed(2)} m along the wall to fit inside it`, ) const common = { object: 'node', @@ -278,35 +449,109 @@ export function authorScene( visible: true, metadata: { authorScene: { id, color: o.color ?? null } }, wallId: node.id, - width: o.width, - height: o.height, + width, + height, rotation: [0, 0, 0], ...(o.name ? { name: o.name } : {}), + ...(compiled + ? { + source: scriptSource(compiled, { category: o.kind }), + slots: matchScriptSlotsToLibrary(compiled.manifest), + } + : {}), + } + let opening: AnyNode + if (o.kind === 'door') { + const type = o.type && !compiled ? o.type : undefined + if (type && !isDoorType(type)) + defect(id, 'type_mismatch', `${type} is a window type; the door is written hinged.`) + const style = o.style && !compiled ? o.style : undefined + if (style && !isDoorStyle(style)) + defect( + id, + 'style_mismatch', + `${style} is a window style; the door keeps its panel leaf.`, + ) + const hingesSide = o.hinge === 'end' ? 'right' : 'left' + const handleSide = hingesSide === 'right' ? 'left' : 'right' + if (o.hinge) say(`door ${id}: handle set opposite the hinge (${handleSide})`) + opening = DoorNode.parse({ + ...common, + type: 'door', + position: [x, height / 2, 0], + ...(type && isDoorType(type) ? doorTypeFields(type) : {}), + ...(style && isDoorStyle(style) ? doorStyleLook(style) : {}), + ...(o.leaves ? { leafCount: o.leaves } : {}), + hingesSide, + handleSide, + ...(o.swing ? { swingDirection: o.swing === 'back' ? 'outward' : 'inward' } : {}), + }) + report.doors++ + } else { + const type = o.type && !compiled ? o.type : undefined + if (type && !isWindowType(type)) + defect(id, 'type_mismatch', `${type} is a door type; the window is written fixed.`) + const windowType = type && isWindowType(type) ? type : undefined + if (!windowType && !compiled) untypedWindows++ + const style = o.style && !compiled ? o.style : undefined + if (style && !isWindowStyle(style)) + defect( + id, + 'style_mismatch', + `${style} is a door style; the window keeps a single pane.`, + ) + const takesStyle = windowTakesStyle(windowType ?? 'fixed') + if (style && isWindowStyle(style) && !takesStyle) + say(`window ${id}: style ${style} dropped, a ${windowType} window draws its own sashes`) + opening = WindowNode.parse({ + ...common, + type: 'window', + position: [x, o.sill + height / 2, 0], + ...(windowType ? windowTypeFields(windowType) : {}), + ...(style && isWindowStyle(style) && takesStyle ? getWindowStyleOverrides(style) : {}), + }) + report.windows++ } - const opening = - o.kind === 'door' - ? DoorNode.parse({ - ...common, - type: 'door', - position: [x, o.height / 2, 0], - ...(o.hinge ? { hingesSide: o.hinge === 'start' ? 'left' : 'right' } : {}), - ...(o.swing ? { swingDirection: o.swing === 'back' ? 'outward' : 'inward' } : {}), - }) - : WindowNode.parse({ - ...common, - type: 'window', - position: [x, o.sill + o.height / 2, 0], - }) draft[opening.id] = opening report.ids[id] = opening.id node.children.push(opening.id as never) - if (o.kind === 'door') report.doors++ - else report.windows++ }) } // ---------- slabs and columns ---------- - for (const s of level.slabs ?? []) { + const slabs = level.slabs ?? [] + if (slabs.length > MANY_SLABS) + warn( + `level/${index}`, + 'many_slabs', + `${slabs.length} slabs on "${level.name}": Pascal builds floor plates, caps and ceilings itself; a slab is a terrace, a platform, a balcony.`, + ) + slabs.forEach((s, i) => { + const id = `slab/${index}/${i}` + const label = s.name ? `"${s.name}"` : `slab ${i}` + const polygon = s.polygon as Vec2[] + // A slab over a wall's footprint, higher than half the wall: a cap, which would lift the + // wall onto it. Pascal caps its walls itself. + const lifted = [...wallNodes.values()].find( + (w) => + s.top > w.spec.height / 2 && + polygonsOverlap(polygon, wallFootprint({ ...w, thickness: w.spec.thickness })), + ) + if (lifted) { + defect( + id, + 'slab_over_wall', + `${label} overlaps wall ${lifted.spec.id} with its top at ${metres(s.top)}, above half the wall's ${metres(lifted.spec.height)}: it would lift the wall onto it. Pascal builds a wall's cap itself; fold it into the wall.`, + ) + return + } + if (s.thickness < THIN_SLAB) + warn( + id, + 'thin_slab', + `${label} is ${(s.thickness * 1000).toFixed(0)} mm thick: a slab is a terrace, a platform, a balcony; a finish goes on the room's floor.`, + ) + const finish = finishRef(s.finish, id) const node = SlabNode.parse({ object: 'node', type: 'slab', @@ -318,15 +563,16 @@ export function authorScene( polygon: s.polygon, elevation: s.top, thickness: s.thickness, - ...(finishRef(s.finish) ? { slots: { surface: finishRef(s.finish) } } : {}), + ...(finish ? { slots: { surface: finish } } : {}), ...(s.railing?.length ? { railing: s.railing.map((r) => ({ start: r.start, end: r.end })) } : {}), }) place(node) + report.ids[id] = node.id report.slabs++ - } - for (const c of level.columns ?? []) { + }) + ;(level.columns ?? []).forEach((c, i) => { const node = ColumnNode.parse({ object: 'node', type: 'column', @@ -344,8 +590,9 @@ export function authorScene( capitalHeight: 0, }) place(node) + report.ids[`column/${index}/${i}`] = node.id report.columns++ - } + }) // ---------- items, hosts first ---------- const pending = [...(level.items ?? [])] @@ -353,8 +600,9 @@ export function authorScene( while (pending.length && progress) { progress = false for (const item of [...pending]) { - const hostId = item.host ? itemNodeIds.get(item.host) : undefined - if (item.host && !hostId) continue + const hostItemId = item.host ? itemNodeIds.get(item.host) : undefined + const hostWall = item.host && !hostItemId ? wallNodes.get(item.host) : undefined + if (item.host && !hostItemId && !hostWall) continue pending.splice(pending.indexOf(item), 1) progress = true const compiled = input.compiled[item.id] @@ -366,35 +614,94 @@ export function authorScene( ) continue } - const hostPosition = hostId ? itemPositions.get(item.host!) : undefined - const position: [number, number, number] = hostPosition - ? [ - item.position[0]! - hostPosition[0], - item.position[1]! - hostPosition[1], - item.position[2]! - hostPosition[2], - ] - : [item.position[0]!, item.position[1]!, item.position[2]!] + const world: Vec3 = [item.position[0]!, item.position[1]!, item.position[2]!] + const yaw = (item.rotation * Math.PI) / 180 + let placed: { + compiled: CompiledGeometryScript + parentId: string + position: Vec3 + rotation: number + side?: 'front' | 'back' + wallT?: number + frame: Frame + } + if (hostWall) { + // On the wall as place_items puts a wall-side item there: along it from its start, the + // payload's height for its bottom, on the face nearest the point, facing the room. + const [width, height] = scriptedSize(compiled.manifest) + const wallHeight = hostWall.spec.height + if (height > wallHeight) { + defect( + item.id, + 'item_too_tall', + `"${item.name}" is ${metres(height)} tall; wall ${item.host} is ${metres(wallHeight)}.`, + ) + continue + } + const length = dist(hostWall.start, hostWall.end) + const along = projectWorldPointToWallLocalX(hostWall, world) + const x = Math.max(width / 2, Math.min(length - width / 2, along)) + const y = Math.max(0, Math.min(wallHeight - height, world[1])) + const [dx, dz] = [ + hostWall.end[0] - hostWall.start[0], + hostWall.end[1] - hostWall.start[1], + ] + // The wall's left (its local +z) is its front. + const left = + -dz * (world[0] - hostWall.start[0]) + dx * (world[2] - hostWall.start[1]) >= 0 + const z = getWallLocalFaceZ(hostWall.node, left ? 'a' : 'b') + if (item.rotation) + say(`${item.id}: rotation ignored on wall ${item.host}, it faces the room`) + const [ux, uz] = [dx / length, dz / length] + placed = { + compiled: { ...compiled, mount: 'wall-side' }, + parentId: hostWall.node.id, + position: [x, y, z], + rotation: left ? 0 : 180, + side: left ? 'front' : 'back', + wallT: x / length, + frame: { + position: [ + hostWall.start[0] + ux * x - uz * z, + y, + hostWall.start[1] + uz * x + ux * z, + ], + yaw: -Math.atan2(dz, dx) + (left ? 0 : Math.PI), + }, + } + } else { + const host = hostItemId ? itemFrames.get(item.host!) : undefined + placed = { + compiled, + parentId: hostItemId ?? levelNode.id, + position: host ? toLocal(host, world) : world, + rotation: host ? item.rotation - (host.yaw * 180) / Math.PI : item.rotation, + frame: { position: world, yaw }, + } + } try { const outcome = addObject( draft, { - compiled: { ...compiled, nodeId: mint('item') }, + compiled: { ...placed.compiled, nodeId: mint('item') }, name: item.name, category: item.category, reason: `author_scene: ${item.name}`, - position, - rotation: item.rotation, - parentId: hostId ?? levelNode.id, + position: placed.position, + rotation: placed.rotation, + parentId: placed.parentId, + ...(placed.side ? { side: placed.side } : {}), }, { ...context, activeLevelId: levelNode.id }, ) for (const { node, parentId } of outcome.changes?.create ?? []) { - draft[node.id] = node + draft[node.id] = + placed.wallT === undefined ? node : ({ ...node, wallT: placed.wallT } as AnyNode) const parent = draft[parentId ?? levelNode.id] as AnyNode & { children?: string[] } parent.children = [...(parent.children ?? []), node.id] itemNodeIds.set(item.id, node.id) + itemFrames.set(item.id, placed.frame) report.ids[item.id] = node.id - itemPositions.set(item.id, [item.position[0]!, item.position[1]!, item.position[2]!]) report.items++ } } catch (error) { @@ -409,11 +716,12 @@ export function authorScene( // ---------- lights: on their fixture, else on the nearest item ---------- for (const [i, light] of level.lights.entries()) { const id = `light/${index}/${i}` + const at: Vec3 = [light.position[0]!, light.position[1]!, light.position[2]!] let hostKey = light.fixture && itemNodeIds.has(light.fixture) ? light.fixture : undefined if (!hostKey) { let best: { key: string; d: number } | null = null - for (const [key, pos] of itemPositions) { - const d = Math.hypot(pos[0] - light.position[0]!, pos[2] - light.position[2]!) + for (const [key, frame] of itemFrames) { + const d = Math.hypot(frame.position[0] - at[0], frame.position[2] - at[2]) if (!best || d < best.d) best = { key, d } } if (!best) { @@ -425,14 +733,13 @@ export function authorScene( continue } hostKey = best.key - report.notes.push( - `${id}: no fixture given, attached to ${hostKey} ${best.d.toFixed(2)} m away; moving that item moves the light.`, + say( + `light at [${at.join(', ')}] has no fixture: hosted on the nearest item ${hostKey} (${best.d.toFixed(2)} m away); moving that item moves the light`, ) } const hostNode = draft[itemNodeIds.get(hostKey)!] as AnyNode & { asset: { interactive?: { controls: unknown[]; effects: unknown[] } } } - const hostPos = itemPositions.get(hostKey)! // Where add_object keeps an object's lights: the asset's interactive block, with a toggle. const interactive = hostNode.asset.interactive ?? { controls: [], effects: [] } const controls = interactive.controls.some((c) => (c as { kind?: string }).kind === 'toggle') @@ -444,11 +751,7 @@ export function authorScene( kind: 'light', color: light.color, intensityRange: [0, light.intensity], - offset: [ - light.position[0]! - hostPos[0], - light.position[1]! - hostPos[1], - light.position[2]! - hostPos[2], - ], + offset: toLocal(itemFrames.get(hostKey)!, at), }, ] draft[hostNode.id] = Object.assign({}, hostNode, { @@ -457,24 +760,43 @@ export function authorScene( report.lights++ } - // ---------- wall paint, through the editor's paint ---------- + // ---------- wall paint, through the editor's paint: both faces, or each its own ---------- for (const w of walls) { - const node = wallNodes.get(w.spec.id) - if (!node || !w.spec.color) continue - try { - const { changes } = paint( - draft, - { targets: [node.id], color: w.spec.color }, - { ...context, activeLevelId: levelNode.id }, - ) - for (const u of changes?.update ?? []) - draft[u.id] = { ...draft[u.id], ...u.data } as AnyNode - } catch (error) { - defect(w.spec.id, 'paint_refused', (error as Error).message) + const node = wallNodes.get(w.spec.id)?.node + if (!node) continue + const faces = { + a: w.spec.colorLeft ?? w.spec.color, + b: w.spec.colorRight ?? w.spec.color, + } + const coats: { role?: 'a' | 'b'; material: string }[] = [] + if (faces.a && faces.a === faces.b) { + const ref = finishRef(faces.a, w.spec.id) + if (ref) coats.push({ material: ref }) + } else + for (const face of ['a', 'b'] as const) { + const ref = finishRef(faces[face], w.spec.id) + if (ref) coats.push({ role: face, material: ref }) + } + for (const coat of coats) { + try { + const { changes } = paint( + draft, + { targets: [node.id], ...coat }, + paintContext(levelNode.id), + ) + for (const u of changes?.update ?? []) + draft[u.id] = { ...draft[u.id], ...u.data } as AnyNode + } catch (error) { + defect(w.spec.id, 'paint_refused', (error as Error).message) + } } } }) report.levels = levelNodes.length + if (untypedWindows) + say( + `${untypedWindows} window${untypedWindows === 1 ? ' has' : 's have'} no type: written fixed (cannot open); give type`, + ) // ---------- rooms: Pascal's own derivation, then the payload's names and floors ---------- const { nodes: withZones, createdZoneIds } = migrateRoomZones(draft) @@ -489,13 +811,14 @@ export function authorScene( payload.levels.forEach((level, index) => { const levelNode = levelNodes[index]! for (const [i, room] of level.rooms.entries()) { + const id = `room/${index}/${i}` const zone = zones.find( - (z) => z.parentId === levelNode.id && pointInPolygon(room.seed, z.polygon), + (z) => z.parentId === levelNode.id && pointInPolygon(room.seed as Vec2, z.polygon), ) // Two seeds in one enclosure: the walls do not separate the rooms the payload names. if (zone && claimed.has(zone.id)) { defect( - `room/${index}/${i}`, + id, 'room_shares_enclosure', `"${room.name}" and "${claimed.get(zone.id)}" are one enclosure: no wall separates them.`, ) @@ -503,17 +826,23 @@ export function authorScene( } if (!zone) { defect( - `room/${index}/${i}`, + id, 'room_not_enclosed', `No closed room around (${room.seed[0]}, ${room.seed[1]}) "${room.name}": its walls do not meet.`, ) continue } - const finish = finishRef(room.floor) + if (room.floorRegions.length > MANY_REGIONS) + warn( + id, + 'many_regions', + `"${room.name}" has ${room.floorRegions.length} floor regions: regions are fixed polygons that do not follow a resize; use one floor finish.`, + ) + const finish = finishRef(room.floor, id) const regions = room.floorRegions.map((r, k) => ({ id: `region_${k}`, polygon: r.polygon, - finish: finishRef(r.finish) ?? r.finish, + finish: finishRef(r.finish, id) ?? r.finish, })) draft[zone.id] = ZoneNode.parse({ ...zone, @@ -531,7 +860,7 @@ export function authorScene( metadata: { ...zone.metadata, authorScene: { seed: room.seed } }, }) claimed.set(zone.id, room.name) - report.ids[`room/${index}/${i}`] = zone.id + report.ids[id] = zone.id report.roomsNamed++ } }) @@ -549,6 +878,7 @@ export function authorScene( return { result: report, changes: { + ...(finishes.created.length ? { materials: finishes.created } : {}), delete: deleted, create: create.map((c) => ({ node: (c as { node: AnyNode }).node, diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index 4a102dc67e..93309cfcd2 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -1,10 +1,14 @@ import { z } from 'zod' +import { DOOR_STYLES, WINDOW_STYLES } from '../building/opening-style-presets' +import { DoorType, WindowType } from '../schema/nodes/opening-types' import { measurement } from './measurement' import { NodeId } from './node-id' const DESCRIPTION = `Turn a whole design you wrote as free three.js into an editable Pascal scene, in one call. You read your own module however you like (its source, running it and reading the objects, both) and hand over a conversion payload: levels, walls with their openings, rooms, slabs, columns, railings, every piece of furniture as an authored object with its own module, and lights. Pascal does the rest server-side: compiles and stores each item, places it, derives the rooms and their floors, paints, runs its checks, saves, and answers with a gate report you iterate against. Nothing you hand over is silently dropped: what cannot be placed comes back by name with a reason, and what Pascal corrected (a wall end snapped onto the centerline it meets) is listed. -Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre. An item's position is where its bottom centre stands; its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is). Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. +Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre; give each opening its type (how it opens) and style (its look), or its own module for a shape they cannot express. An item's position is where its bottom centre stands; its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is). A hex colour is written as an exact material, one per distinct colour. Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and floor planks into the wall, the room's floor finish and the room (say so in notes), never into slabs or regions; a slab is a terrace, a platform, a balcony. Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. + +The report says what was written, what Pascal defaulted (defaults: a window with no type is fixed and cannot open), what it warns about but wrote (warnings: thin slabs, too many slabs or regions) and what it refused (defects, by your ids). Call again with the same scene and a corrected payload to replace the previous conversion (replace: true); the default refuses to overwrite a scene that already holds a conversion.` @@ -25,6 +29,35 @@ export const authorSceneOpening = z.object({ { default: 0 }, ), name: z.string().max(80).optional(), + type: z + .enum([...DoorType.options, ...WindowType.options.filter((t) => t !== 'sliding')]) + .optional() + .describe( + 'How it opens. A door: hinged (default), double (two leaves), french (two glazed leaves), folding, pocket, barn, sliding, garage-sectional, garage-rollup, garage-tiltup. A window: fixed (default; cannot open), sliding, casement, awning, hopper, single-hung, double-hung, bay, bow, louvered. Give it: a window with no type is written fixed.', + ), + style: z + .enum([...DOOR_STYLES, ...WINDOW_STYLES]) + .optional() + .describe( + "Its look, by name. A door's leaf: panel (default), glass, modern, paneled-glass, french, shaker, six-panel, craftsman, half-louvered, barn. A fixed window's panes: single (default), double-hung, triple-hung, casement, sliding, grid, tall-grid, wide-grid, horizontal-bands, transom, picture; an operable window draws its own sashes, so a style on one is dropped and reported.", + ), + leaves: z + .number() + .int() + .min(1) + .max(4) + .optional() + .describe( + "A door's leaves, 1 to 4 (default: what its type has; 2 for double, french and sliding).", + ), + code: z + .string() + .min(1) + .max(48_000) + .optional() + .describe( + "A three.js module for an opening type and style cannot express (an arched double door, a round window): the add_door / add_window code contract, mount 'wall', a cutout mesh the wall cuts, an open clip unless it is fixed. Width and height are what it builds; type and style do not apply. Compiled and stored as an item's module is.", + ), color: z .string() .optional() @@ -32,7 +65,9 @@ export const authorSceneOpening = z.object({ hinge: z .enum(['start', 'end']) .optional() - .describe("Which end of the wall the door's hinge is nearer."), + .describe( + "Which end of the wall the door's hinge is nearer (default start). The handle goes on the opposite edge.", + ), swing: z .enum(['front', 'back']) .optional() @@ -48,7 +83,21 @@ export const authorSceneWall = z.object({ color: z .string() .optional() - .describe('Paint for both faces as #rrggbb; Pascal picks the nearest library colour.'), + .describe( + 'Paint for both faces: #rrggbb, written as an exact material, or a library ref (library:) from search_materials.', + ), + colorLeft: z + .string() + .optional() + .describe( + "Paint for the face on the left when looking from start to end, same forms as color; over color. The left is the wall's front.", + ), + colorRight: z + .string() + .optional() + .describe( + 'Paint for the face on the right when looking from start to end, same forms as color; over color.', + ), openings: z.array(authorSceneOpening).optional(), }) @@ -65,12 +114,14 @@ export const authorSceneRoom = z.object({ .string() .optional() .describe( - "The floor's material: a library ref (library:) from search_materials, or #rrggbb for a flat colour.", + "The floor's material: a library ref (library:) from search_materials, or #rrggbb for an exact flat colour.", ), floorRegions: z .array(z.object({ polygon: z.array(plan('vertex')).min(3), finish: z.string() })) .default([]) - .describe('Parts of the floor in another finish (the kitchen tiles inside an open-plan room).'), + .describe( + 'Parts of the floor in another finish (the kitchen tiles inside an open-plan room), library: or #rrggbb. Regions are fixed polygons that do not follow a resize: one region per finish, never one per plank.', + ), }) export const authorSceneSlab = z.object({ @@ -83,7 +134,7 @@ export const authorSceneSlab = z.object({ default: 0, }, ), - finish: z.string().optional().describe('library: or #rrggbb.'), + finish: z.string().optional().describe('library: or #rrggbb (an exact colour).'), railing: z .array( z.object({ @@ -127,7 +178,7 @@ export const authorSceneItem = z.object({ .string() .optional() .describe( - 'The id of the item this one rests on or hangs from (a lamp on a nightstand), so moving the host moves it.', + 'The id of the item this one rests on or hangs from (a lamp on a nightstand), or of the wall it is fixed to (a baseboard, a casing, a mirror, art), so moving the host moves it. On a wall, position is still the world bottom centre: the item is fixed to the face nearest it and faces the room; rotation is ignored.', ), }) From c23e1da5710c8cba04cb021c23637603afaaa0e5 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 08:10:46 -0400 Subject: [PATCH 10/26] mcp: author_scene compiles an opening's module and keeps hex colours as scene materials The host compiles every module the payload carries, the items' and the openings' with code, keyed by the payload's own ids (`/` for an opening), through the same compileAndStore as add_door and add_window, and lends the operation the scene's materials so a hex colour is one exact scene material rather than the nearest library colour. The stored payload drops an opening's code as it drops an item's: each module is its own artifact. Co-Authored-By: Claude Fable 5.1 --- packages/mcp/src/tools/author-scene.test.ts | 79 ++++++++++++++++++--- packages/mcp/src/tools/author-scene.ts | 48 +++++++++---- 2 files changed, 102 insertions(+), 25 deletions(-) diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts index 877082de41..5ddca4cf39 100644 --- a/packages/mcp/src/tools/author-scene.test.ts +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -18,6 +18,17 @@ export default function build({ THREE }) { return g }` +/** A round window's module: a wall-mounted cutout with an open clip's worth of frame. */ +const ROUND_WINDOW = `import * as THREE from 'three' +export const mount = 'wall' +export default function build({ THREE }) { + const g = new THREE.Group() + const m = new THREE.MeshStandardMaterial({ color: '#dddddd' }); m.name = 'slot_frame' + const ring = new THREE.Mesh(new THREE.TorusGeometry(0.4, 0.04, 8, 24), m); ring.position.y = 0.4; g.add(ring) + const hole = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 0.3, 24)); hole.rotation.x = Math.PI / 2; hole.position.y = 0.4; hole.name = 'cutout'; g.add(hole) + return g +}` + const payload = { levels: [ { @@ -29,7 +40,8 @@ const payload = { end: [2, -2.5], height: 2.7, thickness: 0.12, - color: '#f1ece4', + color: '#8f9e8a', + colorRight: '#f1ece4', }, { id: 'e', @@ -37,7 +49,17 @@ const payload = { end: [2, 2.5], height: 2.7, thickness: 0.12, - openings: [{ kind: 'window', along: 2.5, width: 1.4, height: 1.3, sill: 0.9 }], + openings: [ + { + kind: 'window', + along: 1.2, + width: 1.4, + height: 1.3, + sill: 0.9, + type: 'casement', + }, + { kind: 'window', along: 3.8, width: 1, height: 1, sill: 1, code: ROUND_WINDOW }, + ], }, { id: 's', @@ -45,12 +67,22 @@ const payload = { end: [-2, 2.5], height: 2.7, thickness: 0.12, - openings: [{ kind: 'door', along: 1.2, width: 0.9, height: 2.1 }], + openings: [ + { kind: 'door', along: 1.2, width: 0.9, height: 2.1, style: 'glass', hinge: 'end' }, + ], }, { id: 'w', start: [-2, 2.5], end: [-2, -2.4], height: 2.7, thickness: 0.12 }, ], - rooms: [{ name: 'Bedroom', seed: [0, 0], floor: 'library:wood-floorplank1' }], + rooms: [{ name: 'Bedroom', seed: [0, 0], floor: '#8f9e8a' }], items: [ + { + id: 'skirting', + name: 'Baseboard', + category: 'trim', + code: BOX(3.9, 0.12, 0.015, '#f1ece4'), + position: [0, 0, 2.4], + host: 's', + }, { id: 'bed', name: 'Bed', @@ -114,29 +146,56 @@ describe('author_scene (MCP)', () => { }) const report = result.structuredContent as Record & { defects: { id: string; code: string }[] + defaults: string[] + ids: Record verify: { issues: number } } expect(report.walls).toBe(4) expect(report.doors).toBe(1) - expect(report.windows).toBe(1) + expect(report.windows).toBe(2) expect(report.rooms).toBe(1) - expect(report.items).toBe(2) + expect(report.items).toBe(3) expect(report.lights).toBe(1) - expect(report.itemsCompiled).toBe(2) + expect(report.itemsCompiled).toBe(4) expect(report.defects).toEqual([ { id: 'bad', code: 'compile_failed', message: expect.stringContaining('Unsupported import') }, ]) expect(report.green).toBe(false) + expect(report.warnings).toEqual([]) + expect(report.defaults).toContain('door s/0: handle set opposite the hinge (left)') expect(report.snapped).toEqual([{ id: 'w', end: 'end', movedM: 0.1 }]) expect(report.notes).toContain('The rug was left out.') - // Two artifacts per item (GLB + code), plus the module and the adapter. - expect(stored.filter((s) => s.startsWith('model/gltf-binary'))).toHaveLength(2) - expect(stored.filter((s) => s.startsWith('text/javascript'))).toHaveLength(4) + // Two artifacts per module (GLB + code): three items and the round window, plus the design's + // module and the adapter. + expect(stored.filter((s) => s.startsWith('model/gltf-binary'))).toHaveLength(4) + expect(stored.filter((s) => s.startsWith('text/javascript'))).toHaveLength(6) // The light rides on its fixture through the bridge, not only in the operation's draft. const lamp = await client.callTool({ name: 'find_by_type', arguments: { type: 'item' } }) expect(JSON.stringify(lamp.structuredContent ?? lamp.content)).toContain('Lamp') const walls = await client.callTool({ name: 'get_walls', arguments: {} }) expect(JSON.stringify(walls.structuredContent)).toContain('"height":2.7') + // What the scene holds: the typed window, the scripted one, the styled door with its handle + // opposite the hinge, the baseboard on its wall, and one exact material for the sage. + const node = async (id: string) => { + const got = await client.callTool({ name: 'get_node', arguments: { id } }) + return JSON.parse((got.content as { text: string }[])[0]!.text).node as Record + } + expect((await node(report.ids['e/0']!)).windowType).toBe('casement') + const round = await node(report.ids['e/1']!) + expect(round.source?.kind).toBe('script') + expect(round.width).toBeCloseTo(0.88, 2) + const door = await node(report.ids['s/0']!) + expect(door.segments).toHaveLength(1) + expect([door.hingesSide, door.handleSide]).toEqual(['right', 'left']) + const trim = await node(report.ids.skirting!) + expect(trim.parentId).toBe(report.ids.s) + expect(trim.side).toBe('front') + const north = await node(report.ids.n!) + expect(north.slots.a).toMatch(/^scene:/) + expect(north.slots.b).toMatch(/^scene:/) + expect(north.slots.a).not.toBe(north.slots.b) + const room = await node(report.ids['room/0/0']!) + expect(room.floor.finish).toBe(north.slots.a) }) test('refuses to write over a conversion unless told to', async () => { diff --git a/packages/mcp/src/tools/author-scene.ts b/packages/mcp/src/tools/author-scene.ts index c009097df6..c195057d18 100644 --- a/packages/mcp/src/tools/author-scene.ts +++ b/packages/mcp/src/tools/author-scene.ts @@ -17,7 +17,7 @@ import { GEOMETRY_SCRIPT_MIME_TYPES, } from '@pascal-app/core/schema' import type { SceneOperations } from '../operations' -import { compileAndStore, type GeometryScriptHost } from './add-object' +import { compileAndStore, type GeometryScriptHost, type ScriptedKind } from './add-object' import { DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' import { refusalResult, toolError } from './errors' import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' @@ -62,21 +62,30 @@ export function registerAuthorScene( code: 'no_active_scene', }) } - // The items' modules, compiled and stored one by one; a failure is a defect, not a stop. + // Every module, compiled and stored one by one: the items', and the openings' by + // `/`. A failure is a defect, not a stop. const compiled: Record = {} const compileErrors: Record = {} + const modules: { id: string; code: string; kind: ScriptedKind }[] = [] for (const level of args.payload.levels) { - for (const item of level.items ?? []) { - try { - compiled[item.id] = await compileAndStore( - host, - scene.id, - { code: item.code, language: 'three', params: undefined }, - 'object', - ) - } catch (error) { - compileErrors[item.id] = error instanceof Error ? error.message : String(error) - } + for (const item of level.items ?? []) + modules.push({ id: item.id, code: item.code, kind: 'object' }) + for (const wall of level.walls ?? []) + wall.openings?.forEach((opening, index) => { + if (opening.code) + modules.push({ id: `${wall.id}/${index}`, code: opening.code, kind: opening.kind }) + }) + } + for (const { id, code, kind } of modules) { + try { + compiled[id] = await compileAndStore( + host, + scene.id, + { code, language: 'three', params: undefined }, + kind, + ) + } catch (error) { + compileErrors[id] = error instanceof Error ? error.message : String(error) } } // Provenance: the design's module and the program that read it, kept as scripts of the scene. @@ -101,7 +110,12 @@ export function registerAuthorScene( outcome = authorScene( before, { payload: args.payload, compiled, compileErrors, replace: args.replace }, - { activeLevelId: bridge.activeLevelId?.() ?? null }, + { + activeLevelId: bridge.activeLevelId?.() ?? null, + ...(bridge.supportsMaterialUpserts && bridge.getMaterials + ? { materials: bridge.getMaterials() } + : {}), + }, ) } catch (error) { if (isAgentRefusal(error)) return refusalResult(error) @@ -150,12 +164,16 @@ export function registerAuthorScene( ) } -/** The payload as kept with the scene: every item's code is already its own artifact. */ +/** The payload as kept with the scene: every module is already its own artifact. */ function withoutCode(payload: AuthorScenePayload) { return { ...payload, levels: payload.levels.map((level) => ({ ...level, + walls: (level.walls ?? []).map((wall) => ({ + ...wall, + openings: (wall.openings ?? []).map(({ code: _, ...opening }) => opening), + })), items: (level.items ?? []).map(({ code: _, ...item }) => item), })), } From dda3765c7156bdc44f4a1a546328ca92f2bc9553 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 08:11:56 -0400 Subject: [PATCH 11/26] skill + wiki: author_scene's opening types and styles, face colours, wall hosts, defaults and warnings The pascal-3d skill, its conversion workflow and the agent-surfaces page name the payload's new fields and the report's defaults, warnings and slab_over_wall, and carry two rules the acceptance runs needed: Pascal builds a wall's cap, the floor plate and the ceiling itself, so a module's caps, underlays and planks fold into the wall, the floor finish and the room (said in notes), and whatever opens in the module carries an open clip so it keeps working after conversion. Co-Authored-By: Claude Fable 5.1 --- skills/pascal-3d/SKILL.md | 2 +- skills/pascal-3d/references/tool-workflows.md | 4 ++-- wiki/architecture/agent-surfaces.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index d31b5f2539..576cf4e72e 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -73,7 +73,7 @@ Stair `targets` are design preferences in meters (`maxRiserHeight`, `minimumGoin When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, or with `language: 'bpy'` a Blender module when the shape needs Blender's modelling (bevels, smooth organic forms, booleans, curves), with the naming conventions its description lists for paint slots, parts, lights, cutouts and clips. Edit it by passing its `nodeId`. A new object needs a `reason` (why no Pascal tool or catalog item builds it); `verify_scene` lists every authored object with its reason. On a floor, a plain box with a wall's size or a floor plate is refused with the tool to use (`use_walls`, `use_slab`); a detailed object with a wall's size (a bookcase, a screen) is built with a hint naming `add_wall`. Servers without a script host answer `scripts_unavailable`. The hosted server builds three.js scripts in the user's open Pascal editor tab (Blender modules run in its Blender service and need no tab); when no tab has the project open it answers `editor_tab_required` with an `editorUrl`: give the user that link, wait until they say it is open, then repeat the same call. `editor_tab_busy` means a tab is open but busy or still loading: wait a few seconds and repeat the same call; the user need not be asked. Whether you may open the link yourself is the person's setting ("Agents may open the editor for me"; Ask first: ask the user to open the link (or bring the tab forward), or ask their permission before opening it yourself; Allow: you may open the link yourself, in a tab in front; Never: ask the user, and do not open or switch windows yourself); the refusal's words say which, so follow them. It applies to `editor_tab_required` and `editor_tab_hidden`; `editor_tab_busy` carries no link, and an autonomous agent account, which has no editor login, is refused with `editor_host_identity_required` whatever the setting says. To count or locate things by what they are, including typed parts inside authored objects, use `find_by_type`. To group elements of any kind into a named set the editor lists (all the lights, the windows, the kitchen cabinetry), use `edit_collection`, starting from a template when one fits; `list_collections` reads them back. -When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their openings, rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module, and lights. Pascal compiles and stores the items, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it could not place and why, what it corrected (a wall end snapped onto the centerline it meets). Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and make drawers, doors and lids animation clips: named pieces convert with exact names and moving parts keep moving. +When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their colour (`color`, or `colorLeft` / `colorRight` per face; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open), rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module (`host` names the item it rests on, or the wall a baseboard, casing or mirror is fixed to), and lights. Pascal compiles and stores the modules, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it filled in (`defaults`: a window written fixed, a handle set opposite its hinge, a light hosted on the nearest item), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions), what it could not place and why (`defects`), what it corrected (a wall end snapped onto the centerline it meets). Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and individual floor planks into the native wall, the floor finish and the room, and say so in `notes`; a slab over a wall's top is refused. Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. Pascal uses meters. X and Z are floor-plan axes; Y is vertical. Tool fields that accept measurements may also accept strings such as `"6 ft"` or `"180cm"`, but report final spatial values in meters and retain the user's original units when useful. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 309d6e790a..0a3e0e4ceb 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -56,8 +56,8 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra `author_scene` turns a whole design you wrote as one three.js module into native, editable Pascal nodes in one call. The reading is yours; the writing is Pascal's. 1. Read your module: its source for intent (helper names, declared opening types), the executed graph for truth (world transforms, deleted objects gone). Most designs make walls with a helper such as `wall(x1, z1, x2, z2, height, thickness, openings)`: those arguments are the payload's walls. -2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color`, `openings` with `kind`, `along` from the wall's start, `width`, `height`, `sill`), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`), `slabs`, `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand), `lights` (`position`, `fixture`). End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. -3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `use_slab`…), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. +2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`), `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a baseboard on a wall), `lights` (`position`, `fixture`). End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. +3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `slab_over_wall`, `use_slab`…), `warnings` (written, but doubtful: `thin_slab`, `many_slabs`, `many_regions`), `defaults` (what Pascal filled in or dropped: a window with no `type` written fixed, a handle set opposite its hinge, a style dropped on an operable window, a light hosted on the nearest item), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. 4. Fix the program, not the payload by hand, and call again with `replace: true`. Stop when `green` is true and the scene looks like the design. Report what you could not convert in `notes`. ## Wrap a window around a corner diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 3edde02b55..f696c3f153 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -136,9 +136,9 @@ A write that would leave a populated project empty is far more often an accident ## Converting a whole design: `author_scene` -A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`; registered on the MCP first and not yet in `AGENT_TOOL_CONTRACTS`, since the hosted chat's agent cannot run its own program until its sandbox can emit the payload: a reviewed `gap` in the parity test) takes a conversion payload, levels with walls and their openings, rooms by seed with a floor material, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall, zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, items through `addObject`, lights as light effects on their fixture (or the nearest item: Pascal keeps lights inside objects), paint through `paint`. Ids derive from the payload's hash, so the same payload writes the same scene. +A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`; registered on the MCP first and not yet in `AGENT_TOOL_CONTRACTS`, since the hosted chat's agent cannot run its own program until its sandbox can emit the payload: a reviewed `gap` in the parity test) takes a conversion payload, levels with walls (a colour for both faces or `colorLeft` / `colorRight` for one: the left, looking from start to end, is the wall's front, role `a`) and their openings (`type` as `DoorType` / `WindowType`, `style` as the door and window style presets, `leaves`, or `code` for a module the host compiles like an item's, keyed `/`), rooms by seed with a floor material and regions, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it, or on a wall when `host` names one: a wall-side item on the face nearest its point, as `place_items` writes one), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall (`doorTypeFields` / `windowTypeFields`, `doorStyleLook` / `getWindowStyleOverrides`, the handle opposite the hinge, a scripted opening's `source` and `slots` as `add_door` with code writes them), zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, items through `addObject`, lights as light effects on their fixture (or the nearest item: Pascal keeps lights inside objects) in the host's frame, paint through `paint`. A hex colour is one exact scene material per distinct colour across the payload (`resolveInlinePaintMaterial`, roughness 0.9, metalness 0, named by the hex), reused by walls, floors, regions and slabs alike; a host without scene materials gets the nearest library colour and a `defaults` sentence saying so. Ids derive from the payload's hash, so the same payload writes the same scene. -The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and the one correction Pascal makes itself is its join rule (a wall end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. +The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and the one correction Pascal makes itself is its join rule (a wall end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. The report also says what Pascal filled in or dropped (`defaults`, one sentence per fact: windows with no type written fixed, a door's handle set opposite its hinge, a style dropped on an operable window, a light hosted on the nearest item) and what it wrote but doubts (`warnings` with a code: `thin_slab` under 2 cm, `many_slabs` over 20 on a level, `many_regions` over 24 in a room, since regions are fixed polygons that do not follow a resize). One structural defect refuses the node: `slab_over_wall`, a slab over a wall's footprint on its level with its top above half the wall's height, which would lift the wall onto it (an acceptance run wrote 43 twelve-millimetre "wall cap" slabs at 2.662 m). Pascal builds a wall's cap, the floor plate and the ceiling itself; the skill tells the agent to fold a module's caps, underlays and planks into the wall, the floor finish and the room. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. What the converting agents asked of `verify_scene` is in the same PR: an item hosted by another item is read through its host (`item-space.ts`), a thing resting on its host is stacked, not colliding, a rug or a pendant does not block a door, and a door's keep-out is the full clear depth on its swing side and a walkway on the other. From 3784dae4d870e7068ca0341e32e2e08977984d10 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 08:18:12 -0400 Subject: [PATCH 12/26] core: a slab lying on a wall's top is its cap, never its support An importer wrote 12 mm "wall caps" exactly over 2.6 m walls, and every wall elected its own cap as its base: wall base 2.662, level height 5.26. The election now drops a slab whose underside reaches the wall's own top and whose footprint lies on the wall body (between its faces, within its length, 5 cm of slack). The top is measured from the lowest candidate (an authored height above it, else the storey plane), since the base being elected cannot decide it. A floor or terrace reaching past the wall keeps carrying it however short the wall, and an explicitly preferred host stays. The slab-elevation clamp probes just past its bound instead of past the storey plane, where a slab now reads as resting on the wall. Co-Authored-By: Claude Fable 5.1 --- .../core/src/services/level-height.test.ts | 37 ++++++ .../src/systems/slab/slab-support.test.ts | 53 +++++++++ .../core/src/systems/slab/slab-support.ts | 111 +++++++++++++++++- 3 files changed, 196 insertions(+), 5 deletions(-) diff --git a/packages/core/src/services/level-height.test.ts b/packages/core/src/services/level-height.test.ts index dc31bf0aac..9b3b9e4336 100644 --- a/packages/core/src/services/level-height.test.ts +++ b/packages/core/src/services/level-height.test.ts @@ -87,6 +87,42 @@ function createFixture(): Record { end: [33, 2], height: 2.8, }), + // A 12 mm "wall cap" an importer wrote exactly over a 2.6 m wall: a slab the wall carries. + LevelNode.parse({ + id: 'level_capped', + children: ['slab_capped_floor', 'wall_capped', 'slab_cap'], + }), + SlabNode.parse({ + id: 'slab_capped_floor', + parentId: 'level_capped', + polygon: [ + [50, 0], + [54, 0], + [54, 4], + [50, 4], + ], + elevation: 0, + }), + WallNode.parse({ + id: 'wall_capped', + parentId: 'level_capped', + start: [50, 2], + end: [54, 2], + thickness: 0.2, + height: 2.6, + }), + SlabNode.parse({ + id: 'slab_cap', + parentId: 'level_capped', + polygon: [ + [50, 1.9], + [54, 1.9], + [54, 2.1], + [50, 2.1], + ], + elevation: 2.662, + thickness: 0.012, + }), ] return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record @@ -101,6 +137,7 @@ describe('deriveLegacyLevelHeight', () => { ['level_ceiling', 3.4], ['level_negative_slab', 2.8], ['level_empty', 2.5], + ['level_capped', 2.6], ] as const for (const [levelId, expected] of cases) { diff --git a/packages/core/src/systems/slab/slab-support.test.ts b/packages/core/src/systems/slab/slab-support.test.ts index 34ff3706ac..4dd41f1d74 100644 --- a/packages/core/src/systems/slab/slab-support.test.ts +++ b/packages/core/src/systems/slab/slab-support.test.ts @@ -3,6 +3,7 @@ import { clampSlabElevationForWalls, computeWallSlabSupport, getSlabElevationUpperBound, + LevelNode, MIN_WALL_HEIGHT, SlabNode, WallNode, @@ -157,3 +158,55 @@ describe('computeWallSlabSupport preferred host', () => { expect(support.elevation).toBeCloseTo(0.6) }) }) + +describe('computeWallSlabSupport covering slabs', () => { + const wall = WallNode.parse({ + id: 'wall_capped', + start: [0, 1.5], + end: [4, 1.5], + thickness: 0.2, + height: 2.6, + }) + const floor = SlabNode.parse({ id: 'slab_floor', polygon: SQUARE, elevation: 0 }) + // A 12 mm cap written exactly over the wall's top, covering its whole footprint. + const cap = SlabNode.parse({ + id: 'slab_cap', + polygon: [ + [0, 1.4], + [4, 1.4], + [4, 1.6], + [0, 1.6], + ], + elevation: 2.662, + thickness: 0.012, + }) + + it('a slab at or above the wall top rests on the wall and never lifts it', () => { + const support = computeWallSlabSupport(wall, [floor, cap], [wall]) + expect(support.elevation).toBe(0) + expect(support.electedSlabId).toBe('slab_floor') + }) + + it('a plane-bound wall measures its top at the storey plane', () => { + const { height: _height, ...storeyWall } = wall + const level = LevelNode.parse({ id: 'level_capped', height: 2.6, children: [wall.id] }) + const onLevel = WallNode.parse({ ...storeyWall, parentId: level.id }) + const nodes = { [level.id]: level, [onLevel.id]: onLevel } + expect( + computeWallSlabSupport(onLevel, [floor, cap], [onLevel], undefined, undefined, 0, nodes) + .elevation, + ).toBe(0) + }) + + it('a raised floor reaching past the wall carries it, however short the wall', () => { + const raised = SlabNode.parse({ id: 'slab_raised', polygon: SQUARE, elevation: 0.6 }) + const half = WallNode.parse({ ...wall, id: 'wall_half', height: 0.3 }) + expect(computeWallSlabSupport(half, [floor, raised], [half]).elevation).toBeCloseTo(0.6) + }) + + it("the wall's explicit host wins even above its top", () => { + expect(computeWallSlabSupport(wall, [floor, cap], [wall], 'slab_cap').elevation).toBeCloseTo( + 2.662, + ) + }) +}) diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts index 1529334df6..c0002ed25c 100644 --- a/packages/core/src/systems/slab/slab-support.ts +++ b/packages/core/src/systems/slab/slab-support.ts @@ -9,8 +9,12 @@ import { import { levelBaseElevationAt } from '../../lib/terrain-support-query' import type { AnyNode, SeparatorNode, SlabNode, WallNode, ZoneNode } from '../../schema' import { getWallCurveFrameAt, isCurvedWall } from '../wall/wall-curve' -import { DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint' -import { getWallFaceOffsets, type WallJustification } from '../wall/wall-frame' +import { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint' +import { + getWallFaceOffsets, + type WallFaceOffsets, + type WallJustification, +} from '../wall/wall-frame' import { MIN_WALL_HEIGHT, wallFollowsStorey } from '../wall/wall-top' export type SlabElevationClamp = { @@ -46,11 +50,15 @@ export function clampSlabElevationForWalls( if (proposedElevation <= bound) return { elevation: proposedElevation, clamped: false } if (slab.polygon.length < 3) return { elevation: proposedElevation, clamped: false } + // Probed just past the bound: a wall electing the slab there elects it at any + // height above, whereas a slab past the storey plane reads as resting on the + // wall — the state this clamp exists to prevent. + const probed = Math.min(proposedElevation, bound + MIN_WALL_HEIGHT / 2) const substituted = levelSlabs.some((candidate) => candidate.id === slab.id) ? levelSlabs.map((candidate) => - candidate.id === slab.id ? { ...candidate, elevation: proposedElevation } : candidate, + candidate.id === slab.id ? { ...candidate, elevation: probed } : candidate, ) - : [...levelSlabs, { ...slab, elevation: proposedElevation }] + : [...levelSlabs, { ...slab, elevation: probed }] for (const wall of levelWalls) { if (!wallFollowsStorey(wall, storeyHeight)) continue @@ -73,7 +81,7 @@ export function clampSlabElevationForWalls( 0, nodes, ) - if (Math.abs(support.elevation - proposedElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) { + if (Math.abs(support.elevation - probed) <= WALL_SLAB_ELEVATION_POOL_EPSILON) { return { elevation: bound, clamped: true } } } @@ -298,6 +306,8 @@ export type WallOverlapInput = { thickness?: number justification?: WallJustification supportOffset?: number + /** Authored body height; absent for a plane-bound wall (its top is the storey plane). */ + height?: number } // Minimum length of wall that must lie on/inside a slab polygon before the @@ -819,6 +829,7 @@ export function computeWallSlabSupport( wallLike.thickness, wallLike.justification, wallLike.supportOffset, + wallLike.height, preferredSlabId, maxElevation, levelBase, @@ -919,6 +930,68 @@ function polygonBounds(polygon: MultiPolygon): SupportBounds { return bounds } +/** A cap may run this far past the wall's faces and ends and still be its cap. */ +const WALL_CAP_SLACK = 0.05 + +/** Whether every corner of the footprint lies on the wall body: between its faces, within its length. */ +function footprintOnWallBody( + polygon: MultiPolygon, + center: Array<{ x: number; y: number }>, + offsets: WallFaceOffsets, +): boolean { + const onBody = ([px, pz]: [number, number]) => { + let nearest = Number.POSITIVE_INFINITY + let fits = false + for (let i = 1; i < center.length; i++) { + const s = center[i - 1]!, + e = center[i]! + const dx = e.x - s.x, + dz = e.y - s.y + const length = Math.hypot(dx, dz) + if (length < 1e-9) continue + const along = ((px - s.x) * dx + (pz - s.y) * dz) / length + const across = ((px - s.x) * -dz + (pz - s.y) * dx) / length + const distance = Math.hypot( + Math.max(0, -along, along - length), + Math.max(0, across - offsets.a, offsets.b - across), + ) + if (distance < nearest) { + nearest = distance + fits = distance <= WALL_CAP_SLACK + } + } + return fits + } + return polygon.every((part) => [part.outer, ...part.holes].every((ring) => ring.every(onBody))) +} + +/** + * Level-local Y of the wall's top when it stands at `base`: an authored height + * above it, else the storey plane of the level the walls belong to, else the + * legacy default (a level deriving its first height has no plane yet). + */ +function resolveWallTopAbove( + base: number, + wallLike: WallOverlapInput, + levelWalls: readonly WallNode[], + nodes?: Readonly>, +): number { + const height = + wallLike.height ?? + levelWalls.find( + (wall) => + wall.start[0] === wallLike.start[0] && + wall.start[1] === wallLike.start[1] && + wall.end[0] === wallLike.end[0] && + wall.end[1] === wallLike.end[1], + )?.height + if (height !== undefined) return base + height + const level = nodes?.[levelWalls[0]?.parentId ?? ''] + return level?.type === 'level' && level.height !== undefined + ? level.height + : base + DEFAULT_WALL_HEIGHT +} + function computeWallSlabSupportUncached( memo: SupportMemo, wallLike: WallOverlapInput, @@ -962,6 +1035,13 @@ function computeWallSlabSupportUncached( let preferredElevation: number | null = null let preferredElectedSlabId: string | null = null + type Candidate = { + slab: SlabNode + elevation: number + perPolyline: LengthInterval[][] + polygon: MultiPolygon + } + const candidates: Candidate[] = [] for (const slab of slabs) { if ( slab.support === 'open' || @@ -1001,6 +1081,27 @@ function computeWallSlabSupportUncached( nodes && slabs.some((slab) => slab.plateRole === 'base') ? liftedManualSlab(nodes, slab).elevation : (slab.elevation ?? 0.05) + candidates.push({ slab, elevation, perPolyline, polygon: renderedPolygon }) + } + + // A slab lying on the wall body with its underside at the wall's own top is + // a cap the wall carries (an importer's 12 mm "wall caps"), never a support. + // The top is measured from the lowest candidate, since the base being + // elected cannot decide it. A floor or terrace reaching past the wall keeps + // carrying it however short the wall, and an explicit host is the user's choice. + const floor = Math.min(levelBase, ...candidates.map((candidate) => candidate.elevation)) + const wallTop = resolveWallTopAbove(floor, wallLike, levelWalls, nodes) + const offsets = getWallFaceOffsets({ + ...wallLike, + thickness: Math.max(wallLike.thickness ?? DEFAULT_WALL_THICKNESS, 0), + }) + for (const { slab, elevation, perPolyline, polygon } of candidates) { + if ( + slab.id !== preferredSlabId && + elevation - slab.thickness >= wallTop - 1e-6 && + footprintOnWallBody(polygon, polylines[0]!, offsets) + ) + continue if (preferredSlabId != null && slab.id === preferredSlabId) { preferredElevation = elevation preferredElectedSlabId = slab.id From 0e01f895752287a2be53ea3f4b84e9968a298ae0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 08:18:12 -0400 Subject: [PATCH 13/26] geometry-script: bake the back face of double- and back-sided materials The viewer draws every material front side only (its WebGPU MRT workaround), so a curtain built as a DoubleSide plane showed nothing from the room. The compiler now appends the triangles again, wound the other way with negated normals, and sets the material to FrontSide; a BackSide material keeps only the flipped copy. Runs before the triangle count and the per-material merge, so the manifest counts what is exported. Co-Authored-By: Claude Fable 5.1 --- packages/geometry-script/src/compile.test.ts | 48 ++++++++++++++++++++ packages/geometry-script/src/compile.ts | 42 +++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/packages/geometry-script/src/compile.test.ts b/packages/geometry-script/src/compile.test.ts index 2f4f298aaf..c831ba55ad 100644 --- a/packages/geometry-script/src/compile.test.ts +++ b/packages/geometry-script/src/compile.test.ts @@ -25,6 +25,7 @@ import { AnimationMixer, Box3, BoxGeometry, + FrontSide, Group, LoopOnce, Mesh, @@ -53,6 +54,53 @@ export default function build() { } ` +const plane = (side: string) => ` +import * as THREE from 'three' +export default function build() { + const g = new THREE.Group() + const curtain = new THREE.Mesh(new THREE.PlaneGeometry(1, 2), new THREE.MeshStandardMaterial({ side: THREE.${side} })) + curtain.position.y = 1 + g.add(curtain) + return g +} +` + +describe('material sides', () => { + const triangles = (mesh: Mesh) => + (mesh.geometry.index?.count ?? mesh.geometry.getAttribute('position').count) / 3 + const build = async (side: string) => { + const { glb, manifest } = await compileGeometryScript({ code: plane(side) }) + const gltf = await new GLTFLoader().parseAsync(glb, '') + const mesh = gltf.scene.getObjectByProperty('isMesh', true) as Mesh + return { mesh, manifest, material: mesh.material as MeshStandardMaterial } + } + + test('a double-sided plane is exported with its back face baked in, front side only', async () => { + const { mesh, manifest, material } = await build('DoubleSide') + expect(triangles(mesh)).toBe(4) + expect(manifest.triangles).toBe(4) + expect(material.side).toBe(FrontSide) + const normals = mesh.geometry.getAttribute('normal') + expect(normals.getZ(0)).toBeCloseTo(1) + expect(normals.getZ(normals.count - 1)).toBeCloseTo(-1) + }) + + test('a front-side plane is exported as authored', async () => { + const { mesh, manifest, material } = await build('FrontSide') + expect(triangles(mesh)).toBe(2) + expect(manifest.triangles).toBe(2) + expect(material.side).toBe(FrontSide) + }) + + test('a back-side plane becomes the flipped copy only', async () => { + const { mesh, manifest, material } = await build('BackSide') + expect(triangles(mesh)).toBe(2) + expect(manifest.triangles).toBe(2) + expect(material.side).toBe(FrontSide) + expect(mesh.geometry.getAttribute('normal').getZ(0)).toBeCloseTo(-1) + }) +}) + describe('clips', () => { test('a clip is stored ending on its last pose, not wrapped to its first', async () => { const { glb } = await compileGeometryScript({ code: SASH }) diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index 255e74429a..e20ab03e70 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -536,6 +536,47 @@ function analyseGeometry(root: THREE.Object3D, parts: GeometryArtifactManifest[' } } +function flipWinding(source: THREE.BufferGeometry): THREE.BufferGeometry { + const geometry = source.index ? source.toNonIndexed() : source.clone() + for (const [name, attribute] of Object.entries(geometry.attributes)) { + const { itemSize, array } = attribute as THREE.BufferAttribute + for (let vertex = 0; vertex + 2 < attribute.count; vertex += 3) { + for (let k = 0; k < itemSize; k++) { + const b = (vertex + 1) * itemSize + k + const c = (vertex + 2) * itemSize + k + const swapped = array[b]! + array[b] = array[c]! + array[c] = swapped + } + } + if (name === 'normal') for (let i = 0; i < array.length; i++) array[i] = -array[i]! + } + return geometry +} + +/** + * The viewer draws every material front side only (its WebGPU MRT workaround), + * so a double-sided plane shows nothing from behind. The back face is baked into + * the geometry instead: the triangles again, wound the other way with their + * normals negated; a back-side material keeps only that copy. Runs before the + * triangle count and the merge, so both see what is exported. + */ +function bakeBackFaces(root: THREE.Object3D) { + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh || isHelper(mesh) || Array.isArray(mesh.material)) return + const { side } = mesh.material + if (side === THREE.FrontSide) return + const back = flipWinding(mesh.geometry) + mesh.geometry = + side === THREE.BackSide + ? back + : (BufferGeometryUtils.mergeGeometries([mesh.geometry.toNonIndexed(), back]) ?? + mesh.geometry) + mesh.material.side = THREE.FrontSide + }) +} + /** * Merges each part's meshes by material, so a porch draws as a few dozen * meshes instead of hundreds (draw calls, not triangles, are the cost). Part @@ -859,6 +900,7 @@ export async function finishBuild({ const read = readAnimations(built, root) const clips = sampleTransformClips(root, read.clips) const animated = read.animated + bakeBackFaces(root) const conventions = readConventions(root) if (conventions.triangles > GEOMETRY_SCRIPT_LIMITS.triangles) { throw new Error( From 4f1b897513b755866e6703677665fe9f4ed81423 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 08:18:12 -0400 Subject: [PATCH 14/26] core: a new door's handle hangs opposite its hinges Hinge and handle sides are both measured from the door's own left, so a door written with hingesSide right and the schema's default handle had both on one edge. add_door (and create_room's doors through it) now set handleSide = hingesSide === 'right' ? 'left' : 'right' unless the input names a handle side; the tool takes an optional handleSide. Co-Authored-By: Claude Fable 5.1 --- .../core/src/agent-operations/create-room.ts | 1 + .../core/src/agent-tools/wall-openings.ts | 4 +++ .../__fixtures__/wall-opening-cases.ts | 34 +++++++++++++++++++ packages/core/src/building/wall-openings.ts | 16 ++++++++- 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent-operations/create-room.ts b/packages/core/src/agent-operations/create-room.ts index 1faf27fda2..140bdf2ff8 100644 --- a/packages/core/src/agent-operations/create-room.ts +++ b/packages/core/src/agent-operations/create-room.ts @@ -26,6 +26,7 @@ type OpeningSpec = { height?: number sillHeight?: number hingesSide?: 'left' | 'right' + handleSide?: 'left' | 'right' swingDirection?: 'inward' | 'outward' style?: string } diff --git a/packages/core/src/agent-tools/wall-openings.ts b/packages/core/src/agent-tools/wall-openings.ts index f6d7005456..eb493159fc 100644 --- a/packages/core/src/agent-tools/wall-openings.ts +++ b/packages/core/src/agent-tools/wall-openings.ts @@ -80,6 +80,10 @@ export const addDoorTool = { "door (default), or opening: a passage with no leaf, framed or arched by the outline, as the editor's door panel offers.", ), hingesSide: z.enum(['left', 'right']).optional().describe('Hinge side (default left).'), + handleSide: z + .enum(['left', 'right']) + .optional() + .describe('Handle side (default: opposite the hinges).'), swingDirection: z .enum(['inward', 'outward']) .optional() diff --git a/packages/core/src/building/__fixtures__/wall-opening-cases.ts b/packages/core/src/building/__fixtures__/wall-opening-cases.ts index c1c61096e3..98bec9c06a 100644 --- a/packages/core/src/building/__fixtures__/wall-opening-cases.ts +++ b/packages/core/src/building/__fixtures__/wall-opening-cases.ts @@ -164,6 +164,40 @@ export const WALL_OPENING_CASES: readonly WallOpeningCase[] = [ input: { wallId: main, t: 0.5 }, expect: { localX: 2, centerY: 1.05, clamped: false, node: { rotation: [0, 0, 0] } }, }, + // Its hardware: the handle hangs opposite the hinges unless placed. + { + name: 'a right-hinged door takes its handle on the left', + tool: 'add_door', + input: { wallId: main, t: 0.5, hingesSide: 'right' }, + expect: { + localX: 2, + centerY: 1.05, + clamped: false, + node: { hingesSide: 'right', handleSide: 'left' }, + }, + }, + { + name: 'a left-hinged door takes its handle on the right', + tool: 'add_door', + input: { wallId: main, t: 0.5 }, + expect: { + localX: 2, + centerY: 1.05, + clamped: false, + node: { hingesSide: 'left', handleSide: 'right' }, + }, + }, + { + name: 'a handle side given is kept', + tool: 'add_door', + input: { wallId: main, t: 0.5, hingesSide: 'right', handleSide: 'right' }, + expect: { + localX: 2, + centerY: 1.05, + clamped: false, + node: { hingesSide: 'right', handleSide: 'right' }, + }, + }, // Where it goes { name: 'a door at t 0.5 is centred', diff --git a/packages/core/src/building/wall-openings.ts b/packages/core/src/building/wall-openings.ts index e675cffcc6..4ac793350b 100644 --- a/packages/core/src/building/wall-openings.ts +++ b/packages/core/src/building/wall-openings.ts @@ -180,6 +180,8 @@ export type WallOpeningInput = { height?: number sillHeight?: number hingesSide?: 'left' | 'right' + /** Defaults to the side opposite the hinges. */ + handleSide?: 'left' | 'right' swingDirection?: 'inward' | 'outward' /** A door, or a passage with no leaf (a cased opening, an arch), as the editor's door panel. */ openingKind?: 'door' | 'opening' @@ -244,6 +246,18 @@ export function doorFacing(wall: Pick): { } /** The face a door placed by hand takes: out on an outside wall, else the face hovered. */ +/** + * Hinges and handle are both measured from the door's own left, so a handle + * left where it is would hang on the hinge edge of a right-hinged door. + */ +export function doorHardwareSides(input: Pick) { + const hingesSide = input.hingesSide ?? 'left' + return { + hingesSide, + handleSide: input.handleSide ?? (hingesSide === 'right' ? 'left' : 'right'), + } as const +} + export const placedDoorFace = ( wall: Pick, hovered: 'front' | 'back', @@ -371,7 +385,7 @@ export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { ? DoorNode.parse({ ...base, ...doorFacing(wall), - hingesSide: input.hingesSide ?? 'left', + ...doorHardwareSides(input), swingDirection: input.swingDirection ?? 'inward', ...(input.style ? doorStyleLook(input.style as DoorStyle) : {}), ...(input.doorType ? { doorType: input.doorType } : {}), From c4fefb2268fe46786b04001e96bd602aa2fa036b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 08:18:13 -0400 Subject: [PATCH 15/26] core: pin what a room-edge push keeps on a painted wall A one-face paint looked flipped after a room resize. The push itself (setWallGeometry, which the editor's room arrows commit) keeps every wall, its direction, its slots and its children; what changes on the first structural edit is frontSide/backSide, which the kernel classifies from the room's face. The test writes the walls as an importer does, with the room on the back wall's b face, and pins all of that. Co-Authored-By: Claude Fable 5.1 --- .../src/commands/structure/structure.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/core/src/commands/structure/structure.test.ts b/packages/core/src/commands/structure/structure.test.ts index d8a2dc6376..687882c914 100644 --- a/packages/core/src/commands/structure/structure.test.ts +++ b/packages/core/src/commands/structure/structure.test.ts @@ -186,6 +186,57 @@ describe('structure primitives', () => { end: { x: wall.end[0], y: wall.end[1] }, }) }) + test('pushing a room edge keeps each wall, its direction, its finishes and its children; sides follow the room', () => { + // Walls written like an importer's: all read +x or +z, so the back wall has the room on its b face. + const walls = [ + WallNode.parse({ id: 'wall_south', parentId: 'level_test', start: [0, 0], end: [8, 0] }), + WallNode.parse({ id: 'wall_east', parentId: 'level_test', start: [8, 0], end: [8, 4] }), + WallNode.parse({ + id: 'wall_back', + parentId: 'level_test', + start: [0, 4], + end: [8, 4], + slots: { a: 'scene:mat_x', b: 'library:preset-softwhite' }, + children: ['door_back'], + frontSide: 'interior', + backSide: 'exterior', + }), + WallNode.parse({ id: 'wall_west', parentId: 'level_test', start: [0, 0], end: [0, 4] }), + ] + const door = DoorNode.parse({ + id: 'door_back', + parentId: 'wall_back', + wallId: 'wall_back', + position: [4, 1.05, 0], + }) + const initial = { ...fixture(), ...Object.fromEntries([...walls, door].map((n) => [n.id, n])) } + const nodes = apply( + initial, + createZone(initial, { + levelId: 'level_test', + boundaryIds: walls.map((n) => n.id), + mintId: mint(), + }), + ) + const pushed = apply( + nodes, + setWallGeometry(nodes, { wallId: 'wall_east', start: [9, 0], end: [9, 4], mintId: mint() }), + ) + expect( + Object.values(pushed) + .filter((n) => n.type === 'wall') + .map((n) => n.id), + ).toEqual(walls.map((n) => n.id)) + expect(pushed.wall_back).toMatchObject({ + start: [0, 4], + end: [9, 4], + slots: { a: 'scene:mat_x', b: 'library:preset-softwhite' }, + children: ['door_back'], + frontSide: 'exterior', + backSide: 'interior', + }) + expect(pushed.door_back).toMatchObject({ parentId: 'wall_back', wallId: 'wall_back' }) + }) test('create accepts a boundary set and closes explicit curved wall edges without a chord', () => { const existing = enclosed() const walls = Object.values(existing.nodes).filter((n) => n.type === 'wall') From 64bb180c35a8f29e888936b7509155d755c2acd7 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 08:23:36 -0400 Subject: [PATCH 16/26] core: author_scene reads each wall's room side from the plan instead of stamping interior Every wall was written frontSide interior, backSide exterior whatever its direction, so half the walls of a converted flat carried the room on the face tagged exterior until the first edit reclassified them: the facing hide rule, cladding and door facing then flipped. The same detection the editor runs classifies them at write time. Co-Authored-By: Claude Fable 5.1 --- .../core/src/agent-operations/author-scene.test.ts | 3 +++ packages/core/src/agent-operations/author-scene.ts | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 2d10a6bc99..8509472063 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -186,6 +186,9 @@ describe('author_scene', () => { const byType = (t: string) => created.filter((n) => n.type === t) expect(byType('level')).toHaveLength(1) expect(byType('wall')).toHaveLength(4) + // Each wall knows which face looks into the room, from the plan: never both the same. + for (const wall of byType('wall') as Array) + expect([wall.frontSide, wall.backSide].sort()).toEqual(['exterior', 'interior']) expect(byType('zone')).toHaveLength(1) const zone = byType('zone')[0] as AnyNode & { name: string; floor?: { finish?: string } } expect(zone.name).toBe('Bedroom') diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 6eaa5c3a5b..5611c83ca1 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -16,6 +16,8 @@ import { scriptedSize, scriptSource, } from '../lib/geometry-script-node' +import type { BoundaryNode } from '../lib/room-topology-index' +import { detectSpacesForLevel } from '../lib/space-detection' import { reconcileSceneStructure } from '../lib/structure-reconcile' import { nearestLibraryColorRef } from '../procedural-items/library-colors' import { @@ -391,8 +393,6 @@ export function authorScene( end: w.end, height: w.spec.height, thickness: w.spec.thickness, - frontSide: 'interior', - backSide: 'exterior', }) place(node) wallNodes.set(w.spec.id, { ...w, node }) @@ -798,6 +798,16 @@ export function authorScene( `${untypedWindows} window${untypedWindows === 1 ? ' has' : 's have'} no type: written fixed (cannot open); give type`, ) + // ---------- which face of each wall looks into a room: read from the plan, as the editor does ---------- + for (const levelNode of levelNodes) { + const boundaries = Object.values(draft).filter( + (n): n is BoundaryNode => + n.parentId === levelNode.id && (n.type === 'wall' || n.type === 'separator'), + ) + for (const { wallId, ...sides } of detectSpacesForLevel(levelNode.id, boundaries).wallUpdates) + draft[wallId] = { ...draft[wallId], ...sides } as AnyNode + } + // ---------- rooms: Pascal's own derivation, then the payload's names and floors ---------- const { nodes: withZones, createdZoneIds } = migrateRoomZones(draft) const zones = createdZoneIds.map((id) => ZoneNode.parse(withZones[id])) From a2fba24bc796aefbcfa4c7b0505a04661c83b626 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 09:16:41 -0400 Subject: [PATCH 17/26] core: author_scene's replacement salt probes a level id, so the same payload written again is not wiped The salt loop probed a 'probe' id no conversion ever writes, so a payload written over itself kept revision 0, minted the ids already in the scene, created nothing and deleted the old level: the hosted store refused it as a scene wipe. The test passed vacuously on an empty create list; it now demands the second write's creates and the first level's deletion. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/agent-operations/author-scene.test.ts | 4 ++++ packages/core/src/agent-operations/author-scene.ts | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 8509472063..a21c844d34 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -263,7 +263,11 @@ describe('author_scene', () => { expect(again.result.walls).toBe(4) // Written over itself, the same payload gets fresh ids, so nothing is created and then deleted. const firstIds = new Set(first.changes!.create!.map((c) => c.node.id)) + expect(again.changes!.create!.length).toBe(first.changes!.create!.length) expect(again.changes!.create!.some((c) => firstIds.has(c.node.id))).toBe(false) + expect(again.changes!.delete).toEqual([ + first.changes!.create!.find((c) => c.node.type === 'level')!.node.id, + ]) }) test('an opening takes its type, style, leaves and a handle opposite the hinge', () => { diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 5611c83ca1..08a0a0903a 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -261,9 +261,10 @@ export function authorScene( 'This scene already holds a conversion. Pass replace: true to write over it.', ) - // The first revision whose ids are free in this scene. + // The first revision whose ids are free in this scene: every conversion writes a level, so its + // first level id tells whether this payload was written here before. let revision = 0 - while (revision < 32 && scene[idFactory(payload, revision)('probe')]) revision++ + while (revision < 32 && scene[idFactory(payload, revision)('level')]) revision++ const mint = idFactory(payload, revision) const report: AuthorSceneReport = { levels: 0, From a7b3710c689d85b5249113ad85a2cc102b7e5cde Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 10:02:54 -0400 Subject: [PATCH 18/26] core + mcp: author_scene keeps a wall item's distance from the face, exact item and opening colours, and ceiling points for lights with no fixture What the four converting agents asked for after comparing pictures. A wall-hosted item was flattened onto the wall face, so a pleated curtain sat half inside the wall: its outward distance from the face is kept, and only a point inside the wall body is moved to the face and reported. An authored item's slots went through the library match, silently (oak to an orange texture, a red book to parquet): they are written as exact scene materials now, as walls already were; an opening's color is painted on its panel and frame (frameColor for a frame of its own); a scripted opening keeps its declared type. A light with no fixture was hosted on the nearest item (a rug, a bed): it now hangs from the ceiling of the room containing it, on a minimal ceiling point the operation writes itself, with the nearest item only as the reported fallback outside any room. Co-Authored-By: Claude Fable 5.1 --- .../src/agent-operations/author-scene.test.ts | 313 +++++++++++++++++- .../core/src/agent-operations/author-scene.ts | 243 ++++++++++---- packages/core/src/agent-operations/index.ts | 1 + .../core/src/agent-operations/paint.test.ts | 18 + packages/core/src/agent-operations/paint.ts | 14 + .../agent-operations/wall-skirting.test.ts | 117 +++++++ .../src/agent-operations/wall-skirting.ts | 121 +++++++ packages/core/src/agent-tools/author-scene.ts | 16 +- packages/core/src/agent-tools/paint.ts | 2 +- packages/core/src/index.ts | 1 + packages/core/src/systems/wall/wall-trim.ts | 20 ++ packages/mcp/src/tools/author-scene.test.ts | 83 ++++- packages/mcp/src/tools/author-scene.ts | 7 + packages/nodes/src/wall/panel-model.ts | 5 +- packages/nodes/src/wall/panel.tsx | 10 +- packages/nodes/src/wall/treatments.test.ts | 20 ++ skills/pascal-3d/SKILL.md | 2 +- skills/pascal-3d/references/tool-workflows.md | 4 +- wiki/architecture/agent-surfaces.md | 6 +- 19 files changed, 912 insertions(+), 91 deletions(-) create mode 100644 packages/core/src/agent-operations/wall-skirting.test.ts create mode 100644 packages/core/src/agent-operations/wall-skirting.ts create mode 100644 packages/core/src/systems/wall/wall-trim.ts diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index a21c844d34..50db58bad8 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test } from 'bun:test' import type { AuthorScenePayload } from '../agent-tools/author-scene' import { authorScenePayload } from '../agent-tools/author-scene' import type { AnyNode, CompiledGeometryScript, SceneMaterial } from '../schema' -import { authorScene } from './author-scene' +import { CEILING_DRAW_OFFSET, resolveCeilingHeight } from '../services' +import { authorScene, authorSceneCeilingPoints } from './index' /** A site, a building and the empty level a new project starts with. */ function scaffold(): Record { @@ -156,7 +157,10 @@ const bedroom: AuthorScenePayload = authorScenePayload.parse({ host: 'bed', }, ], - lights: [{ position: [1.2, 1.1, -2], fixture: 'lamp' }, { position: [0, 2.5, 1] }], + lights: [ + { position: [1.2, 1.1, -2], fixture: 'lamp' }, + { position: [0, 2.5, 1], fixture: 'bed' }, + ], }, ], notes: ['The rug was left out.'], @@ -213,6 +217,122 @@ describe('author_scene', () => { expect(Object.keys(painted.slots ?? {}).length).toBeGreaterThan(0) }) + test('a light without a fixture gets a flush authored ceiling point in its room, with its effect', () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [{ position: [0.5, 2.4, -0.75], color: '#ffeedd', intensity: 2 }], + }, + ], + }) + const points = authorSceneCeilingPoints(payload) + expect(points.map((point) => point.id)).toEqual(['light/0/0']) + const compiled = { + ...bed, + mount: 'ceiling', + manifest: { ...bed.manifest, bounds: { min: [-0.03, -0.008, -0.03], max: [0.03, 0, 0.03] } }, + } as CompiledGeometryScript + const outcome = run(payload, { [points[0]!.id]: compiled }, {}) + expect(outcome.result.defects).toEqual([]) + expect(outcome.result.lights).toBe(1) + expect(outcome.result.items).toBe(1) + expect(outcome.result.defaults).toContain( + 'light at [0.5, 2.4, -0.75] has no fixture: a ceiling point in Bedroom', + ) + const point = byAuthorId(outcome, 'light/0/0') + const ceiling = nodesOf(outcome).find((node) => node.id === point.parentId)! + expect(ceiling.type).toBe('ceiling') + expect((ceiling as { zoneId?: string }).zoneId).toBe(outcome.result.ids['room/0/0']) + expect((ceiling as { children?: string[] }).children).toContain(point.id) + expect(point.asset.category).toBe('light') + expect(point.asset.attachTo).toBe('ceiling') + expect(point.position).toEqual([0.5, 0, -0.75]) + expect(point.asset.interactive.controls).toContainEqual({ + kind: 'toggle', + label: 'Lights', + default: true, + }) + expect(point.asset.interactive.effects).toEqual([ + { + kind: 'light', + color: '#ffeedd', + intensityRange: [0, 2], + offset: [0, expect.any(Number), 0], + }, + ]) + expect(point.asset.interactive.effects[0].offset[1]).toBeCloseTo( + 2.4 - + resolveCeilingHeight( + ceiling as never, + Object.fromEntries(nodesOf(outcome).map((n) => [n.id, n])), + ) + + CEILING_DRAW_OFFSET, + 9, + ) + expect(authorSceneCeilingPoints(bedroom)).toEqual([]) + }) + + test('a light outside any room falls back to the nearest item on its own level and says why', () => { + const payload = authorScenePayload.parse({ + levels: [ + { + name: 'Lower', + items: [ + { + id: 'lower', + name: 'Lower bed', + category: 'bed', + code: '// bed', + position: [0, 0, 0], + }, + ], + }, + { + name: 'Upper', + items: [ + { + id: 'upper', + name: 'Upper bed', + category: 'bed', + code: '// bed', + position: [3, 0, 0], + }, + ], + lights: [{ position: [0, 2, 0] }], + }, + ], + }) + const outcome = run(payload, { lower: bed, upper: bed }, {}) + expect(outcome.result.lights).toBe(1) + expect(outcome.result.defects).toEqual([]) + expect(byAuthorId(outcome, 'lower').asset.interactive).toBeUndefined() + expect(byAuthorId(outcome, 'upper').asset.interactive.effects).toEqual([ + { kind: 'light', color: '#ffffff', intensityRange: [0, 1], offset: [-3, 2, 0] }, + ]) + expect(outcome.result.defaults).toContain( + 'light at [0, 2, 0] has no fixture: no room contains its point; hosted on the nearest item upper (3.00 m away); moving that item moves the light', + ) + }) + + test('a ceiling point whose module failed is reported by the light id', () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [{ ...bedroom.levels[0], items: [], lights: [{ position: [0, 2.4, 0] }] }], + }) + const outcome = authorScene( + scaffold(), + { payload, compiled: {}, compileErrors: { 'light/0/0': 'Compiler unavailable' } }, + { activeLevelId: null, materials: {} }, + ) + expect(outcome.result.lights).toBe(0) + expect(outcome.result.defects).toEqual([ + { id: 'light/0/0', code: 'compile_failed', message: 'Compiler unavailable' }, + ]) + }) + test('the same payload writes the same ids', () => { const a = authorScene( scaffold(), @@ -373,12 +493,12 @@ describe('author_scene', () => { const outcome = authorScene( scaffold(), { payload, compiled: { 'south/0': arch }, compileErrors: { 'south/1': 'Syntax error' } }, - { activeLevelId: 'level_0' }, + { activeLevelId: 'level_0', materials: {} }, ) const door = byAuthorId(outcome, 'south/0') expect(door.source.kind).toBe('script') expect(door.source.artifact).toBe(arch.sha256) - expect(door.slots.wood).toStartWith('library:wood-') + expect(door.slots.wood).toStartWith('scene:') expect([door.width, door.height]).toEqual([1, 2.1]) expect(outcome.result.defects).toEqual([ { id: 'south/1', code: 'compile_failed', message: 'Syntax error' }, @@ -386,6 +506,146 @@ describe('author_scene', () => { expect(outcome.result.windows).toBe(1) }) + test('authored slot colours stay exact and share materials with walls; unhinted slots keep their default', () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + lights: [], + items: [bedroom.levels[0]!.items![0]], + }, + ], + }) + const compiled = { + ...bed, + manifest: { + ...bed.manifest, + slots: [ + { id: 'wood', color: '#8a6a4a' }, + { id: 'book', color: '#ff0000' }, + { id: 'rod', color: '#000000' }, + { id: 'curtain', color: '#f1ece4' }, + { id: 'unhinted' }, + ], + }, + } as CompiledGeometryScript + const outcome = run(payload, { bed: compiled }, {}) + const item = byAuthorId(outcome, 'bed') + const materials = outcome.changes!.materials! + for (const slot of compiled.manifest.slots.filter((s) => s.color)) { + const material = materials.find((m) => `scene:${m.id}` === item.slots[slot.id])! + expect(material.material.properties.color).toBe(slot.color!) + } + expect(item.slots.curtain).toBe(byAuthorId(outcome, 'north').slots.a) + expect(item.slots.unhinted).toBeUndefined() + expect(outcome.result.defaults.some((s) => s.includes('colour'))).toBe(false) + expect(outcome.result.defects).toEqual([]) + }) + + test('opening colours paint panel and frame exactly, with an optional different frame', () => { + const payload = authorScenePayload.parse({ + levels: [ + { + name: 'Ground', + walls: [ + { + id: 'wall', + start: [0, 0], + end: [8, 0], + height: 3, + thickness: 0.2, + openings: [ + { kind: 'door', along: 1, width: 1, height: 2, color: '#123456' }, + { + kind: 'door', + along: 3, + width: 1, + height: 2, + color: '#123456', + frameColor: '#abcdef', + }, + { kind: 'window', along: 5, width: 1, height: 1, color: '#123456' }, + { + kind: 'window', + along: 7, + width: 1, + height: 1, + color: '#123456', + frameColor: '#abcdef', + }, + ], + }, + ], + }, + ], + }) + const outcome = run(payload, {}, {}) + const materials = outcome.changes!.materials! + const ref = (color: string) => + `scene:${materials.find((m) => m.material.properties.color === color)!.id}` + expect(materials).toHaveLength(2) + expect(byAuthorId(outcome, 'wall/0').slots).toEqual({ + panel: ref('#123456'), + frame: ref('#123456'), + }) + expect(byAuthorId(outcome, 'wall/1').slots).toEqual({ + panel: ref('#123456'), + frame: ref('#abcdef'), + }) + expect(byAuthorId(outcome, 'wall/2').slots).toEqual({ frame: ref('#123456') }) + expect(byAuthorId(outcome, 'wall/3').slots).toEqual({ frame: ref('#abcdef') }) + expect(outcome.result.defects).toEqual([]) + }) + + test('scripted openings retain their declared type and report only an ignored style', () => { + const payload = authorScenePayload.parse({ + levels: [ + { + name: 'Ground', + walls: [ + { + id: 'wall', + start: [0, 0], + end: [4, 0], + height: 3, + thickness: 0.2, + openings: [ + { + kind: 'door', + along: 1, + width: 1, + height: 2, + code: '// door', + type: 'french', + style: 'modern', + color: '#123456', + }, + { + kind: 'window', + along: 3, + width: 1, + height: 1, + code: '// window', + type: 'casement', + frameColor: '#123456', + }, + ], + }, + ], + }, + ], + }) + const outcome = run(payload, { 'wall/0': arch, 'wall/1': arch }, {}) + expect(byAuthorId(outcome, 'wall/0').doorType).toBe('french') + expect(byAuthorId(outcome, 'wall/1').windowType).toBe('casement') + expect(byAuthorId(outcome, 'wall/0').slots.panel).toBe( + byAuthorId(outcome, 'wall/1').slots.frame, + ) + expect(outcome.result.defaults).toEqual(['door wall/0: style ignored, its module builds it']) + expect(outcome.result.defects).toEqual([]) + }) + test('a hex colour is one exact scene material per colour, a wall face each its own', () => { const payload = authorScenePayload.parse({ ...bedroom, @@ -489,13 +749,56 @@ describe('author_scene', () => { expect(trim.side).toBe('front') expect(trim.position[0]).toBeCloseTo(2, 6) expect(trim.position[1]).toBe(0) - expect(trim.position[2]).toBeCloseTo(0.06, 6) + expect(trim.position[2]).toBeCloseTo(0.1, 6) expect(trim.wallT).toBeCloseTo(0.5, 6) expect(trim.rotation[1]).toBe(0) expect(trim.asset.attachTo).toBe('wall-side') expect(wall.children).toContain(trim.id) }) + test('a wall curtain keeps its 4 cm face offset; points inside the wall move to the face', () => { + for (const [position, side, z] of [ + [[0, 0, -2.4], 'front', 0.1], + [[0, 0, -2.6], 'back', -0.1], + [[0, 0, -2.48], 'front', 0.06], + [[0, 0, -2.44], 'front', 0.06], + ] as const) { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + lights: [], + items: [ + { + id: 'curtain', + name: 'Curtain', + category: 'curtain', + code: '// curtain', + position, + host: 'north', + }, + ], + }, + ], + }) + const curtain = { + ...bed, + manifest: { ...bed.manifest, bounds: { min: [-1, 0, -0.035], max: [1, 2.4, 0.035] } }, + } as CompiledGeometryScript + const outcome = run(payload, { curtain }) + expect(outcome.result.defects).toEqual([]) + expect(byAuthorId(outcome, 'curtain').side).toBe(side) + expect(byAuthorId(outcome, 'curtain').position[2]).toBeCloseTo(z, 6) + const corrections = outcome.result.defaults.filter((s) => s.startsWith('curtain:')) + expect(corrections).toEqual( + position[2] === -2.48 + ? ['curtain: point inside wall north, moved to its nearest face'] + : [], + ) + } + }) + test('warns about thin and many slabs and many regions, refuses a slab that would cap a wall', () => { const slab = (top: number, thickness: number, polygon: number[][], name: string) => ({ name, diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 08a0a0903a..644250d693 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -22,6 +22,7 @@ import { reconcileSceneStructure } from '../lib/structure-reconcile' import { nearestLibraryColorRef } from '../procedural-items/library-colors' import { type AnyNode, + type CeilingNode, ColumnNode, type CompiledGeometryScript, DoorNode, @@ -34,6 +35,8 @@ import { ZoneNode, } from '../schema' import { DoorType, WindowType } from '../schema/nodes/opening-types' +import { resolveCeilingHeight } from '../services/level-height' +import { CEILING_DRAW_OFFSET } from '../services/storey' import { getWallLocalFaceZ } from '../systems/wall/wall-frame' import { migrateRoomZones } from '../utils/room-zone-migration' import { addObject } from './add-object' @@ -47,20 +50,20 @@ import type { AgentContext, AgentOperationOutcome, NodeWrite, SceneNodes } from * over as a payload and written as native Pascal nodes in one step. Not in AGENT_OPERATIONS: the * host compiles every module first (the MCP on its server, the chat in its worker) and passes the * results as `compiled`, keyed by the payload's item ids and, for an opening with code, by - * `/`; a module whose compile failed is absent there and comes back in the report + * `/`. Candidate ceiling points use `light//` from + * `authorSceneCeilingPoints`; a module whose compile failed is absent and comes back in the report * with the host's reason. * * The policy, decided with the owner: accept and report. Everything valid is written; every * defect comes back by the payload's own id with a reason; what Pascal fills in or drops is a - * sentence in `defaults`; what it writes but doubts is a `warning`. The one correction Pascal - * makes itself is its join rule (a wall end within reach of another wall's centerline snaps onto - * it), reported per wall. Ids are derived from the payload, so the same payload writes the same + * sentence in `defaults`; what it writes but doubts is a `warning`. Its join rule snaps a wall + * end within reach of another wall's centerline onto it, reported per wall. Ids are derived from the payload, so the same payload writes the same * scene. */ export type AuthorSceneInput = { payload: AuthorScenePayload compiled: Record - /** Why a module did not compile, by the payload's item id or `/`, for the report. */ + /** Why a module did not compile, keyed like `compiled`, for the report. */ compileErrors?: Record replace?: boolean } @@ -86,7 +89,7 @@ export type AuthorSceneReport = { warnings: AuthorSceneDefect[] defects: AuthorSceneDefect[] notes: string[] - /** The payload's own ids (walls, `wall/i` openings, `room/level/i`, items) to the nodes written. */ + /** Payload ids, `wall/i` openings, `room/level/i`, and `light/level/i` ceiling points to nodes. */ ids: Record } @@ -243,6 +246,24 @@ const isDoorStyle = (style: string): style is DoorStyle => const isWindowStyle = (style: string): style is WindowStyle => (WINDOW_STYLES as readonly string[]).includes(style) +const CEILING_POINT_CODE = `export const mount = 'ceiling' +export default function build({ THREE }) { + const material = new THREE.MeshStandardMaterial({ color: '#ffffff' }) + material.name = 'slot_body' + return new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.008, 24), material) +}` + +/** Candidate modules, compiled by the host before room derivation decides which points are used. */ +export function authorSceneCeilingPoints( + payload: AuthorScenePayload, +): { id: string; code: string }[] { + return payload.levels.flatMap((level, index) => + level.lights.flatMap((light, i) => + light.fixture ? [] : [{ id: `light/${index}/${i}`, code: CEILING_POINT_CODE }], + ), + ) +} + export function authorScene( scene: SceneNodes, input: AuthorSceneInput, @@ -299,6 +320,15 @@ export function authorScene( } return got.ref } + const exactSlots = (compiled: CompiledGeometryScript, id: string) => { + const slots = matchScriptSlotsToLibrary(compiled.manifest) + for (const slot of compiled.manifest.slots) { + if (!slot.color) continue + const ref = finishRef(slot.color, id) + if (ref) slots[slot.id] = ref + } + return slots + } const paintContext = (levelId: string): AgentContext => ({ ...context, activeLevelId: levelId, @@ -403,7 +433,7 @@ export function authorScene( const length = dist(w.start, w.end) ;(w.spec.openings ?? []).forEach((o, i) => { const id = `${w.spec.id}/${i}` - // A module builds the opening: its bounds are its size, its type and style do not apply. + // A module supplies geometry; the declared type still describes how the opening works. let compiled: CompiledGeometryScript | undefined if (o.code) { compiled = input.compiled[id] @@ -426,8 +456,7 @@ export function authorScene( defect(id, e.code ?? 'unmeasurable_object', e.message ?? String(error)) return } - if (o.type || o.style) - say(`${o.kind} ${id}: type and style ignored, its module builds it`) + if (o.style) say(`${o.kind} ${id}: style ignored, its module builds it`) } const [width, height] = compiled ? scriptedSize(compiled.manifest) : [o.width, o.height] if (width > length + 0.01) { @@ -457,13 +486,13 @@ export function authorScene( ...(compiled ? { source: scriptSource(compiled, { category: o.kind }), - slots: matchScriptSlotsToLibrary(compiled.manifest), + slots: exactSlots(compiled, id), } : {}), } let opening: AnyNode if (o.kind === 'door') { - const type = o.type && !compiled ? o.type : undefined + const type = o.type if (type && !isDoorType(type)) defect(id, 'type_mismatch', `${type} is a window type; the door is written hinged.`) const style = o.style && !compiled ? o.style : undefined @@ -489,7 +518,7 @@ export function authorScene( }) report.doors++ } else { - const type = o.type && !compiled ? o.type : undefined + const type = o.type if (type && !isWindowType(type)) defect(id, 'type_mismatch', `${type} is a door type; the window is written fixed.`) const windowType = type && isWindowType(type) ? type : undefined @@ -513,6 +542,17 @@ export function authorScene( }) report.windows++ } + const panel = o.kind === 'door' ? finishRef(o.color, id) : undefined + const frame = finishRef(o.frameColor ?? o.color, id) + if (panel || frame) + opening = { + ...opening, + slots: { + ...('slots' in opening ? opening.slots : {}), + ...(panel ? { panel } : {}), + ...(frame ? { frame } : {}), + }, + } as AnyNode draft[opening.id] = opening report.ids[id] = opening.id node.children.push(opening.id as never) @@ -648,9 +688,14 @@ export function authorScene( hostWall.end[1] - hostWall.start[1], ] // The wall's left (its local +z) is its front. - const left = - -dz * (world[0] - hostWall.start[0]) + dx * (world[2] - hostWall.start[1]) >= 0 - const z = getWallLocalFaceZ(hostWall.node, left ? 'a' : 'b') + const localZ = + (-dz * (world[0] - hostWall.start[0]) + dx * (world[2] - hostWall.start[1])) / length + const left = localZ >= 0 + const faceZ = getWallLocalFaceZ(hostWall.node, left ? 'a' : 'b') + const offset = (localZ - faceZ) * (left ? 1 : -1) + const z = faceZ + (left ? 1 : -1) * Math.max(0, offset) + if (offset < -1e-9) + say(`${item.id}: point inside wall ${item.host}, moved to its nearest face`) if (item.rotation) say(`${item.id}: rotation ignored on wall ${item.host}, it faces the room`) const [ux, uz] = [dx / length, dz / length] @@ -696,8 +741,11 @@ export function authorScene( { ...context, activeLevelId: levelNode.id }, ) for (const { node, parentId } of outcome.changes?.create ?? []) { - draft[node.id] = - placed.wallT === undefined ? node : ({ ...node, wallT: placed.wallT } as AnyNode) + draft[node.id] = { + ...node, + slots: exactSlots(compiled, item.id), + ...(placed.wallT === undefined ? {} : { wallT: placed.wallT }), + } as AnyNode const parent = draft[parentId ?? levelNode.id] as AnyNode & { children?: string[] } parent.children = [...(parent.children ?? []), node.id] itemNodeIds.set(item.id, node.id) @@ -714,53 +762,6 @@ export function authorScene( for (const item of pending) defect(item.id, 'host_missing', `Its host ${item.host} was not placed.`) - // ---------- lights: on their fixture, else on the nearest item ---------- - for (const [i, light] of level.lights.entries()) { - const id = `light/${index}/${i}` - const at: Vec3 = [light.position[0]!, light.position[1]!, light.position[2]!] - let hostKey = light.fixture && itemNodeIds.has(light.fixture) ? light.fixture : undefined - if (!hostKey) { - let best: { key: string; d: number } | null = null - for (const [key, frame] of itemFrames) { - const d = Math.hypot(frame.position[0] - at[0], frame.position[2] - at[2]) - if (!best || d < best.d) best = { key, d } - } - if (!best) { - defect( - id, - 'light_unhosted', - 'No item on this level to hold the light; Pascal keeps lights inside objects.', - ) - continue - } - hostKey = best.key - say( - `light at [${at.join(', ')}] has no fixture: hosted on the nearest item ${hostKey} (${best.d.toFixed(2)} m away); moving that item moves the light`, - ) - } - const hostNode = draft[itemNodeIds.get(hostKey)!] as AnyNode & { - asset: { interactive?: { controls: unknown[]; effects: unknown[] } } - } - // Where add_object keeps an object's lights: the asset's interactive block, with a toggle. - const interactive = hostNode.asset.interactive ?? { controls: [], effects: [] } - const controls = interactive.controls.some((c) => (c as { kind?: string }).kind === 'toggle') - ? interactive.controls - : [...interactive.controls, { kind: 'toggle', label: 'Lights', default: true }] - const effects = [ - ...interactive.effects, - { - kind: 'light', - color: light.color, - intensityRange: [0, light.intensity], - offset: toLocal(itemFrames.get(hostKey)!, at), - }, - ] - draft[hostNode.id] = Object.assign({}, hostNode, { - asset: { ...hostNode.asset, interactive: { controls, effects } }, - }) as unknown as AnyNode - report.lights++ - } - // ---------- wall paint, through the editor's paint: both faces, or each its own ---------- for (const w of walls) { const node = wallNodes.get(w.spec.id)?.node @@ -883,7 +884,119 @@ export function authorScene( mintId: (kind) => mint(kind), }) - const create: NodeWrite[] = Object.values(structure.nodes) + // Ceilings exist only after structure reconciliation; lights can now use their room's host. + const written = { ...structure.nodes } + payload.levels.forEach((level, index) => { + const levelNode = levelNodes[index]! + for (const [i, light] of level.lights.entries()) { + const id = `light/${index}/${i}` + const at: Vec3 = [light.position[0]!, light.position[1]!, light.position[2]!] + let hostId = light.fixture ? itemNodeIds.get(light.fixture) : undefined + let hostFrame = light.fixture ? itemFrames.get(light.fixture) : undefined + const room = !light.fixture + ? Object.values(written).find( + (node): node is ZoneNode => + node.type === 'zone' && + node.spaceRole === 'room' && + node.parentId === levelNode.id && + pointInPolygon([at[0], at[2]], node.polygon) && + !node.holes.some((hole) => pointInPolygon([at[0], at[2]], hole)), + ) + : undefined + const ceiling = room + ? Object.values(written).find( + (node): node is CeilingNode => + node.type === 'ceiling' && node.zoneId === room.id && node.parentId === levelNode.id, + ) + : undefined + if (ceiling) { + const compiled = input.compiled[id] + if (!compiled) { + defect( + id, + 'compile_failed', + input.compileErrors?.[id] ?? 'The ceiling point module did not compile.', + ) + continue + } + try { + const outcome = addObject( + written, + { + compiled: { ...compiled, nodeId: mint('item') }, + name: `Ceiling point in ${room!.name}`, + category: 'light', + reason: 'author_scene: ceiling point for a light without a fixture', + parentId: ceiling.id, + position: [at[0], 0, at[2]], + rotation: 0, + }, + { ...context, activeLevelId: levelNode.id }, + ) + const node = outcome.changes!.create![0]!.node + written[node.id] = { ...node, slots: exactSlots(compiled, id) } as AnyNode + written[ceiling.id] = { ...ceiling, children: [...ceiling.children, node.id as never] } + hostId = node.id + hostFrame = { + position: [at[0], resolveCeilingHeight(ceiling, written) - CEILING_DRAW_OFFSET, at[2]], + yaw: 0, + } + report.ids[id] = node.id + report.items++ + say(`light at [${at.join(', ')}] has no fixture: a ceiling point in ${room!.name}`) + } catch (error) { + const e = error as { code?: string; message?: string } + defect(id, e.code ?? 'item_refused', e.message ?? String(error)) + continue + } + } + if (!hostId) { + let best: { key: string; d: number } | null = null + for (const item of level.items ?? []) { + const frame = itemFrames.get(item.id) + if (!frame) continue + const d = Math.hypot(frame.position[0] - at[0], frame.position[2] - at[2]) + if (!best || d < best.d) best = { key: item.id, d } + } + if (!best) { + defect( + id, + 'light_unhosted', + 'No item on this level to hold the light; Pascal keeps lights inside objects.', + ) + continue + } + hostId = itemNodeIds.get(best.key)! + hostFrame = itemFrames.get(best.key)! + say( + `light at [${at.join(', ')}] has no fixture: ${!light.fixture ? 'no room contains its point; ' : ''}hosted on the nearest item ${best.key} (${best.d.toFixed(2)} m away); moving that item moves the light`, + ) + } + const hostNode = written[hostId] as AnyNode & { + asset: { interactive?: { controls: unknown[]; effects: unknown[] } } + } + const interactive = hostNode.asset.interactive ?? { controls: [], effects: [] } + const controls = interactive.controls.some((c) => (c as { kind?: string }).kind === 'toggle') + ? interactive.controls + : [...interactive.controls, { kind: 'toggle', label: 'Lights', default: true }] + const effects = [ + ...interactive.effects, + { + kind: 'light', + color: light.color, + intensityRange: [0, light.intensity], + offset: toLocal(hostFrame!, at), + }, + ] + written[hostNode.id] = { + ...hostNode, + asset: { ...hostNode.asset, interactive: { controls, effects } }, + } as unknown as AnyNode + report.lights++ + } + }) + + const create: NodeWrite[] = Object.values(written) .filter((node) => !scene[node.id]) .map((node) => ({ op: 'create', node, parentId: node.parentId ?? undefined })) return { diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index ba3ad569b9..cb8e4f9f9a 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -85,6 +85,7 @@ export * from './types' export * from './verify-scene' export * from './wall-object-observations' export * from './wall-opening' +export * from './wall-skirting' const SINGLE_OPERATIONS = { measure_stair: measureStairOperation, diff --git a/packages/core/src/agent-operations/paint.test.ts b/packages/core/src/agent-operations/paint.test.ts index 2be0aed5df..0c344f994c 100644 --- a/packages/core/src/agent-operations/paint.test.ts +++ b/packages/core/src/agent-operations/paint.test.ts @@ -345,6 +345,24 @@ describe('a wall in a room with a wall finish', () => { }) }) +describe("a wall's trim", () => { + test('named without a face is painted on both faces, the faces themselves untouched', () => { + const nodes = room() + const { changes } = paint(nodes, { + targets: ['wall_south'], + role: 'skirting', + material: 'library:preset-white', + }) + const wall = applied(nodes, {}, changes).nodes.wall_south as WallNode + expect(wall.slots).toEqual({ + a: DRYWALL, + b: 'library:concrete-raw', + aSkirting: 'library:preset-white', + bSkirting: 'library:preset-white', + }) + }) +}) + 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'], diff --git a/packages/core/src/agent-operations/paint.ts b/packages/core/src/agent-operations/paint.ts index 95864ba8ea..06036ededa 100644 --- a/packages/core/src/agent-operations/paint.ts +++ b/packages/core/src/agent-operations/paint.ts @@ -5,6 +5,7 @@ import { nearestLibraryColorRef } from '../procedural-items/library-colors' import { type AnyNode, getEffectiveSegmentSurfaceMaterial, + getWallTrimSlotId, isGlazedRoofSegment, type MaterialSchema, type RoofNode, @@ -12,6 +13,7 @@ import { type SceneMaterial, type WallFace, type WallNode, + type WallTrimKind, } from '../schema' import { buildWallFinishLayout, getWallLevelZones } from '../systems/wall/wall-finish' import { planWallPaint } from '../systems/wall/wall-paint-plan' @@ -39,6 +41,8 @@ type PaintInput = { erase?: true } +const WALL_TRIMS: readonly WallTrimKind[] = ['skirting', 'crown', 'chairRail'] + const ROOF_ROLES = ['top', 'edge', 'wall', 'fascia'] as const type RoofRole = (typeof ROOF_ROLES)[number] /** The surfaces the roof itself holds; its fascia is held by each of its segments. */ @@ -325,6 +329,16 @@ export const paint: AgentOperation = (nodes, input, context) => { `Wall ${id} has no ${input.role} face by its rooms or its stored sides: name its face, a or b.`, { target: id }, ) + } else if (node.type === 'wall' && WALL_TRIMS.includes(input.role as WallTrimKind)) { + // A trim named without a face is the trim on both faces, as a wall without a role is. + roles = (['a', 'b'] as const).map((face) => + getWallTrimSlotId(face, input.role as WallTrimKind), + ) + if (!roles.every((role) => ids.includes(role))) + refuse('unknown_role', `A ${node.type}'s roles are ${ids.join(', ')}.`, { + target: id, + roles: ids, + }) } else if (input.role) { if (!ids.includes(input.role)) refuse('unknown_role', `A ${node.type}'s roles are ${ids.join(', ')}.`, { diff --git a/packages/core/src/agent-operations/wall-skirting.test.ts b/packages/core/src/agent-operations/wall-skirting.test.ts new file mode 100644 index 0000000000..539e04fbfe --- /dev/null +++ b/packages/core/src/agent-operations/wall-skirting.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test' +import { isAgentRefusal } from '../agent-tools' +import { setWallGeometry } from '../commands/structure' +import { type AnyNode, LevelNode, WallNode } from '../schema' +import { applySceneChanges } from './apply-changes' +import type { SceneNodes } from './types' +import { type SetWallSkirtingInput, setWallSkirting } from './wall-skirting' + +/** A 5 × 4 m room of four walls running anticlockwise, so face a of each is inside. */ +function room(): Record { + const corners: [number, number][] = [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ] + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: 'level_skirting', + start, + end: corners[(index + 1) % 4], + thickness: 0.2, + }), + ) + const level = LevelNode.parse({ + id: 'level_skirting', + level: 0, + children: walls.map((wall) => wall.id), + }) + return Object.fromEntries([level, ...walls].map((node) => [node.id, node])) +} + +function run(nodes: SceneNodes, input: SetWallSkirtingInput) { + const outcome = setWallSkirting(nodes, input, { activeLevelId: 'level_skirting' }) + return { ...outcome, nodes: applySceneChanges(nodes, outcome.changes) as Record } +} + +function refusal(action: () => unknown): string { + try { + action() + } catch (error) { + if (isAgentRefusal(error)) return error.code + throw error + } + throw new Error('expected a refusal') +} + +describe('setWallSkirting', () => { + test('runs along the face its room stands on, at the size given', () => { + const { nodes, result } = run(room(), { wallIds: ['wall_0'], height: 0.14, depth: 0.012 }) + expect((nodes.wall_0 as WallNode).skirting).toMatchObject({ + enabled: true, + sides: 'a', + height: 0.14, + proud: 0.012, + }) + expect(result.walls).toEqual([{ id: 'wall_0', faces: ['a'], height: 0.14, depth: 0.012 }]) + }) + + test('paints the skirting slot of each face it runs along, and no other', () => { + const { nodes } = run(room(), { + wallIds: ['wall_0', 'wall_1'], + faces: 'both', + finish: 'library:preset-white', + }) + for (const id of ['wall_0', 'wall_1']) + expect((nodes[id] as WallNode).slots).toEqual({ + aSkirting: 'library:preset-white', + bSkirting: 'library:preset-white', + }) + expect((nodes.wall_2 as WallNode).slots).toBeUndefined() + }) + + test('hidden, it keeps its settings, as the panel hides it', () => { + const shown = run(room(), { wallIds: ['wall_0'], height: 0.2 }).nodes + const { nodes } = run(shown, { wallIds: ['wall_0'], enabled: false }) + expect((nodes.wall_0 as WallNode).skirting).toMatchObject({ enabled: false, height: 0.2 }) + }) + + test('a wall with no room on either face names its faces', () => { + const nodes = { + ...room(), + wall_free: WallNode.parse({ + id: 'wall_free', + parentId: 'level_skirting', + start: [10, 0], + end: [12, 0], + }), + } + expect(refusal(() => run(nodes, { wallIds: ['wall_free'] }))).toBe('side_unknown') + expect(run(nodes, { wallIds: ['wall_free'], faces: 'b' }).result.walls).toEqual([ + { id: 'wall_free', faces: ['b'], height: 0.12, depth: 0.02 }, + ]) + }) + + test('refuses a size that is no length, and a node that is no wall', () => { + expect(refusal(() => run(room(), { wallIds: ['wall_0'], height: 0 }))).toBe('invalid_size') + expect(refusal(() => run(room(), { wallIds: ['level_skirting'] }))).toBe('target_not_found') + }) + + test('is a property of the wall: a moved wall keeps it', () => { + const skirted = run(room(), { wallIds: ['wall_0'], height: 0.15 }).nodes + const before = (skirted.wall_0 as WallNode).skirting + const plan = setWallGeometry(skirted, { + wallId: 'wall_0', + start: [0, -1], + end: [5, -1], + mintId: (kind) => `${kind}_moved`, + }) + const change = plan.changes.find((entry) => entry.op === 'update' && entry.id === 'wall_0') + if (change?.op !== 'update') throw new Error('expected the wall to move') + const moved = { ...skirted.wall_0, ...change.data } as WallNode + expect(moved.start).toEqual([0, -1]) + expect(moved.skirting).toEqual(before) + }) +}) diff --git a/packages/core/src/agent-operations/wall-skirting.ts b/packages/core/src/agent-operations/wall-skirting.ts new file mode 100644 index 0000000000..e07770764d --- /dev/null +++ b/packages/core/src/agent-operations/wall-skirting.ts @@ -0,0 +1,121 @@ +import { refuse } from '../agent-tools/refusal' +import { + type AnyNode, + getWallTrimFaces, + getWallTrimSlotId, + type WallFace, + type WallNode, + type WallTrimProfile, +} from '../schema' +import { roomFaceCover } from '../systems/wall/wall-room-sides' +import { wallTrimPatch } from '../systems/wall/wall-trim' +import { paint } from './paint' +import type { AgentOperation, SceneChanges, SceneNodes } from './types' + +export type SetWallSkirtingInput = { + wallIds: string[] + /** False hides the skirting and keeps its settings; absent or true shows it. */ + enabled?: boolean + /** Metres up the face from its base. */ + height?: number + /** Metres it stands proud of the face (stored as `proud`). */ + depth?: number + profile?: WallTrimProfile + /** The faces it runs along; absent, the faces a room stands on. */ + faces?: 'a' | 'b' | 'both' + /** A `library:` / `scene:` ref or a `#rrggbb` colour, painted as `paint` paints it; absent keeps the skirting's finish. */ + finish?: string +} + +/** + * The faces a wall's skirting runs along when none are named: those a room stands on, else those + * its stored sides call interior. A wall with neither is refused, so the caller names its faces. + */ +function roomFaces(nodes: SceneNodes, wall: WallNode): WallFace[] { + const cover = roomFaceCover(nodes, wall.id) + const faces = (['a', 'b'] as const).filter((face) => cover[face] !== 'none') + if (faces.length) return faces + const interior = (['a', 'b'] as const).filter( + (face) => (face === 'a' ? wall.frontSide : wall.backSide) === 'interior', + ) + if (interior.length) return interior + refuse( + 'side_unknown', + `Wall ${wall.id} has no room on either face: name the faces its skirting runs along, a, b or both.`, + { target: wall.id }, + ) +} + +const positive = (value: number | undefined, field: string) => { + if (value !== undefined && !(Number.isFinite(value) && value > 0)) + refuse('invalid_size', `A skirting's ${field} is a length above 0 m.`, { field }) +} + +/** + * `setWallSkirting`: a wall's skirting (baseboard) shown, sized and finished in one write, the one + * the wall panel makes. It is a property of the wall, so it follows the wall wherever the wall + * goes, steps with its base and breaks at its doors and floor-length openings. The finish is the + * skirting slot of each face it runs along, painted through `paint`. + */ +export const setWallSkirting: AgentOperation = (nodes, input, context) => { + positive(input.height, 'height') + positive(input.depth, 'depth') + const scratch: Record = { ...nodes } + const walls: { id: string; faces: WallFace[]; height: number; depth: number }[] = [] + for (const id of new Set(input.wallIds)) { + const wall = nodes[id] + if (wall?.type !== 'wall') + refuse('target_not_found', `${id} is not a wall in the scene: nothing was changed.`, { + target: id, + }) + if (wall.wallType === 'curtain') + refuse('curtain_wall', `Wall ${id} is a curtain wall: it has no skirting.`, { target: id }) + const faces = input.faces === undefined ? roomFaces(nodes, wall) : getWallTrimFaces(input.faces) + const { skirting } = wallTrimPatch(wall, 'skirting', { + enabled: input.enabled ?? true, + sides: faces.length === 2 ? 'both' : faces[0], + ...(input.height === undefined ? {} : { height: input.height }), + ...(input.depth === undefined ? {} : { proud: input.depth }), + ...(input.profile === undefined ? {} : { profile: input.profile }), + }) + scratch[id] = { ...wall, skirting } + walls.push({ id, faces, height: skirting.height, depth: skirting.proud }) + } + + let note: string | undefined + if (input.finish !== undefined) { + const finish = /^#[0-9a-fA-F]{6}$/.test(input.finish) + ? { color: input.finish } + : { material: input.finish } + for (const face of ['a', 'b'] as const) { + const targets = walls.filter((wall) => wall.faces.includes(face)).map((wall) => wall.id) + if (!targets.length) continue + const painted = paint( + scratch, + { targets, role: getWallTrimSlotId(face, 'skirting'), ...finish }, + context, + ) + for (const { id, data } of painted.changes?.update ?? []) + scratch[id] = { ...scratch[id], ...data } as AnyNode + note ??= painted.result.note as string | undefined + } + } + + const update: NonNullable = Object.keys(scratch).flatMap((id) => { + const [before, after] = [ + nodes[id] as Record, + scratch[id] as Record, + ] + if (before === after) return [] + const data = Object.fromEntries( + Object.keys(after) + .filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key])) + .map((key) => [key, after[key]]), + ) + return Object.keys(data).length ? [{ id, data: data as Partial }] : [] + }) + return { + result: { ok: true, walls, ...(note ? { note } : {}) }, + changes: { update }, + } +} diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index 93309cfcd2..30e15c3a3c 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -56,12 +56,20 @@ export const authorSceneOpening = z.object({ .max(48_000) .optional() .describe( - "A three.js module for an opening type and style cannot express (an arched double door, a round window): the add_door / add_window code contract, mount 'wall', a cutout mesh the wall cuts, an open clip unless it is fixed. Width and height are what it builds; type and style do not apply. Compiled and stored as an item's module is.", + "A three.js module for an opening type and style cannot express (an arched double door, a round window): the add_door / add_window code contract, mount 'wall', a cutout mesh the wall cuts, an open clip unless it is fixed. Width and height are what it builds; type keeps its meaning (default hinged for a door, fixed for a window), style is ignored. Compiled and stored as an item's module is.", ), color: z .string() .optional() - .describe("The leaf's or frame's colour as #rrggbb, when the design has one."), + .describe( + 'Exact #rrggbb for a door’s panel and frame or a window’s frame (default: the opening’s own finish).', + ), + frameColor: z + .string() + .optional() + .describe( + 'Exact #rrggbb for the frame (default: color, or the opening’s own finish when color is absent).', + ), hinge: z .enum(['start', 'end']) .optional() @@ -178,7 +186,7 @@ export const authorSceneItem = z.object({ .string() .optional() .describe( - 'The id of the item this one rests on or hangs from (a lamp on a nightstand), or of the wall it is fixed to (a baseboard, a casing, a mirror, art), so moving the host moves it. On a wall, position is still the world bottom centre: the item is fixed to the face nearest it and faces the room; rotation is ignored.', + 'The id of the item this one rests on or hangs from (a lamp on a nightstand), or of the wall it is fixed to (a baseboard, a casing, a mirror, art), so moving the host moves it. On a wall, position is still the world bottom centre: the item faces the room on the nearest face, keeping its outward distance from that face; a point inside the wall moves to the face and is reported. Rotation is ignored.', ), }) @@ -194,7 +202,7 @@ export const authorSceneLight = z.object({ .string() .optional() .describe( - 'The id of the item holding this light (a pendant, a lamp). Without one, the light is attached to the nearest item.', + 'The id of the item holding this light (a pendant, a lamp). Default: without a fixture, a minimal ceiling point is created on the ceiling of the room containing the light’s plan position; outside any room, it attaches to the nearest item on this level and reports the fallback.', ), }) diff --git a/packages/core/src/agent-tools/paint.ts b/packages/core/src/agent-tools/paint.ts index bdd7e93ba2..ada62a55a4 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, slabs, ceilings, roofs, columns, fences, doors, windows, stairs, blocks, cabinets, shelves, items and the other built parts 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 and interior (the faces turned out of its rooms or into them, else by its stored sides; a partition is interior on both; a wall whose sides are unknown, or with rooms along only part of a face, is refused: name its face, a or b), its trims aSkirting, bSkirting, aCrown, bCrown, aChairRail, bChairRail, a curtain wall's curtain-frame, curtain-glass, curtain-solid; a slab's surface, side, edge, riser, underside or foundation; a ceiling's surface; a roof's top (the covering), edge (the gable ends and the trim band), wall (the soffit and the deck edge, and the fascia and rake boards unless they have their own) or fascia (the fascia and rake boards, a fascia band); a glazed roof's glazing-frame, glazing-glass or glazing-solid; a column's shaft, base, capital or frame; a fence's posts, infill, base or rail; a door's panel, frame, glass or hardware; a window's frame or glass; a stair's treads, body or railing; a block's body; a cabinet's front, carcass, countertop, plinth, hardware or glass; another part's own slots, which a refusal names; an item's surfaces by the names its model gives them (its painted ones are in get_node's slots), which paint cannot check. Without a role: both faces of a wall, a slab's or a ceiling's surface, a roof's top, a glazed roof's frame and solid panels, a column's shaft, a whole fence, a door's panel and frame, a window's frame, a block's body, a part's one surface. A roof shows one finish per surface: a segment's finish goes on its roof, and a roof's on all of its segments. A glazed segment holds its own glazing finishes: a roof's glazing roles go on its glazed segments, its other roles on the roof, and without a role both. 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.", + "Give walls, slabs, ceilings, roofs, columns, fences, doors, windows, stairs, blocks, cabinets, shelves, items and the other built parts 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 and interior (the faces turned out of its rooms or into them, else by its stored sides; a partition is interior on both; a wall whose sides are unknown, or with rooms along only part of a face, is refused: name its face, a or b), its trims aSkirting, bSkirting, aCrown, bCrown, aChairRail, bChairRail (skirting, crown or chairRail: that trim on both faces), a curtain wall's curtain-frame, curtain-glass, curtain-solid; a slab's surface, side, edge, riser, underside or foundation; a ceiling's surface; a roof's top (the covering), edge (the gable ends and the trim band), wall (the soffit and the deck edge, and the fascia and rake boards unless they have their own) or fascia (the fascia and rake boards, a fascia band); a glazed roof's glazing-frame, glazing-glass or glazing-solid; a column's shaft, base, capital or frame; a fence's posts, infill, base or rail; a door's panel, frame, glass or hardware; a window's frame or glass; a stair's treads, body or railing; a block's body; a cabinet's front, carcass, countertop, plinth, hardware or glass; another part's own slots, which a refusal names; an item's surfaces by the names its model gives them (its painted ones are in get_node's slots), which paint cannot check. Without a role: both faces of a wall, a slab's or a ceiling's surface, a roof's top, a glazed roof's frame and solid panels, a column's shaft, a whole fence, a door's panel and frame, a window's frame, a block's body, a part's one surface. A roof shows one finish per surface: a segment's finish goes on its roof, and a roof's on all of its segments. A glazed segment holds its own glazing finishes: a roof's glazing roles go on its glazed segments, its other roles on the roof, and without a role both. 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/index.ts b/packages/core/src/index.ts index c7fc1391e9..3fa03d451f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1006,6 +1006,7 @@ export { type WallJoinResult, type WallTopologyChanges, } from './systems/wall/wall-topology' +export { wallTrimPatch } from './systems/wall/wall-trim' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { areSemanticValuesEqual } from './utils/semantic-equal' diff --git a/packages/core/src/systems/wall/wall-trim.ts b/packages/core/src/systems/wall/wall-trim.ts new file mode 100644 index 0000000000..fd30a688a7 --- /dev/null +++ b/packages/core/src/systems/wall/wall-trim.ts @@ -0,0 +1,20 @@ +import { + WALL_TRIM_DEFAULTS, + type WallNode, + WallTrimConfig, + type WallTrimKind, +} from '../../schema/nodes/wall' + +/** + * A wall's skirting, crown or chair rail with `patch` applied over what it shows now (its stored + * config over the kind's defaults): the one write the wall panel and the agent operations share. + */ +export function wallTrimPatch( + wall: Partial>, + kind: K, + patch: Partial, +): Record { + return { + [kind]: WallTrimConfig.parse({ ...WALL_TRIM_DEFAULTS[kind], ...wall[kind], ...patch }), + } as Record +} diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts index 5ddca4cf39..ef160d34e4 100644 --- a/packages/mcp/src/tools/author-scene.test.ts +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -58,7 +58,16 @@ const payload = { sill: 0.9, type: 'casement', }, - { kind: 'window', along: 3.8, width: 1, height: 1, sill: 1, code: ROUND_WINDOW }, + { + kind: 'window', + along: 3.8, + width: 1, + height: 1, + sill: 1, + type: 'casement', + code: ROUND_WINDOW, + frameColor: '#f1ece4', + }, ], }, { @@ -68,7 +77,16 @@ const payload = { height: 2.7, thickness: 0.12, openings: [ - { kind: 'door', along: 1.2, width: 0.9, height: 2.1, style: 'glass', hinge: 'end' }, + { + kind: 'door', + along: 1.2, + width: 0.9, + height: 2.1, + style: 'glass', + hinge: 'end', + color: '#8f9e8a', + frameColor: '#f1ece4', + }, ], }, { id: 'w', start: [-2, 2.5], end: [-2, -2.4], height: 2.7, thickness: 0.12 }, @@ -130,6 +148,7 @@ describe('author_scene (MCP)', () => { beforeEach(async () => { stored.length = 0 const bridge = new SceneBridge() + bridge.setScene({}, [], { materials: {} }) const operations = createSceneOperations({ bridge }) operations.loadDefault() bridge.setActiveScene({ id: 'scene_test', name: 'Test' } as never) @@ -183,6 +202,7 @@ describe('author_scene (MCP)', () => { expect((await node(report.ids['e/0']!)).windowType).toBe('casement') const round = await node(report.ids['e/1']!) expect(round.source?.kind).toBe('script') + expect(round.windowType).toBe('casement') expect(round.width).toBeCloseTo(0.88, 2) const door = await node(report.ids['s/0']!) expect(door.segments).toHaveLength(1) @@ -196,6 +216,65 @@ describe('author_scene (MCP)', () => { expect(north.slots.a).not.toBe(north.slots.b) const room = await node(report.ids['room/0/0']!) expect(room.floor.finish).toBe(north.slots.a) + expect(door.slots.panel).toBe(north.slots.a) + expect(door.slots.frame).toBe(north.slots.b) + expect(round.slots.frame).toBe(north.slots.b) + expect(trim.slots.body).toBe(north.slots.b) + }) + + test('compiles a 60 mm ceiling point and persists its room ceiling host and light effect', async () => { + const design = { + ...payload, + levels: [ + { + ...payload.levels[0], + items: [], + lights: [{ position: [0, 2.4, 0], color: '#ffeedd', intensity: 2 }], + }, + ], + } + const response = await client.callTool({ name: 'author_scene', arguments: { payload: design } }) + expect(response.isError, JSON.stringify(response)).not.toBe(true) + const report = response.structuredContent as { + items: number + lights: number + itemsCompiled: number + defects: unknown[] + defaults: string[] + ids: Record + } + expect(report.defects).toEqual([]) + expect(report.items).toBe(1) + expect(report.lights).toBe(1) + expect(report.itemsCompiled).toBe(2) + expect(report.defaults).toContain( + 'light at [0, 2.4, 0] has no fixture: a ceiling point in Bedroom', + ) + const read = async (id: string) => { + const got = await client.callTool({ name: 'get_node', arguments: { id } }) + return JSON.parse((got.content as { text: string }[])[0]!.text).node as Record + } + const point = await read(report.ids['light/0/0']!) + const ceiling = await read(point.parentId) + expect(ceiling.type).toBe('ceiling') + expect(ceiling.zoneId).toBe(report.ids['room/0/0']) + expect(ceiling.children).toContain(point.id) + expect(point.source.kind).toBe('script') + expect(point.asset.category).toBe('light') + expect(point.asset.attachTo).toBe('ceiling') + expect(point.asset.dimensions[0]).toBeCloseTo(0.06, 6) + expect(point.asset.dimensions[1]).toBeCloseTo(0.008, 6) + expect(point.asset.dimensions[2]).toBeCloseTo(0.06, 6) + expect(point.position).toEqual([0, 0, 0]) + expect(point.asset.interactive.effects).toEqual([ + { + kind: 'light', + color: '#ffeedd', + intensityRange: [0, 2], + offset: [0, expect.any(Number), 0], + }, + ]) + expect(stored.filter((s) => s.startsWith('model/gltf-binary'))).toHaveLength(2) }) test('refuses to write over a conversion unless told to', async () => { diff --git a/packages/mcp/src/tools/author-scene.ts b/packages/mcp/src/tools/author-scene.ts index c195057d18..eef9d66402 100644 --- a/packages/mcp/src/tools/author-scene.ts +++ b/packages/mcp/src/tools/author-scene.ts @@ -3,6 +3,7 @@ import { type AuthorSceneReport, achievedChanges, authorScene, + authorSceneCeilingPoints, type SceneNodes, verifyScene, } from '@pascal-app/core/agent-operations' @@ -76,6 +77,12 @@ export function registerAuthorScene( modules.push({ id: `${wall.id}/${index}`, code: opening.code, kind: opening.kind }) }) } + modules.push( + ...authorSceneCeilingPoints(args.payload).map((point) => ({ + ...point, + kind: 'object' as const, + })), + ) for (const { id, code, kind } of modules) { try { compiled[id] = await compileAndStore( diff --git a/packages/nodes/src/wall/panel-model.ts b/packages/nodes/src/wall/panel-model.ts index 46fd20f058..d034889a61 100644 --- a/packages/nodes/src/wall/panel-model.ts +++ b/packages/nodes/src/wall/panel-model.ts @@ -17,6 +17,7 @@ import { WALL_SKIRTING_DEFAULT, type WallNode, type WallTrimProfile, + wallTrimPatch, } from '@pascal-app/core' import { curveReshapeScope, @@ -267,7 +268,7 @@ export function wallSettings( ['chairRail', 'Chair rail', WALL_CHAIR_RAIL_DEFAULT], ] as const) { const trim = { ...defaults, ...node[key] } - const patch = (value: Partial) => update({ [key]: { ...trim, ...value } }) + const patch = (value: Partial) => update(wallTrimPatch(node, key, value)) choice(title, `${key}-enabled`, title, trim.enabled ? 'On' : 'Off', () => patch({ enabled: !trim.enabled }), ) @@ -289,7 +290,7 @@ export function wallSettings( 0.01, (height) => patch({ height }), ) - number(title, `${key}-proud`, 'Projection', trim.proud, 0.001, 0.2, 0.005, (proud) => + number(title, `${key}-proud`, 'Depth', trim.proud, 0.001, 0.2, 0.005, (proud) => patch({ proud }), ) if (key === 'chairRail') diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 22d8156a5e..b5c443a2e8 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -38,6 +38,7 @@ import { wallAssemblyPatch, wallAssemblyToLegacy, wallAssemblyUnverifiedNote, + wallTrimPatch, } from '@pascal-app/core' import { ActionButton, @@ -512,12 +513,7 @@ function WallTrimSection({ wallHeightMeters: number }) { const updateTrim = (patch: Partial>) => - onUpdate({ - [trimKey]: { - ...trimValue, - ...patch, - }, - } as Partial) + onUpdate(wallTrimPatch(node, trimKey, patch)) const profileOptions = WALL_TRIM_PROFILE_OPTIONS[trimKey] const selectedProfile = profileOptions.some((option) => option.value === trimValue.profile) ? trimValue.profile @@ -571,7 +567,7 @@ function WallTrimSection({ value={metersToLinearUnit(trimValue.height, unit)} /> diff --git a/packages/nodes/src/wall/treatments.test.ts b/packages/nodes/src/wall/treatments.test.ts index f4bdc4f482..dd598dafe9 100644 --- a/packages/nodes/src/wall/treatments.test.ts +++ b/packages/nodes/src/wall/treatments.test.ts @@ -220,4 +220,24 @@ describe('wall treatment miters', () => { expect(xs.every((x) => x <= 1 + 1e-6 || x >= 2 - 1e-6)).toBe(true) geometry.dispose() }) + + test('runs on under a window and breaks only under one that reaches the floor', () => { + const node = wall('A', [0, 0], [3, 0]) + const xsUnder = (sill: number) => { + const geometry = buildTrimGeometry( + node, + 'a', + trim, + 'skirting', + [{ type: 'window', width: 1, height: 1.2, position: [1.5, sill + 0.6, 0] }], + treatmentLevelData([node]), + ) + if (!geometry) throw new Error('expected trim geometry') + const xs = allPositions(geometry).map((point) => point.x) + geometry.dispose() + return xs + } + expect(xsUnder(0.9).some((x) => Math.abs(x - 1) < 1e-6 || Math.abs(x - 2) < 1e-6)).toBe(false) + expect(xsUnder(0).every((x) => x <= 1 + 1e-6 || x >= 2 - 1e-6)).toBe(true) + }) }) diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 576cf4e72e..814aef0076 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -73,7 +73,7 @@ Stair `targets` are design preferences in meters (`maxRiserHeight`, `minimumGoin When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, or with `language: 'bpy'` a Blender module when the shape needs Blender's modelling (bevels, smooth organic forms, booleans, curves), with the naming conventions its description lists for paint slots, parts, lights, cutouts and clips. Edit it by passing its `nodeId`. A new object needs a `reason` (why no Pascal tool or catalog item builds it); `verify_scene` lists every authored object with its reason. On a floor, a plain box with a wall's size or a floor plate is refused with the tool to use (`use_walls`, `use_slab`); a detailed object with a wall's size (a bookcase, a screen) is built with a hint naming `add_wall`. Servers without a script host answer `scripts_unavailable`. The hosted server builds three.js scripts in the user's open Pascal editor tab (Blender modules run in its Blender service and need no tab); when no tab has the project open it answers `editor_tab_required` with an `editorUrl`: give the user that link, wait until they say it is open, then repeat the same call. `editor_tab_busy` means a tab is open but busy or still loading: wait a few seconds and repeat the same call; the user need not be asked. Whether you may open the link yourself is the person's setting ("Agents may open the editor for me"; Ask first: ask the user to open the link (or bring the tab forward), or ask their permission before opening it yourself; Allow: you may open the link yourself, in a tab in front; Never: ask the user, and do not open or switch windows yourself); the refusal's words say which, so follow them. It applies to `editor_tab_required` and `editor_tab_hidden`; `editor_tab_busy` carries no link, and an autonomous agent account, which has no editor login, is refused with `editor_host_identity_required` whatever the setting says. To count or locate things by what they are, including typed parts inside authored objects, use `find_by_type`. To group elements of any kind into a named set the editor lists (all the lights, the windows, the kitchen cabinetry), use `edit_collection`, starting from a template when one fits; `list_collections` reads them back. -When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their colour (`color`, or `colorLeft` / `colorRight` per face; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open), rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module (`host` names the item it rests on, or the wall a baseboard, casing or mirror is fixed to), and lights. Pascal compiles and stores the modules, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it filled in (`defaults`: a window written fixed, a handle set opposite its hinge, a light hosted on the nearest item), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions), what it could not place and why (`defects`), what it corrected (a wall end snapped onto the centerline it meets). Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and individual floor planks into the native wall, the floor finish and the room, and say so in `notes`; a slab over a wall's top is refused. Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. +When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their colour (`color`, or `colorLeft` / `colorRight` per face; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open), rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module (`host` names the item it rests on, or the wall a baseboard, casing or mirror is fixed to), and lights. A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. Pascal compiles and stores the modules, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it filled in (`defaults`: a window written fixed, a handle set opposite its hinge, a ceiling point for a light without a fixture), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions), what it could not place and why (`defects`), what it corrected (a wall end snapped onto the centerline it meets). Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and individual floor planks into the native wall, the floor finish and the room, and say so in `notes`; a slab over a wall's top is refused. Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. Pascal uses meters. X and Z are floor-plan axes; Y is vertical. Tool fields that accept measurements may also accept strings such as `"6 ft"` or `"180cm"`, but report final spatial values in meters and retain the user's original units when useful. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 0a3e0e4ceb..eeb5506888 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -56,8 +56,8 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra `author_scene` turns a whole design you wrote as one three.js module into native, editable Pascal nodes in one call. The reading is yours; the writing is Pascal's. 1. Read your module: its source for intent (helper names, declared opening types), the executed graph for truth (world transforms, deleted objects gone). Most designs make walls with a helper such as `wall(x1, z1, x2, z2, height, thickness, openings)`: those arguments are the payload's walls. -2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`), `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a baseboard on a wall), `lights` (`position`, `fixture`). End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. -3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `slab_over_wall`, `use_slab`…), `warnings` (written, but doubtful: `thin_slab`, `many_slabs`, `many_regions`), `defaults` (what Pascal filled in or dropped: a window with no `type` written fixed, a handle set opposite its hinge, a style dropped on an operable window, a light hosted on the nearest item), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. +2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`), `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a baseboard on a wall), `lights` (`position`, `fixture`). A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. +3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `slab_over_wall`, `use_slab`…), `warnings` (written, but doubtful: `thin_slab`, `many_slabs`, `many_regions`), `defaults` (what Pascal filled in or dropped: a window with no `type` written fixed, a handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. 4. Fix the program, not the payload by hand, and call again with `replace: true`. Stop when `green` is true and the scene looks like the design. Report what you could not convert in `notes`. ## Wrap a window around a corner diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index f696c3f153..53f0a60e4b 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -136,9 +136,11 @@ A write that would leave a populated project empty is far more often an accident ## Converting a whole design: `author_scene` -A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`; registered on the MCP first and not yet in `AGENT_TOOL_CONTRACTS`, since the hosted chat's agent cannot run its own program until its sandbox can emit the payload: a reviewed `gap` in the parity test) takes a conversion payload, levels with walls (a colour for both faces or `colorLeft` / `colorRight` for one: the left, looking from start to end, is the wall's front, role `a`) and their openings (`type` as `DoorType` / `WindowType`, `style` as the door and window style presets, `leaves`, or `code` for a module the host compiles like an item's, keyed `/`), rooms by seed with a floor material and regions, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it, or on a wall when `host` names one: a wall-side item on the face nearest its point, as `place_items` writes one), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall (`doorTypeFields` / `windowTypeFields`, `doorStyleLook` / `getWindowStyleOverrides`, the handle opposite the hinge, a scripted opening's `source` and `slots` as `add_door` with code writes them), zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, items through `addObject`, lights as light effects on their fixture (or the nearest item: Pascal keeps lights inside objects) in the host's frame, paint through `paint`. A hex colour is one exact scene material per distinct colour across the payload (`resolveInlinePaintMaterial`, roughness 0.9, metalness 0, named by the hex), reused by walls, floors, regions and slabs alike; a host without scene materials gets the nearest library colour and a `defaults` sentence saying so. Ids derive from the payload's hash, so the same payload writes the same scene. +Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep the bottom centre’s outward distance from the nearest face; a point inside the wall body moves to the face and is reported in `defaults`. -The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and the one correction Pascal makes itself is its join rule (a wall end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. The report also says what Pascal filled in or dropped (`defaults`, one sentence per fact: windows with no type written fixed, a door's handle set opposite its hinge, a style dropped on an operable window, a light hosted on the nearest item) and what it wrote but doubts (`warnings` with a code: `thin_slab` under 2 cm, `many_slabs` over 20 on a level, `many_regions` over 24 in a room, since regions are fixed polygons that do not follow a resize). One structural defect refuses the node: `slab_over_wall`, a slab over a wall's footprint on its level with its top above half the wall's height, which would lift the wall onto it (an acceptance run wrote 43 twelve-millimetre "wall cap" slabs at 2.662 m). Pascal builds a wall's cap, the floor plate and the ceiling itself; the skill tells the agent to fold a module's caps, underlays and planks into the wall, the floor finish and the room. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. +A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`; registered on the MCP first and not yet in `AGENT_TOOL_CONTRACTS`, since the hosted chat's agent cannot run its own program until its sandbox can emit the payload: a reviewed `gap` in the parity test) takes a conversion payload, levels with walls (a colour for both faces or `colorLeft` / `colorRight` for one: the left, looking from start to end, is the wall's front, role `a`) and their openings (`type` as `DoorType` / `WindowType`, `style` as the door and window style presets, `leaves`, or `code` for a module the host compiles like an item's, keyed `/`), rooms by seed with a floor material and regions, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it, or on a wall when `host` names one: a wall-side item on the face nearest its point, as `place_items` writes one), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall (`doorTypeFields` / `windowTypeFields`, `doorStyleLook` / `getWindowStyleOverrides`, the handle opposite the hinge, a scripted opening's `source` and `slots` as `add_door` with code writes them), zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, items through `addObject`, lights as light effects on their fixture (or a compiled 60 mm × 8 mm ceiling point hosted on its room’s ceiling when no fixture is given, or the nearest item on the level outside rooms: Pascal keeps lights inside objects) in the host's frame, paint through `paint`. A hex colour is one exact scene material per distinct colour across the payload (`resolveInlinePaintMaterial`, roughness 0.9, metalness 0, named by the hex), reused by item slots, opening panels and frames, walls, floors, regions and slabs alike; a host without scene materials gets the nearest library colour and a `defaults` sentence saying so. Ids derive from the payload's hash, so the same payload writes the same scene. + +The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and its join rule corrects wall ends (an end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. The report also says what Pascal filled in or dropped (`defaults`, one sentence per fact: windows with no type written fixed, a door's handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture) and what it wrote but doubts (`warnings` with a code: `thin_slab` under 2 cm, `many_slabs` over 20 on a level, `many_regions` over 24 in a room, since regions are fixed polygons that do not follow a resize). One structural defect refuses the node: `slab_over_wall`, a slab over a wall's footprint on its level with its top above half the wall's height, which would lift the wall onto it (an acceptance run wrote 43 twelve-millimetre "wall cap" slabs at 2.662 m). Pascal builds a wall's cap, the floor plate and the ceiling itself; the skill tells the agent to fold a module's caps, underlays and planks into the wall, the floor finish and the room. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host also compiles the candidate ceiling-point modules listed by `authorSceneCeilingPoints`; after reconciliation the operation uses the ceiling belonging to the room containing each light’s plan position. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. What the converting agents asked of `verify_scene` is in the same PR: an item hosted by another item is read through its host (`item-space.ts`), a thing resting on its host is stacked, not colliding, a rug or a pendant does not block a door, and a door's keep-out is the full clear depth on its swing side and a walkway on the other. From dce153245641fafd346fb26e0f8c3ee208a3c976 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 10:14:14 -0400 Subject: [PATCH 19/26] rooms: a floor finish built by a script for the room's floor, rebuilt when the room changes A design converted from three.js faked plank and tile floors with free-standing objects or fixed floor regions that did not follow the room. A room's floor finish can now be a three.js module that Pascal keeps on the zone and runs for exactly the room's floor. - Schema: zone.floor.script (ZoneFloorScript) is the script source record items use (language, script and artifact hashes, params, manifest) plus `inputs`, the key of the floor it was built for. Absent on every existing scene. - geometry-script: compile input `inputs` reaches the build in `params` (and as `inputs`), is never returned among the stored params, and keeps the build in the host's frame instead of re-origining it for its mount. The sandbox worker and the editor's worker client carry it. - core lib/floor-script: floorScriptInputs (the room's outline inside the wall faces clipped to its plate, holes, and the plate top, level coordinates), floorScriptInputsKey, staleFloorScripts, refreshFloorScripts(nodes, host) for any FloorScriptHost (read the module, compile and store for a floor). - core setRoomFloorScript operation: writes a compile on the zone with the key of its inputs, or removes it; refuses Blender builds, a room with no floor, unmeasurable output. - Paint: the script owns the floor's colours. A scripted room gets its own plate top cell, and floorPlatePaintRefusal (the editor's Paint, and the slab paint commit) and set_zone_intent's floor finish/regions refuse with a sentence until the script is removed. - Rendering: the zone renderer draws the artifact through the scripted-model path, 1 mm over the plate, with no pointer events, so clicks reach the floor. - Editor: the tab rebuilds stale floor scripts after a settled change (sandbox compile, artifact store), writing outside history like the reconcile's derived writes and only while the room's floor is still the one built for; the room panel shows "Scripted finish" with View code and Remove; a paste into another project brings the floor script's artifacts. - Tests: built for the floor inside the wall faces at the plate top; compiled once and again only after the structure reconcile re-derives the room (fake host); paint refused through the Paint tool, the slab commit and the room intent until removed; no floor, no script; an old zone parses unchanged; compile with inputs stays in the host frame; clipboard copies the artifacts. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/agent-operations/index.ts | 1 + .../src/agent-operations/room-floor-script.ts | 81 +++++++ .../src/commands/structure/set-zone-intent.ts | 8 + .../core/src/contracts/reference-inventory.ts | 33 +-- packages/core/src/index.ts | 17 +- packages/core/src/lib/floor-plate-paint.ts | 31 ++- packages/core/src/lib/floor-script.test.ts | 211 ++++++++++++++++++ packages/core/src/lib/floor-script.ts | 171 ++++++++++++++ packages/core/src/lib/plate-surface.ts | 4 +- packages/core/src/schema/index.ts | 2 +- packages/core/src/schema/nodes/zone.ts | 12 + .../editor/src/components/editor/index.tsx | 3 + .../src/components/ui/panels/room-panel.tsx | 65 ++++++ packages/editor/src/index.tsx | 5 + .../editor/src/lib/geometry-script/client.ts | 9 +- .../src/lib/geometry-script/floor-scripts.ts | 128 +++++++++++ .../src/lib/geometry-script/protocol.ts | 1 + .../editor/src/lib/scene-clipboard.test.ts | 54 +++++ packages/editor/src/lib/scene-clipboard.ts | 6 +- packages/geometry-script/src/compile.test.ts | 52 +++++ packages/geometry-script/src/compile.ts | 27 ++- .../geometry-script/src/sandbox-worker.ts | 11 +- .../src/slab/__tests__/plate-paint.test.ts | 21 ++ packages/nodes/src/slab/paint.ts | 5 +- packages/nodes/src/zone/renderer.tsx | 43 ++++ wiki/architecture/authored-objects.md | 6 + 26 files changed, 969 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/agent-operations/room-floor-script.ts create mode 100644 packages/core/src/lib/floor-script.test.ts create mode 100644 packages/core/src/lib/floor-script.ts create mode 100644 packages/editor/src/lib/geometry-script/floor-scripts.ts diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index cb8e4f9f9a..5ee982b60d 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -72,6 +72,7 @@ export * from './plan-geometry' export * from './point-context' export * from './project-files' export * from './roof-writes' +export * from './room-floor-script' export * from './room-structure' export * from './scene-materials' export * from './scene-measure' diff --git a/packages/core/src/agent-operations/room-floor-script.ts b/packages/core/src/agent-operations/room-floor-script.ts new file mode 100644 index 0000000000..a6800f5248 --- /dev/null +++ b/packages/core/src/agent-operations/room-floor-script.ts @@ -0,0 +1,81 @@ +import { refuse } from '../agent-tools/refusal' +import { type FloorScriptInputs, floorScriptInputs, floorScriptRecord } from '../lib/floor-script' +import { scriptedSize } from '../lib/geometry-script-node' +import { type CompiledGeometryScript, ZoneFloorScript, type ZoneNode } from '../schema' +import type { AgentOperation } from './types' + +export type SetRoomFloorScriptInput = + | { + zoneId: string + /** The module compiled for `inputs` (`floorScriptInputs` of the room, read before compiling). */ + compiled: CompiledGeometryScript + inputs: FloorScriptInputs + } + | { zoneId: string; remove: true } + +const round = (value: number) => Math.round(value * 1000) / 1000 + +/** + * `setRoomFloorScript`: a room's floor finish built by a three.js module, or the module taken off. + * The host reads the room's floor (`floorScriptInputs`), compiles the module for it (geometry-script + * `inputs`: the module reads `params.polygon`, `params.holes`, `params.elevation`, in level + * coordinates) and stores the artifacts; this writes the result on the zone, with the key of the + * floor it was built for. When the structure re-derives the room's floor the script is stale and + * `refreshFloorScripts` runs it again. The script owns the floor's colours: paint on that floor is + * refused while it is there. + */ +export const setRoomFloorScript: AgentOperation = (nodes, input) => { + const zone = nodes[input.zoneId] + if (zone?.type !== 'zone') + refuse('target_not_found', `${input.zoneId} is not a room in the scene: nothing was changed.`, { + target: input.zoneId, + }) + const name = zone.name?.trim() || zone.id + + if ('remove' in input) { + if (!zone.floor?.script) return { result: { ok: true, zoneId: zone.id, removed: false } } + const { script: _, ...floor } = zone.floor + return { + result: { ok: true, zoneId: zone.id, removed: true }, + changes: { + update: [{ id: zone.id, data: { floor: Object.keys(floor).length ? floor : undefined } }], + }, + } + } + + const { compiled } = input + if (compiled.language !== 'three') + refuse( + 'three_only', + `A room's floor script is a three.js module: it is given the room's floor, which a Blender build is not. Nothing was changed on ${name}.`, + { target: zone.id }, + ) + if (!floorScriptInputs(nodes, zone.id)) + refuse( + 'no_floor', + `${name} has no floor to build on (its walls do not close it, or its floor is off): nothing was changed.`, + { target: zone.id }, + ) + const { min, max } = compiled.manifest.bounds + if (![...min, ...max].every(Number.isFinite)) + refuse( + 'unmeasurable_object', + `${name}'s floor script built geometry that cannot be measured: a mesh in it has a position, rotation or scale that is not a number. Nothing was changed.`, + { target: zone.id }, + ) + const script = ZoneFloorScript.parse( + floorScriptRecord(compiled, input.inputs, zone.floor?.script), + ) + const floor: NonNullable = { ...zone.floor, script } + return { + result: { + ok: true, + zoneId: zone.id, + size: scriptedSize(compiled.manifest).map(round), + slots: compiled.manifest.slots.map((slot) => slot.id), + triangles: compiled.manifest.triangles, + note: `The script owns ${name}'s floor colours: painting that floor is refused until the script is removed. It runs again whenever the room's floor changes.`, + }, + changes: { update: [{ id: zone.id, data: { floor } }] }, + } +} diff --git a/packages/core/src/commands/structure/set-zone-intent.ts b/packages/core/src/commands/structure/set-zone-intent.ts index 141b9ba43a..06d6efb914 100644 --- a/packages/core/src/commands/structure/set-zone-intent.ts +++ b/packages/core/src/commands/structure/set-zone-intent.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { floorIntentConflicts } from '../../lib/floor-intent-changes' +import { floorScriptPaintRefusal } from '../../lib/floor-plate-paint' import { ownFloorIntentChanges } from '../../lib/own-floor-intent' import { drawnFloorElevationConflict } from '../../lib/room-drawn-floor' import { roundFloorElevation } from '../../lib/room-floor-feasibility' @@ -42,6 +43,13 @@ export function setZoneIntent( ): StructurePlan { const zone = requireZone(nodes, input.zoneId) const patch = ZoneIntentPatch.parse(input.patch) + const painted = zone.floor?.script && (patch.floor?.finish ?? patch.floor?.regions) + const scripted = painted ? floorScriptPaintRefusal(zone) : undefined + if (scripted) + return { + changes: [], + conflicts: [{ code: scripted.code, nodeIds: [zone.id], message: scripted.message }], + } const requestedKey = patch.floor?.footprint if ( requestedKey && diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 0b0dbf52d1..39493ee365 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -745,27 +745,28 @@ export const NON_REFERENCES: readonly { kind: string; path: string; reason: stri reason: 'Inline versioned recipe (R7 stores recipes above 24 KiB by hash).', }, { kind: 'procedural-item', path: 'parameters.@key', reason: 'Recipe parameter name.' }, - // Scripted nodes (an authored item, or a window or door built from code) share one `source`. - ...(['item', 'window', 'door', 'column'] as const).flatMap((kind) => [ - ...( - [ - 'source.manifest.anchors[].id', - 'source.manifest.lights[].id', - 'source.manifest.params[].id', - 'source.manifest.parts[].id', - 'source.manifest.slots[].id', - ] as const - ).map((path) => ({ + // Scripted nodes (an authored item, or a window or door built from code) share one `source`; + // a room's floor script keeps the same record on its zone. + ...( + [ + ['item', 'source'], + ['window', 'source'], + ['door', 'source'], + ['column', 'source'], + ['zone', 'floor.script'], + ] as const + ).flatMap(([kind, source]) => [ + ...(['anchors', 'lights', 'params', 'parts', 'slots'] as const).map((list) => ({ kind, - path, + path: `${source}.manifest.${list}[].id`, reason: "Defines a key in a scripted node's compiled manifest, read from its script.", })), - { kind, path: 'source.params.@key', reason: 'Script parameter name.' }, - { kind, path: 'source.params.*', reason: 'Script parameter value.' }, + { kind, path: `${source}.params.@key`, reason: 'Script parameter name.' }, + { kind, path: `${source}.params.*`, reason: 'Script parameter value.' }, { kind, - path: 'source.images.thumbnail', - reason: 'Content hash of a project artifact, like source.artifact; forks carry it.', + path: `${source}.images.thumbnail`, + reason: `Content hash of a project artifact, like ${source}.artifact; forks carry it.`, }, ]), { kind: 'scan', path: 'layers.@key', reason: 'Layer visibility flag name.' }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3fa03d451f..5f44092405 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -189,7 +189,22 @@ export { floorOpeningTargets, openingsForSurface, } from './lib/floor-opening-intent' -export { type FloorPlatePaintRefusal, floorPlatePaintRefusal } from './lib/floor-plate-paint' +export { + type FloorPlatePaintRefusal, + floorPlatePaintRefusal, + floorScriptPaintRefusal, +} from './lib/floor-plate-paint' +export { + type FloorScriptHost, + type FloorScriptInputs, + type FloorScriptRefresh, + floorScriptInputs, + floorScriptInputsKey, + floorScriptRecord, + refreshFloorScripts, + type StaleFloorScript, + staleFloorScripts, +} from './lib/floor-script' export { type FloorStepOverride, floorStepOverrideFor, diff --git a/packages/core/src/lib/floor-plate-paint.ts b/packages/core/src/lib/floor-plate-paint.ts index 60ec39a4d5..706ae1bc8b 100644 --- a/packages/core/src/lib/floor-plate-paint.ts +++ b/packages/core/src/lib/floor-plate-paint.ts @@ -1,25 +1,42 @@ import type { AnyNode } from '../schema' +import { parseRoomFinishRole } from './plate-surface' export type FloorPlatePaintRefusal = { status: 'refused' - code: 'floor-surface-without-room' + code: 'floor-surface-without-room' | 'floor-built-by-script' message: string } +/** Why a room's floor cannot be painted: a script builds it, and the script owns its colours. */ +export function floorScriptPaintRefusal( + zone: AnyNode | undefined, +): FloorPlatePaintRefusal | undefined { + if (zone?.type === 'zone' && zone.floor?.script) + return { + status: 'refused', + code: 'floor-built-by-script', + message: `${zone.name?.trim() || 'This room'}'s floor is built by a script, which owns its colours. Remove the script to paint the floor.`, + } +} + export function floorPlatePaintRefusal( nodes: Readonly>, node: AnyNode, role: string, ): FloorPlatePaintRefusal | undefined { - if ( - node.type === 'slab' && - node.plateRole === 'base' && - role === 'surface' && - !node.zoneIds?.some((id) => nodes[id]?.type === 'zone' && nodes[id].hasFloor !== false) - ) + if (node.type !== 'slab') return + const room = parseRoomFinishRole(role) + if (room) return floorScriptPaintRefusal(nodes[room.zoneId]) + if (node.plateRole !== 'base' || role !== 'surface') return + const zones = (node.zoneIds ?? []).map((id) => nodes[id]) + if (!zones.some((zone) => zone?.type === 'zone' && zone.hasFloor !== false)) return { status: 'refused', code: 'floor-surface-without-room', message: 'This structural floor band has no room finish to paint. Paint its edge instead.', } + for (const zone of zones) { + const refusal = floorScriptPaintRefusal(zone) + if (refusal) return refusal + } } diff --git a/packages/core/src/lib/floor-script.test.ts b/packages/core/src/lib/floor-script.test.ts new file mode 100644 index 0000000000..f5ef594038 --- /dev/null +++ b/packages/core/src/lib/floor-script.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from 'bun:test' +import { applySceneChanges } from '../agent-operations/apply-changes' +import { setRoomFloorScript } from '../agent-operations/room-floor-script' +import { isAgentRefusal } from '../agent-tools' +import { setZoneIntent } from '../commands/structure' +import { + type AnyNode, + type CompiledGeometryScript, + GeometryArtifactManifest, + LevelNode, + type SlabNode, + WallNode, + ZoneNode, +} from '../schema' +import { floorPlatePaintRefusal } from './floor-plate-paint' +import { + type FloorScriptHost, + type FloorScriptInputs, + floorScriptInputs, + refreshFloorScripts, + staleFloorScripts, +} from './floor-script' +import { computePlateSurfacePartition, plateLevelContext } from './plate-surface' +import { area } from './polygon-boolean' +import type { SceneNodes } from './structure-kernel' +import { reconcileSceneStructure } from './structure-reconcile' + +const LEVEL = 'level_floor_script' +const corners: [number, number][] = [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], +] + +/** An 8 × 4 m room of 0.2 m walls, reconciled into its zone and floor plate. */ +function room(): SceneNodes { + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_fs_${index}`, + parentId: LEVEL, + start, + end: corners[(index + 1) % 4], + thickness: 0.2, + }), + ) + const level = LevelNode.parse({ id: LEVEL, children: walls.map((wall) => wall.id) }) + const nodes = Object.fromEntries([level, ...walls].map((node) => [node.id, node])) + return reconcile(nodes) +} + +let minted = 0 +function reconcile(nodes: SceneNodes, previousNodes?: SceneNodes): SceneNodes { + return reconcileSceneStructure({ + nodes, + previousNodes, + mintId: (kind) => `${kind}_fs_${minted++}`, + }).nodes +} + +const zoneOf = (nodes: SceneNodes) => + Object.values(nodes).find((node): node is ZoneNode => node.type === 'zone')! + +/** A host that compiles nothing: it counts its builds and answers with hashes of what it was given. */ +function fakeHost() { + const builds: FloorScriptInputs[] = [] + const host: FloorScriptHost = { + readScript: async (sha) => (sha === 'a'.repeat(64) ? 'export default () => {}' : null), + build: async ({ inputs }) => { + builds.push(inputs) + const xs = inputs.polygon.map(([x]) => x) + const zs = inputs.polygon.map(([, z]) => z) + return { + sha256: builds.length.toString(16).padStart(64, '0'), + script: 'a'.repeat(64), + language: 'three', + mount: 'floor', + params: { plank: 0.18 }, + manifest: GeometryArtifactManifest.parse({ + bounds: { + min: [Math.min(...xs), inputs.elevation, Math.min(...zs)], + max: [Math.max(...xs), inputs.elevation + 0.01, Math.max(...zs)], + }, + slots: [{ id: 'oak' }], + triangles: 12, + }), + } satisfies CompiledGeometryScript + }, + } + return { host, builds } +} + +/** The room with a floor script, built once by `host` for the room's floor. */ +async function scripted(host: FloorScriptHost) { + const nodes = room() + const zone = zoneOf(nodes) + const inputs = floorScriptInputs(nodes, zone.id)! + const compiled = await host.build({ zoneId: zone.id, code: '', params: {}, inputs }) + const { changes } = setRoomFloorScript( + nodes, + { zoneId: zone.id, compiled, inputs }, + { + activeLevelId: LEVEL, + }, + ) + return applySceneChanges(nodes, changes) as Record +} + +function refusal(action: () => unknown): string { + try { + action() + } catch (error) { + if (isAgentRefusal(error)) return error.code + throw error + } + throw new Error('expected a refusal') +} + +/** The room's north wall moved 1 m north, its neighbours following, then reconciled. */ +function pushNorth(nodes: SceneNodes): SceneNodes { + const moved: Record = { ...nodes } + const wall = (id: string, data: Partial) => { + moved[id] = { ...(nodes[id] as WallNode), ...data } + } + wall('wall_fs_1', { end: [8, 5] }) + wall('wall_fs_2', { start: [8, 5], end: [0, 5] }) + wall('wall_fs_3', { start: [0, 5] }) + return reconcile(moved, nodes) +} + +describe("a room's floor script", () => { + test('is built for the floor inside the wall faces, at the plate top', () => { + const nodes = room() + const inputs = floorScriptInputs(nodes, zoneOf(nodes).id)! + expect(area([{ outer: inputs.polygon, holes: inputs.holes }])).toBeCloseTo(7.8 * 3.8, 3) + const plate = Object.values(nodes).find((node): node is SlabNode => node.type === 'slab')! + expect(inputs.elevation).toBeCloseTo(plate.elevation ?? 0, 6) + }) + + test('compiles once, and again only after the reconcile re-derives its room', async () => { + const { host, builds } = fakeHost() + let nodes: SceneNodes = await scripted(host) + expect(builds).toHaveLength(1) + expect(staleFloorScripts(nodes)).toEqual([]) + expect(await refreshFloorScripts(nodes, host)).toEqual({ update: [], failed: [] }) + expect(builds).toHaveLength(1) + + nodes = pushNorth(nodes) + const zone = zoneOf(nodes) + expect(zone.floor?.script?.artifact).toBe('1'.padStart(64, '0')) + expect(staleFloorScripts(nodes).map((stale) => stale.zoneId)).toEqual([zone.id]) + + const refresh = await refreshFloorScripts(nodes, host) + expect(refresh.failed).toEqual([]) + expect(builds).toHaveLength(2) + expect(Math.max(...builds[1]!.polygon.map(([, z]) => z))).toBeCloseTo(4.9, 6) + nodes = applySceneChanges(nodes, refresh) as SceneNodes + const rebuilt = zoneOf(nodes).floor?.script + expect(rebuilt?.artifact).toBe('2'.padStart(64, '0')) + expect(rebuilt?.params).toEqual({ plank: 0.18 }) + expect(staleFloorScripts(nodes)).toEqual([]) + }) + + test('refuses paint on its floor, through the Paint tool and the room intent, until removed', async () => { + let nodes: SceneNodes = await scripted(fakeHost().host) + const zone = zoneOf(nodes) + const plate = Object.values(nodes).find((node): node is SlabNode => node.type === 'slab')! + const role = computePlateSurfacePartition( + plate, + plateLevelContext(nodes[LEVEL]!, (id) => nodes[id]), + )!.cells.find((cell) => cell.role.startsWith('room:'))!.role + expect(floorPlatePaintRefusal(nodes, plate, role)?.code).toBe('floor-built-by-script') + expect( + setZoneIntent(nodes, { + zoneId: zone.id, + patch: { floor: { finish: 'library:preset-white' } }, + }).conflicts?.[0]?.code, + ).toBe('floor-built-by-script') + + nodes = applySceneChanges( + nodes, + setRoomFloorScript(nodes, { zoneId: zone.id, remove: true }, { activeLevelId: LEVEL }) + .changes, + ) as SceneNodes + expect(zoneOf(nodes).floor?.script).toBeUndefined() + expect(floorPlatePaintRefusal(nodes, plate, role)).toBeUndefined() + }) + + test('a room with no floor has no script built for it', async () => { + const nodes = room() + const zone = zoneOf(nodes) + const { host } = fakeHost() + const inputs = floorScriptInputs(nodes, zone.id)! + const compiled = await host.build({ zoneId: zone.id, code: '', params: {}, inputs }) + const off = reconcile({ ...nodes, [zone.id]: { ...zone, hasFloor: false } }, nodes) + expect(floorScriptInputs(off, zone.id)).toBeNull() + expect( + refusal(() => + setRoomFloorScript(off, { zoneId: zone.id, compiled, inputs }, { activeLevelId: LEVEL }), + ), + ).toBe('no_floor') + }) +}) + +test('a zone saved before floor scripts parses as it did, and nothing in its scene is stale', () => { + const nodes = room() + const zone = zoneOf(nodes) + const saved = JSON.parse(JSON.stringify({ ...zone, floor: { finish: 'library:preset-white' } })) + expect(ZoneNode.parse(saved)).toEqual(saved) + expect(staleFloorScripts({ ...nodes, [zone.id]: saved })).toEqual([]) +}) diff --git a/packages/core/src/lib/floor-script.ts b/packages/core/src/lib/floor-script.ts new file mode 100644 index 0000000000..63e612e501 --- /dev/null +++ b/packages/core/src/lib/floor-script.ts @@ -0,0 +1,171 @@ +import type { + AnyNode, + CompiledGeometryScript, + GeometryScriptParamValue, + ZoneFloorScript, + ZoneNode, +} from '../schema' +import { DEFAULT_SLAB_ELEVATION } from '../schema/nodes/slab' +import { scriptSource } from './geometry-script-node' +import { levelWallCover, plateLevelContext } from './plate-surface' +import { + area, + difference, + intersection, + type MultiPolygon, + type Ring, + union, +} from './polygon-boolean' +import { roomFloorPlate } from './room-floor-plate' + +type SceneNodes = Readonly> + +/** + * What a room's floor script is built for, in level coordinates and metres: the room's walking + * floor (its outline inside the wall faces, on its floor plate) as `polygon` with its `holes`, and + * the plate's top, `elevation`. The module lays its boards on exactly that; clipping is its job. + */ +export type FloorScriptInputs = { + polygon: [number, number][] + holes: [number, number][][] + elevation: number +} + +const round = (value: number) => Math.round(value * 1e4) / 1e4 +const roundRing = (ring: Ring): [number, number][] => ring.map(([x, z]) => [round(x), round(z)]) + +/** Inputs by wall cover (memoised per wall signature in `levelWallCover`), then by room and plate. */ +const inputsByCover = new WeakMap>() + +/** + * The floor a room's script builds on, or null when the room has none (no floor plate: `hasFloor` + * false, or not enclosed yet). Several pieces (a floor cut through by a wall) give the largest. + */ +export function floorScriptInputs(nodes: SceneNodes, zoneId: string): FloorScriptInputs | null { + const zone = nodes[zoneId] + if (zone?.type !== 'zone' || zone.polygon.length < 3 || !zone.parentId) return null + const context = plateLevelContext(nodes[zone.parentId] ?? null, (id) => nodes[id]) + const plate = roomFloorPlate(context.slabs, zone.id) + if (!plate || plate.polygon.length < 3) return null + const cover = levelWallCover(context.walls) + const cache = inputsByCover.get(cover) ?? new Map() + inputsByCover.set(cover, cache) + const elevation = round(plate.elevation ?? DEFAULT_SLAB_ELEVATION) + const key = JSON.stringify([zone.polygon, zone.holes, plate.polygon, plate.holes, elevation]) + if (cache.has(key)) return cache.get(key)! + + const room = union([{ outer: zone.polygon, holes: zone.holes ?? [] }]) + const floor = intersection( + cover.length ? difference(room, cover) : room, + union([{ outer: plate.polygon, holes: plate.holes ?? [] }]), + ) + let largest: MultiPolygon[number] | undefined + for (const piece of floor) if (!largest || area([piece]) > area([largest])) largest = piece + const inputs = largest + ? { polygon: roundRing(largest.outer), holes: largest.holes.map(roundRing), elevation } + : null + if (cache.size > 64) cache.delete(cache.keys().next().value!) + cache.set(key, inputs) + return inputs +} + +/** 32-bit FNV-1a: a change detector for a floor's inputs, not a content address. */ +function fnv1a(text: string, seed: number): string { + let hash = seed + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +/** The key a floor script keeps of the floor it was built for (`ZoneFloorScript.inputs`). */ +export function floorScriptInputsKey(inputs: FloorScriptInputs): string { + const text = JSON.stringify([inputs.polygon, inputs.holes, inputs.elevation]) + return fnv1a(text, 0x811c9dc5) + fnv1a(text, 0x050c5d1f) +} + +/** A compile as the zone keeps it: its script source and the key of the floor it was built for. */ +export function floorScriptRecord( + compiled: CompiledGeometryScript, + inputs: FloorScriptInputs, + previous?: ZoneFloorScript, +): ZoneFloorScript { + return { ...scriptSource(compiled, {}, previous), inputs: floorScriptInputsKey(inputs) } +} + +export type StaleFloorScript = { + zoneId: string + script: ZoneFloorScript + /** The room's floor now, which the script runs again for. */ + inputs: FloorScriptInputs +} + +/** + * The floor scripts whose room's floor is no longer the one they were built for: the structure + * reconcile re-derived the room's polygon or plate (a wall moved, a room edge pushed, a floor + * raised), so the key differs. A room with no floor now keeps its script as it was. + */ +export function staleFloorScripts(nodes: SceneNodes): StaleFloorScript[] { + return Object.values(nodes).flatMap((node) => { + const script = node.type === 'zone' ? node.floor?.script : undefined + if (!script) return [] + const inputs = floorScriptInputs(nodes, node.id) + return inputs && floorScriptInputsKey(inputs) !== script.inputs + ? [{ zoneId: node.id, script, inputs }] + : [] + }) +} + +/** + * How a host runs a floor script: reads its module back by hash, and compiles and stores it for a + * floor (`@pascal-app/geometry-script`'s `compileGeometryScript` with `inputs`, then the artifact + * store), answering the compile with the stored hashes. + */ +export type FloorScriptHost = { + readScript(sha256: string): Promise + build(input: { + zoneId: string + code: string + params: Record + inputs: FloorScriptInputs + }): Promise +} + +export type FloorScriptRefresh = { + /** Each rebuilt room's new `floor`, its other floor fields as they were when the refresh began. */ + update: { id: string; data: Pick }[] + failed: { zoneId: string; error: string }[] +} + +/** + * Runs every stale floor script again for its room's current floor, with its stored params. A host + * applies an update while that room's floor is still the one it was built for (else a newer + * refresh follows), and writes only `floor.script` over what the room holds then. + */ +export async function refreshFloorScripts( + nodes: SceneNodes, + host: FloorScriptHost, +): Promise { + const refresh: FloorScriptRefresh = { update: [], failed: [] } + await Promise.all( + staleFloorScripts(nodes).map(async ({ zoneId, script, inputs }) => { + try { + const code = await host.readScript(script.script) + if (code === null) throw new Error('its module could not be read') + const compiled = await host.build({ zoneId, code, params: script.params, inputs }) + const zone = nodes[zoneId] as ZoneNode + refresh.update.push({ + id: zoneId, + data: { floor: { ...zone.floor, script: floorScriptRecord(compiled, inputs, script) } }, + }) + } catch (error) { + refresh.failed.push({ + zoneId, + error: error instanceof Error ? error.message : String(error), + }) + } + }), + ) + return refresh +} diff --git a/packages/core/src/lib/plate-surface.ts b/packages/core/src/lib/plate-surface.ts index 969a0180dd..a93300aab1 100644 --- a/packages/core/src/lib/plate-surface.ts +++ b/packages/core/src/lib/plate-surface.ts @@ -224,6 +224,7 @@ function zoneSignature(zones: readonly ZoneNode[]): string { zone.polygon, zone.holes, zone.floor?.finish ?? null, + zone.floor?.script !== undefined, zone.floorStepFinish, zone.floorStepOverrides ?? null, zone.floorEdgeFinish, @@ -483,7 +484,8 @@ function partitionTop( for (const zone of zones) { const clear = clearByZone.get(zone.id) const finish = zone.floor?.finish - if (!clear || (finish === undefined && !plate.plateRole)) continue + // A scripted floor is the room's own cell, so a hit on it names the room its script belongs to. + if (!clear || (finish === undefined && !zone.floor?.script && !plate.plateRole)) continue candidates.push({ role: roomFinishRole(zone.id), finish, polygons: clear }) } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 509c379b71..37e7f7d722 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -445,7 +445,7 @@ export { WindowNode, WindowType, } from './nodes/window' -export { ZoneNode } from './nodes/zone' +export { ZoneFloorScript, ZoneNode } from './nodes/zone' // Typed source identity (D5) export { PROVENANCE_MAX_ID_BYTES, diff --git a/packages/core/src/schema/nodes/zone.ts b/packages/core/src/schema/nodes/zone.ts index f87247850e..2bc81bd511 100644 --- a/packages/core/src/schema/nodes/zone.ts +++ b/packages/core/src/schema/nodes/zone.ts @@ -1,8 +1,18 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { GeometryScriptSource } from '../geometry-source' import { SurfacePaintRegion } from './surface-paint-region' +/** + * A room's floor finish built by a three.js module for the room's floor (`lib/floor-script`): the + * module gets the floor's polygon and elevation and lays the planks, tiles or boards on exactly that + * outline. `inputs` is the key of the floor it was built for; once the room's floor differs (a wall + * moved, an edge pushed) the script is stale and runs again for the new one. + */ +export const ZoneFloorScript = GeometryScriptSource.extend({ inputs: z.string() }) +export type ZoneFloorScript = z.infer + export const ZoneNode = BaseNode.extend({ id: objectId('zone'), type: nodeType('zone'), @@ -26,6 +36,7 @@ export const ZoneNode = BaseNode.extend({ sourceSlabId: z.templateLiteral(['slab_', z.string()]).optional(), finish: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(), regions: z.array(SurfacePaintRegion).optional(), + script: ZoneFloorScript.optional(), }) .optional(), // Painted parts of the room's ceiling. The automatic ceilings are rebuilt @@ -87,6 +98,7 @@ export const ZoneNode = BaseNode.extend({ - spaceRole: generic site/analysis zone or architectural room - roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata - floor.regions / ceiling.regions: painted parts of the room's floor and ceiling ([x, z] polygons, later wins) + - floor.script: a three.js module that builds the room's floor finish (boards, tiles) for its floor polygon, drawn over the floor; it owns its colours - floorStepFinish: finish of the room's steps; floorStepOverrides: per-doorway step finishes keyed by door id (or the lower room id), optional step index - enclosureStatus: auto-detected, explicitly enclosed, or open - clearDimensionPolicy: optional room clear-dimension datum preference diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 6664b51bab..f5c76f8479 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -43,6 +43,7 @@ import { useSaveShortcut } from '../../hooks/use-save-shortcut' import { useCeilingEditSessionOwner } from '../../lib/ceiling-edit-session' import { showsWholeBuilding, useEditorLevelDisplay } from '../../lib/editor-level-display' import { isSceneView } from '../../lib/editor-views' +import { initFloorScriptRefresh } from '../../lib/geometry-script/floor-scripts' import { useGestureLifecycleOwner } from '../../lib/gesture-lifecycle' import { shouldShowEditingControls } from '../../lib/interaction/overlay-policy' import { @@ -183,11 +184,13 @@ const EDITOR_DEFAULT_RENDER = { shading: 'solid' } as const function initializeEditorRuntime(): () => void { const unsubscribeSpatialGrid = initSpatialGridSync() const unsubscribeSpaceDetection = initSpaceDetectionSync(useScene, useEditor) + const stopFloorScriptRefresh = initFloorScriptRefresh() initSFXBus() return () => { unsubscribeSpatialGrid() unsubscribeSpaceDetection?.() + stopFloorScriptRefresh() spatialGridManager.clear() disposeSFXBus() diff --git a/packages/editor/src/components/ui/panels/room-panel.tsx b/packages/editor/src/components/ui/panels/room-panel.tsx index 3b0d8b1ba3..93bcdee88c 100644 --- a/packages/editor/src/components/ui/panels/room-panel.tsx +++ b/packages/editor/src/components/ui/panels/room-panel.tsx @@ -5,14 +5,17 @@ import { type AnyNodeId, area, getLevelDisplayName, + runAsSingleSceneHistoryStep, useScene, type ZoneNode, } from '@pascal-app/core' +import { setRoomFloorScript } from '@pascal-app/core/agent-operations' import { useViewer } from '@pascal-app/viewer' import { Check, ChevronDown, Plus } from 'lucide-react' import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from 'react' import { startCeilingEdit } from '../../../lib/ceiling-edit-session' import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy' +import { floorScriptCode } from '../../../lib/geometry-script/floor-scripts' import { formatAreaLabel, formatLinearMeasurement } from '../../../lib/measurements' import { addMezzanineStairs } from '../../../lib/mezzanine-stairs' import { roomFloorBase, roomRelativeFloorHeight } from '../../../lib/room-construction-commands' @@ -111,6 +114,7 @@ export function RoomPanel({ ) : ( )} + ) @@ -222,6 +226,67 @@ export function RoomUnitRow({ zoneId }: { zoneId: string }) { ) } +/** + * "Scripted finish": a floor a script builds (it owns the floor's colours, so Paint leaves it), + * with its code to read and a way to take it off, back to the floor's own finish. + */ +function RoomFloorScriptRow({ zoneId }: { zoneId: string }) { + const sha = useScene((scene) => { + const zone = scene.nodes[zoneId as AnyNodeId] + return zone?.type === 'zone' ? zone.floor?.script?.script : undefined + }) + const [code, setCode] = useState<{ sha: string; text: string } | null>(null) + const [open, setOpen] = useState(false) + if (!sha) return null + const toggleCode = () => { + setOpen((shown) => !shown) + if (code?.sha === sha) return + floorScriptCode(sha).then( + (text) => setCode({ sha, text: text ?? 'The script could not be read.' }), + () => setCode({ sha, text: 'The script could not be read.' }), + ) + } + const remove = () => { + const { changes } = setRoomFloorScript( + useScene.getState().nodes, + { zoneId, remove: true }, + { activeLevelId: null }, + ) + runAsSingleSceneHistoryStep(useScene, () => { + for (const { id, data } of changes?.update ?? []) + useScene.getState().updateNode(id as AnyNodeId, data) + }) + } + return ( +
+
+ Scripted finish +
+ + +
+
+ {open && ( +
+          {code?.sha === sha ? code.text : 'Loading…'}
+        
+ )} +
+ ) +} + const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}` /** "2 doors · 3 windows" (the level is in the kicker). */ diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 1a456f90ff..d13901d0f8 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -579,6 +579,11 @@ export { storedScript, } from './lib/geometry-script/author' export { compileGeometryScriptInWorker } from './lib/geometry-script/client' +export { + compileAndStoreFloorScript, + editorFloorScriptHost, + floorScriptCode, +} from './lib/geometry-script/floor-scripts' export { exportSceneToGlb, type GlbExportOptions } from './lib/glb-export' export { type EditorGridEvent, diff --git a/packages/editor/src/lib/geometry-script/client.ts b/packages/editor/src/lib/geometry-script/client.ts index 2a8f12e583..71896486cb 100644 --- a/packages/editor/src/lib/geometry-script/client.ts +++ b/packages/editor/src/lib/geometry-script/client.ts @@ -138,10 +138,17 @@ function startSandbox(): Promise { export async function compileGeometryScriptInWorker(input: { code: string params?: Record + /** What the host builds it for (a room's floor), in the host's frame. */ + inputs?: Record }): Promise { const target = await startSandbox() const id = nextId++ - const request: GeometryScriptWorkerRequest = { id, code: input.code, params: input.params } + const request: GeometryScriptWorkerRequest = { + id, + code: input.code, + params: input.params, + inputs: input.inputs, + } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reset(new Error(`The script ran longer than ${COMPILE_TIMEOUT_MS / 1000} s and was stopped`)) diff --git a/packages/editor/src/lib/geometry-script/floor-scripts.ts b/packages/editor/src/lib/geometry-script/floor-scripts.ts new file mode 100644 index 0000000000..f9a5d6e051 --- /dev/null +++ b/packages/editor/src/lib/geometry-script/floor-scripts.ts @@ -0,0 +1,128 @@ +import { + type AnyNodeId, + type CompiledGeometryScript, + type FloorScriptHost, + type FloorScriptInputs, + floorScriptInputs, + floorScriptInputsKey, + GEOMETRY_SCRIPT_MIME_TYPES, + type GeometryScriptParamValue, + getArtifactStore, + pauseSceneHistory, + refreshFloorScripts, + resumeSceneHistory, + staleFloorScripts, + useScene, +} from '@pascal-app/core' +import { compileGeometryScriptInWorker } from './client' + +/** + * Runs a room's floor script in the sandbox for its floor and stores the GLB and the module text, + * answering the compile with the stored hashes (`setRoomFloorScript` takes it with `inputs`). + */ +export async function compileAndStoreFloorScript(input: { + zoneId: string + code: string + params?: Record + inputs: FloorScriptInputs +}): Promise { + const { glb, ...compiled } = await compileGeometryScriptInWorker({ + code: input.code, + params: input.params, + inputs: input.inputs, + }) + const context = { + nodeId: input.zoneId, + metadata: { name: 'Floor finish', mount: compiled.mount }, + } + const store = getArtifactStore() + const [sha256] = await Promise.all([ + store.put(compiled.sha256, glb, 'model/gltf-binary', context), + store.put( + compiled.script, + new TextEncoder().encode(input.code), + GEOMETRY_SCRIPT_MIME_TYPES.three, + context, + ), + ]) + return { ...compiled, sha256 } +} + +/** The editor tab's host for floor scripts: the sandbox compile and the scene's artifact store. */ +export const editorFloorScriptHost: FloorScriptHost = { + readScript: (sha256) => getArtifactStore().text(sha256), + build: compileAndStoreFloorScript, +} + +/** The module text of a room's floor script, read back from the artifact store. */ +export function floorScriptCode(sha256: string): Promise { + return getArtifactStore().text(sha256) +} + +const SETTLE_MS = 150 + +/** + * Keeps every room's floor script built for the room's floor: after a scene change settles, each + * script whose room the structure re-derived runs again in the sandbox. The new build is written + * outside history, as the reconcile's derived writes are, and only while the room's floor is still + * the one it was built for; undo brings back the floor and the script built for it together. + */ +export function initFloorScriptRefresh(): () => void { + let timer: ReturnType | null = null + let running = false + let again = false + + const run = async () => { + if (running) { + again = true + return + } + running = true + try { + const nodes = useScene.getState().nodes + if (useScene.getState().readOnly || staleFloorScripts(nodes).length === 0) return + const refresh = await refreshFloorScripts(nodes, editorFloorScriptHost) + for (const { zoneId, error } of refresh.failed) + console.error(`[floor script] ${zoneId} could not be rebuilt: ${error}`) + const current = useScene.getState().nodes + const writes = refresh.update.flatMap(({ id, data }) => { + const zone = current[id as AnyNodeId] + const script = data.floor?.script + const inputs = floorScriptInputs(current, id) + if (zone?.type !== 'zone' || !zone.floor?.script || !script || !inputs) return [] + if (floorScriptInputsKey(inputs) !== script.inputs) return [] + return [{ id: id as AnyNodeId, data: { floor: { ...zone.floor, script } } }] + }) + if (writes.length === 0) return + pauseSceneHistory(useScene) + try { + useScene.getState().updateNodes(writes) + } finally { + resumeSceneHistory(useScene) + } + } finally { + running = false + if (again) { + again = false + schedule() + } + } + } + + const schedule = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = null + void run() + }, SETTLE_MS) + } + + const unsubscribe = useScene.subscribe((state, previous) => { + if (state.nodes !== previous.nodes) schedule() + }) + schedule() + return () => { + unsubscribe() + if (timer) clearTimeout(timer) + } +} diff --git a/packages/editor/src/lib/geometry-script/protocol.ts b/packages/editor/src/lib/geometry-script/protocol.ts index df918a7528..a075bdbbff 100644 --- a/packages/editor/src/lib/geometry-script/protocol.ts +++ b/packages/editor/src/lib/geometry-script/protocol.ts @@ -5,6 +5,7 @@ export type GeometryScriptWorkerRequest = { id: number code: string params?: Record + inputs?: Record } export type GeometryScriptWorkerResponse = diff --git a/packages/editor/src/lib/scene-clipboard.test.ts b/packages/editor/src/lib/scene-clipboard.test.ts index 1069a7b2dc..9454fc4648 100644 --- a/packages/editor/src/lib/scene-clipboard.test.ts +++ b/packages/editor/src/lib/scene-clipboard.test.ts @@ -22,6 +22,7 @@ import { useScene, WallNode, WindowNode, + ZoneNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { @@ -712,6 +713,59 @@ describe('scene clipboard', () => { } }) + test("a room copied to another project brings its floor script's artifacts", async () => { + let systemClipboardText = '' + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + readText: async () => systemClipboardText, + writeText: async (text: string) => { + systemClipboardText = text + }, + }, + }) + const room = ZoneNode.parse({ + id: 'zone_clipboard-floor-script', + parentId: sourceLevelId, + name: 'Hall', + polygon: [ + [0, 0], + [3, 0], + [3, 3], + [0, 3], + ], + floor: { + script: { + kind: 'script', + script: 'e'.repeat(64), + artifact: 'f'.repeat(64), + inputs: 'key', + manifest: { bounds: { min: [0, 0, 0], max: [3, 0.01, 3] }, triangles: 2 }, + }, + }, + }) + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [sourceLevelId]: makeLevel(sourceLevelId, [room.id]), + [room.id]: room, + }, + })) + useViewer.getState().setProjectId('project_clipboard-source') + expect(copySelectedNodesToEditorClipboard([room.id])).toBe(true) + useViewer.getState().setProjectId('project_clipboard-target') + const copyFrom = mock(async (_projectId: string, _sha256s: string[]) => [] as string[]) + const previousStore = getArtifactStore() + configureArtifactStore({ ...previousStore, copyFrom }) + try { + await pasteSystemEditorClipboardToLevel(targetLevelId) + expect(new Set(copyFrom.mock.calls[0]![1])).toEqual(new Set(['e'.repeat(64), 'f'.repeat(64)])) + } finally { + configureArtifactStore(previousStore) + useViewer.getState().setProjectId(null) + } + }) + test('navigation during artifact copy does not paste into the next project', async () => { copyScriptedWindow() const previousStore = getArtifactStore() diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index e6492864b3..3fa94fd226 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -488,9 +488,11 @@ export async function pasteSystemEditorClipboardToLevel( return applyClipboardPayloadToLevel(payload, targetLevel.id, refused) } +/** The script and GLB a scripted node, or a room's floor script, references. */ function artifactHashes(node: AnyNode): string[] { - const source = 'source' in node ? node.source : undefined - return typeof source === 'object' && source.kind === 'script' + const source = + node.type === 'zone' ? node.floor?.script : 'source' in node ? node.source : undefined + return typeof source === 'object' && source?.kind === 'script' ? [source.script, source.artifact] : [] } diff --git a/packages/geometry-script/src/compile.test.ts b/packages/geometry-script/src/compile.test.ts index c831ba55ad..cf4054a161 100644 --- a/packages/geometry-script/src/compile.test.ts +++ b/packages/geometry-script/src/compile.test.ts @@ -681,3 +681,55 @@ describe('what refuses an object that cannot be measured, for a Blender build to expect((await finish(built)).language).toBe('bpy') }) }) + +describe("a build for its host's inputs", () => { + const FLOOR = ` +import * as THREE from 'three' +export const params = { thickness: { default: 0.01, min: 0.005, max: 0.03 } } +export default function build({ params, inputs }) { + const xs = params.polygon.map((p) => p[0]) + const zs = params.polygon.map((p) => p[1]) + const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)] + const board = new THREE.Mesh( + new THREE.BoxGeometry(x1 - x0, params.thickness, z1 - z0), + new THREE.MeshStandardMaterial({ name: 'slot_oak' }), + ) + board.position.set((x0 + x1) / 2, inputs.elevation + params.thickness / 2, (z0 + z1) / 2) + const group = new THREE.Group() + group.add(board) + return group +} +` + const inputs = { + polygon: [ + [2, 1], + [6, 1], + [6, 4], + [2, 4], + ], + holes: [], + elevation: 0.3, + } + + test('reads them in params, stays in their frame and keeps only its own params', async () => { + const compiled = await compileGeometryScript({ code: FLOOR, inputs }) + const { min, max } = compiled.manifest.bounds + expect(min[0]).toBeCloseTo(2, 5) + expect(max[0]).toBeCloseTo(6, 5) + expect(min[1]).toBeCloseTo(0.3, 5) + expect(min[2]).toBeCloseTo(1, 5) + expect(max[2]).toBeCloseTo(4, 5) + expect(compiled.params).toEqual({ thickness: 0.01 }) + }) + + test('without them, the same kind of module is re-origined for its mount', async () => { + const compiled = await compileGeometryScript({ + code: FLOOR.replaceAll('params.polygon', '[[2, 1], [6, 1], [6, 4], [2, 4]]').replace( + 'inputs.elevation', + '0.3', + ), + }) + expect(compiled.manifest.bounds.min[0]).toBeCloseTo(-2, 5) + expect(compiled.manifest.bounds.min[1]).toBeCloseTo(0, 5) + }) +}) diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index e20ab03e70..0ef96d3838 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -24,6 +24,13 @@ export type { GeometryScriptMount } export type GeometryScriptCompileInput = { code: string params?: Record + /** + * What the host builds the module for (a room floor's `polygon` and `elevation`): the build reads + * them in `params` beside its declared ones, and as `inputs`; they are not among the returned + * params, which the scene keeps. A build given them is in the host's frame, so it is not + * re-origined for its mount. + */ + inputs?: Record } export type GeometryScriptCompileOutput = CompiledGeometryScript & { glb: ArrayBuffer } @@ -872,6 +879,7 @@ export async function finishBuild({ params, code, language, + hostFrame = false, }: { built: THREE.Object3D mount: GeometryScriptMount @@ -879,6 +887,8 @@ export async function finishBuild({ params: Record code: string language: GeometryScriptLanguage + /** Keep the build where it stands: its host placed it, so its mount does not re-origin it. */ + hostFrame?: boolean }): Promise { const root = new THREE.Group() root.name = 'pascal_script_root' @@ -894,7 +904,7 @@ export async function finishBuild({ `The object is ${vec(size).join(' × ')} m; the limit is ${GEOMETRY_SCRIPT_LIMITS.extent} m per side. Units are metres.`, ) } - built.position.sub(originFor(box, mount)) + if (!hostFrame) built.position.sub(originFor(box, mount)) root.updateWorldMatrix(true, true) const read = readAnimations(built, root) @@ -980,9 +990,10 @@ export async function compileGeometryScript( const specs = readParamSpecs(exported.params) const params = resolveParams(specs, input.params ?? {}) + const inputs = input.inputs ?? {} const built = await (exported.build as (ctx: unknown) => unknown)({ - params, - inputs: {}, + params: { ...params, ...inputs }, + inputs, THREE, lib: LIB, }) @@ -990,5 +1001,13 @@ export async function compileGeometryScript( throw new Error('build() must return a THREE.Object3D (usually a THREE.Group)') } - return finishBuild({ built, mount, specs, params, code: input.code, language: 'three' }) + return finishBuild({ + built, + mount, + specs, + params, + code: input.code, + language: 'three', + hostFrame: input.inputs !== undefined, + }) } diff --git a/packages/geometry-script/src/sandbox-worker.ts b/packages/geometry-script/src/sandbox-worker.ts index 4bb16fdf1c..5078dab65f 100644 --- a/packages/geometry-script/src/sandbox-worker.ts +++ b/packages/geometry-script/src/sandbox-worker.ts @@ -44,12 +44,17 @@ for (const name of BLOCKED) { } } -type Request = { id: number; code: string; params?: Record } +type Request = { + id: number + code: string + params?: Record + inputs?: Record +} self.addEventListener('message', async (event: MessageEvent) => { - const { id, code, params } = event.data + const { id, code, params, inputs } = event.data try { - const output = await compileGeometryScript({ code, params }) + const output = await compileGeometryScript({ code, params, inputs }) post({ id, ok: true, output }, [output.glb]) } catch (error) { post({ id, ok: false, error: error instanceof Error ? error.message : String(error) }) diff --git a/packages/nodes/src/slab/__tests__/plate-paint.test.ts b/packages/nodes/src/slab/__tests__/plate-paint.test.ts index eb8581c523..2dd0d2cf48 100644 --- a/packages/nodes/src/slab/__tests__/plate-paint.test.ts +++ b/packages/nodes/src/slab/__tests__/plate-paint.test.ts @@ -5,6 +5,7 @@ import { MaterialSchema, SlabNode, useScene, + ZoneFloorScript, type ZoneNode, ZoneNode as ZoneSchema, } from '@pascal-app/core' @@ -360,3 +361,23 @@ test('a zoneless base surface returns a notice instead of throwing or recording expect(useScene.getState().nodes).toBe(before) expect(useScene.temporal.getState().pastStates).toHaveLength(0) }) + +test("a floor built by a script refuses the room's paint, which its script owns", () => { + const script = ZoneFloorScript.parse({ + kind: 'script', + script: 'a'.repeat(64), + artifact: 'b'.repeat(64), + inputs: 'key', + manifest: { bounds: { min: [0, 0, 0], max: [4, 0.01, 4] }, triangles: 2 }, + }) + zone = { ...zone, floor: { ...zone.floor, script } } + useScene.setState({ nodes: { ...useScene.getState().nodes, [zone.id]: zone } }) + useScene.temporal.getState().clear() + const before = useScene.getState().nodes + for (const role of ['room:zone_a', 'room:zone_a/region_1']) + expect( + slabPaint.commit({ node: plate, role, material: undefined, materialPreset: TILE }), + ).toMatchObject({ status: 'refused', code: 'floor-built-by-script' }) + expect(useScene.getState().nodes).toBe(before) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) +}) diff --git a/packages/nodes/src/slab/paint.ts b/packages/nodes/src/slab/paint.ts index 0f45f9547d..8ecf576f0b 100644 --- a/packages/nodes/src/slab/paint.ts +++ b/packages/nodes/src/slab/paint.ts @@ -499,10 +499,11 @@ export const slabPaint: PaintCapability & { ? {} : base.buildPatch(args), commit: (args: PaintPatchArgs) => { + const refusal = floorPlatePaintRefusal(useScene.getState().nodes, args.node, args.role) + if (refusal) return refusal if (args.role === 'surface' && args.node.type === 'slab' && args.node.plateRole === 'base') { const role = baseSurfaceRoom(args.node) - if (!role) - return floorPlatePaintRefusal(useScene.getState().nodes, args.node, args.role) ?? undefined + if (!role) return commitRoomFinish(role, args.material, args.materialPreset) return } diff --git a/packages/nodes/src/zone/renderer.tsx b/packages/nodes/src/zone/renderer.tsx index 8200eead0f..1563ca3cb3 100644 --- a/packages/nodes/src/zone/renderer.tsx +++ b/packages/nodes/src/zone/renderer.tsx @@ -1,10 +1,14 @@ 'use client' import { + artifactUrl, + type ItemNode, polygonInteriorPoint, + scriptedSize, useLiveNodeOverrides, useRegistry, useScene, + type ZoneFloorScript, type ZoneNode, } from '@pascal-app/core' import { @@ -19,6 +23,7 @@ import { useEffect, useMemo, useRef } from 'react' import { Color, DoubleSide, type Group } from 'three' import { color, float, uniform, uv } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' +import { ScriptedModel } from '../item/renderer' import { owningUnitForZone } from './unit-membership' const Y_OFFSET = 0.01 @@ -71,13 +76,51 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { const ref = useRef(null!) useRegistry(node.id, 'zone', ref) const showZones = useViewer((state) => state.showZones) + const script = node.floor?.script return ( {showZones ? : null} + {script ? : null} ) } +/** Over the floor plate's top, so the plate's own finish never shows through the boards. */ +const FLOOR_SCRIPT_LIFT = 0.001 + +/** + * The room's floor finish its script built, drawn as an authored object's artifact is (its slots, + * the render shading), in the level's frame it was built in, just over the plate. It belongs to the + * room: no pointer events of its own, so a click on it reaches the floor below. + */ +function FloorScriptModel({ node, script }: { node: ZoneNode; script: ZoneFloorScript }) { + const view = useMemo(() => { + const [width, height, depth] = scriptedSize(script.manifest) + return { + id: node.id, + type: 'item', + parentId: node.parentId, + metadata: {}, + source: script, + scale: [1, 1, 1], + asset: { + src: artifactUrl(script.artifact), + dimensions: [width, height, depth], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + } as unknown as ItemNode + }, [node.id, node.parentId, script]) + return ( + + + + ) +} + +const settleNothing = () => {} + function ZoneVisuals({ node }: { node: ZoneNode }) { // Group-transform drags publish a translated/rotated polygon to // `useLiveNodeOverrides`; merge it so the zone previews live instead of diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 89a2e7d786..d57c8da7da 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -130,6 +130,12 @@ cuts withhold wall-body/contact evidence. Curved walls, nested hosts, deformed, animated, unfinished, dirty and oversized meshes are not assessed. No placement, artifact or scene data is changed by these checks. +## Room floor scripts + +A room's floor finish can be a script: `zone.floor.script` holds the same source record as an item's `source` (language, script and artifact hashes, params, manifest) plus `inputs`, the key of the floor it was built for. The host reads the room's floor with `floorScriptInputs(nodes, zoneId)` (core): `polygon` and `holes`, the room's outline inside the wall faces clipped to its floor plate, and `elevation`, the plate's top, all in level coordinates and metres. It compiles the module with geometry-script's `inputs` (the module reads them as `params.polygon`, `params.holes`, `params.elevation`; they are never stored as params, and the build is kept in the level's frame instead of being re-origined for its mount) and stores the artifacts; `setRoomFloorScript` writes the result, or takes it off with `remove`. Only three.js modules: a Blender build is not given the floor. + +The zone renderer draws the artifact 1 mm over the plate, with no pointer events of its own, so a click reaches the floor beneath. The script owns the floor's colours: the plate gives a scripted room its own top cell, and `floorPlatePaintRefusal` (the editor's Paint) and `set_zone_intent` (`floor.finish`, `floor.regions`) refuse paint there with a sentence until it is removed. When the structure reconcile re-derives the room (a wall moved, an edge pushed, the floor raised), the key no longer matches (`staleFloorScripts`) and `refreshFloorScripts(nodes, host)` runs the stored module and params again for the new floor; the editor tab does this after each settled change, writing the rebuild outside history like the reconcile's derived writes. The room panel shows "Scripted finish" with its code and Remove. + ## Cutter binding A bare `cutout` cuts the surface the object is mounted on. A wall or ceiling parent is the host; a floor item uses its preferred support slab while it overlaps, otherwise the same highest overlapping slab as floor placement. Items resting on other items do not cut a slab beneath them. `cut:wall`, `cut:ceiling` and `cut:slab` require that kind of host; they never select a neighbouring surface. From 8f58f32e1e1971e06792080a3c90b1df8c899fa3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 10:24:31 -0400 Subject: [PATCH 20/26] core + mcp: author_scene writes a wall's skirting and a room's floor script Converting agents faked baseboards with free-standing items hosted on walls and plank floors with fixed floor regions, because the payload had no way to say either. Both finishes are native now, so the payload carries them. - Payload: a wall's `skirting { height (0.1 m), depth (0.012 m), color? }` and a room's `floorCode` (a three.js module given params.polygon, params.holes and params.elevation, at most 48k), with `floor` as its fallback. Old payloads parse unchanged. - Operation: once the rooms exist, setWallSkirting on each wall with skirting, on the faces a room stands on, its colour through the payload's exact materials; a refusal is a `skirting_refused` defect naming the wall. Core cannot compile, so authorSceneFloorScripts(payload, report) lists { roomId, zoneId, code } for the host; the report gains `floorScripts`. - MCP: after the write, each room's floor (floorScriptInputs) is compiled through the GeometryScriptHost with `inputs` (compile and build carry them; ScriptedKind 'floor', no artifact kind) and written with setRoomFloorScript; a failure is `floor_script_failed` and the room keeps its floor. The stored payload drops floorCode like the other modules. - get_source: readableScript reads a room's floor script by its zone id (zone.floor.script); the contract says so for both surfaces. - Skill, workflow reference and agent-surfaces describe both. - Tests: skirting on the room's face in its exact colour, defaults, no skirting when absent; a wall with no room is a defect; rooms with floorCode listed by zone, unenclosed ones not; through a real MCP client and the real compiler, a wall's skirting painted and a zone's floor.script built inside the wall faces and read back with get_source; a failing floor module is a defect and the room keeps its finish. Co-Authored-By: Claude Fable 5.1 --- .../core/src/agent-operations/add-object.ts | 17 ++- .../src/agent-operations/author-scene.test.ts | 91 +++++++++++++- .../core/src/agent-operations/author-scene.ts | 68 ++++++++++- packages/core/src/agent-tools/add-object.ts | 6 +- packages/core/src/agent-tools/author-scene.ts | 31 ++++- packages/mcp/src/tools/add-object.ts | 51 ++++++-- packages/mcp/src/tools/author-scene.test.ts | 113 +++++++++++++++++- packages/mcp/src/tools/author-scene.ts | 49 +++++++- skills/pascal-3d/SKILL.md | 2 +- skills/pascal-3d/references/tool-workflows.md | 4 +- wiki/architecture/agent-surfaces.md | 4 +- 11 files changed, 403 insertions(+), 33 deletions(-) diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index a952b668de..e6a31a4420 100644 --- a/packages/core/src/agent-operations/add-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -621,8 +621,23 @@ export function scriptedFieldRefusal(node: AnyNode, data: Record & { + id: string + type: ScriptedNode['type'] | 'zone' +} + +/** The script `get_source` reads at `nodeId`: an authored object's, or a room's floor script. */ +export function readableScript(nodes: Record, nodeId: string): ScriptReadable { + const node = nodes[nodeId] + if (node?.type !== 'zone') return authoredObject(nodes, nodeId) + if (!node.floor?.script) + refuse('not_authored', `${node.name || nodeId} has no floor script.`, { id: nodeId }) + return { id: node.id, type: 'zone', name: node.name, source: node.floor.script } +} + /** What `get_source` answers once the host has the module's text. */ -export function readSourceResult(node: ScriptedNode, code: string) { +export function readSourceResult(node: ScriptReadable, code: string) { return { nodeId: node.id, type: node.type, diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 50db58bad8..89025bd2cc 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -3,7 +3,7 @@ import type { AuthorScenePayload } from '../agent-tools/author-scene' import { authorScenePayload } from '../agent-tools/author-scene' import type { AnyNode, CompiledGeometryScript, SceneMaterial } from '../schema' import { CEILING_DRAW_OFFSET, resolveCeilingHeight } from '../services' -import { authorScene, authorSceneCeilingPoints } from './index' +import { authorScene, authorSceneCeilingPoints, authorSceneFloorScripts } from './index' /** A site, a building and the empty level a new project starts with. */ function scaffold(): Record { @@ -899,4 +899,93 @@ describe('author_scene', () => { }, ]) }) + + test("a wall's skirting is native on the wall, on its room's face, in its exact colour", () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + walls: bedroom.levels[0]!.walls!.map((w) => + w.id === 'north' + ? { ...w, skirting: { color: '#fafafa' } } + : w.id === 'east' + ? { ...w, skirting: { height: 0.14, depth: 0.02 } } + : w, + ), + }, + ], + }) + const outcome = run(payload, {}, {}) + expect(outcome.result.defects).toEqual([]) + const white = outcome.changes!.materials!.find((m) => m.name === '#fafafa')! + // The room is south of the north wall, on its left looking from start to end: face a. + const north = byAuthorId(outcome, 'north') + expect(north.skirting).toMatchObject({ enabled: true, sides: 'a', height: 0.1, proud: 0.012 }) + expect(north.slots.aSkirting).toBe(`scene:${white.id}`) + expect(north.slots.bSkirting).toBeUndefined() + const east = byAuthorId(outcome, 'east') + expect(east.skirting).toMatchObject({ enabled: true, height: 0.14, proud: 0.02 }) + expect(east.slots?.aSkirting).toBeUndefined() + // A payload without skirting writes walls without one, as before. + expect(payload.levels[0]!.walls!.find((w) => w.id === 'south')).not.toHaveProperty('skirting') + expect(byAuthorId(outcome, 'south').skirting).toBeUndefined() + }) + + test('a skirting with no room on either face of its wall is a defect naming the wall', () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + walls: [ + ...bedroom.levels[0]!.walls!, + { id: 'garden', start: [4, 4], end: [7, 4], height: 1.2, thickness: 0.2, skirting: {} }, + ], + }, + ], + }) + const outcome = run(payload, {}) + expect(outcome.result.defects).toEqual([ + { + id: 'garden', + code: 'skirting_refused', + message: 'No room stands on either face of wall garden: its skirting was not written.', + }, + ]) + expect(byAuthorId(outcome, 'garden').skirting).toBeUndefined() + }) + + test('rooms with a floor module are listed by their zone for the host to compile', () => { + const floorCode = 'export default function build({ THREE }) { return new THREE.Group() }' + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + rooms: [ + { name: 'Bedroom', seed: [0, 0], floor: 'library:wood-floorplank1', floorCode }, + { name: 'Garden', seed: [9, 9], floorCode }, + ], + }, + ], + }) + const outcome = run(payload, {}) + expect(authorSceneFloorScripts(payload, outcome.result)).toEqual([ + { roomId: 'room/0/0', zoneId: outcome.result.ids['room/0/0']!, code: floorCode }, + ]) + expect(outcome.result.floorScripts).toBe(0) + expect(outcome.result.defects.map((d) => [d.id, d.code])).toEqual([ + ['room/0/1', 'room_not_enclosed'], + ]) + // The room keeps its floor finish as the fallback until the host writes the script. + expect(byAuthorId(outcome, 'room/0/0').floor.finish).toBe('library:wood-floorplank1') + expect(authorSceneFloorScripts(bedroom, outcome.result)).toEqual([]) + }) }) diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 644250d693..760cdd7ba7 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -44,6 +44,7 @@ import { paint } from './paint' import { pointInPolygon, projectWorldPointToWallLocalX, type Vec2 } from './plan-geometry' import { resolveInlinePaintMaterial } from './scene-materials' import type { AgentContext, AgentOperationOutcome, NodeWrite, SceneNodes } from './types' +import { setWallSkirting } from './wall-skirting' /** * `author_scene`: a whole design, read by its author from the three.js module it wrote, handed @@ -81,6 +82,8 @@ export type AuthorSceneReport = { columns: number items: number lights: number + /** Rooms whose floor finish is their module, counted by the host that compiles them after the write. */ + floorScripts: number /** The join rule applied: wall ends moved onto a centerline, with how far. */ snapped: { id: string; end: 'start' | 'end'; movedM: number }[] /** What Pascal filled in or dropped, one sentence per fact. */ @@ -100,6 +103,7 @@ const MANY_REGIONS = 24 const HEX = /^#[0-9a-f]{6}$/i type Vec3 = [number, number, number] +type AuthorSceneWall = NonNullable[number] /** * Small, stable ids: the payload's own hash and a counter per kind, so a replay writes the same @@ -264,6 +268,25 @@ export function authorSceneCeilingPoints( ) } +/** + * The rooms whose floor finish is a module, by their zone once the write made it: the host reads + * each room's floor (`floorScriptInputs`), compiles the module for it and writes it with + * `setRoomFloorScript`, which core cannot do without a three.js runtime. A room whose seed found + * no enclosure has no zone and is already a defect. + */ +export function authorSceneFloorScripts( + payload: AuthorScenePayload, + report: Pick, +): { roomId: string; zoneId: string; code: string }[] { + return payload.levels.flatMap((level, index) => + level.rooms.flatMap((room, i) => { + const roomId = `room/${index}/${i}` + const zoneId = report.ids[roomId] + return room.floorCode && zoneId ? [{ roomId, zoneId, code: room.floorCode }] : [] + }), + ) +} + export function authorScene( scene: SceneNodes, input: AuthorSceneInput, @@ -298,6 +321,7 @@ export function authorScene( columns: 0, items: 0, lights: 0, + floorScripts: 0, snapped: [], defaults: [], warnings: [], @@ -360,6 +384,12 @@ export function authorScene( const levelNodes: ReturnType[] = [] const itemNodeIds = new Map() const itemFrames = new Map() + const skirtings: { + id: string + nodeId: string + levelId: string + skirting: NonNullable + }[] = [] let untypedWindows = 0 payload.levels.forEach((level, index) => { @@ -427,6 +457,13 @@ export function authorScene( }) place(node) wallNodes.set(w.spec.id, { ...w, node }) + if (w.spec.skirting) + skirtings.push({ + id: w.spec.id, + nodeId: node.id, + levelId: levelNode.id, + skirting: w.spec.skirting, + }) report.ids[w.spec.id] = node.id report.walls++ // ---------- its openings ---------- @@ -884,8 +921,37 @@ export function authorScene( mintId: (kind) => mint(kind), }) - // Ceilings exist only after structure reconciliation; lights can now use their room's host. const written = { ...structure.nodes } + + // ---------- skirting, native on each wall, along the faces its rooms stand on ---------- + for (const { id, nodeId, levelId, skirting } of skirtings) { + const finish = finishRef(skirting.color, id) + try { + const { changes } = setWallSkirting( + written, + { + wallIds: [nodeId], + height: skirting.height, + depth: skirting.depth, + ...(finish ? { finish } : {}), + }, + paintContext(levelId), + ) + for (const u of changes?.update ?? []) + written[u.id] = { ...written[u.id], ...u.data } as AnyNode + } catch (error) { + const e = error as { code?: string; message?: string } + defect( + id, + 'skirting_refused', + e.code === 'side_unknown' + ? `No room stands on either face of wall ${id}: its skirting was not written.` + : `Wall ${id}: ${e.message ?? String(error)}`, + ) + } + } + + // Ceilings exist only after structure reconciliation; lights can now use their room's host. payload.levels.forEach((level, index) => { const levelNode = levelNodes[index]! for (const [i, light] of level.lights.entries()) { diff --git a/packages/core/src/agent-tools/add-object.ts b/packages/core/src/agent-tools/add-object.ts index 9aa722e7c3..5048d680d3 100644 --- a/packages/core/src/agent-tools/add-object.ts +++ b/packages/core/src/agent-tools/add-object.ts @@ -104,8 +104,10 @@ export const getSourceTool = { name: 'get_source', title: 'Read object script', description: - 'The module (three.js or Blender bpy, named by language) an object (or a window, door or column built from code) runs, with its params and their current values. Optional source evidence when the current module is needed. An edited module can be passed back with the same nodeId: to add_object for an object, to add_window / add_door / add_column for their respective kinds.', + "The module (three.js or Blender bpy, named by language) an object (or a window, door or column built from code) runs, with its params and their current values; a room's id reads its floor script, the module that builds its floor finish. Optional source evidence when the current module is needed. An edited module can be passed back with the same nodeId: to add_object for an object, to add_window / add_door / add_column for their respective kinds.", input: { - nodeId: NodeId.describe('An object, window, door or column built from code.'), + nodeId: NodeId.describe( + 'An object, window, door or column built from code, or a room with a floor script.', + ), }, } diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index 30e15c3a3c..157be575f2 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -6,7 +6,7 @@ import { NodeId } from './node-id' const DESCRIPTION = `Turn a whole design you wrote as free three.js into an editable Pascal scene, in one call. You read your own module however you like (its source, running it and reading the objects, both) and hand over a conversion payload: levels, walls with their openings, rooms, slabs, columns, railings, every piece of furniture as an authored object with its own module, and lights. Pascal does the rest server-side: compiles and stores each item, places it, derives the rooms and their floors, paints, runs its checks, saves, and answers with a gate report you iterate against. Nothing you hand over is silently dropped: what cannot be placed comes back by name with a reason, and what Pascal corrected (a wall end snapped onto the centerline it meets) is listed. -Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre; give each opening its type (how it opens) and style (its look), or its own module for a shape they cannot express. An item's position is where its bottom centre stands; its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is). A hex colour is written as an exact material, one per distinct colour. Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and floor planks into the wall, the room's floor finish and the room (say so in notes), never into slabs or regions; a slab is a terrace, a platform, a balcony. Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. +Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre; give each opening its type (how it opens) and style (its look), or its own module for a shape they cannot express. An item's position is where its bottom centre stands; its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is). A hex colour is written as an exact material, one per distinct colour. Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and floor planks into the wall, the room's floor finish and the room (say so in notes), never into slabs or regions: baseboards are the wall's skirting, a plank or tile floor is the room's floorCode; a slab is a terrace, a platform, a balcony. Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. The report says what was written, what Pascal defaulted (defaults: a window with no type is fixed and cannot open), what it warns about but wrote (warnings: thin slabs, too many slabs or regions) and what it refused (defects, by your ids). @@ -107,6 +107,27 @@ export const authorSceneWall = z.object({ 'Paint for the face on the right when looking from start to end, same forms as color; over color.', ), openings: z.array(authorSceneOpening).optional(), + skirting: z + .object({ + height: length('Skirting height up the face (default 0.1 m).', { + default: 0.1, + positive: true, + }), + depth: length('How far it stands proud of the face (default 0.012 m).', { + default: 0.012, + positive: true, + }), + color: z + .string() + .optional() + .describe( + "#rrggbb, written as an exact material, or library: (default: the skirting's own finish as Pascal draws it).", + ), + }) + .optional() + .describe( + "The wall's skirting (baseboard), native on the wall: it runs along the faces a room stands on, steps with the floor and breaks at doors. Give it here, never as an item.", + ), }) export const authorSceneRoom = z.object({ @@ -130,6 +151,14 @@ export const authorSceneRoom = z.object({ .describe( 'Parts of the floor in another finish (the kitchen tiles inside an open-plan room), library: or #rrggbb. Regions are fixed polygons that do not follow a resize: one region per finish, never one per plank.', ), + floorCode: z + .string() + .min(1) + .max(48_000) + .optional() + .describe( + "A three.js module that builds the floor finish for the room's polygon: it receives params.polygon, params.holes and params.elevation (level coordinates, metres) and returns the planks, tiles or boards clipped to that polygon. Pascal rebuilds it when the room changes; floor stays the fallback finish.", + ), }) export const authorSceneSlab = z.object({ diff --git a/packages/mcp/src/tools/add-object.ts b/packages/mcp/src/tools/add-object.ts index f8dae12c08..73bc3bfc93 100644 --- a/packages/mcp/src/tools/add-object.ts +++ b/packages/mcp/src/tools/add-object.ts @@ -8,9 +8,11 @@ import { editedScriptLanguage, editedScriptParams, furnishingRefusal, + readableScript, readSourceResult, requireAddObjectReason, type SceneNodes, + type ScriptReadable, } from '@pascal-app/core/agent-operations' import { addObjectTool, @@ -45,6 +47,8 @@ export type GeometryScriptHost = { code: string language: GeometryScriptLanguage params?: Record + /** What the host builds it for (a room's floor: `floorScriptInputs`), kept in the host's frame. */ + inputs?: Record }): Promise storeArtifact(input: { sceneId: string @@ -65,6 +69,7 @@ export type GeometryScriptHost = { code: string language: GeometryScriptLanguage params?: Record + inputs?: Record /** What the script builds, for the host to name it to the user. */ kind: ScriptedKind nodeId: string @@ -72,7 +77,16 @@ export type GeometryScriptHost = { }): Promise } -export type ScriptedKind = 'object' | 'window' | 'door' | 'column' +export type ScriptedKind = 'object' | 'window' | 'door' | 'column' | 'floor' + +/** The kind an artifact records; a room's floor finish is none of the node kinds. */ +const ARTIFACT_KIND: Record = { + object: 'item', + window: 'window', + door: 'door', + column: 'column', + floor: undefined, +} /** Compiles a module on the host and stores its GLB and text for the scene: the step every scripted tool shares. */ export async function compileAndStore( @@ -82,25 +96,27 @@ export async function compileAndStore( code, language, params, + inputs, }: { code: string language: GeometryScriptLanguage params: Record | undefined + inputs?: Record }, kind: ScriptedKind, context: { nodeId?: string; metadata?: GeometryArtifactMetadata } = {}, ): Promise { - const nodeId = context.nodeId ?? generateId(kind === 'object' ? 'item' : kind) + const nodeId = context.nodeId ?? generateId(ARTIFACT_KIND[kind] ?? 'item') const metadata = GeometryArtifactMetadata.parse({ ...context.metadata, - kind: kind === 'object' ? 'item' : kind, + kind: ARTIFACT_KIND[kind], }) if (host.build) return { - ...(await host.build({ sceneId, code, language, params, kind, nodeId, metadata })), + ...(await host.build({ sceneId, code, language, params, inputs, kind, nodeId, metadata })), nodeId, } - const { glb, ...compiled } = await host.compile({ code, language, params }) + const { glb, ...compiled } = await host.compile({ code, language, params, inputs }) metadata.mount = compiled.mount const [sha256] = await Promise.all([ host.storeArtifact({ @@ -123,18 +139,29 @@ export async function compileAndStore( return { ...compiled, sha256, nodeId } } -export async function readScript( +async function scriptText( host: GeometryScriptHost, sceneId: string, - bridge: SceneOperations, - nodeId: string, + node: ScriptReadable, ): Promise { - const node = authoredObject(bridge.getNodes() as Record, nodeId) const bytes = await host.readArtifact({ sceneId, sha256: node.source.script }) - if (!bytes) throw new Error(`The script of ${nodeId} could not be read`) + if (!bytes) throw new Error(`The script of ${node.id} could not be read`) return new TextDecoder().decode(bytes) } +export function readScript( + host: GeometryScriptHost, + sceneId: string, + bridge: SceneOperations, + nodeId: string, +): Promise { + return scriptText( + host, + sceneId, + authoredObject(bridge.getNodes() as Record, nodeId), + ) +} + /** * One object's module compiled and stored by the host: a new one from its code, an edit from its * code or its stored script, with the params it keeps. The step a lone add_object and each @@ -280,8 +307,8 @@ export function registerGetSource( const scene = bridge.getActiveScene() if (!scene) return toolError('Open a scene first.', { code: 'no_active_scene' }) try { - const node = authoredObject(bridge.getNodes() as Record, nodeId) - const payload = readSourceResult(node, await readScript(host, scene.id, bridge, nodeId)) + const node = readableScript(bridge.getNodes() as Record, nodeId) + const payload = readSourceResult(node, await scriptText(host, scene.id, node)) return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], structuredContent: payload, diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts index ef160d34e4..e0000b3854 100644 --- a/packages/mcp/src/tools/author-scene.test.ts +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -130,19 +130,36 @@ const payload = { notes: ['The rug was left out.'], } +/** A floor of four boards across the bounds of the room's floor, at its top. */ +const BOARDS = `export default function build({ THREE, params }) { + const xs = params.polygon.map((p) => p[0]), zs = params.polygon.map((p) => p[1]) + const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)] + const g = new THREE.Group() + const m = new THREE.MeshStandardMaterial({ color: '#b08060' }); m.name = 'slot_wood' + const w = (x1 - x0) / 4 + for (let i = 0; i < 4; i++) { + const b = new THREE.Mesh(new THREE.BoxGeometry(w, 0.01, z1 - z0), m) + b.position.set(x0 + w * (i + 0.5), params.elevation + 0.005, (z0 + z1) / 2) + g.add(b) + } + return g +}` + describe('author_scene (MCP)', () => { let client: Client const stored: string[] = [] + const artifacts = new Map() const host: GeometryScriptHost = { - compile: async ({ code }) => { - const { glb, ...compiled } = await compileGeometryScript({ code }) + compile: async ({ code, inputs }) => { + const { glb, ...compiled } = await compileGeometryScript({ code, inputs }) return { ...compiled, glb: new Uint8Array(glb) } }, - storeArtifact: async ({ sha256, mimeType }) => { + storeArtifact: async ({ sha256, mimeType, bytes }) => { stored.push(`${mimeType} ${sha256.slice(0, 8)}`) + artifacts.set(sha256, bytes) return sha256 }, - readArtifact: async () => null, + readArtifact: async ({ sha256 }) => artifacts.get(sha256) ?? null, } beforeEach(async () => { @@ -287,4 +304,92 @@ describe('author_scene (MCP)', () => { }) expect((replaced.structuredContent as { walls: number }).walls).toBe(4) }) + + test("writes a wall's skirting and a room's floor script, readable with get_source", async () => { + const level = payload.levels[0]! + const design = { + ...payload, + levels: [ + { + ...level, + items: [], + lights: [], + walls: level.walls.map((w) => + w.id === 'n' ? { ...w, skirting: { color: '#f1ece4' } } : w, + ), + rooms: [{ ...level.rooms[0]!, floorCode: BOARDS }], + }, + ], + } + const response = await client.callTool({ name: 'author_scene', arguments: { payload: design } }) + const report = response.structuredContent as { + floorScripts: number + defects: unknown[] + ids: Record + payload?: unknown + } + expect(report.defects).toEqual([]) + expect(report.floorScripts).toBe(1) + const read = async (id: string) => { + const got = await client.callTool({ name: 'get_node', arguments: { id } }) + return JSON.parse((got.content as { text: string }[])[0]!.text).node as Record + } + const room = await read(report.ids['room/0/0']!) + expect(room.floor.finish).toMatch(/^scene:/) + expect(room.floor.script.language).toBe('three') + expect(room.floor.script.manifest.slots.map((s: { id: string }) => s.id)).toEqual(['wood']) + expect(room.floor.script.inputs).toEqual(expect.any(String)) + // The boards span the room inside the walls' faces: 4 m − 0.12 m by 5 m − 0.12 m. + const { min, max } = room.floor.script.manifest.bounds + expect(max[0] - min[0]).toBeCloseTo(3.88, 2) + expect(max[2] - min[2]).toBeCloseTo(4.88, 2) + const north = await read(report.ids.n!) + expect(north.skirting).toMatchObject({ enabled: true, sides: 'a', height: 0.1, proud: 0.012 }) + expect(north.slots.aSkirting).toBe(north.slots.b) + const source = await client.callTool({ + name: 'get_source', + arguments: { nodeId: report.ids['room/0/0']! }, + }) + expect(source.structuredContent).toMatchObject({ + type: 'zone', + language: 'three', + code: BOARDS, + }) + }) + + test('a floor script that fails is a defect and the room keeps its floor finish', async () => { + const level = payload.levels[0]! + const design = { + ...payload, + levels: [ + { + ...level, + items: [], + lights: [], + rooms: [{ ...level.rooms[0]!, floorCode: 'import x from "nowhere"' }], + }, + ], + } + const response = await client.callTool({ name: 'author_scene', arguments: { payload: design } }) + const report = response.structuredContent as { + floorScripts: number + defects: { id: string; code: string; message: string }[] + ids: Record + } + expect(report.floorScripts).toBe(0) + expect(report.defects).toEqual([ + { + id: 'room/0/0', + code: 'floor_script_failed', + message: expect.stringContaining('Unsupported import'), + }, + ]) + const got = await client.callTool({ + name: 'get_node', + arguments: { id: report.ids['room/0/0']! }, + }) + const room = JSON.parse((got.content as { text: string }[])[0]!.text).node + expect(room.floor.finish).toMatch(/^scene:/) + expect(room.floor.script).toBeUndefined() + }) }) diff --git a/packages/mcp/src/tools/author-scene.ts b/packages/mcp/src/tools/author-scene.ts index eef9d66402..e9fdf27b35 100644 --- a/packages/mcp/src/tools/author-scene.ts +++ b/packages/mcp/src/tools/author-scene.ts @@ -1,10 +1,14 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { floorScriptInputs } from '@pascal-app/core' import { + type AgentContext, type AuthorSceneReport, achievedChanges, authorScene, authorSceneCeilingPoints, + authorSceneFloorScripts, type SceneNodes, + setRoomFloorScript, verifyScene, } from '@pascal-app/core/agent-operations' import { @@ -112,17 +116,18 @@ export function registerAuthorScene( } const before = { ...(bridge.getNodes() as Record) } + const context: AgentContext = { + activeLevelId: bridge.activeLevelId?.() ?? null, + ...(bridge.supportsMaterialUpserts && bridge.getMaterials + ? { materials: bridge.getMaterials() } + : {}), + } let outcome: ReturnType try { outcome = authorScene( before, { payload: args.payload, compiled, compileErrors, replace: args.replace }, - { - activeLevelId: bridge.activeLevelId?.() ?? null, - ...(bridge.supportsMaterialUpserts && bridge.getMaterials - ? { materials: bridge.getMaterials() } - : {}), - }, + context, ) } catch (error) { if (isAgentRefusal(error)) return refusalResult(error) @@ -132,6 +137,37 @@ export function registerAuthorScene( } const patches = outcome.changes ? toPatches(outcome.changes) : [] if (patches.length) bridge.applyPatch(patches) + // A room's floor module needs its floor, which exists only now: compiled for it and written + // on the zone; a failure leaves the room its `floor` finish. + for (const { roomId, zoneId, code } of authorSceneFloorScripts( + args.payload, + outcome.result, + )) { + try { + const inputs = floorScriptInputs(bridge.getNodes() as SceneNodes, zoneId) + if (!inputs) throw new Error('The room has no floor to build on.') + const built = await compileAndStore( + host, + scene.id, + { code, language: 'three', params: undefined, inputs }, + 'floor', + { nodeId: zoneId, metadata: { name: 'Floor finish' } }, + ) + const written = setRoomFloorScript( + bridge.getNodes() as SceneNodes, + { zoneId, compiled: built, inputs }, + context, + ) + if (written.changes) bridge.applyPatch(toPatches(written.changes)) + outcome.result.floorScripts++ + } catch (error) { + outcome.result.defects.push({ + id: roomId, + code: 'floor_script_failed', + message: `${error instanceof Error ? error.message : String(error)} (the room keeps its floor finish)`, + }) + } + } const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building') if (building) { bridge.updateNode(building.id, { @@ -182,6 +218,7 @@ function withoutCode(payload: AuthorScenePayload) { openings: (wall.openings ?? []).map(({ code: _, ...opening }) => opening), })), items: (level.items ?? []).map(({ code: _, ...item }) => item), + rooms: level.rooms.map(({ floorCode: _, ...room }) => room), })), } } diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 814aef0076..8c8c92bac2 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -73,7 +73,7 @@ Stair `targets` are design preferences in meters (`maxRiserHeight`, `minimumGoin When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, or with `language: 'bpy'` a Blender module when the shape needs Blender's modelling (bevels, smooth organic forms, booleans, curves), with the naming conventions its description lists for paint slots, parts, lights, cutouts and clips. Edit it by passing its `nodeId`. A new object needs a `reason` (why no Pascal tool or catalog item builds it); `verify_scene` lists every authored object with its reason. On a floor, a plain box with a wall's size or a floor plate is refused with the tool to use (`use_walls`, `use_slab`); a detailed object with a wall's size (a bookcase, a screen) is built with a hint naming `add_wall`. Servers without a script host answer `scripts_unavailable`. The hosted server builds three.js scripts in the user's open Pascal editor tab (Blender modules run in its Blender service and need no tab); when no tab has the project open it answers `editor_tab_required` with an `editorUrl`: give the user that link, wait until they say it is open, then repeat the same call. `editor_tab_busy` means a tab is open but busy or still loading: wait a few seconds and repeat the same call; the user need not be asked. Whether you may open the link yourself is the person's setting ("Agents may open the editor for me"; Ask first: ask the user to open the link (or bring the tab forward), or ask their permission before opening it yourself; Allow: you may open the link yourself, in a tab in front; Never: ask the user, and do not open or switch windows yourself); the refusal's words say which, so follow them. It applies to `editor_tab_required` and `editor_tab_hidden`; `editor_tab_busy` carries no link, and an autonomous agent account, which has no editor login, is refused with `editor_host_identity_required` whatever the setting says. To count or locate things by what they are, including typed parts inside authored objects, use `find_by_type`. To group elements of any kind into a named set the editor lists (all the lights, the windows, the kitchen cabinetry), use `edit_collection`, starting from a template when one fits; `list_collections` reads them back. -When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their colour (`color`, or `colorLeft` / `colorRight` per face; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open), rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module (`host` names the item it rests on, or the wall a baseboard, casing or mirror is fixed to), and lights. A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. Pascal compiles and stores the modules, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it filled in (`defaults`: a window written fixed, a handle set opposite its hinge, a ceiling point for a light without a fixture), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions), what it could not place and why (`defects`), what it corrected (a wall end snapped onto the centerline it meets). Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and individual floor planks into the native wall, the floor finish and the room, and say so in `notes`; a slab over a wall's top is refused. Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. +When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their colour (`color`, or `colorLeft` / `colorRight` per face; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open), rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module (`host` names the item it rests on, or the wall a casing or mirror is fixed to), and lights. A wall's baseboard is its `skirting` (`height`, `depth`, `color`), native on the wall along the faces its rooms stand on, never an item. A plank, tile or board floor is the room's `floorCode`, a module that receives `params.polygon`, `params.holes` and `params.elevation` and builds the finish for exactly that floor; Pascal rebuilds it when the room changes, keeps `floor` as the fallback, and `get_source` on the room reads it back. A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. Pascal compiles and stores the modules, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it filled in (`defaults`: a window written fixed, a handle set opposite its hinge, a ceiling point for a light without a fixture), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions), what it could not place and why (`defects`: a floor module that failed is `floor_script_failed`, the room keeping its `floor`), what it corrected (a wall end snapped onto the centerline it meets). Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and individual floor planks into the native wall, the floor finish and the room, and say so in `notes`; a slab over a wall's top is refused. Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. Pascal uses meters. X and Z are floor-plan axes; Y is vertical. Tool fields that accept measurements may also accept strings such as `"6 ft"` or `"180cm"`, but report final spatial values in meters and retain the user's original units when useful. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index eeb5506888..197419f763 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -56,8 +56,8 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra `author_scene` turns a whole design you wrote as one three.js module into native, editable Pascal nodes in one call. The reading is yours; the writing is Pascal's. 1. Read your module: its source for intent (helper names, declared opening types), the executed graph for truth (world transforms, deleted objects gone). Most designs make walls with a helper such as `wall(x1, z1, x2, z2, height, thickness, openings)`: those arguments are the payload's walls. -2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`), `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a baseboard on a wall), `lights` (`position`, `fixture`). A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. -3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `slab_over_wall`, `use_slab`…), `warnings` (written, but doubtful: `thin_slab`, `many_slabs`, `many_regions`), `defaults` (what Pascal filled in or dropped: a window with no `type` written fixed, a handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. +2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour; `skirting` with `height` (default 0.1 m), `depth` (default 0.012 m) and `color` for the baseboard, native on the wall along the faces its rooms stand on), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`; `floorCode` for planks, tiles or boards, a module that receives `params.polygon`, `params.holes` and `params.elevation`, level coordinates in metres, and builds the finish clipped to that floor: Pascal rebuilds it when the room changes, `floor` stays the fallback, `get_source` with the room's id reads it back), `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a mirror on a wall), `lights` (`position`, `fixture`). A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. +3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `slab_over_wall`, `use_slab`, `skirting_refused` for a wall with no room on either face, `floor_script_failed` for a floor module that did not build…), `floorScripts` (rooms whose floor module was built), `warnings` (written, but doubtful: `thin_slab`, `many_slabs`, `many_regions`), `defaults` (what Pascal filled in or dropped: a window with no `type` written fixed, a handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. 4. Fix the program, not the payload by hand, and call again with `replace: true`. Stop when `green` is true and the scene looks like the design. Report what you could not convert in `notes`. ## Wrap a window around a corner diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 53f0a60e4b..fdeb047554 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -138,9 +138,9 @@ A write that would leave a populated project empty is far more often an accident Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep the bottom centre’s outward distance from the nearest face; a point inside the wall body moves to the face and is reported in `defaults`. -A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`; registered on the MCP first and not yet in `AGENT_TOOL_CONTRACTS`, since the hosted chat's agent cannot run its own program until its sandbox can emit the payload: a reviewed `gap` in the parity test) takes a conversion payload, levels with walls (a colour for both faces or `colorLeft` / `colorRight` for one: the left, looking from start to end, is the wall's front, role `a`) and their openings (`type` as `DoorType` / `WindowType`, `style` as the door and window style presets, `leaves`, or `code` for a module the host compiles like an item's, keyed `/`), rooms by seed with a floor material and regions, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it, or on a wall when `host` names one: a wall-side item on the face nearest its point, as `place_items` writes one), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall (`doorTypeFields` / `windowTypeFields`, `doorStyleLook` / `getWindowStyleOverrides`, the handle opposite the hinge, a scripted opening's `source` and `slots` as `add_door` with code writes them), zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, items through `addObject`, lights as light effects on their fixture (or a compiled 60 mm × 8 mm ceiling point hosted on its room’s ceiling when no fixture is given, or the nearest item on the level outside rooms: Pascal keeps lights inside objects) in the host's frame, paint through `paint`. A hex colour is one exact scene material per distinct colour across the payload (`resolveInlinePaintMaterial`, roughness 0.9, metalness 0, named by the hex), reused by item slots, opening panels and frames, walls, floors, regions and slabs alike; a host without scene materials gets the nearest library colour and a `defaults` sentence saying so. Ids derive from the payload's hash, so the same payload writes the same scene. +A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`; registered on the MCP first and not yet in `AGENT_TOOL_CONTRACTS`, since the hosted chat's agent cannot run its own program until its sandbox can emit the payload: a reviewed `gap` in the parity test) takes a conversion payload, levels with walls (a colour for both faces or `colorLeft` / `colorRight` for one: the left, looking from start to end, is the wall's front, role `a`; a `skirting` with height, depth and colour) and their openings (`type` as `DoorType` / `WindowType`, `style` as the door and window style presets, `leaves`, or `code` for a module the host compiles like an item's, keyed `/`), rooms by seed with a floor material and regions and an optional `floorCode`, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it, or on a wall when `host` names one: a wall-side item on the face nearest its point, as `place_items` writes one), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall (`doorTypeFields` / `windowTypeFields`, `doorStyleLook` / `getWindowStyleOverrides`, the handle opposite the hinge, a scripted opening's `source` and `slots` as `add_door` with code writes them), zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, each wall's skirting through `setWallSkirting` once the rooms exist (on the faces a room stands on, its colour through the same exact materials; a refusal is a `skirting_refused` defect), items through `addObject`, lights as light effects on their fixture (or a compiled 60 mm × 8 mm ceiling point hosted on its room’s ceiling when no fixture is given, or the nearest item on the level outside rooms: Pascal keeps lights inside objects) in the host's frame, paint through `paint`. A hex colour is one exact scene material per distinct colour across the payload (`resolveInlinePaintMaterial`, roughness 0.9, metalness 0, named by the hex), reused by item slots, opening panels and frames, walls, floors, regions and slabs alike; a host without scene materials gets the nearest library colour and a `defaults` sentence saying so. Ids derive from the payload's hash, so the same payload writes the same scene. -The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and its join rule corrects wall ends (an end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. The report also says what Pascal filled in or dropped (`defaults`, one sentence per fact: windows with no type written fixed, a door's handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture) and what it wrote but doubts (`warnings` with a code: `thin_slab` under 2 cm, `many_slabs` over 20 on a level, `many_regions` over 24 in a room, since regions are fixed polygons that do not follow a resize). One structural defect refuses the node: `slab_over_wall`, a slab over a wall's footprint on its level with its top above half the wall's height, which would lift the wall onto it (an acceptance run wrote 43 twelve-millimetre "wall cap" slabs at 2.662 m). Pascal builds a wall's cap, the floor plate and the ceiling itself; the skill tells the agent to fold a module's caps, underlays and planks into the wall, the floor finish and the room. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host also compiles the candidate ceiling-point modules listed by `authorSceneCeilingPoints`; after reconciliation the operation uses the ceiling belonging to the room containing each light’s plan position. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. +The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and its join rule corrects wall ends (an end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. The report also says what Pascal filled in or dropped (`defaults`, one sentence per fact: windows with no type written fixed, a door's handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture) and what it wrote but doubts (`warnings` with a code: `thin_slab` under 2 cm, `many_slabs` over 20 on a level, `many_regions` over 24 in a room, since regions are fixed polygons that do not follow a resize). One structural defect refuses the node: `slab_over_wall`, a slab over a wall's footprint on its level with its top above half the wall's height, which would lift the wall onto it (an acceptance run wrote 43 twelve-millimetre "wall cap" slabs at 2.662 m). Pascal builds a wall's cap, the floor plate and the ceiling itself; the skill tells the agent to fold a module's caps, underlays and planks into the wall, the floor finish and the room. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host also compiles the candidate ceiling-point modules listed by `authorSceneCeilingPoints`; after reconciliation the operation uses the ceiling belonging to the room containing each light’s plan position. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. A room's `floorCode` is its floor script (`authored-objects.md`, "Room floor scripts"): core cannot compile, so the operation lists the rooms that have one by zone (`authorSceneFloorScripts`), and the host, once the write is applied, reads each room's floor (`floorScriptInputs`), compiles the module for it through the `GeometryScriptHost` with `inputs` (the hosted server's editor tab and its server compile carry them too) and writes it with `setRoomFloorScript`, counting `floorScripts`; a failure is a `floor_script_failed` defect and the room keeps its `floor`. `get_source` reads a room's floor script by its zone id. The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items', openings' and floors' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. What the converting agents asked of `verify_scene` is in the same PR: an item hosted by another item is read through its host (`item-space.ts`), a thing resting on its host is stacked, not colliding, a rug or a pendant does not block a door, and a door's keep-out is the full clear depth on its swing side and a walkway on the other. From a81c1018b9b44e236b2a5768b7ecc63648ec6c48 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 11:44:03 -0400 Subject: [PATCH 21/26] walls: a face finish built by a script for exactly one face, rebuilt when the wall changes A converted design faked bathroom tiles, panelling to a dado and boards with objects hosted on the wall, which did not follow the wall or its openings. A wall face's finish can now be a three.js module that Pascal keeps on the wall and runs for exactly that face, the floor script's twin. - Schema: wall.finishScripts { a?, b? } (WallFaceScript) is the script source record a floor script is, plus `inputs` (the key of the face it was built for) and `toHeight` (metres above the face's base; absent, the whole face). Absent on every existing scene. - core lib/wall-face-script: wallFaceScriptInputs(nodes, wallId, face, toHeight?) gives the face in its own frame (x along the wall from its start, y up from the face's lowest floor, z out of the face): `outline` {x0,y0,x1,y1} between the face's mitred ends up to the wall's top or toHeight, `holes` (doors from the floor under them, windows from their sill, clipped), `base` ([x, y] points of the face's floor), `thickness`; wallFaceScriptInputsKey, wallFaceScriptRecord, wallFaceScriptPlacement, wallFaceScriptSkirtingGap, wallFaceScriptPaintRefusal. Curved and curtain walls have no face to build on. - One refresh for both finishes: staleFloorScripts / refreshFloorScripts / FloorScriptHost become lib/finish-script's staleFinishScripts, refreshFinishScripts and FinishScriptHost over rooms' floors and walls' faces, and finishScriptWrites, which writes a rebuild only while its floor or face is still the one it was rebuilt for. The editor tab's refresh (initFinishScriptRefresh, finish-scripts.ts) runs both, outside history. - core setWallFaceScript operation: writes a compile on a face with its key and toHeight, or removes it; refuses Blender builds, a face with nothing to build on, unmeasurable output, a toHeight not above 0. - Rendering: the wall renderer draws the artifact under the wall mesh at the face's base, 1 mm proud of the face, with no pointer events; face b's build is mirrored through the wall (scale z -1) so x still runs from the wall's start. The zone and wall renderers share FinishScriptModel. - Skirting: it keeps running along the base and stops over the build's span where the build comes down into its band, as at a door; a splashback above it leaves it whole. - Paint: a face scripted to the top refuses paint (the face, its regions, the room's finish on it) in the editor's Paint, the wall's paint commit and the `paint` operation, with a sentence; the other face, and a face scripted to toHeight, stay paintable. - get_source: readableScript reads a wall's face script with a `face` argument (optional when one face has one); the contract says so. ScriptedKind gains 'wall-face'. Turning a wall around swaps its face scripts; the reference inventory and the clipboard's artifact copy know them. - Wall panel: "Scripted finish" with View code and Remove per scripted side. - Tests: the face's outline, door and window in wall-local metres; built once and again only after an opening is added or the wall lengthened (fake host); paint refused on the scripted face, allowed on the other and on a dado face, through the operation and the wall's paint commit; the skirting gives way under panelling and not under a splashback; placement on each face; a curved wall refused; get_source through a real MCP client; reversal swaps the scripts; an old wall parses unchanged. Co-Authored-By: Claude Fable 5.1 --- .../core/src/agent-operations/add-object.ts | 45 ++- packages/core/src/agent-operations/index.ts | 1 + packages/core/src/agent-operations/paint.ts | 7 + .../src/agent-operations/room-floor-script.ts | 2 +- .../src/agent-operations/wall-face-script.ts | 103 ++++++ packages/core/src/agent-tools/add-object.ts | 8 +- .../core/src/contracts/reference-inventory.ts | 4 +- packages/core/src/index.ts | 27 +- packages/core/src/lib/finish-script.ts | 161 +++++++++ packages/core/src/lib/floor-script.test.ts | 41 +-- packages/core/src/lib/floor-script.ts | 98 +----- packages/core/src/lib/script-inputs-key.ts | 14 + .../core/src/lib/wall-face-script.test.ts | 301 ++++++++++++++++ packages/core/src/lib/wall-face-script.ts | 322 ++++++++++++++++++ packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/wall.ts | 20 ++ .../core/src/systems/wall/wall-frame.test.ts | 10 + packages/core/src/systems/wall/wall-frame.ts | 8 + .../editor/src/components/editor/index.tsx | 6 +- .../components/editor/selection-manager.tsx | 8 +- .../src/components/ui/panels/room-panel.tsx | 4 +- packages/editor/src/index.tsx | 8 +- .../src/lib/geometry-script/finish-scripts.ts | 127 +++++++ .../src/lib/geometry-script/floor-scripts.ts | 128 ------- packages/editor/src/lib/scene-clipboard.ts | 16 +- packages/mcp/src/tools/add-object.test.ts | 60 +++- packages/mcp/src/tools/add-object.ts | 11 +- .../nodes/src/shared/finish-script-model.tsx | 47 +++ packages/nodes/src/wall/paint.test.ts | 34 +- packages/nodes/src/wall/paint.ts | 3 + packages/nodes/src/wall/panel.tsx | 83 +++++ packages/nodes/src/wall/renderer.tsx | 40 +++ packages/nodes/src/wall/treatments.test.ts | 33 ++ packages/nodes/src/wall/treatments.tsx | 9 + packages/nodes/src/zone/renderer.tsx | 33 +- wiki/architecture/agent-surfaces.md | 2 +- wiki/architecture/authored-objects.md | 8 +- 37 files changed, 1521 insertions(+), 312 deletions(-) create mode 100644 packages/core/src/agent-operations/wall-face-script.ts create mode 100644 packages/core/src/lib/finish-script.ts create mode 100644 packages/core/src/lib/script-inputs-key.ts create mode 100644 packages/core/src/lib/wall-face-script.test.ts create mode 100644 packages/core/src/lib/wall-face-script.ts create mode 100644 packages/editor/src/lib/geometry-script/finish-scripts.ts delete mode 100644 packages/editor/src/lib/geometry-script/floor-scripts.ts create mode 100644 packages/nodes/src/shared/finish-script-model.tsx diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index e6a31a4420..85db85495a 100644 --- a/packages/core/src/agent-operations/add-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -23,6 +23,7 @@ import { generateId, ItemNode, type SlabNode, + type WallFace, } from '../schema' import { achievedChanges } from './achieved' import { targetLevel } from './level-target' @@ -621,15 +622,50 @@ export function scriptedFieldRefusal(node: AnyNode, data: Record & { id: string - type: ScriptedNode['type'] | 'zone' + type: ScriptedNode['type'] | 'zone' | 'wall' + /** The wall face whose script it is. */ + face?: WallFace } -/** The script `get_source` reads at `nodeId`: an authored object's, or a room's floor script. */ -export function readableScript(nodes: Record, nodeId: string): ScriptReadable { +/** + * The script `get_source` reads at `nodeId`: an authored object's, a room's floor script by its + * zone id, or a wall's face script (`face`, which a wall with one scripted face may leave out). + */ +export function readableScript( + nodes: Record, + nodeId: string, + face?: WallFace, +): ScriptReadable { const node = nodes[nodeId] + if (node?.type === 'wall') { + const faces = (['a', 'b'] as const).filter((side) => node.finishScripts?.[side]) + const read = face ?? (faces.length === 1 ? faces[0] : undefined) + if (!faces.length) + refuse('not_authored', `${node.name || nodeId} has no face script.`, { id: nodeId }) + if (!read) + refuse( + 'face_required', + `Both faces of ${node.name || nodeId} have a script: name the face, a or b.`, + { + id: nodeId, + faces, + }, + ) + const source = node.finishScripts?.[read] + if (!source) + refuse( + 'not_authored', + `Face ${read} of ${node.name || nodeId} has no script; face ${faces[0]} has.`, + { + id: nodeId, + faces, + }, + ) + return { id: node.id, type: 'wall', name: node.name, source, face: read } + } if (node?.type !== 'zone') return authoredObject(nodes, nodeId) if (!node.floor?.script) refuse('not_authored', `${node.name || nodeId} has no floor script.`, { id: nodeId }) @@ -641,6 +677,7 @@ export function readSourceResult(node: ScriptReadable, code: string) { return { nodeId: node.id, type: node.type, + ...(node.face ? { face: node.face } : {}), name: node.name, language: node.source.language, code, diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index 5ee982b60d..8bbb46d39a 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -84,6 +84,7 @@ export * from './site' export * from './stairs' export * from './types' export * from './verify-scene' +export * from './wall-face-script' export * from './wall-object-observations' export * from './wall-opening' export * from './wall-skirting' diff --git a/packages/core/src/agent-operations/paint.ts b/packages/core/src/agent-operations/paint.ts index 06036ededa..d90c670085 100644 --- a/packages/core/src/agent-operations/paint.ts +++ b/packages/core/src/agent-operations/paint.ts @@ -1,4 +1,5 @@ import { refuse } from '../agent-tools/refusal' +import { wallFaceScriptPaintRefusal } from '../lib/wall-face-script' import type { MaterialSurface } from '../material-kinds' import { GLAZED_ROOF_SLOTS, nodeSlotDeclarations } from '../node-slots' import { nearestLibraryColorRef } from '../procedural-items/library-colors' @@ -357,6 +358,12 @@ export const paint: AgentOperation = (nodes, input, context) => { const target = current(id)! if (target.type === 'wall') { const face = role === 'a' || role === 'b' ? role : null + const scripted = face ? wallFaceScriptPaintRefusal(target, face) : undefined + if (scripted) + refuse('face_built_by_script', `${scripted.message} Nothing was painted.`, { + target: id, + face, + }) Object.assign( written, face diff --git a/packages/core/src/agent-operations/room-floor-script.ts b/packages/core/src/agent-operations/room-floor-script.ts index a6800f5248..8cacc2ef58 100644 --- a/packages/core/src/agent-operations/room-floor-script.ts +++ b/packages/core/src/agent-operations/room-floor-script.ts @@ -21,7 +21,7 @@ const round = (value: number) => Math.round(value * 1000) / 1000 * `inputs`: the module reads `params.polygon`, `params.holes`, `params.elevation`, in level * coordinates) and stores the artifacts; this writes the result on the zone, with the key of the * floor it was built for. When the structure re-derives the room's floor the script is stale and - * `refreshFloorScripts` runs it again. The script owns the floor's colours: paint on that floor is + * `refreshFinishScripts` runs it again. The script owns the floor's colours: paint on that floor is * refused while it is there. */ export const setRoomFloorScript: AgentOperation = (nodes, input) => { diff --git a/packages/core/src/agent-operations/wall-face-script.ts b/packages/core/src/agent-operations/wall-face-script.ts new file mode 100644 index 0000000000..094ec5166b --- /dev/null +++ b/packages/core/src/agent-operations/wall-face-script.ts @@ -0,0 +1,103 @@ +import { refuse } from '../agent-tools/refusal' +import { scriptedSize } from '../lib/geometry-script-node' +import { + type WallFaceScriptInputs, + wallFaceScriptInputs, + wallFaceScriptRecord, +} from '../lib/wall-face-script' +import { type CompiledGeometryScript, WallFaceScript, type WallNode } from '../schema' +import type { WallFace } from '../schema/nodes/wall' +import type { AgentOperation } from './types' + +export type SetWallFaceScriptInput = + | { + wallId: string + face: WallFace + /** The module compiled for `inputs` (`wallFaceScriptInputs` of the face, read before compiling). */ + compiled: CompiledGeometryScript + inputs: WallFaceScriptInputs + /** Metres above the face's base the script stops at; absent, it covers the whole face. */ + toHeight?: number + } + | { wallId: string; face: WallFace; remove: true } + +const round = (value: number) => Math.round(value * 1000) / 1000 + +/** + * `setWallFaceScript`: one wall face's finish built by a three.js module, or the module taken off. + * The host reads the face (`wallFaceScriptInputs`), compiles the module for it (geometry-script + * `inputs`: the module reads `params.outline`, `params.holes`, `params.base`, `params.thickness` + * in the face's frame) and stores the artifacts; this writes the result on the wall, with the key + * of the face it was built for. When the wall or its openings change the script is stale and + * `refreshFinishScripts` runs it again. A script to the top owns the face's colours: paint on that + * face is refused while it is there. + */ +export const setWallFaceScript: AgentOperation = (nodes, input) => { + const wall = nodes[input.wallId] + if (wall?.type !== 'wall') + refuse('target_not_found', `${input.wallId} is not a wall in the scene: nothing was changed.`, { + target: input.wallId, + }) + const name = wall.name?.trim() || wall.id + const { face } = input + + if ('remove' in input) { + if (!wall.finishScripts?.[face]) + return { result: { ok: true, wallId: wall.id, face, removed: false } } + const { [face]: _, ...kept } = wall.finishScripts + const finishScripts: WallNode['finishScripts'] = Object.keys(kept).length ? kept : undefined + return { + result: { ok: true, wallId: wall.id, face, removed: true }, + changes: { update: [{ id: wall.id, data: { finishScripts } }] }, + } + } + + const { compiled, toHeight } = input + if (compiled.language !== 'three') + refuse( + 'three_only', + `A wall face script is a three.js module: it is given the face, which a Blender build is not. Nothing was changed on ${name}.`, + { target: wall.id }, + ) + if (toHeight !== undefined && !(toHeight > 0)) + refuse( + 'invalid_height', + `toHeight is metres above the face's base, above 0: nothing was changed on ${name}.`, + { + target: wall.id, + }, + ) + if (!wallFaceScriptInputs(nodes, wall.id, face, toHeight)) + refuse( + 'no_face', + `${name}'s face ${face} has nothing a script can build on (a curved or curtain wall, or no height): nothing was changed.`, + { target: wall.id }, + ) + const { min, max } = compiled.manifest.bounds + if (![...min, ...max].every(Number.isFinite)) + refuse( + 'unmeasurable_object', + `${name}'s face script built geometry that cannot be measured: a mesh in it has a position, rotation or scale that is not a number. Nothing was changed.`, + { target: wall.id }, + ) + const script = WallFaceScript.parse( + wallFaceScriptRecord(compiled, input.inputs, toHeight, wall.finishScripts?.[face]), + ) + return { + result: { + ok: true, + wallId: wall.id, + face, + size: scriptedSize(compiled.manifest).map(round), + slots: compiled.manifest.slots.map((slot) => slot.id), + triangles: compiled.manifest.triangles, + note: + toHeight === undefined + ? `The script owns the colours of ${name}'s face ${face}: painting that face is refused until the script is removed. It runs again whenever the wall or its openings change.` + : `The script builds ${name}'s face ${face} up to ${round(toHeight)} m; the face above stays paintable. It runs again whenever the wall or its openings change.`, + }, + changes: { + update: [{ id: wall.id, data: { finishScripts: { ...wall.finishScripts, [face]: script } } }], + }, + } +} diff --git a/packages/core/src/agent-tools/add-object.ts b/packages/core/src/agent-tools/add-object.ts index 5048d680d3..a3996906f5 100644 --- a/packages/core/src/agent-tools/add-object.ts +++ b/packages/core/src/agent-tools/add-object.ts @@ -104,10 +104,14 @@ export const getSourceTool = { name: 'get_source', title: 'Read object script', description: - "The module (three.js or Blender bpy, named by language) an object (or a window, door or column built from code) runs, with its params and their current values; a room's id reads its floor script, the module that builds its floor finish. Optional source evidence when the current module is needed. An edited module can be passed back with the same nodeId: to add_object for an object, to add_window / add_door / add_column for their respective kinds.", + "The module (three.js or Blender bpy, named by language) an object (or a window, door or column built from code) runs, with its params and their current values; a room's id reads its floor script, the module that builds its floor finish; a wall's id with face reads that face's script (face may be left out when only one face has one). Optional source evidence when the current module is needed. An edited module can be passed back with the same nodeId: to add_object for an object, to add_window / add_door / add_column for their respective kinds.", input: { nodeId: NodeId.describe( - 'An object, window, door or column built from code, or a room with a floor script.', + 'An object, window, door or column built from code, a room with a floor script, or a wall with a face script.', ), + face: z + .enum(['a', 'b']) + .optional() + .describe("A wall's face whose script to read; needed when both faces have one."), }, } diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 39493ee365..1651e27ab4 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -746,7 +746,7 @@ export const NON_REFERENCES: readonly { kind: string; path: string; reason: stri }, { kind: 'procedural-item', path: 'parameters.@key', reason: 'Recipe parameter name.' }, // Scripted nodes (an authored item, or a window or door built from code) share one `source`; - // a room's floor script keeps the same record on its zone. + // a room's floor script keeps the same record on its zone, a wall face's script on its wall. ...( [ ['item', 'source'], @@ -754,6 +754,8 @@ export const NON_REFERENCES: readonly { kind: string; path: string; reason: stri ['door', 'source'], ['column', 'source'], ['zone', 'floor.script'], + ['wall', 'finishScripts.a'], + ['wall', 'finishScripts.b'], ] as const ).flatMap(([kind, source]) => [ ...(['anchors', 'lights', 'params', 'parts', 'slots'] as const).map((list) => ({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5f44092405..c871589859 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -165,6 +165,16 @@ export { isOperationDoorType, SECTIONAL_GARAGE_RENDER_OPEN_SCALE, } from './lib/door-operation' +export { + type FinishScriptHost, + type FinishScriptInputs, + type FinishScriptRefresh, + type FinishScriptTarget, + finishScriptWrites, + refreshFinishScripts, + type StaleFinishScript, + staleFinishScripts, +} from './lib/finish-script' export { floorConstructionLift, liftedManualSlab } from './lib/floor-construction-lift' export { floorFootprintName } from './lib/floor-footprint-name' export { @@ -195,15 +205,10 @@ export { floorScriptPaintRefusal, } from './lib/floor-plate-paint' export { - type FloorScriptHost, type FloorScriptInputs, - type FloorScriptRefresh, floorScriptInputs, floorScriptInputsKey, floorScriptRecord, - refreshFloorScripts, - type StaleFloorScript, - staleFloorScripts, } from './lib/floor-script' export { type FloorStepOverride, @@ -539,6 +544,18 @@ export { type WallSegment, type WallSegmentClosest, } from './lib/wall-distance' +export { + type WallFacePaintRefusal, + type WallFaceRect, + type WallFaceScriptInputs, + wallFaceBaseOffset, + wallFaceScriptInputs, + wallFaceScriptInputsKey, + wallFaceScriptPaintRefusal, + wallFaceScriptPlacement, + wallFaceScriptRecord, + wallFaceScriptSkirtingGap, +} from './lib/wall-face-script' export { ZONE_COLOR_PALETTE, zoneColorForSeed } from './lib/zone-colors' export { deriveZoneQuantityReport, diff --git a/packages/core/src/lib/finish-script.ts b/packages/core/src/lib/finish-script.ts new file mode 100644 index 0000000000..9ef7aea304 --- /dev/null +++ b/packages/core/src/lib/finish-script.ts @@ -0,0 +1,161 @@ +import type { + AnyNode, + CompiledGeometryScript, + GeometryScriptParamValue, + WallFaceScript, + WallNode, + ZoneFloorScript, +} from '../schema' +import type { WallFace } from '../schema/nodes/wall' +import { + type FloorScriptInputs, + floorScriptInputs, + floorScriptInputsKey, + floorScriptRecord, +} from './floor-script' +import { + type WallFaceScriptInputs, + wallFaceScriptInputs, + wallFaceScriptInputsKey, + wallFaceScriptRecord, +} from './wall-face-script' + +type SceneNodes = Readonly> + +/** + * A finish a script builds for its host: a room's floor (`nodeId` a zone) or one face of a wall + * (`nodeId` a wall, with `face`). + */ +export type FinishScriptTarget = { nodeId: string; face?: WallFace } +export type FinishScriptInputs = FloorScriptInputs | WallFaceScriptInputs +type FinishScript = ZoneFloorScript | WallFaceScript + +function finishScriptsOf(node: AnyNode): (FinishScriptTarget & { script: FinishScript })[] { + if (node.type === 'zone') + return node.floor?.script ? [{ nodeId: node.id, script: node.floor.script }] : [] + if (node.type !== 'wall') return [] + return (['a', 'b'] as const).flatMap((face) => { + const script = node.finishScripts?.[face] + return script ? [{ nodeId: node.id, face, script }] : [] + }) +} + +/** What a finish script is built for now, with its key; null when its host has nothing to build on. */ +function currentInputs( + nodes: SceneNodes, + { nodeId, face }: FinishScriptTarget, + script: FinishScript, +): { inputs: FinishScriptInputs; key: string } | null { + if (face) { + const inputs = wallFaceScriptInputs(nodes, nodeId, face, (script as WallFaceScript).toHeight) + return inputs && { inputs, key: wallFaceScriptInputsKey(inputs) } + } + const inputs = floorScriptInputs(nodes, nodeId) + return inputs && { inputs, key: floorScriptInputsKey(inputs) } +} + +export type StaleFinishScript = FinishScriptTarget & { + script: FinishScript + /** Its floor or face now, which the script runs again for. */ + inputs: FinishScriptInputs +} + +/** + * The finish scripts whose floor or face is no longer the one they were built for: the structure + * reconcile re-derived a room (a wall moved, an edge pushed, a floor raised), or a wall changed + * length, height or openings, so the key differs. A host with nothing to build on now (a room with + * no floor, a wall turned curved) keeps its script as it was. + */ +export function staleFinishScripts(nodes: SceneNodes): StaleFinishScript[] { + return Object.values(nodes).flatMap((node) => + finishScriptsOf(node).flatMap((entry) => { + const now = currentInputs(nodes, entry, entry.script) + return now && now.key !== entry.script.inputs ? [{ ...entry, inputs: now.inputs }] : [] + }), + ) +} + +/** + * How a host runs a finish script: reads its module back by hash, and compiles and stores it for + * its floor or face (`@pascal-app/geometry-script`'s `compileGeometryScript` with `inputs`, then the + * artifact store), answering the compile with the stored hashes. + */ +export type FinishScriptHost = { + readScript(sha256: string): Promise + build( + input: FinishScriptTarget & { + code: string + params: Record + inputs: FinishScriptInputs + }, + ): Promise +} + +export type FinishScriptRefresh = { + rebuilt: (FinishScriptTarget & { script: FinishScript })[] + failed: (FinishScriptTarget & { error: string })[] +} + +/** Runs every stale finish script again for its floor or face now, with its stored params. */ +export async function refreshFinishScripts( + nodes: SceneNodes, + host: FinishScriptHost, +): Promise { + const refresh: FinishScriptRefresh = { rebuilt: [], failed: [] } + await Promise.all( + staleFinishScripts(nodes).map(async ({ script, inputs, ...target }) => { + try { + const code = await host.readScript(script.script) + if (code === null) throw new Error('its module could not be read') + const compiled = await host.build({ ...target, code, params: script.params, inputs }) + refresh.rebuilt.push({ + ...target, + script: target.face + ? wallFaceScriptRecord( + compiled, + inputs as WallFaceScriptInputs, + (script as WallFaceScript).toHeight, + script as WallFaceScript, + ) + : floorScriptRecord(compiled, inputs as FloorScriptInputs, script as ZoneFloorScript), + }) + } catch (error) { + refresh.failed.push({ + ...target, + error: error instanceof Error ? error.message : String(error), + }) + } + }), + ) + return refresh +} + +/** + * The writes that put rebuilt finish scripts on `nodes`: each only while its host still has the + * script and its floor or face is still the one it was rebuilt for (else a newer refresh follows), + * over what the host holds now, so a wall's other face and a room's other floor fields are kept. + */ +export function finishScriptWrites( + nodes: SceneNodes, + rebuilt: FinishScriptRefresh['rebuilt'], +): { id: string; data: Partial }[] { + const writes = new Map>() + for (const { script, ...target } of rebuilt) { + const node = nodes[target.nodeId] + const held = node && finishScriptsOf(node).find((entry) => entry.face === target.face) + if (!held || currentInputs(nodes, target, held.script)?.key !== script.inputs) continue + if (node.type === 'zone') { + writes.set(node.id, { floor: { ...node.floor, script: script as ZoneFloorScript } }) + } else if (node.type === 'wall' && target.face) { + const earlier = writes.get(node.id) as Pick | undefined + writes.set(node.id, { + finishScripts: { + ...node.finishScripts, + ...(earlier?.finishScripts ?? {}), + [target.face]: script as WallFaceScript, + }, + }) + } + } + return [...writes].map(([id, data]) => ({ id, data })) +} diff --git a/packages/core/src/lib/floor-script.test.ts b/packages/core/src/lib/floor-script.test.ts index f5ef594038..44762d3afa 100644 --- a/packages/core/src/lib/floor-script.test.ts +++ b/packages/core/src/lib/floor-script.test.ts @@ -12,14 +12,14 @@ import { WallNode, ZoneNode, } from '../schema' -import { floorPlatePaintRefusal } from './floor-plate-paint' import { - type FloorScriptHost, - type FloorScriptInputs, - floorScriptInputs, - refreshFloorScripts, - staleFloorScripts, -} from './floor-script' + type FinishScriptHost, + finishScriptWrites, + refreshFinishScripts, + staleFinishScripts, +} from './finish-script' +import { floorPlatePaintRefusal } from './floor-plate-paint' +import { type FloorScriptInputs, floorScriptInputs } from './floor-script' import { computePlateSurfacePartition, plateLevelContext } from './plate-surface' import { area } from './polygon-boolean' import type { SceneNodes } from './structure-kernel' @@ -64,9 +64,10 @@ const zoneOf = (nodes: SceneNodes) => /** A host that compiles nothing: it counts its builds and answers with hashes of what it was given. */ function fakeHost() { const builds: FloorScriptInputs[] = [] - const host: FloorScriptHost = { + const host: FinishScriptHost = { readScript: async (sha) => (sha === 'a'.repeat(64) ? 'export default () => {}' : null), - build: async ({ inputs }) => { + build: async (input) => { + const inputs = input.inputs as FloorScriptInputs builds.push(inputs) const xs = inputs.polygon.map(([x]) => x) const zs = inputs.polygon.map(([, z]) => z) @@ -91,11 +92,11 @@ function fakeHost() { } /** The room with a floor script, built once by `host` for the room's floor. */ -async function scripted(host: FloorScriptHost) { +async function scripted(host: FinishScriptHost) { const nodes = room() const zone = zoneOf(nodes) const inputs = floorScriptInputs(nodes, zone.id)! - const compiled = await host.build({ zoneId: zone.id, code: '', params: {}, inputs }) + const compiled = await host.build({ nodeId: zone.id, code: '', params: {}, inputs }) const { changes } = setRoomFloorScript( nodes, { zoneId: zone.id, compiled, inputs }, @@ -141,24 +142,26 @@ describe("a room's floor script", () => { const { host, builds } = fakeHost() let nodes: SceneNodes = await scripted(host) expect(builds).toHaveLength(1) - expect(staleFloorScripts(nodes)).toEqual([]) - expect(await refreshFloorScripts(nodes, host)).toEqual({ update: [], failed: [] }) + expect(staleFinishScripts(nodes)).toEqual([]) + expect(await refreshFinishScripts(nodes, host)).toEqual({ rebuilt: [], failed: [] }) expect(builds).toHaveLength(1) nodes = pushNorth(nodes) const zone = zoneOf(nodes) expect(zone.floor?.script?.artifact).toBe('1'.padStart(64, '0')) - expect(staleFloorScripts(nodes).map((stale) => stale.zoneId)).toEqual([zone.id]) + expect(staleFinishScripts(nodes).map((stale) => stale.nodeId)).toEqual([zone.id]) - const refresh = await refreshFloorScripts(nodes, host) + const refresh = await refreshFinishScripts(nodes, host) expect(refresh.failed).toEqual([]) expect(builds).toHaveLength(2) expect(Math.max(...builds[1]!.polygon.map(([, z]) => z))).toBeCloseTo(4.9, 6) - nodes = applySceneChanges(nodes, refresh) as SceneNodes + nodes = applySceneChanges(nodes, { + update: finishScriptWrites(nodes, refresh.rebuilt), + }) as SceneNodes const rebuilt = zoneOf(nodes).floor?.script expect(rebuilt?.artifact).toBe('2'.padStart(64, '0')) expect(rebuilt?.params).toEqual({ plank: 0.18 }) - expect(staleFloorScripts(nodes)).toEqual([]) + expect(staleFinishScripts(nodes)).toEqual([]) }) test('refuses paint on its floor, through the Paint tool and the room intent, until removed', async () => { @@ -191,7 +194,7 @@ describe("a room's floor script", () => { const zone = zoneOf(nodes) const { host } = fakeHost() const inputs = floorScriptInputs(nodes, zone.id)! - const compiled = await host.build({ zoneId: zone.id, code: '', params: {}, inputs }) + const compiled = await host.build({ nodeId: zone.id, code: '', params: {}, inputs }) const off = reconcile({ ...nodes, [zone.id]: { ...zone, hasFloor: false } }, nodes) expect(floorScriptInputs(off, zone.id)).toBeNull() expect( @@ -207,5 +210,5 @@ test('a zone saved before floor scripts parses as it did, and nothing in its sce const zone = zoneOf(nodes) const saved = JSON.parse(JSON.stringify({ ...zone, floor: { finish: 'library:preset-white' } })) expect(ZoneNode.parse(saved)).toEqual(saved) - expect(staleFloorScripts({ ...nodes, [zone.id]: saved })).toEqual([]) + expect(staleFinishScripts({ ...nodes, [zone.id]: saved })).toEqual([]) }) diff --git a/packages/core/src/lib/floor-script.ts b/packages/core/src/lib/floor-script.ts index 63e612e501..0f3c379a6f 100644 --- a/packages/core/src/lib/floor-script.ts +++ b/packages/core/src/lib/floor-script.ts @@ -1,10 +1,4 @@ -import type { - AnyNode, - CompiledGeometryScript, - GeometryScriptParamValue, - ZoneFloorScript, - ZoneNode, -} from '../schema' +import type { AnyNode, CompiledGeometryScript, ZoneFloorScript } from '../schema' import { DEFAULT_SLAB_ELEVATION } from '../schema/nodes/slab' import { scriptSource } from './geometry-script-node' import { levelWallCover, plateLevelContext } from './plate-surface' @@ -17,6 +11,7 @@ import { union, } from './polygon-boolean' import { roomFloorPlate } from './room-floor-plate' +import { scriptInputsKey } from './script-inputs-key' type SceneNodes = Readonly> @@ -69,20 +64,9 @@ export function floorScriptInputs(nodes: SceneNodes, zoneId: string): FloorScrip return inputs } -/** 32-bit FNV-1a: a change detector for a floor's inputs, not a content address. */ -function fnv1a(text: string, seed: number): string { - let hash = seed - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index) - hash = Math.imul(hash, 0x01000193) - } - return (hash >>> 0).toString(16).padStart(8, '0') -} - /** The key a floor script keeps of the floor it was built for (`ZoneFloorScript.inputs`). */ export function floorScriptInputsKey(inputs: FloorScriptInputs): string { - const text = JSON.stringify([inputs.polygon, inputs.holes, inputs.elevation]) - return fnv1a(text, 0x811c9dc5) + fnv1a(text, 0x050c5d1f) + return scriptInputsKey(JSON.stringify([inputs.polygon, inputs.holes, inputs.elevation])) } /** A compile as the zone keeps it: its script source and the key of the floor it was built for. */ @@ -93,79 +77,3 @@ export function floorScriptRecord( ): ZoneFloorScript { return { ...scriptSource(compiled, {}, previous), inputs: floorScriptInputsKey(inputs) } } - -export type StaleFloorScript = { - zoneId: string - script: ZoneFloorScript - /** The room's floor now, which the script runs again for. */ - inputs: FloorScriptInputs -} - -/** - * The floor scripts whose room's floor is no longer the one they were built for: the structure - * reconcile re-derived the room's polygon or plate (a wall moved, a room edge pushed, a floor - * raised), so the key differs. A room with no floor now keeps its script as it was. - */ -export function staleFloorScripts(nodes: SceneNodes): StaleFloorScript[] { - return Object.values(nodes).flatMap((node) => { - const script = node.type === 'zone' ? node.floor?.script : undefined - if (!script) return [] - const inputs = floorScriptInputs(nodes, node.id) - return inputs && floorScriptInputsKey(inputs) !== script.inputs - ? [{ zoneId: node.id, script, inputs }] - : [] - }) -} - -/** - * How a host runs a floor script: reads its module back by hash, and compiles and stores it for a - * floor (`@pascal-app/geometry-script`'s `compileGeometryScript` with `inputs`, then the artifact - * store), answering the compile with the stored hashes. - */ -export type FloorScriptHost = { - readScript(sha256: string): Promise - build(input: { - zoneId: string - code: string - params: Record - inputs: FloorScriptInputs - }): Promise -} - -export type FloorScriptRefresh = { - /** Each rebuilt room's new `floor`, its other floor fields as they were when the refresh began. */ - update: { id: string; data: Pick }[] - failed: { zoneId: string; error: string }[] -} - -/** - * Runs every stale floor script again for its room's current floor, with its stored params. A host - * applies an update while that room's floor is still the one it was built for (else a newer - * refresh follows), and writes only `floor.script` over what the room holds then. - */ -export async function refreshFloorScripts( - nodes: SceneNodes, - host: FloorScriptHost, -): Promise { - const refresh: FloorScriptRefresh = { update: [], failed: [] } - await Promise.all( - staleFloorScripts(nodes).map(async ({ zoneId, script, inputs }) => { - try { - const code = await host.readScript(script.script) - if (code === null) throw new Error('its module could not be read') - const compiled = await host.build({ zoneId, code, params: script.params, inputs }) - const zone = nodes[zoneId] as ZoneNode - refresh.update.push({ - id: zoneId, - data: { floor: { ...zone.floor, script: floorScriptRecord(compiled, inputs, script) } }, - }) - } catch (error) { - refresh.failed.push({ - zoneId, - error: error instanceof Error ? error.message : String(error), - }) - } - }), - ) - return refresh -} diff --git a/packages/core/src/lib/script-inputs-key.ts b/packages/core/src/lib/script-inputs-key.ts new file mode 100644 index 0000000000..282ba3992b --- /dev/null +++ b/packages/core/src/lib/script-inputs-key.ts @@ -0,0 +1,14 @@ +/** 32-bit FNV-1a: a change detector for a script's inputs, not a content address. */ +function fnv1a(text: string, seed: number): string { + let hash = seed + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +/** The key a finish script keeps of what it was built for, from its inputs' text. */ +export function scriptInputsKey(text: string): string { + return fnv1a(text, 0x811c9dc5) + fnv1a(text, 0x050c5d1f) +} diff --git a/packages/core/src/lib/wall-face-script.test.ts b/packages/core/src/lib/wall-face-script.test.ts new file mode 100644 index 0000000000..58c592e706 --- /dev/null +++ b/packages/core/src/lib/wall-face-script.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, test } from 'bun:test' +import { applySceneChanges } from '../agent-operations/apply-changes' +import { paint } from '../agent-operations/paint' +import { setWallFaceScript } from '../agent-operations/wall-face-script' +import { isAgentRefusal } from '../agent-tools' +import { + type AnyNode, + type CompiledGeometryScript, + DoorNode, + GeometryArtifactManifest, + LevelNode, + WallNode, + WindowNode, +} from '../schema' +import { + type FinishScriptHost, + finishScriptWrites, + refreshFinishScripts, + staleFinishScripts, +} from './finish-script' +import type { SceneNodes } from './structure-kernel' +import { reconcileSceneStructure } from './structure-reconcile' +import { + type WallFaceScriptInputs, + wallFaceScriptInputs, + wallFaceScriptPaintRefusal, + wallFaceScriptPlacement, +} from './wall-face-script' + +const LEVEL = 'level_face_script' +const SOUTH = 'wall_wf_0' +const corners: [number, number][] = [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], +] + +let minted = 0 +/** + * A copy of the reconciled scene: the reconcile patches its draft in place after reading wall + * support from it, and support is memoised per nodes object, so the draft itself reads stale. + */ +function reconcile(nodes: SceneNodes, previousNodes?: SceneNodes): SceneNodes { + return { + ...reconcileSceneStructure({ + nodes, + previousNodes, + mintId: (kind) => `${kind}_wf_${minted++}`, + }).nodes, + } +} + +/** + * An 8 × 4 m room of 0.2 m walls, reconciled into its zone and floor plate. Its south wall runs + * from (0, 0) to (8, 0), so its face a looks into the room; it has a 0.9 × 2.1 m door 2 m along + * and a 1.2 × 1 m window 5 m along with its sill at 1 m. + */ +function room(): SceneNodes { + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_wf_${index}`, + parentId: LEVEL, + start, + end: corners[(index + 1) % 4], + thickness: 0.2, + }), + ) + const door = DoorNode.parse({ + id: 'door_wf', + parentId: SOUTH, + wallId: SOUTH, + position: [2, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const window = WindowNode.parse({ + id: 'window_wf', + parentId: SOUTH, + wallId: SOUTH, + position: [5, 1.5, 0], + width: 1.2, + height: 1, + }) + walls[0] = { ...walls[0]!, children: [door.id, window.id] } + const level = LevelNode.parse({ id: LEVEL, children: walls.map((wall) => wall.id) }) + return reconcile( + Object.fromEntries([level, ...walls, door, window].map((node) => [node.id, node])), + ) +} + +const wallOf = (nodes: SceneNodes, id = SOUTH) => nodes[id] as WallNode + +/** A host that compiles nothing: it counts its builds and answers with the face's rectangle. */ +function fakeHost() { + const builds: WallFaceScriptInputs[] = [] + const host: FinishScriptHost = { + readScript: async (sha) => (sha === 'c'.repeat(64) ? 'export default () => {}' : null), + build: async (input) => { + const { outline } = input.inputs as WallFaceScriptInputs + builds.push(input.inputs as WallFaceScriptInputs) + return { + sha256: builds.length.toString(16).padStart(64, '0'), + script: 'c'.repeat(64), + language: 'three', + mount: 'wall-side', + params: { tile: 0.3 }, + manifest: GeometryArtifactManifest.parse({ + bounds: { + min: [outline.x0, outline.y0, 0], + max: [outline.x1, outline.y1, 0.01], + }, + slots: [{ id: 'tile' }], + triangles: 12, + }), + } satisfies CompiledGeometryScript + }, + } + return { host, builds } +} + +/** The south wall with a script on its face a (to `toHeight`), built once by `host`. */ +async function scripted(host: FinishScriptHost, toHeight?: number, nodes = room()) { + const inputs = wallFaceScriptInputs(nodes, SOUTH, 'a', toHeight)! + const compiled = await host.build({ nodeId: SOUTH, face: 'a', code: '', params: {}, inputs }) + const { changes } = setWallFaceScript( + nodes, + { wallId: SOUTH, face: 'a', compiled, inputs, toHeight }, + { activeLevelId: LEVEL }, + ) + return applySceneChanges(nodes, changes) as SceneNodes +} + +function refusal(action: () => unknown): string { + try { + action() + } catch (error) { + if (isAgentRefusal(error)) return error.code + throw error + } + throw new Error('expected a refusal') +} + +describe("a wall face's script", () => { + test('is given its face in wall-local metres: a door from the floor, a window at its sill', () => { + const nodes = room() + // Face a meets the side walls' inner faces 0.1 m in from each end; face b runs past them. + // Both stand on the 0.05 m floor plate, 2.45 m under the 2.5 m storey. + expect(wallFaceScriptInputs(nodes, SOUTH, 'a')).toEqual({ + outline: { x0: 0.1, y0: 0, x1: 7.9, y1: 2.45 }, + holes: [ + { x0: 1.55, y0: 0, x1: 2.45, y1: 2.1 }, + { x0: 4.4, y0: 1, x1: 5.6, y1: 2 }, + ], + base: [ + [0.1, 0], + [7.9, 0], + ], + thickness: 0.2, + }) + expect(wallFaceScriptInputs(nodes, SOUTH, 'b')?.outline).toEqual({ + x0: -0.1, + y0: 0, + x1: 8.1, + y1: 2.45, + }) + // A dado to 1.2 m keeps the door, cut at its top, and the window's lower 0.2 m. + expect(wallFaceScriptInputs(nodes, SOUTH, 'a', 1.2)?.holes).toEqual([ + { x0: 1.55, y0: 0, x1: 2.45, y1: 1.2 }, + { x0: 4.4, y0: 1, x1: 5.6, y1: 1.2 }, + ]) + }) + + test('is built once, and again only after an opening is added or the wall is lengthened', async () => { + const { host, builds } = fakeHost() + let nodes = await scripted(host) + expect(builds).toHaveLength(1) + expect(staleFinishScripts(nodes)).toEqual([]) + expect(await refreshFinishScripts(nodes, host)).toEqual({ rebuilt: [], failed: [] }) + // Painting the other face changes nothing the script was built for. + nodes = applySceneChanges( + nodes, + paint(nodes, { targets: [SOUTH], role: 'b', color: '#336699' }, { activeLevelId: LEVEL }) + .changes, + ) as SceneNodes + expect(staleFinishScripts(nodes)).toEqual([]) + + const rebuild = async () => { + expect(staleFinishScripts(nodes).map(({ nodeId, face }) => [nodeId, face])).toEqual([ + [SOUTH, 'a'], + ]) + const refresh = await refreshFinishScripts(nodes, host) + expect(refresh.failed).toEqual([]) + nodes = applySceneChanges(nodes, { + update: finishScriptWrites(nodes, refresh.rebuilt), + }) as SceneNodes + expect(staleFinishScripts(nodes)).toEqual([]) + } + + const window = WindowNode.parse({ + id: 'window_wf_2', + parentId: SOUTH, + wallId: SOUTH, + position: [6.8, 1.5, 0], + width: 0.6, + height: 0.6, + }) + nodes = { + ...nodes, + [window.id]: window, + [SOUTH]: { ...wallOf(nodes), children: [...wallOf(nodes).children, window.id] }, + } + await rebuild() + expect(builds).toHaveLength(2) + expect(builds[1]!.holes).toHaveLength(3) + + const lengthened: Record = { ...nodes } + const wall = (id: string, data: Partial) => { + lengthened[id] = { ...wallOf(nodes, id), ...data } + } + wall(SOUTH, { end: [9, 0] }) + wall('wall_wf_1', { start: [9, 0], end: [9, 4] }) + wall('wall_wf_2', { start: [9, 4] }) + nodes = reconcile(lengthened, nodes) + await rebuild() + expect(builds).toHaveLength(3) + expect(builds[2]!.outline.x1).toBeCloseTo(8.9, 6) + const rebuilt = wallOf(nodes).finishScripts?.a + expect(rebuilt?.artifact).toBe('3'.padStart(64, '0')) + expect(rebuilt?.params).toEqual({ tile: 0.3 }) + }) + + test('refuses paint on its face until removed, and leaves the other face and a dado face paintable', async () => { + let nodes = await scripted(fakeHost().host) + const region = { id: 'region_wf', face: 'a' as const, v1: 0.9, finish: 'library:preset-white' } + nodes = { ...nodes, [SOUTH]: { ...wallOf(nodes), faceRegions: [region] } } + const zoneId = Object.values(nodes).find((node) => node.type === 'zone')!.id + for (const role of ['a', `region:${region.id}`, `room:${zoneId}/a`]) + expect(wallFaceScriptPaintRefusal(wallOf(nodes), role)?.code).toBe( + 'wall-face-built-by-script', + ) + expect(wallFaceScriptPaintRefusal(wallOf(nodes), 'b')).toBeUndefined() + expect(wallFaceScriptPaintRefusal(wallOf(nodes), 'aSkirting')).toBeUndefined() + const painting = (role: string) => + paint(nodes, { targets: [SOUTH], role, color: '#336699' }, { activeLevelId: LEVEL }) + expect(refusal(() => painting('a'))).toBe('face_built_by_script') + expect(painting('b').changes?.update).toHaveLength(1) + + const dado = await scripted(fakeHost().host, 1.2) + expect(wallFaceScriptPaintRefusal(wallOf(dado), 'a')).toBeUndefined() + + nodes = applySceneChanges( + nodes, + setWallFaceScript(nodes, { wallId: SOUTH, face: 'a', remove: true }, { activeLevelId: LEVEL }) + .changes, + ) as SceneNodes + expect(wallOf(nodes).finishScripts).toBeUndefined() + expect(wallFaceScriptPaintRefusal(wallOf(nodes), 'a')).toBeUndefined() + }) + + test('is placed on its own face, 1 mm proud, its z out of that face', () => { + const nodes = room() + // The south wall's local frame is the plan's: x east from its start, local z = plan y (north). + const onPlan = (face: 'a' | 'b', [x, y, z]: [number, number, number]) => { + const { position, scale } = wallFaceScriptPlacement(nodes, SOUTH, face)! + return [position[0] + x * scale[0], position[1] + y * scale[1], position[2] + z * scale[2]] + } + // A tile 1 cm thick, 1 m along: on face a its front is in the room, on face b outside it. + const [ax, ay, az] = onPlan('a', [1, 0.5, 0.01]) + expect([ax, ay]).toEqual([1, 0.5]) + expect(az).toBeCloseTo(0.1 + 0.001 + 0.01, 9) + const [bx, , bz] = onPlan('b', [1, 0.5, 0.01]) + expect(bx).toBe(1) + expect(bz).toBeCloseTo(-(0.1 + 0.001 + 0.01), 9) + }) + + test('a curved wall has no face a script can build on', async () => { + const nodes = room() + const { host } = fakeHost() + const inputs = wallFaceScriptInputs(nodes, SOUTH, 'a')! + const compiled = await host.build({ nodeId: SOUTH, face: 'a', code: '', params: {}, inputs }) + const curved = { ...nodes, [SOUTH]: { ...wallOf(nodes), curveOffset: 0.5 } } + expect(wallFaceScriptInputs(curved, SOUTH, 'a')).toBeNull() + expect( + refusal(() => + setWallFaceScript( + curved, + { wallId: SOUTH, face: 'a', compiled, inputs }, + { activeLevelId: LEVEL }, + ), + ), + ).toBe('no_face') + }) +}) + +test('a wall saved before face scripts parses as it did, and nothing in its scene is stale', () => { + const nodes = room() + const saved = JSON.parse(JSON.stringify({ ...wallOf(nodes), slots: { a: 'library:tile' } })) + expect(WallNode.parse(saved)).toEqual(saved) + expect(staleFinishScripts({ ...nodes, [SOUTH]: saved })).toEqual([]) +}) diff --git a/packages/core/src/lib/wall-face-script.ts b/packages/core/src/lib/wall-face-script.ts new file mode 100644 index 0000000000..4f0e3f6703 --- /dev/null +++ b/packages/core/src/lib/wall-face-script.ts @@ -0,0 +1,322 @@ +import type { + AnyNode, + CompiledGeometryScript, + DoorNode, + WallFaceScript, + WallNode, + WindowNode, +} from '../schema' +import type { WallFace } from '../schema/nodes/wall' +import { getWallTop } from '../services/storey' +import type { WallSlabSupport } from '../systems/slab/slab-support' +import { isCurvedWall } from '../systems/wall/wall-curve' +import { parseWallPaintRole } from '../systems/wall/wall-finish' +import { getWallThickness } from '../systems/wall/wall-footprint' +import { getWallLocalFaceZ } from '../systems/wall/wall-frame' +import { + calculateLevelMiters, + getWallMiterBoundaryPoints, + type WallMiterData, +} from '../systems/wall/wall-mitering' +import { isFloorAnchoredOpening } from './floor-opening-footprints' +import { scriptSource } from './geometry-script-node' +import { getOpeningFloorDatum, wallSupportForNodes } from './opening-floor-datum' +import { scriptInputsKey } from './script-inputs-key' + +type SceneNodes = Readonly> + +/** A rectangle on a wall face, in the face's frame and metres. */ +export type WallFaceRect = { x0: number; y0: number; x1: number; y1: number } + +/** + * What a wall face's script is built for, in the face's frame and metres: x along the wall from + * its start, y up from the face's base (its lowest floor), z out of the face. `outline` is the + * face's rectangle (its mitred ends, up to the wall's top or the script's `toHeight`); `holes` the + * doors and windows cutting it, clipped to it (a door from the floor under it, a window from its + * sill); `base` the face's floor from end to end as `[x, y]` points (one level run on a flat floor, + * a jump where a raised room meets it); `thickness` the wall's. The build stays in this frame. + */ +export type WallFaceScriptInputs = { + outline: WallFaceRect + holes: WallFaceRect[] + base: [number, number][] + thickness: number +} + +const EPS = 1e-6 +const round = (value: number) => Math.round(value * 1e4) / 1e4 + +/** How far the face's frame stands above the wall's own origin: its lowest floor, wall-local. */ +export function wallFaceBaseOffset(support: WallSlabSupport, face: WallFace): number { + const runs = support.faceDatum[face] + if (!runs.length) return 0 + const lowest = Math.min( + ...runs.map((run) => Math.min(run.elevation, run.endElevation ?? run.elevation)), + ) + return lowest - support.elevation +} + +const mitersByNodes = new WeakMap>() + +function levelMiters(nodes: SceneNodes, levelId: string): WallMiterData { + let levels = mitersByNodes.get(nodes) + if (!levels) { + levels = new Map() + mitersByNodes.set(nodes, levels) + } + let miters = levels.get(levelId) + if (!miters) { + const walls = Object.values(nodes) + .filter((node): node is WallNode => node.type === 'wall' && node.parentId === levelId) + .sort((a, b) => a.id.localeCompare(b.id)) + miters = calculateLevelMiters(walls) + levels.set(levelId, miters) + } + return miters +} + +type FaceFrame = { + wall: WallNode + length: number + support: WallSlabSupport + /** The frame's origin above the wall's own, wall-local metres. */ + offset: number + /** The face's mitred ends along the wall. */ + x0: number + x1: number + /** The wall's top in the face's frame. */ + top: number +} + +/** A wall whose faces a script can build on: straight, not a curtain wall, standing on a level. */ +function scriptableWall(nodes: SceneNodes, wallId: string): WallNode | null { + const wall = nodes[wallId] + if (wall?.type !== 'wall' || !wall.parentId) return null + if (wall.wallType === 'curtain' || isCurvedWall(wall)) return null + return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) < EPS ? null : wall +} + +function faceFrame(nodes: SceneNodes, wallId: string, face: WallFace): FaceFrame | null { + const wall = scriptableWall(nodes, wallId) + if (!wall?.parentId) return null + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + const support = wallSupportForNodes(wall, nodes) + const offset = wallFaceBaseOffset(support, face) + const top = + getWallTop(wall, wall.parentId, nodes as Record, support.elevation) - + support.elevation - + offset + const station = (point: { x: number; y: number }) => + ((point.x - wall.start[0]) * dx + (point.y - wall.start[1]) * dz) / length + const ends = getWallMiterBoundaryPoints(wall, levelMiters(nodes, wall.parentId)) + const x0 = ends ? station(face === 'a' ? ends.startLeft : ends.startRight) : 0 + const x1 = ends ? station(face === 'a' ? ends.endLeft : ends.endRight) : length + if (top <= EPS || x1 - x0 <= EPS) return null + return { wall, length, support, offset, x0, x1, top } +} + +/** The face's floor as `[x, y]` points from `x0` to `x1`, in the face's frame. */ +function faceBase(frame: FaceFrame, face: WallFace): [number, number][] { + const { support, length, offset, x0, x1 } = frame + const runs = [...support.faceDatum[face]].sort((a, b) => a.start - b.start) + const points: [number, number][] = runs.length + ? runs.flatMap((run): [number, number][] => [ + [run.start * length, run.elevation - support.elevation - offset], + [run.end * length, (run.endElevation ?? run.elevation) - support.elevation - offset], + ]) + : [ + [0, 0], + [length, 0], + ] + const yAt = (x: number) => { + if (x <= points[0]![0]) return points[0]![1] + for (let index = 1; index < points.length; index += 1) { + const [ax, ay] = points[index - 1]! + const [bx, by] = points[index]! + if (x <= bx) return bx - ax < EPS ? by : ay + ((x - ax) / (bx - ax)) * (by - ay) + } + return points[points.length - 1]![1] + } + const kept: [number, number][] = [ + [x0, yAt(x0)], + ...points.filter(([x]) => x > x0 + EPS && x < x1 - EPS), + [x1, yAt(x1)], + ] + const base = kept.map(([x, y]): [number, number] => [round(x), round(y)]) + // A point on the straight line between its neighbours (a run's end where the next runs on + // level) says nothing; a jump (two points at one x) or a bend stays. + const simplified: [number, number][] = [] + for (const point of base) { + const previous = simplified[simplified.length - 1] + if (previous && previous[0] === point[0] && previous[1] === point[1]) continue + const before = simplified[simplified.length - 2] + if ( + before && + previous && + before[0] < previous[0] && + previous[0] < point[0] && + Math.abs( + (previous[0] - before[0]) * (point[1] - before[1]) - + (previous[1] - before[1]) * (point[0] - before[0]), + ) < EPS + ) + simplified.pop() + simplified.push(point) + } + return simplified +} + +/** The lowest point of `base` over `[x0, x1]`. */ +function baseLowUnder(base: [number, number][], x0: number, x1: number): number { + let low = Number.POSITIVE_INFINITY + for (let index = 1; index < base.length; index += 1) { + const [ax, ay] = base[index - 1]! + const [bx, by] = base[index]! + if (bx < x0 || ax > x1) continue + const at = (x: number) => + bx - ax < EPS ? Math.min(ay, by) : ay + ((x - ax) / (bx - ax)) * (by - ay) + low = Math.min(low, at(Math.max(ax, x0)), at(Math.min(bx, x1))) + } + return Number.isFinite(low) ? low : 0 +} + +/** + * The face a script builds on, or null when it has none: a curved or curtain wall, a wall with no + * level, or a face with no height above its floor. + */ +export function wallFaceScriptInputs( + nodes: SceneNodes, + wallId: string, + face: WallFace, + toHeight?: number, +): WallFaceScriptInputs | null { + const frame = faceFrame(nodes, wallId, face) + if (!frame) return null + const { wall, support, offset, x0, x1, top } = frame + const outline = { + x0: round(x0), + y0: 0, + x1: round(x1), + y1: round(toHeight === undefined ? top : Math.min(toHeight, top)), + } + const base = faceBase(frame, face) + const holes = (wall.children ?? []).flatMap((childId) => { + const opening = nodes[childId] + if (opening?.type !== 'door' && opening?.type !== 'window') return [] + const { position, width, height } = opening as DoorNode | WindowNode + const centre = + getOpeningFloorDatum(wall, opening, nodes, support) - support.elevation - offset + position[1] + const left = position[0] - width / 2 + const right = position[0] + width / 2 + const bottom = isFloorAnchoredOpening(opening) + ? Math.min(centre - height / 2, baseLowUnder(base, left, right)) + : centre - height / 2 + const hole = { + x0: round(Math.max(left, outline.x0)), + y0: round(Math.max(bottom, outline.y0)), + x1: round(Math.min(right, outline.x1)), + y1: round(Math.min(centre + height / 2, outline.y1)), + } + return hole.x1 - hole.x0 > EPS && hole.y1 - hole.y0 > EPS ? [hole] : [] + }) + holes.sort((a, b) => a.x0 - b.x0 || a.y0 - b.y0) + return { outline, holes, base, thickness: round(getWallThickness(wall)) } +} + +/** The key a face script keeps of the face it was built for (`WallFaceScript.inputs`). */ +export function wallFaceScriptInputsKey(inputs: WallFaceScriptInputs): string { + return scriptInputsKey( + JSON.stringify([inputs.outline, inputs.holes, inputs.base, inputs.thickness]), + ) +} + +/** A compile as the wall keeps it on a face: its script source, its face's key, its `toHeight`. */ +export function wallFaceScriptRecord( + compiled: CompiledGeometryScript, + inputs: WallFaceScriptInputs, + toHeight: number | undefined, + previous?: WallFaceScript, +): WallFaceScript { + return { + ...scriptSource(compiled, {}, previous), + inputs: wallFaceScriptInputsKey(inputs), + ...(toHeight === undefined ? {} : { toHeight }), + } +} + +/** Over the face, so the face's own finish never shows through the tiles. */ +const WALL_FACE_SCRIPT_PROUD = 0.001 + +/** + * Where a face's build goes in the wall's local frame (x along the wall from its start, z toward + * face a): its frame's origin at the face's base, 1 mm proud of the face. Face b's frame has z out + * of face b, so the build is mirrored through the wall (`scale` z −1; three.js flips the winding of + * a mirrored mesh) and its x still runs from the wall's start. Null when the face has no frame. + */ +export function wallFaceScriptPlacement( + nodes: SceneNodes, + wallId: string, + face: WallFace, +): { position: [number, number, number]; scale: [number, number, number] } | null { + const wall = scriptableWall(nodes, wallId) + if (!wall) return null + const out = face === 'a' ? 1 : -1 + return { + position: [ + 0, + wallFaceBaseOffset(wallSupportForNodes(wall, nodes), face), + getWallLocalFaceZ(wall, face) + out * WALL_FACE_SCRIPT_PROUD, + ], + scale: [1, 1, out], + } +} + +/** + * Where a face's skirting gives way to its script, as it does at a door: the build's span along + * the wall when the build comes down into the skirting's band (its bottom below `bandTop`, in the + * face's frame), else null. The script wins visually where it covers the band; a splashback above + * the skirting leaves it whole. + */ +export function wallFaceScriptSkirtingGap( + script: WallFaceScript, + bandTop: number, +): [number, number] | null { + const { min, max } = script.manifest.bounds + return min[1] < bandTop - EPS ? [min[0], max[0]] : null +} + +export type WallFacePaintRefusal = { + status: 'refused' + code: 'wall-face-built-by-script' + message: string +} + +/** + * Why a paint on a wall's `role` is refused: it lands on a face (the face itself, a region of it, + * the room's finish on it) that a script builds to the top, and the script owns its colours. A + * script stopping at `toHeight` leaves its face paintable, since the paint shows above it. + */ +export function wallFaceScriptPaintRefusal( + wall: AnyNode | undefined, + role: string, +): WallFacePaintRefusal | undefined { + if (wall?.type !== 'wall') return + const parsed = parseWallPaintRole(role, (slotId) => slotId === 'a' || slotId === 'b') + const face = + parsed?.kind === 'slot' + ? (parsed.slotId as WallFace) + : parsed?.kind === 'room-face' + ? parsed.face + : parsed?.kind === 'region' + ? wall.faceRegions?.find((region) => region.id === parsed.regionId)?.face + : undefined + const script = face && wall.finishScripts?.[face] + if (!face || !script || script.toHeight !== undefined) return + return { + status: 'refused', + code: 'wall-face-built-by-script', + message: `${wall.name?.trim() || 'This wall'}'s side ${face.toUpperCase()} is built by a script, which owns its colours. Remove the script to paint that side.`, + } +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 37e7f7d722..6481140ac2 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -435,6 +435,7 @@ export { WallAssemblyInteriorFinish, WallAssemblySheathingMaterial, WallFaceRegion, + WallFaceScript, WallNode, WallTreatmentSide, WallTrimProfile, diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 15d71938c7..1f15d6596f 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -2,6 +2,7 @@ import dedent from 'dedent' import { z } from 'zod' import { Assembly } from '../assembly' import { BaseNode, nodeType, objectId } from '../base' +import { GeometryScriptSource } from '../geometry-source' import { MaterialSchema } from '../material' import { CurtainWallConfig } from './curtain-wall' import { DoorNode } from './door' @@ -117,6 +118,20 @@ export const WallFaceRegion = z.object({ }) export type WallFaceRegion = z.infer +/** + * One wall face's finish built by a three.js module for exactly that face (`lib/wall-face-script`): + * the module gets the face's rectangle, its openings and its base in the face's own frame and lays + * tiles, boards or panelling on them. `inputs` is the key of the face it was built for; once the + * face differs (the wall lengthened, an opening added) the script is stale and runs again. + * `toHeight` (metres above the face's base) stops it there, as a dado does; absent, it covers the + * whole face. + */ +export const WallFaceScript = GeometryScriptSource.extend({ + inputs: z.string(), + toHeight: z.number().positive().optional(), +}) +export type WallFaceScript = z.infer + const WallSurfaceMaterialSpecSchema = z.object({ material: MaterialSchema.optional(), materialPreset: z.string().optional(), @@ -303,6 +318,10 @@ export const WallNode = BaseNode.extend({ skirting: WallTrimConfig.optional(), crown: WallTrimConfig.optional(), chairRail: WallTrimConfig.optional(), + // Per-face finish built by a script — see WallFaceScript. + finishScripts: z + .object({ a: WallFaceScript.optional(), b: WallFaceScript.optional() }) + .optional(), // e.g., start/end points for path start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), @@ -321,6 +340,7 @@ export const WallNode = BaseNode.extend({ - underpinning: { rim, stem } — the finish carried rim metres below the base over the floor platform's edge, then stem metres of concrete stemwall to the ground (the foundation slot) - curveOffset: midpoint sagitta offset used to bend the wall into an arc + - finishScripts: { a?, b? } — a three.js module that builds one face's finish (tiles, boards, panelling) for that face, optionally only up to toHeight; it owns that face's colours - start: start point of the wall in level coordinate system - end: end point of the wall in level coordinate system - size: size of the wall in grid units diff --git a/packages/core/src/systems/wall/wall-frame.test.ts b/packages/core/src/systems/wall/wall-frame.test.ts index e42b977340..70a98243ff 100644 --- a/packages/core/src/systems/wall/wall-frame.test.ts +++ b/packages/core/src/systems/wall/wall-frame.test.ts @@ -50,6 +50,15 @@ describe('wall reference frame', () => { legacyFaceMaterials: { a: { materialPreset: 'library:legacy-left' } }, faceRegions: [{ id: 'r', face: 'a', u0: 1, v1: 0.9, finish: 'library:wainscot' }], skirting: { enabled: true, sides: 'a' }, + finishScripts: { + a: { + kind: 'script', + script: 'a'.repeat(64), + artifact: 'b'.repeat(64), + inputs: 'key', + manifest: { bounds: { min: [0, 0, 0], max: [4, 1, 0.01] }, triangles: 2 }, + }, + }, }) for (const original of [wall, { ...wall, curveOffset: 0.4 }]) { const reversed = { ...original, ...reverseWallDirection(original) } @@ -68,6 +77,7 @@ describe('wall reference frame', () => { { id: 'r', face: 'b', u1: length - 1, v1: 0.9, finish: 'library:wainscot' }, ]) expect(reversed.skirting?.sides).toBe('b') + expect(reversed.finishScripts).toEqual({ b: original.finishScripts!.a }) expect(reversed.frontSide).toBe(original.backSide) expect(reversed.backSide).toBe(original.frontSide) expect('justification' in reversed).toBe('justification' in original) diff --git a/packages/core/src/systems/wall/wall-frame.ts b/packages/core/src/systems/wall/wall-frame.ts index be9fce693c..2c203c5907 100644 --- a/packages/core/src/systems/wall/wall-frame.ts +++ b/packages/core/src/systems/wall/wall-frame.ts @@ -178,6 +178,14 @@ export function reverseWallDirection(wall: WallNode): Partial { ...(u0 === undefined ? {} : { u1: length - u0 }), })), }), + ...(wall.finishScripts === undefined + ? {} + : { + finishScripts: { + ...(wall.finishScripts.b === undefined ? {} : { a: wall.finishScripts.b }), + ...(wall.finishScripts.a === undefined ? {} : { b: wall.finishScripts.a }), + }, + }), ...(wall.skirting === undefined ? {} : { skirting: reverseTrim(wall.skirting) }), ...(wall.crown === undefined ? {} : { crown: reverseTrim(wall.crown) }), ...(wall.chairRail === undefined ? {} : { chairRail: reverseTrim(wall.chairRail) }), diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index f5c76f8479..18144109b7 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -43,7 +43,7 @@ import { useSaveShortcut } from '../../hooks/use-save-shortcut' import { useCeilingEditSessionOwner } from '../../lib/ceiling-edit-session' import { showsWholeBuilding, useEditorLevelDisplay } from '../../lib/editor-level-display' import { isSceneView } from '../../lib/editor-views' -import { initFloorScriptRefresh } from '../../lib/geometry-script/floor-scripts' +import { initFinishScriptRefresh } from '../../lib/geometry-script/finish-scripts' import { useGestureLifecycleOwner } from '../../lib/gesture-lifecycle' import { shouldShowEditingControls } from '../../lib/interaction/overlay-policy' import { @@ -184,13 +184,13 @@ const EDITOR_DEFAULT_RENDER = { shading: 'solid' } as const function initializeEditorRuntime(): () => void { const unsubscribeSpatialGrid = initSpatialGridSync() const unsubscribeSpaceDetection = initSpaceDetectionSync(useScene, useEditor) - const stopFloorScriptRefresh = initFloorScriptRefresh() + const stopFinishScriptRefresh = initFinishScriptRefresh() initSFXBus() return () => { unsubscribeSpatialGrid() unsubscribeSpaceDetection?.() - stopFloorScriptRefresh() + stopFinishScriptRefresh() spatialGridManager.clear() disposeSFXBus() diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index a8288b7c6d..1d099638e2 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -27,6 +27,7 @@ import { useLiveNodeOverrides, useRegistryVersion, useScene, + wallFaceScriptPaintRefusal, } from '@pascal-app/core' import { getRoofMaterialArray, registerMaterialCacheCleanup, useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' @@ -982,8 +983,11 @@ export const SelectionManager = () => { compatible && role ? () => { // A surface with nothing of its own to paint (a footprint's - // top where no room stands) says so instead of painting. - const refusal = floorPlatePaintRefusal(useScene.getState().nodes, node, role) + // top where no room stands), or one a script builds, says + // so instead of painting. + const refusal = + floorPlatePaintRefusal(useScene.getState().nodes, node, role) ?? + wallFaceScriptPaintRefusal(node, role) usePaintRegionMode.getState().setNotice(refusal?.message ?? null) if (refusal) return // Spread targets are all the same slot-model kind, so one diff --git a/packages/editor/src/components/ui/panels/room-panel.tsx b/packages/editor/src/components/ui/panels/room-panel.tsx index 93bcdee88c..334c571cd3 100644 --- a/packages/editor/src/components/ui/panels/room-panel.tsx +++ b/packages/editor/src/components/ui/panels/room-panel.tsx @@ -15,7 +15,7 @@ import { Check, ChevronDown, Plus } from 'lucide-react' import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from 'react' import { startCeilingEdit } from '../../../lib/ceiling-edit-session' import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy' -import { floorScriptCode } from '../../../lib/geometry-script/floor-scripts' +import { finishScriptCode } from '../../../lib/geometry-script/finish-scripts' import { formatAreaLabel, formatLinearMeasurement } from '../../../lib/measurements' import { addMezzanineStairs } from '../../../lib/mezzanine-stairs' import { roomFloorBase, roomRelativeFloorHeight } from '../../../lib/room-construction-commands' @@ -241,7 +241,7 @@ function RoomFloorScriptRow({ zoneId }: { zoneId: string }) { const toggleCode = () => { setOpen((shown) => !shown) if (code?.sha === sha) return - floorScriptCode(sha).then( + finishScriptCode(sha).then( (text) => setCode({ sha, text: text ?? 'The script could not be read.' }), () => setCode({ sha, text: 'The script could not be read.' }), ) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d13901d0f8..5292aca737 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -580,10 +580,10 @@ export { } from './lib/geometry-script/author' export { compileGeometryScriptInWorker } from './lib/geometry-script/client' export { - compileAndStoreFloorScript, - editorFloorScriptHost, - floorScriptCode, -} from './lib/geometry-script/floor-scripts' + compileAndStoreFinishScript, + editorFinishScriptHost, + finishScriptCode, +} from './lib/geometry-script/finish-scripts' export { exportSceneToGlb, type GlbExportOptions } from './lib/glb-export' export { type EditorGridEvent, diff --git a/packages/editor/src/lib/geometry-script/finish-scripts.ts b/packages/editor/src/lib/geometry-script/finish-scripts.ts new file mode 100644 index 0000000000..d28eb015fd --- /dev/null +++ b/packages/editor/src/lib/geometry-script/finish-scripts.ts @@ -0,0 +1,127 @@ +import { + type AnyNodeId, + type CompiledGeometryScript, + type FinishScriptHost, + type FinishScriptInputs, + type FinishScriptTarget, + finishScriptWrites, + GEOMETRY_SCRIPT_MIME_TYPES, + type GeometryScriptParamValue, + getArtifactStore, + pauseSceneHistory, + refreshFinishScripts, + resumeSceneHistory, + staleFinishScripts, + useScene, +} from '@pascal-app/core' +import { compileGeometryScriptInWorker } from './client' + +/** + * Runs a finish script (a room's floor, a wall's face) in the sandbox for what it finishes and + * stores the GLB and the module text, answering the compile with the stored hashes + * (`setRoomFloorScript` and `setWallFaceScript` take it with its `inputs`). + */ +export async function compileAndStoreFinishScript( + input: FinishScriptTarget & { + code: string + params?: Record + inputs: FinishScriptInputs + }, +): Promise { + const { glb, ...compiled } = await compileGeometryScriptInWorker({ + code: input.code, + params: input.params, + inputs: input.inputs, + }) + const context = { + nodeId: input.nodeId, + metadata: { name: input.face ? 'Wall finish' : 'Floor finish', mount: compiled.mount }, + } + const store = getArtifactStore() + const [sha256] = await Promise.all([ + store.put(compiled.sha256, glb, 'model/gltf-binary', context), + store.put( + compiled.script, + new TextEncoder().encode(input.code), + GEOMETRY_SCRIPT_MIME_TYPES.three, + context, + ), + ]) + return { ...compiled, sha256 } +} + +/** The editor tab's host for finish scripts: the sandbox compile and the scene's artifact store. */ +export const editorFinishScriptHost: FinishScriptHost = { + readScript: (sha256) => getArtifactStore().text(sha256), + build: compileAndStoreFinishScript, +} + +/** The module text of a finish script, read back from the artifact store. */ +export function finishScriptCode(sha256: string): Promise { + return getArtifactStore().text(sha256) +} + +const SETTLE_MS = 150 + +/** + * Keeps every finish script built for what it finishes: after a scene change settles, each script + * whose room's floor the structure re-derived, or whose wall face changed, runs again in the + * sandbox. The new build is written outside history, as the reconcile's derived writes are, and + * only while its floor or face is still the one it was built for; undo brings back the room or + * wall and the script built for it together. + */ +export function initFinishScriptRefresh(): () => void { + let timer: ReturnType | null = null + let running = false + let again = false + + const run = async () => { + if (running) { + again = true + return + } + running = true + try { + const nodes = useScene.getState().nodes + if (useScene.getState().readOnly || staleFinishScripts(nodes).length === 0) return + const refresh = await refreshFinishScripts(nodes, editorFinishScriptHost) + for (const { nodeId, face, error } of refresh.failed) + console.error( + `[finish script] ${nodeId}${face ? ` face ${face}` : ''} could not be rebuilt: ${error}`, + ) + const writes = finishScriptWrites(useScene.getState().nodes, refresh.rebuilt) + if (writes.length === 0) return + pauseSceneHistory(useScene) + try { + useScene + .getState() + .updateNodes(writes.map(({ id, data }) => ({ id: id as AnyNodeId, data }))) + } finally { + resumeSceneHistory(useScene) + } + } finally { + running = false + if (again) { + again = false + schedule() + } + } + } + + const schedule = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = null + void run() + }, SETTLE_MS) + } + + const unsubscribe = useScene.subscribe((state, previous) => { + if (state.nodes !== previous.nodes) schedule() + }) + schedule() + return () => { + unsubscribe() + if (timer) clearTimeout(timer) + } +} diff --git a/packages/editor/src/lib/geometry-script/floor-scripts.ts b/packages/editor/src/lib/geometry-script/floor-scripts.ts deleted file mode 100644 index f9a5d6e051..0000000000 --- a/packages/editor/src/lib/geometry-script/floor-scripts.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - type AnyNodeId, - type CompiledGeometryScript, - type FloorScriptHost, - type FloorScriptInputs, - floorScriptInputs, - floorScriptInputsKey, - GEOMETRY_SCRIPT_MIME_TYPES, - type GeometryScriptParamValue, - getArtifactStore, - pauseSceneHistory, - refreshFloorScripts, - resumeSceneHistory, - staleFloorScripts, - useScene, -} from '@pascal-app/core' -import { compileGeometryScriptInWorker } from './client' - -/** - * Runs a room's floor script in the sandbox for its floor and stores the GLB and the module text, - * answering the compile with the stored hashes (`setRoomFloorScript` takes it with `inputs`). - */ -export async function compileAndStoreFloorScript(input: { - zoneId: string - code: string - params?: Record - inputs: FloorScriptInputs -}): Promise { - const { glb, ...compiled } = await compileGeometryScriptInWorker({ - code: input.code, - params: input.params, - inputs: input.inputs, - }) - const context = { - nodeId: input.zoneId, - metadata: { name: 'Floor finish', mount: compiled.mount }, - } - const store = getArtifactStore() - const [sha256] = await Promise.all([ - store.put(compiled.sha256, glb, 'model/gltf-binary', context), - store.put( - compiled.script, - new TextEncoder().encode(input.code), - GEOMETRY_SCRIPT_MIME_TYPES.three, - context, - ), - ]) - return { ...compiled, sha256 } -} - -/** The editor tab's host for floor scripts: the sandbox compile and the scene's artifact store. */ -export const editorFloorScriptHost: FloorScriptHost = { - readScript: (sha256) => getArtifactStore().text(sha256), - build: compileAndStoreFloorScript, -} - -/** The module text of a room's floor script, read back from the artifact store. */ -export function floorScriptCode(sha256: string): Promise { - return getArtifactStore().text(sha256) -} - -const SETTLE_MS = 150 - -/** - * Keeps every room's floor script built for the room's floor: after a scene change settles, each - * script whose room the structure re-derived runs again in the sandbox. The new build is written - * outside history, as the reconcile's derived writes are, and only while the room's floor is still - * the one it was built for; undo brings back the floor and the script built for it together. - */ -export function initFloorScriptRefresh(): () => void { - let timer: ReturnType | null = null - let running = false - let again = false - - const run = async () => { - if (running) { - again = true - return - } - running = true - try { - const nodes = useScene.getState().nodes - if (useScene.getState().readOnly || staleFloorScripts(nodes).length === 0) return - const refresh = await refreshFloorScripts(nodes, editorFloorScriptHost) - for (const { zoneId, error } of refresh.failed) - console.error(`[floor script] ${zoneId} could not be rebuilt: ${error}`) - const current = useScene.getState().nodes - const writes = refresh.update.flatMap(({ id, data }) => { - const zone = current[id as AnyNodeId] - const script = data.floor?.script - const inputs = floorScriptInputs(current, id) - if (zone?.type !== 'zone' || !zone.floor?.script || !script || !inputs) return [] - if (floorScriptInputsKey(inputs) !== script.inputs) return [] - return [{ id: id as AnyNodeId, data: { floor: { ...zone.floor, script } } }] - }) - if (writes.length === 0) return - pauseSceneHistory(useScene) - try { - useScene.getState().updateNodes(writes) - } finally { - resumeSceneHistory(useScene) - } - } finally { - running = false - if (again) { - again = false - schedule() - } - } - } - - const schedule = () => { - if (timer) clearTimeout(timer) - timer = setTimeout(() => { - timer = null - void run() - }, SETTLE_MS) - } - - const unsubscribe = useScene.subscribe((state, previous) => { - if (state.nodes !== previous.nodes) schedule() - }) - schedule() - return () => { - unsubscribe() - if (timer) clearTimeout(timer) - } -} diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index 3fa94fd226..520116124c 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -488,13 +488,17 @@ export async function pasteSystemEditorClipboardToLevel( return applyClipboardPayloadToLevel(payload, targetLevel.id, refused) } -/** The script and GLB a scripted node, or a room's floor script, references. */ +/** The script and GLB a scripted node, a room's floor script or a wall's face scripts reference. */ function artifactHashes(node: AnyNode): string[] { - const source = - node.type === 'zone' ? node.floor?.script : 'source' in node ? node.source : undefined - return typeof source === 'object' && source?.kind === 'script' - ? [source.script, source.artifact] - : [] + const sources = + node.type === 'zone' + ? [node.floor?.script] + : node.type === 'wall' + ? [node.finishScripts?.a, node.finishScripts?.b] + : ['source' in node ? node.source : undefined] + return sources.flatMap((source) => + typeof source === 'object' && source?.kind === 'script' ? [source.script, source.artifact] : [], + ) } /** diff --git a/packages/mcp/src/tools/add-object.test.ts b/packages/mcp/src/tools/add-object.test.ts index 3223a52e4d..9aef332888 100644 --- a/packages/mcp/src/tools/add-object.test.ts +++ b/packages/mcp/src/tools/add-object.test.ts @@ -6,13 +6,19 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { PASCAL_TYPES } from '@pascal-app/core/agent-operations' -import type { AnyNodeId, CompiledGeometryScript } from '@pascal-app/core/schema' +import { + type AnyNodeId, + type CompiledGeometryScript, + LevelNode, + WallFaceScript, + WallNode, +} from '@pascal-app/core/schema' import { ADD_OBJECT_CASES } from '../../../core/src/agent-operations/__fixtures__/add-object-cases' import { SceneBridge } from '../bridge/scene-bridge' import { createSceneOperations } from '../operations' import { createPascalMcpServer } from '../server' import { SqliteSceneStore } from '../storage/sqlite-scene-store' -import { type GeometryScriptHost, registerAddObject } from './add-object' +import { type GeometryScriptHost, registerAddObject, registerGetSource } from './add-object' // Layer 2 of 3: add_object through a real client, the host's compile answering with the case's. type Result = { isError?: boolean; content: Array<{ type: string; text: string }> } @@ -102,3 +108,53 @@ test('every tool an add_object hint names is one the server offers', async () => rmSync(directory, { recursive: true, force: true }) } }) + +// A wall face's script, read back by the wall's id: its one scripted face, or the face named. +test("get_source reads a wall face's script by the wall's id", async () => { + const CODE = 'export default function build() {}' + const script = WallFaceScript.parse({ + kind: 'script', + script: 'd'.repeat(64), + artifact: 'e'.repeat(64), + inputs: 'key', + toHeight: 1.2, + manifest: { bounds: { min: [0, 0, 0], max: [4, 1.2, 0.01] }, triangles: 2 }, + }) + const level = LevelNode.parse({ id: 'level_face_source', children: ['wall_face_source'] }) + const wall = WallNode.parse({ + id: 'wall_face_source', + parentId: level.id, + start: [0, 0], + end: [4, 0], + finishScripts: { b: script }, + }) + const bridge = new SceneBridge() + bridge.setScene({ [level.id]: level, [wall.id]: wall } as never, [level.id] as never) + bridge.setActiveScene({ id: 'scene_face_source', name: 'Face source' } as never) + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerGetSource(server, createSceneOperations({ bridge, activeLevelId: () => null }), { + compile: async () => { + throw new Error('get_source compiles nothing') + }, + storeArtifact: async ({ sha256 }) => sha256, + readArtifact: async ({ sha256 }) => + sha256 === script.script ? new TextEncoder().encode(CODE) : null, + }) + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + const read = (args: Record) => + client.callTool({ name: 'get_source', arguments: { nodeId: wall.id, ...args } }) + + expect((await read({})).structuredContent).toMatchObject({ + nodeId: wall.id, + type: 'wall', + face: 'b', + language: 'three', + code: CODE, + }) + expect((await read({ face: 'b' })).structuredContent).toMatchObject({ face: 'b', code: CODE }) + const other = (await read({ face: 'a' })) as Result + expect(other.isError).toBe(true) + expect(JSON.parse(other.content[0]!.text).code).toBe('not_authored') +}) diff --git a/packages/mcp/src/tools/add-object.ts b/packages/mcp/src/tools/add-object.ts index 73bc3bfc93..a78bb9dca7 100644 --- a/packages/mcp/src/tools/add-object.ts +++ b/packages/mcp/src/tools/add-object.ts @@ -47,7 +47,7 @@ export type GeometryScriptHost = { code: string language: GeometryScriptLanguage params?: Record - /** What the host builds it for (a room's floor: `floorScriptInputs`), kept in the host's frame. */ + /** What the host builds it for (a room's floor, a wall's face: `floorScriptInputs`, `wallFaceScriptInputs`), kept in the host's frame. */ inputs?: Record }): Promise storeArtifact(input: { @@ -77,15 +77,16 @@ export type GeometryScriptHost = { }): Promise } -export type ScriptedKind = 'object' | 'window' | 'door' | 'column' | 'floor' +export type ScriptedKind = 'object' | 'window' | 'door' | 'column' | 'floor' | 'wall-face' -/** The kind an artifact records; a room's floor finish is none of the node kinds. */ +/** The kind an artifact records; a room's floor finish and a wall face's are none of the node kinds. */ const ARTIFACT_KIND: Record = { object: 'item', window: 'window', door: 'door', column: 'column', floor: undefined, + 'wall-face': undefined, } /** Compiles a module on the host and stores its GLB and text for the scene: the step every scripted tool shares. */ @@ -298,7 +299,7 @@ export function registerGetSource( inputSchema: getSourceTool.input, annotations: READ_ONLY_TOOL_ANNOTATIONS, }, - async ({ nodeId }: { nodeId: string }) => { + async ({ nodeId, face }: { nodeId: string; face?: 'a' | 'b' }) => { if (!host) { return toolError('This Pascal server cannot read geometry scripts.', { code: 'scripts_unavailable', @@ -307,7 +308,7 @@ export function registerGetSource( const scene = bridge.getActiveScene() if (!scene) return toolError('Open a scene first.', { code: 'no_active_scene' }) try { - const node = readableScript(bridge.getNodes() as Record, nodeId) + const node = readableScript(bridge.getNodes() as Record, nodeId, face) const payload = readSourceResult(node, await scriptText(host, scene.id, node)) return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/nodes/src/shared/finish-script-model.tsx b/packages/nodes/src/shared/finish-script-model.tsx new file mode 100644 index 0000000000..5b55a19a9b --- /dev/null +++ b/packages/nodes/src/shared/finish-script-model.tsx @@ -0,0 +1,47 @@ +'use client' + +import { + artifactUrl, + type GeometryScriptSource, + type ItemNode, + scriptedSize, +} from '@pascal-app/core' +import { useMemo } from 'react' +import { ScriptedModel } from '../item/renderer' + +/** + * A finish its script built (a room's floor, a wall's face), drawn as an authored object's artifact + * is (its slots, the render shading), in the frame it was built in. It belongs to its host: no + * pointer events of its own, so a click on it reaches the floor or the wall beneath. + */ +export function FinishScriptModel({ + ownerId, + parentId, + script, +}: { + ownerId: string + parentId: string | null + script: GeometryScriptSource +}) { + const view = useMemo(() => { + const [width, height, depth] = scriptedSize(script.manifest) + return { + id: ownerId, + type: 'item', + parentId, + metadata: {}, + source: script, + scale: [1, 1, 1], + asset: { + src: artifactUrl(script.artifact), + dimensions: [width, height, depth], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + } as unknown as ItemNode + }, [ownerId, parentId, script]) + return +} + +const settleNothing = () => {} diff --git a/packages/nodes/src/wall/paint.test.ts b/packages/nodes/src/wall/paint.test.ts index e82b45bbe1..41f87987cf 100644 --- a/packages/nodes/src/wall/paint.test.ts +++ b/packages/nodes/src/wall/paint.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { type AnyNode, sceneRegistry, useScene, type WallNode, ZoneNode } from '@pascal-app/core' +import { + type AnyNode, + sceneRegistry, + useScene, + WallFaceScript, + type WallNode, + ZoneNode, +} from '@pascal-app/core' import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Ray, Vector3 } from 'three' import { parseWallPaintRole, @@ -293,6 +300,31 @@ describe('wall paint roles', () => { ).toEqual({}) }) + test('a face its script builds refuses the commit; the other face takes it', () => { + const script = WallFaceScript.parse({ + kind: 'script', + script: 'a'.repeat(64), + artifact: 'b'.repeat(64), + inputs: 'key', + manifest: { bounds: { min: [0, 0, 0], max: [4, 2.5, 0.01] }, triangles: 2 }, + }) + const wall = { ...baseWall, finishScripts: { a: script } } + useScene.setState({ nodes: scene(wall, [room]), materials: {} } as never) + const commit = (role: string) => + wallPaint.commit!({ + node: useScene.getState().nodes[wall.id]!, + role, + material: undefined, + materialPreset: 'library:tile', + }) + const before = useScene.getState().nodes + for (const role of ['a', wallRoomFaceRole('zone_room', 'a')]) + expect(commit(role)).toMatchObject({ status: 'refused', code: 'wall-face-built-by-script' }) + expect(useScene.getState().nodes).toBe(before) + commit('b') + expect((useScene.getState().nodes[wall.id] as WallNode).slots).toEqual({ b: 'library:tile' }) + }) + test('the face picker reads migrated legacy finishes', () => { const wall = { ...baseWall, diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index 84ae198449..c54242148a 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -25,6 +25,7 @@ import { type WallFaceChainFinish, type WallNode, type WallSurfaceSlotId, + wallFaceScriptPaintRefusal, wallRegionRole, wallRoomFaceRole, wallRoomFinishRole, @@ -471,6 +472,8 @@ export const wallPaint: PaintCapability = { buildPatch: (args: PaintPatchArgs) => parseWallPaintRole(args.role)?.kind === 'slot' ? base.buildPatch(args) : {}, commit: (args: PaintPatchArgs) => { + const refusal = wallFaceScriptPaintRefusal(args.node, args.role) + if (refusal) return refusal const parsed = parseWallPaintRole(args.role) if (!parsed) return // A trim is a slot like any other; faces, regions and rooms go through the plan. diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index b5c443a2e8..72fa69193e 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -16,6 +16,7 @@ import { getWallCurveLength, normalizeWallCurveOffset, resolveWallAssembly, + runAsSingleSceneHistoryStep, SIDING_LAP, STONE_VENEER_UNVERIFIED, STUCCO_3_COAT, @@ -31,6 +32,7 @@ import { type WallAssemblyFramingKind, type WallAssemblyInteriorFinish, type WallAssemblySheathingMaterial, + type WallFace, type WallNode, type WallTrimProfile, WSP_SHEATHING, @@ -40,10 +42,12 @@ import { wallAssemblyUnverifiedNote, wallTrimPatch, } from '@pascal-app/core' +import { setWallFaceScript } from '@pascal-app/core/agent-operations' import { ActionButton, ActionGroup, curveReshapeScope, + finishScriptCode, formatLinearMeasurement, getLinearUnitLabel, linearControlValueToMeters, @@ -59,6 +63,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Spline } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' import { CurtainWallPanel } from './curtain-wall-panel' import { hasWallCurveBlockingChildren } from './curve-eligibility' @@ -445,6 +450,8 @@ export default function WallPanel() { + + { + const wall = scene.nodes[wallId as AnyNodeId] + return wall?.type === 'wall' + ? [wall.finishScripts?.a?.script, wall.finishScripts?.b?.script] + : [] + }), + ) + if (!(shas[0] || shas[1])) return null + return ( + + {(['a', 'b'] as const).map((face, index) => { + const sha = shas[index] + return sha ? : null + })} + + ) +} + +function WallFaceScriptRow({ face, sha, wallId }: { face: WallFace; sha: string; wallId: string }) { + const [code, setCode] = useState<{ sha: string; text: string } | null>(null) + const [open, setOpen] = useState(false) + const toggleCode = () => { + setOpen((shown) => !shown) + if (code?.sha === sha) return + finishScriptCode(sha).then( + (text) => setCode({ sha, text: text ?? 'The script could not be read.' }), + () => setCode({ sha, text: 'The script could not be read.' }), + ) + } + const remove = () => { + const { changes } = setWallFaceScript( + useScene.getState().nodes, + { wallId, face, remove: true }, + { activeLevelId: null }, + ) + runAsSingleSceneHistoryStep(useScene, () => { + for (const { id, data } of changes?.update ?? []) + useScene.getState().updateNode(id as AnyNodeId, data) + }) + } + return ( +
+
+ Side {face.toUpperCase()} +
+ + +
+
+ {open && ( +
+          {code?.sha === sha ? code.text : 'Loading…'}
+        
+ )} +
+ ) +} + function WallTrimSection({ node, onUpdate, diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 8d3117185d..7baf57aa6c 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -7,7 +7,10 @@ import { useLiveNodeOverrides, useRegistry, useScene, + type WallFace, + type WallFaceScript, type WallNode, + wallFaceScriptPlacement, } from '@pascal-app/core' import { getWallFinishRefs, @@ -20,6 +23,7 @@ import { import { type ComponentProps, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' import { useShallow } from 'zustand/react/shallow' +import { FinishScriptModel } from '../shared/finish-script-model' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' import { getCurtainAwareWallMaterials } from './curtain-wall-materials' import { @@ -36,6 +40,34 @@ import { wallTreatmentProudOffsets, } from './treatments' +/** + * A face's finish its script built, under the wall like the wall's other face geometry: the + * build's frame at the face's base, 1 mm proud of the face, mirrored through the wall for face b. + */ +function WallFaceScriptModel({ + node, + face, + script, +}: { + node: WallNode + face: WallFace + script: WallFaceScript +}) { + const placement = useScene( + useShallow((state) => { + const at = wallFaceScriptPlacement(state.nodes, node.id, face) + return at ? [...at.position, at.scale[2]] : [] + }), + ) + if (placement.length === 0) return null + const [x, y, z, out] = placement as [number, number, number, number] + return ( + + + + ) +} + function WallTreatmentSubscription( props: Omit, 'levelData'>, ) { @@ -218,6 +250,14 @@ const WallRenderer = ({ node }: { node: WallNode }) => { /> )} + {node.wallType !== 'curtain' && + (['a', 'b'] as const).map((face) => { + const script = node.finishScripts?.[face] + return script ? ( + + ) : null + })} + {(node.children ?? []).map((childId) => ( ))} diff --git a/packages/nodes/src/wall/treatments.test.ts b/packages/nodes/src/wall/treatments.test.ts index dd598dafe9..231c5f93ab 100644 --- a/packages/nodes/src/wall/treatments.test.ts +++ b/packages/nodes/src/wall/treatments.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { calculateLevelMiters, getWallThickness, + WallFaceScript, type WallNode, type WallTrimConfig, } from '@pascal-app/core' @@ -240,4 +241,36 @@ describe('wall treatment miters', () => { expect(xsUnder(0.9).some((x) => Math.abs(x - 1) < 1e-6 || Math.abs(x - 2) < 1e-6)).toBe(false) expect(xsUnder(0).every((x) => x <= 1 + 1e-6 || x >= 2 - 1e-6)).toBe(true) }) + + test("gives way to its face's script where the build covers it, and runs whole under a splashback", () => { + const scriptOver = (bottom: number) => + WallFaceScript.parse({ + kind: 'script', + script: 'a'.repeat(64), + artifact: 'b'.repeat(64), + inputs: 'key', + manifest: { bounds: { min: [1, bottom, 0], max: [2, 1.2, 0.01] }, triangles: 2 }, + }) + const xsOn = (side: 'a' | 'b', bottom: number) => { + const node = { ...wall('A', [0, 0], [3, 0]), finishScripts: { a: scriptOver(bottom) } } + const geometry = buildTrimGeometry( + node, + side, + trim, + 'skirting', + [], + treatmentLevelData([node]), + ) + if (!geometry) throw new Error('expected trim geometry') + const xs = allPositions(geometry).map((point) => point.x) + geometry.dispose() + return xs + } + // Panelling from the floor: the skirting stops at its ends on its face, as at a door. + expect(xsOn('a', 0).every((x) => x <= 1 + 1e-6 || x >= 2 - 1e-6)).toBe(true) + expect(xsOn('a', 0).some((x) => Math.abs(x - 1) < 1e-6)).toBe(true) + // The other face keeps its skirting; a splashback from 0.9 m leaves the skirting whole. + for (const xs of [xsOn('b', 0), xsOn('a', 0.9)]) + expect(xs.some((x) => Math.abs(x - 1) < 1e-6 || Math.abs(x - 2) < 1e-6)).toBe(false) + }) }) diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index 811e84670e..92219df783 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -22,6 +22,8 @@ import { type WallTrimProfile, type WallTrimSlotId, wallDoorStepRuns, + wallFaceBaseOffset, + wallFaceScriptSkirtingGap, } from '@pascal-app/core' import { baseMaterial, @@ -540,6 +542,8 @@ export function buildTrimGeometry( const frame = getWallCurveFrameAt(node, t) return wallToLocalTransform(node)(frame.point.x, frame.point.y).x } + const face = resolveTreatmentSideSign(node, side) > 0 ? 'a' : 'b' + const faceScript = kind === 'skirting' ? node.finishScripts?.[face] : undefined const runs = bases.flatMap((base) => { const bottom = support ? base.elevation - support.elevation : yBottom const openingRanges = trimOpeningRanges(node, childrenNodes, bottom, height) @@ -549,6 +553,11 @@ export function buildTrimGeometry( if (run.bottom < bottom + height && run.top > bottom) openingRanges.push([run.start, run.end]) } + const scripted = + faceScript && support + ? wallFaceScriptSkirtingGap(faceScript, bottom + height - wallFaceBaseOffset(support, face)) + : null + if (scripted) openingRanges.push(scripted) return subtractOpeningRanges([[stationX(base.start), stationX(base.end)]], openingRanges).map( ([start, end]) => ({ start, diff --git a/packages/nodes/src/zone/renderer.tsx b/packages/nodes/src/zone/renderer.tsx index 1563ca3cb3..66a607cdfc 100644 --- a/packages/nodes/src/zone/renderer.tsx +++ b/packages/nodes/src/zone/renderer.tsx @@ -1,10 +1,7 @@ 'use client' import { - artifactUrl, - type ItemNode, polygonInteriorPoint, - scriptedSize, useLiveNodeOverrides, useRegistry, useScene, @@ -23,7 +20,7 @@ import { useEffect, useMemo, useRef } from 'react' import { Color, DoubleSide, type Group } from 'three' import { color, float, uniform, uv } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' -import { ScriptedModel } from '../item/renderer' +import { FinishScriptModel } from '../shared/finish-script-model' import { owningUnitForZone } from './unit-membership' const Y_OFFSET = 0.01 @@ -88,39 +85,15 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { /** Over the floor plate's top, so the plate's own finish never shows through the boards. */ const FLOOR_SCRIPT_LIFT = 0.001 -/** - * The room's floor finish its script built, drawn as an authored object's artifact is (its slots, - * the render shading), in the level's frame it was built in, just over the plate. It belongs to the - * room: no pointer events of its own, so a click on it reaches the floor below. - */ +/** The room's floor finish its script built, in the level's frame, just over the plate. */ function FloorScriptModel({ node, script }: { node: ZoneNode; script: ZoneFloorScript }) { - const view = useMemo(() => { - const [width, height, depth] = scriptedSize(script.manifest) - return { - id: node.id, - type: 'item', - parentId: node.parentId, - metadata: {}, - source: script, - scale: [1, 1, 1], - asset: { - src: artifactUrl(script.artifact), - dimensions: [width, height, depth], - offset: [0, 0, 0], - rotation: [0, 0, 0], - scale: [1, 1, 1], - }, - } as unknown as ItemNode - }, [node.id, node.parentId, script]) return ( - + ) } -const settleNothing = () => {} - function ZoneVisuals({ node }: { node: ZoneNode }) { // Group-transform drags publish a translated/rotated polygon to // `useLiveNodeOverrides`; merge it so the zone previews live instead of diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index fdeb047554..8e8c31eecb 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -140,7 +140,7 @@ Item slots keep their authored colours as exact materials. Opening `color` paint A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`; registered on the MCP first and not yet in `AGENT_TOOL_CONTRACTS`, since the hosted chat's agent cannot run its own program until its sandbox can emit the payload: a reviewed `gap` in the parity test) takes a conversion payload, levels with walls (a colour for both faces or `colorLeft` / `colorRight` for one: the left, looking from start to end, is the wall's front, role `a`; a `skirting` with height, depth and colour) and their openings (`type` as `DoorType` / `WindowType`, `style` as the door and window style presets, `leaves`, or `code` for a module the host compiles like an item's, keyed `/`), rooms by seed with a floor material and regions and an optional `floorCode`, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it, or on a wall when `host` names one: a wall-side item on the face nearest its point, as `place_items` writes one), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall (`doorTypeFields` / `windowTypeFields`, `doorStyleLook` / `getWindowStyleOverrides`, the handle opposite the hinge, a scripted opening's `source` and `slots` as `add_door` with code writes them), zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, each wall's skirting through `setWallSkirting` once the rooms exist (on the faces a room stands on, its colour through the same exact materials; a refusal is a `skirting_refused` defect), items through `addObject`, lights as light effects on their fixture (or a compiled 60 mm × 8 mm ceiling point hosted on its room’s ceiling when no fixture is given, or the nearest item on the level outside rooms: Pascal keeps lights inside objects) in the host's frame, paint through `paint`. A hex colour is one exact scene material per distinct colour across the payload (`resolveInlinePaintMaterial`, roughness 0.9, metalness 0, named by the hex), reused by item slots, opening panels and frames, walls, floors, regions and slabs alike; a host without scene materials gets the nearest library colour and a `defaults` sentence saying so. Ids derive from the payload's hash, so the same payload writes the same scene. -The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and its join rule corrects wall ends (an end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. The report also says what Pascal filled in or dropped (`defaults`, one sentence per fact: windows with no type written fixed, a door's handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture) and what it wrote but doubts (`warnings` with a code: `thin_slab` under 2 cm, `many_slabs` over 20 on a level, `many_regions` over 24 in a room, since regions are fixed polygons that do not follow a resize). One structural defect refuses the node: `slab_over_wall`, a slab over a wall's footprint on its level with its top above half the wall's height, which would lift the wall onto it (an acceptance run wrote 43 twelve-millimetre "wall cap" slabs at 2.662 m). Pascal builds a wall's cap, the floor plate and the ceiling itself; the skill tells the agent to fold a module's caps, underlays and planks into the wall, the floor finish and the room. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host also compiles the candidate ceiling-point modules listed by `authorSceneCeilingPoints`; after reconciliation the operation uses the ceiling belonging to the room containing each light’s plan position. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. A room's `floorCode` is its floor script (`authored-objects.md`, "Room floor scripts"): core cannot compile, so the operation lists the rooms that have one by zone (`authorSceneFloorScripts`), and the host, once the write is applied, reads each room's floor (`floorScriptInputs`), compiles the module for it through the `GeometryScriptHost` with `inputs` (the hosted server's editor tab and its server compile carry them too) and writes it with `setRoomFloorScript`, counting `floorScripts`; a failure is a `floor_script_failed` defect and the room keeps its `floor`. `get_source` reads a room's floor script by its zone id. The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items', openings' and floors' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. +The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and its join rule corrects wall ends (an end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. The report also says what Pascal filled in or dropped (`defaults`, one sentence per fact: windows with no type written fixed, a door's handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture) and what it wrote but doubts (`warnings` with a code: `thin_slab` under 2 cm, `many_slabs` over 20 on a level, `many_regions` over 24 in a room, since regions are fixed polygons that do not follow a resize). One structural defect refuses the node: `slab_over_wall`, a slab over a wall's footprint on its level with its top above half the wall's height, which would lift the wall onto it (an acceptance run wrote 43 twelve-millimetre "wall cap" slabs at 2.662 m). Pascal builds a wall's cap, the floor plate and the ceiling itself; the skill tells the agent to fold a module's caps, underlays and planks into the wall, the floor finish and the room. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host also compiles the candidate ceiling-point modules listed by `authorSceneCeilingPoints`; after reconciliation the operation uses the ceiling belonging to the room containing each light’s plan position. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. A room's `floorCode` is its floor script (`authored-objects.md`, "Room floor scripts"): core cannot compile, so the operation lists the rooms that have one by zone (`authorSceneFloorScripts`), and the host, once the write is applied, reads each room's floor (`floorScriptInputs`), compiles the module for it through the `GeometryScriptHost` with `inputs` (the hosted server's editor tab and its server compile carry them too) and writes it with `setRoomFloorScript`, counting `floorScripts`; a failure is a `floor_script_failed` defect and the room keeps its `floor`. `get_source` reads a room's floor script by its zone id, and a wall face's script by the wall's id and `face` (`authored-objects.md`, "Wall face scripts"). The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items', openings' and floors' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. What the converting agents asked of `verify_scene` is in the same PR: an item hosted by another item is read through its host (`item-space.ts`), a thing resting on its host is stacked, not colliding, a rug or a pendant does not block a door, and a door's keep-out is the full clear depth on its swing side and a walkway on the other. diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index d57c8da7da..28b02711b6 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -134,7 +134,13 @@ artifact or scene data is changed by these checks. A room's floor finish can be a script: `zone.floor.script` holds the same source record as an item's `source` (language, script and artifact hashes, params, manifest) plus `inputs`, the key of the floor it was built for. The host reads the room's floor with `floorScriptInputs(nodes, zoneId)` (core): `polygon` and `holes`, the room's outline inside the wall faces clipped to its floor plate, and `elevation`, the plate's top, all in level coordinates and metres. It compiles the module with geometry-script's `inputs` (the module reads them as `params.polygon`, `params.holes`, `params.elevation`; they are never stored as params, and the build is kept in the level's frame instead of being re-origined for its mount) and stores the artifacts; `setRoomFloorScript` writes the result, or takes it off with `remove`. Only three.js modules: a Blender build is not given the floor. -The zone renderer draws the artifact 1 mm over the plate, with no pointer events of its own, so a click reaches the floor beneath. The script owns the floor's colours: the plate gives a scripted room its own top cell, and `floorPlatePaintRefusal` (the editor's Paint) and `set_zone_intent` (`floor.finish`, `floor.regions`) refuse paint there with a sentence until it is removed. When the structure reconcile re-derives the room (a wall moved, an edge pushed, the floor raised), the key no longer matches (`staleFloorScripts`) and `refreshFloorScripts(nodes, host)` runs the stored module and params again for the new floor; the editor tab does this after each settled change, writing the rebuild outside history like the reconcile's derived writes. The room panel shows "Scripted finish" with its code and Remove. +The zone renderer draws the artifact 1 mm over the plate, with no pointer events of its own, so a click reaches the floor beneath. The script owns the floor's colours: the plate gives a scripted room its own top cell, and `floorPlatePaintRefusal` (the editor's Paint) and `set_zone_intent` (`floor.finish`, `floor.regions`) refuse paint there with a sentence until it is removed. When the structure reconcile re-derives the room (a wall moved, an edge pushed, the floor raised), the key no longer matches (`staleFinishScripts`) and `refreshFinishScripts(nodes, host)` runs the stored module and params again for the new floor; `finishScriptWrites` writes each rebuild only while the floor is still the one it was rebuilt for. The editor tab does this after each settled change (`initFinishScriptRefresh`), writing the rebuild outside history like the reconcile's derived writes. The room panel shows "Scripted finish" with its code and Remove. + +## Wall face scripts + +The twin of a floor script for one face of one wall: tiles with real joints, panelling to a dado, boards. `wall.finishScripts.a` / `.b` hold the same source record plus `inputs`, the key of the face it was built for, and `toHeight`, metres above the face's base where it stops (absent: the whole face). `wallFaceScriptInputs(nodes, wallId, face, toHeight?)` (core) reads the face in its own frame, in metres: x along the wall from its start, y up from the face's base (its lowest floor), z out of the face. The module gets `params.outline`, the face's rectangle `{ x0, y0, x1, y1 }` between its mitred ends and up to the wall's top or `toHeight`; `params.holes`, the doors and windows cutting it as rectangles clipped to the outline (a door from the floor under it, a window from its sill); `params.base`, the face's floor as `[x, y]` points from end to end (one level run, or a jump where a raised room meets it); and `params.thickness`. Curved and curtain walls have no face to build on. `setWallFaceScript` writes a compile on the face (`face`, `compiled`, `inputs`, `toHeight`) or takes it off with `remove`; three.js modules only. + +The wall renderer draws the artifact under the wall mesh, like the trims: `wallFaceScriptPlacement` puts the build's origin at the face's base, 1 mm proud of the face (`getWallLocalFaceZ`), and mirrors face b's build through the wall (scale z −1, which three.js renders with the winding flipped) so x still runs from the wall's start and z points out of face b. It has no pointer events, so clicks reach the wall. The skirting keeps running along the base; where the build comes down into the skirting's band (its bounds' bottom under the skirting's top on that face) the skirting stops over the build's span, as at a door, so the script wins visually; a splashback above it leaves it whole. A script to the top owns the face's colours: `wallFaceScriptPaintRefusal` refuses the face, its paint regions and the room's finish on it in the editor's Paint and the wall's paint commit, and `paint` refuses the face, until the script is removed; a script stopping at `toHeight` leaves the face paintable, since the paint shows above it. The other face is untouched. Stale face scripts (the wall lengthened, an opening added or moved, a floor raised) rebuild through the same `staleFinishScripts` / `refreshFinishScripts` as floors. Turning a wall around swaps its face scripts; a split copies them onto both pieces, which then rebuild for their own faces. `get_source` reads a face script with the wall's id and `face` (optional when one face has one). The wall panel shows "Scripted finish" per face with its code and Remove. ## Cutter binding From a3dbe7d1625a573ce02283819c3583b663f868d0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 11:53:54 -0400 Subject: [PATCH 22/26] core + mcp: author_scene writes wall face scripts from a wall's faceLeft/faceRight and a room's wallCode Converting agents still faked bathroom tiles, panelling and boards with items hosted on walls, because the payload had no way to say a wall face's finish. Wall face scripts are native now, so the payload carries them, wired the way room floor scripts are. - Payload: a wall's `faceLeft` / `faceRight` ({ code (at most 48k), toHeight? metres from the face's base, default the whole face }), left and right as colorLeft / colorRight define them (left = front = face a), and a room's `wallCode` (+ `wallToHeight`) for every face the room looks into, a wall's own module winning. Old payloads parse unchanged. - Operation: authorSceneFaceScripts(payload, report, nodes) lists { wallId, face, nodeId, code, toHeight? } once walls and zones exist; a room's faces are the spans of the derived room its zone was made from (the faces setWallSkirting runs along). It needs the nodes because a zone alone does not say which face of each wall it looks into. The report gains `faceScripts`; a face no room looks into (or with nothing to build on) is a `no_face` defect named /, and a face two rooms' different wallCodes claim is a `face_shared` warning (the first room builds it). - MCP: after the write and the floor scripts, each face is read (wallFaceScriptInputs with toHeight), compiled as a 'wall-face' with `inputs` and written with setWallFaceScript; a failure is `face_script_failed` naming / and the face keeps its paint. The stored payload drops the faces' and wallCode's code. - Skill, workflow reference and agent-surfaces describe it. - Tests: faces listed for a wall's own module and a room's wallCode (to 1.2 m), the wall's own winning, none for an old payload; a face no room looks into is no_face; two rooms on one face warn; through a real MCP client and the real compiler, tiles skipping the windows on one face inside its bounds, the other walls panelled to 1.2 m, read back with get_source and face; a failing face module is a defect and the face stays painted. Co-Authored-By: Claude Fable 5.1 --- .../src/agent-operations/author-scene.test.ts | 128 ++++++++++++++- .../core/src/agent-operations/author-scene.ts | 146 ++++++++++++++++++ packages/core/src/agent-tools/author-scene.ts | 34 +++- packages/mcp/src/tools/author-scene.test.ts | 114 ++++++++++++++ packages/mcp/src/tools/author-scene.ts | 47 +++++- skills/pascal-3d/SKILL.md | 2 +- skills/pascal-3d/references/tool-workflows.md | 4 +- wiki/architecture/agent-surfaces.md | 2 +- 8 files changed, 468 insertions(+), 9 deletions(-) diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 89025bd2cc..4abde2d860 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -3,7 +3,12 @@ import type { AuthorScenePayload } from '../agent-tools/author-scene' import { authorScenePayload } from '../agent-tools/author-scene' import type { AnyNode, CompiledGeometryScript, SceneMaterial } from '../schema' import { CEILING_DRAW_OFFSET, resolveCeilingHeight } from '../services' -import { authorScene, authorSceneCeilingPoints, authorSceneFloorScripts } from './index' +import { + authorScene, + authorSceneCeilingPoints, + authorSceneFaceScripts, + authorSceneFloorScripts, +} from './index' /** A site, a building and the empty level a new project starts with. */ function scaffold(): Record { @@ -102,6 +107,11 @@ const run = ( const nodesOf = (outcome: ReturnType) => outcome.changes!.create!.map((c) => c.node) +/** The scene once the outcome is applied, as the host reads it. */ +const sceneOf = (outcome: ReturnType) => ({ + ...scaffold(), + ...Object.fromEntries(nodesOf(outcome).map((n) => [n.id, n])), +}) const byAuthorId = (outcome: ReturnType, id: string) => nodesOf(outcome).find((n) => n.id === outcome.result.ids[id]) as AnyNode & Record @@ -988,4 +998,120 @@ describe('author_scene', () => { expect(byAuthorId(outcome, 'room/0/0').floor.finish).toBe('library:wood-floorplank1') expect(authorSceneFloorScripts(bedroom, outcome.result)).toEqual([]) }) + + test("wall face modules are listed by face; a room's wallCode covers the faces it looks into, a wall's own module over it", () => { + const tiles = 'export default function build({ THREE }) { return new THREE.Group() }' + const panels = 'export default function build({ THREE }) { return new THREE.Mesh() }' + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + walls: bedroom.levels[0]!.walls!.map((w) => + w.id === 'east' ? { ...w, faceLeft: { code: tiles } } : w, + ), + rooms: [{ ...bedroom.levels[0]!.rooms[0]!, wallCode: panels, wallToHeight: '120cm' }], + }, + ], + }) + const outcome = run(payload, {}) + expect(outcome.result.defects).toEqual([]) + expect(outcome.result.faceScripts).toBe(0) + const ids = outcome.result.ids + // The room stands on each wall's left face (face a), the walls running round it. + expect(authorSceneFaceScripts(payload, outcome.result, sceneOf(outcome))).toEqual([ + { wallId: 'east', face: 'a', nodeId: ids.east!, code: tiles }, + { wallId: 'north', face: 'a', nodeId: ids.north!, code: panels, toHeight: 1.2 }, + { wallId: 'south', face: 'a', nodeId: ids.south!, code: panels, toHeight: 1.2 }, + { wallId: 'west', face: 'a', nodeId: ids.west!, code: panels, toHeight: 1.2 }, + ]) + // The faces keep their paint until the host writes the scripts; old payloads list none. + expect(byAuthorId(outcome, 'north').slots.a).toEqual(expect.any(String)) + expect(byAuthorId(outcome, 'east').finishScripts).toBeUndefined() + const plain = run(bedroom, {}) + expect(authorSceneFaceScripts(bedroom, plain.result, sceneOf(plain))).toEqual([]) + }) + + test('a face module on a face no room looks into is a no_face defect naming the wall and face', () => { + const tiles = 'export default function build({ THREE }) { return new THREE.Group() }' + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + walls: [ + ...bedroom.levels[0]!.walls!.map((w) => + w.id === 'north' ? { ...w, faceRight: { code: tiles } } : w, + ), + { + id: 'garden', + start: [4, 4], + end: [7, 4], + height: 1.2, + thickness: 0.2, + faceLeft: { code: tiles }, + }, + ], + }, + ], + }) + const outcome = run(payload, {}) + expect(outcome.result.defects).toEqual([ + { + id: 'north/b', + code: 'no_face', + message: + 'No room looks into the right face of wall north (left and right as seen from its start to its end): its face script was not written.', + }, + { + id: 'garden/a', + code: 'no_face', + message: + 'No room looks into the left face of wall garden (left and right as seen from its start to its end): its face script was not written.', + }, + ]) + expect(authorSceneFaceScripts(payload, outcome.result, sceneOf(outcome))).toEqual([]) + }) + + test("two rooms on one face: the first room's wallCode builds it and the other is warned", () => { + const [west, east] = ['export default () => 1', 'export default () => 2'] + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [], + lights: [], + walls: [ + ...bedroom.levels[0]!.walls!, + { id: 'middle', start: [0, -2.5], end: [0, 2.5], height: 2.7, thickness: 0.1 }, + ], + rooms: [ + { name: 'Study', seed: [-1, 0], wallCode: west }, + { name: 'Nursery', seed: [1, 0], wallCode: east }, + ], + }, + ], + }) + const outcome = run(payload, {}) + expect(outcome.result.defects).toEqual([]) + // The north and south walls run past the partition: one face each, seen from both rooms. + expect(outcome.result.warnings.map((w) => [w.id, w.code])).toEqual([ + ['north/a', 'face_shared'], + ['south/a', 'face_shared'], + ]) + const listed = authorSceneFaceScripts(payload, outcome.result, sceneOf(outcome)) + expect(listed.map((f) => [f.wallId, f.face, f.code])).toEqual([ + ['north', 'a', west], + ['south', 'a', west], + ['west', 'a', west], + ['middle', 'a', west], + ['east', 'a', east], + ['middle', 'b', east], + ]) + }) }) diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 760cdd7ba7..40372d69bd 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -10,6 +10,7 @@ import { type WindowStyle, } from '../building/opening-style-presets' import { windowTakesStyle, windowTypeFields } from '../building/window-types' +import { floorRoomFaces } from '../lib/floor-room-faces' import { matchScriptSlotsToLibrary, requireMeasured, @@ -19,6 +20,7 @@ import { import type { BoundaryNode } from '../lib/room-topology-index' import { detectSpacesForLevel } from '../lib/space-detection' import { reconcileSceneStructure } from '../lib/structure-reconcile' +import { wallFaceScriptInputs } from '../lib/wall-face-script' import { nearestLibraryColorRef } from '../procedural-items/library-colors' import { type AnyNode, @@ -30,6 +32,7 @@ import { type SceneMaterial, type SceneMaterialId, SlabNode, + type WallFace, WallNode, WindowNode, ZoneNode, @@ -38,6 +41,7 @@ import { DoorType, WindowType } from '../schema/nodes/opening-types' import { resolveCeilingHeight } from '../services/level-height' import { CEILING_DRAW_OFFSET } from '../services/storey' import { getWallLocalFaceZ } from '../systems/wall/wall-frame' +import { roomFaceCover } from '../systems/wall/wall-room-sides' import { migrateRoomZones } from '../utils/room-zone-migration' import { addObject } from './add-object' import { paint } from './paint' @@ -84,6 +88,8 @@ export type AuthorSceneReport = { lights: number /** Rooms whose floor finish is their module, counted by the host that compiles them after the write. */ floorScripts: number + /** Wall faces whose finish is a module, counted by the host that compiles them after the write. */ + faceScripts: number /** The join rule applied: wall ends moved onto a centerline, with how far. */ snapped: { id: string; end: 'start' | 'end'; movedM: number }[] /** What Pascal filled in or dropped, one sentence per fact. */ @@ -287,6 +293,140 @@ export function authorSceneFloorScripts( ) } +/** A wall face whose finish is a module, for the host to compile once the write is applied. */ +export type AuthorSceneFaceScript = { + /** The payload's wall id. */ + wallId: string + face: WallFace + nodeId: string + code: string + toHeight?: number +} + +/** + * Which faces the payload's face modules build on, and why the others cannot be: a wall's own + * `faceLeft` (face a) and `faceRight` (face b), then each room's `wallCode` on the faces its floor + * meets (the spans of the room the zone was derived from, the faces `setWallSkirting` runs along), + * where a wall's own module wins. A face with nothing to build on (a curved or curtain wall) or + * that no room looks into is a `no_face` defect. + */ +function resolveFaceScripts( + payload: AuthorScenePayload, + ids: AuthorSceneReport['ids'], + nodes: SceneNodes, +): { + scripts: AuthorSceneFaceScript[] + defects: AuthorSceneDefect[] + warnings: AuthorSceneDefect[] +} { + const scripts: AuthorSceneFaceScript[] = [] + const defects: AuthorSceneDefect[] = [] + const warnings: AuthorSceneDefect[] = [] + const side = (face: WallFace) => (face === 'a' ? 'left' : 'right') + const add = (wallId: string, face: WallFace, nodeId: string, code: string, toHeight?: number) => + scripts.push({ wallId, face, nodeId, code, ...(toHeight === undefined ? {} : { toHeight }) }) + const flat = 'is curved, a curtain wall or has no height' + payload.levels.forEach((level, index) => { + const walls = (level.walls ?? []).flatMap((spec) => { + const nodeId = ids[spec.id] + return nodeId && nodes[nodeId]?.type === 'wall' ? [{ spec, nodeId }] : [] + }) + const owned = new Set() + for (const { spec, nodeId } of walls) { + for (const [face, module] of [ + ['a', spec.faceLeft], + ['b', spec.faceRight], + ] as const) { + if (!module) continue + owned.add(`${nodeId}/${face}`) + const id = `${spec.id}/${face}` + if (!wallFaceScriptInputs(nodes, nodeId, face, module.toHeight)) + defects.push({ + id, + code: 'no_face', + message: `Wall ${spec.id} ${flat}: its ${side(face)} face has nothing a script can build on and keeps its paint.`, + }) + else if (roomFaceCover(nodes, nodeId)[face] === 'none') + defects.push({ + id, + code: 'no_face', + message: `No room looks into the ${side(face)} face of wall ${spec.id} (left and right as seen from its start to its end): its face script was not written.`, + }) + else add(spec.id, face, nodeId, module.code, module.toHeight) + } + } + const byRoom = new Map() + for (const [i, room] of level.rooms.entries()) { + const roomId = `room/${index}/${i}` + const zone = nodes[ids[roomId] ?? ''] + if (!room.wallCode || zone?.type !== 'zone') continue + const boundaries = Object.values(nodes).filter( + (n): n is BoundaryNode => + n.parentId === zone.parentId && (n.type === 'wall' || n.type === 'separator'), + ) + const zoneWalls = [...zone.boundaryWallIds].sort().join() + const derived = floorRoomFaces(boundaries).find( + (r) => + [...new Set(r.spans.filter((s) => s.kind === 'wall').map((s) => s.boundaryId))] + .sort() + .join() === zoneWalls && pointInPolygon(room.seed as Vec2, r.referencePolygon), + ) + const faces = new Set( + (derived?.spans ?? []) + .filter((s) => s.kind === 'wall' && s.t1 > s.t0) + .map((s) => `${s.boundaryId}/${s.face}`), + ) + if (!faces.size) { + defects.push({ + id: roomId, + code: 'no_face', + message: `No wall face looks into "${room.name}": its wallCode was not written.`, + }) + continue + } + for (const { spec, nodeId } of walls) { + for (const face of ['a', 'b'] as const) { + const key = `${nodeId}/${face}` + if (!faces.has(key) || owned.has(key)) continue + const other = byRoom.get(key) + if (other) { + if (other.code !== room.wallCode || other.toHeight !== room.wallToHeight) + warnings.push({ + id: `${spec.id}/${face}`, + code: 'face_shared', + message: `The ${side(face)} face of wall ${spec.id} looks into "${other.name}" and "${room.name}": it is built by "${other.name}"'s wallCode.`, + }) + continue + } + byRoom.set(key, { name: room.name, code: room.wallCode, toHeight: room.wallToHeight }) + if (!wallFaceScriptInputs(nodes, nodeId, face, room.wallToHeight)) + defects.push({ + id: `${spec.id}/${face}`, + code: 'no_face', + message: `Wall ${spec.id} ${flat}: "${room.name}"'s wallCode was not written on its ${side(face)} face, which keeps its paint.`, + }) + else add(spec.id, face, nodeId, room.wallCode, room.wallToHeight) + } + } + } + }) + return { scripts, defects, warnings } +} + +/** + * The wall faces whose finish is a module, once the write made the walls and the rooms: the host + * reads each face (`wallFaceScriptInputs`), compiles the module for it and writes it with + * `setWallFaceScript`, which core cannot do without a three.js runtime. The faces that cannot take + * one are already `no_face` defects in the operation's report. + */ +export function authorSceneFaceScripts( + payload: AuthorScenePayload, + report: Pick, + nodes: SceneNodes, +): AuthorSceneFaceScript[] { + return resolveFaceScripts(payload, report.ids, nodes).scripts +} + export function authorScene( scene: SceneNodes, input: AuthorSceneInput, @@ -322,6 +462,7 @@ export function authorScene( items: 0, lights: 0, floorScripts: 0, + faceScripts: 0, snapped: [], defaults: [], warnings: [], @@ -951,6 +1092,11 @@ export function authorScene( } } + // ---------- face modules: which faces they build on is known now; the host compiles them ---------- + const faces = resolveFaceScripts(payload, report.ids, written) + report.defects.push(...faces.defects) + report.warnings.push(...faces.warnings) + // Ceilings exist only after structure reconciliation; lights can now use their room's host. payload.levels.forEach((level, index) => { const levelNode = levelNodes[index]! diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index 157be575f2..3bd2f7fbba 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -6,7 +6,7 @@ import { NodeId } from './node-id' const DESCRIPTION = `Turn a whole design you wrote as free three.js into an editable Pascal scene, in one call. You read your own module however you like (its source, running it and reading the objects, both) and hand over a conversion payload: levels, walls with their openings, rooms, slabs, columns, railings, every piece of furniture as an authored object with its own module, and lights. Pascal does the rest server-side: compiles and stores each item, places it, derives the rooms and their floors, paints, runs its checks, saves, and answers with a gate report you iterate against. Nothing you hand over is silently dropped: what cannot be placed comes back by name with a reason, and what Pascal corrected (a wall end snapped onto the centerline it meets) is listed. -Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre; give each opening its type (how it opens) and style (its look), or its own module for a shape they cannot express. An item's position is where its bottom centre stands; its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is). A hex colour is written as an exact material, one per distinct colour. Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and floor planks into the wall, the room's floor finish and the room (say so in notes), never into slabs or regions: baseboards are the wall's skirting, a plank or tile floor is the room's floorCode; a slab is a terrace, a platform, a balcony. Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. +Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre; give each opening its type (how it opens) and style (its look), or its own module for a shape they cannot express. An item's position is where its bottom centre stands; its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is). A hex colour is written as an exact material, one per distinct colour. Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and floor planks into the wall, the room's floor finish and the room (say so in notes), never into slabs or regions: baseboards are the wall's skirting, a plank or tile floor is the room's floorCode, tiles, panelling or boards on a wall are its faceLeft / faceRight (or the room's wallCode); a slab is a terrace, a platform, a balcony. Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. The report says what was written, what Pascal defaulted (defaults: a window with no type is fixed and cannot open), what it warns about but wrote (warnings: thin slabs, too many slabs or regions) and what it refused (defects, by your ids). @@ -82,6 +82,19 @@ export const authorSceneOpening = z.object({ .describe("The side the door opens towards: the wall's front or back face."), }) +const WALL_FACE_MODULE = + "a three.js module that builds this face's finish (tiles with joints, panelling, boards) for exactly the face: it receives params.outline, params.holes (its doors and windows), params.base and params.thickness in wall-local metres (x along the wall from its start, y up from the face's base, z out of the face) and returns the finish clipped to the outline; Pascal rebuilds it when the wall or its openings change" + +const faceToHeight = (whose: string) => + length(`Where ${whose} stops, metres up from the face's base (default: the full face).`, { + positive: true, + }).optional() + +const authorSceneWallFace = z.object({ + code: z.string().min(1).max(48_000).describe(`The face's finish: ${WALL_FACE_MODULE}.`), + toHeight: faceToHeight('the finish'), +}) + export const authorSceneWall = z.object({ id: z.string().min(1).max(64).describe('Your own id, for the report.'), start: plan('start'), @@ -128,6 +141,16 @@ export const authorSceneWall = z.object({ .describe( "The wall's skirting (baseboard), native on the wall: it runs along the faces a room stands on, steps with the floor and breaks at doors. Give it here, never as an item.", ), + faceLeft: authorSceneWallFace + .optional() + .describe( + `The face on the left looking from start to end (the front, colorLeft's face), built by ${WALL_FACE_MODULE}; colorLeft stays the paint under it. Over the room's wallCode.`, + ), + faceRight: authorSceneWallFace + .optional() + .describe( + `The face on the right looking from start to end (colorRight's face), built by ${WALL_FACE_MODULE}; colorRight stays the paint under it. Over the room's wallCode.`, + ), }) export const authorSceneRoom = z.object({ @@ -159,6 +182,15 @@ export const authorSceneRoom = z.object({ .describe( "A three.js module that builds the floor finish for the room's polygon: it receives params.polygon, params.holes and params.elevation (level coordinates, metres) and returns the planks, tiles or boards clipped to that polygon. Pascal rebuilds it when the room changes; floor stays the fallback finish.", ), + wallCode: z + .string() + .min(1) + .max(48_000) + .optional() + .describe( + `The finish of every wall face that looks into this room (the faces its floor meets, where the skirting runs), each built by ${WALL_FACE_MODULE}. A wall's own faceLeft / faceRight wins over it.`, + ), + wallToHeight: faceToHeight("the room's wallCode"), }) export const authorSceneSlab = z.object({ diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts index e0000b3854..623bf2874d 100644 --- a/packages/mcp/src/tools/author-scene.test.ts +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -145,6 +145,32 @@ const BOARDS = `export default function build({ THREE, params }) { return g }` +/** Half-metre tiles over a wall face, none over its doors and windows. */ +const TILES = `export default function build({ THREE, params }) { + const { outline, holes } = params + const g = new THREE.Group() + const m = new THREE.MeshStandardMaterial({ color: '#ffffff' }); m.name = 'slot_tile' + const size = 0.5 + for (let x = outline.x0; x + size <= outline.x1 + 1e-6; x += size) + for (let y = outline.y0; y + size <= outline.y1 + 1e-6; y += size) { + if (holes.some((h) => x < h.x1 && x + size > h.x0 && y < h.y1 && y + size > h.y0)) continue + const t = new THREE.Mesh(new THREE.BoxGeometry(size - 0.004, size - 0.004, 0.008), m) + t.position.set(x + size / 2, y + size / 2, 0.004) + g.add(t) + } + return g +}` + +/** One panel over the whole outline of a wall face. */ +const PANEL = `export default function build({ THREE, params }) { + const { x0, y0, x1, y1 } = params.outline + const m = new THREE.MeshStandardMaterial({ color: '#c8b89a' }); m.name = 'slot_panel' + const b = new THREE.Mesh(new THREE.BoxGeometry(x1 - x0, y1 - y0, 0.01), m) + b.position.set((x0 + x1) / 2, (y0 + y1) / 2, 0.005) + const g = new THREE.Group(); g.add(b) + return g +}` + describe('author_scene (MCP)', () => { let client: Client const stored: string[] = [] @@ -392,4 +418,92 @@ describe('author_scene (MCP)', () => { expect(room.floor.finish).toMatch(/^scene:/) expect(room.floor.script).toBeUndefined() }) + + test("writes a wall's face module and a room's wallCode on its faces, readable with get_source", async () => { + const level = payload.levels[0]! + const design = { + ...payload, + levels: [ + { + ...level, + items: [], + lights: [], + walls: level.walls.map((w) => (w.id === 'e' ? { ...w, faceLeft: { code: TILES } } : w)), + rooms: [{ ...level.rooms[0]!, wallCode: PANEL, wallToHeight: 1.2 }], + }, + ], + } + const response = await client.callTool({ name: 'author_scene', arguments: { payload: design } }) + const report = response.structuredContent as { + faceScripts: number + defects: unknown[] + ids: Record + } + expect(report.defects).toEqual([]) + expect(report.faceScripts).toBe(4) + const read = async (id: string) => { + const got = await client.callTool({ name: 'get_node', arguments: { id } }) + return JSON.parse((got.content as { text: string }[])[0]!.text).node as Record + } + // The tiles stay on the east wall's room face, between its mitred ends and under its top. + const east = await read(report.ids.e!) + const tiles = east.finishScripts.a + expect(east.finishScripts.b).toBeUndefined() + expect(tiles.language).toBe('three') + expect(tiles.toHeight).toBeUndefined() + expect(tiles.manifest.slots.map((s: { id: string }) => s.id)).toEqual(['tile']) + const { min, max } = tiles.manifest.bounds + expect(min[0]).toBeGreaterThanOrEqual(0) + expect(max[0]).toBeLessThanOrEqual(5) + expect(min[1]).toBeGreaterThanOrEqual(0) + expect(max[1]).toBeLessThanOrEqual(2.7) + expect(max[2] - min[2]).toBeCloseTo(0.008, 3) + // The room's other walls are panelled to 1.2 m on the face it looks into. + for (const id of ['n', 's', 'w']) { + const wall = await read(report.ids[id]!) + expect(wall.finishScripts.a.toHeight).toBe(1.2) + expect(wall.finishScripts.a.manifest.bounds.max[1]).toBeCloseTo(1.2, 3) + expect(wall.finishScripts.b).toBeUndefined() + } + const source = await client.callTool({ + name: 'get_source', + arguments: { nodeId: report.ids.e!, face: 'a' }, + }) + expect(source.structuredContent).toMatchObject({ type: 'wall', language: 'three', code: TILES }) + }) + + test('a face module that fails is a defect and the face keeps its paint', async () => { + const level = payload.levels[0]! + const design = { + ...payload, + levels: [ + { + ...level, + items: [], + lights: [], + walls: level.walls.map((w) => + w.id === 'n' ? { ...w, faceLeft: { code: 'import x from "nowhere"' } } : w, + ), + }, + ], + } + const response = await client.callTool({ name: 'author_scene', arguments: { payload: design } }) + const report = response.structuredContent as { + faceScripts: number + defects: { id: string; code: string; message: string }[] + ids: Record + } + expect(report.faceScripts).toBe(0) + expect(report.defects).toEqual([ + { + id: 'n/a', + code: 'face_script_failed', + message: expect.stringContaining('Unsupported import'), + }, + ]) + const got = await client.callTool({ name: 'get_node', arguments: { id: report.ids.n! } }) + const north = JSON.parse((got.content as { text: string }[])[0]!.text).node + expect(north.slots.a).toMatch(/^scene:/) + expect(north.finishScripts).toBeUndefined() + }) }) diff --git a/packages/mcp/src/tools/author-scene.ts b/packages/mcp/src/tools/author-scene.ts index e9fdf27b35..2ad25eddcd 100644 --- a/packages/mcp/src/tools/author-scene.ts +++ b/packages/mcp/src/tools/author-scene.ts @@ -1,14 +1,16 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { floorScriptInputs } from '@pascal-app/core' +import { floorScriptInputs, wallFaceScriptInputs } from '@pascal-app/core' import { type AgentContext, type AuthorSceneReport, achievedChanges, authorScene, authorSceneCeilingPoints, + authorSceneFaceScripts, authorSceneFloorScripts, type SceneNodes, setRoomFloorScript, + setWallFaceScript, verifyScene, } from '@pascal-app/core/agent-operations' import { @@ -168,6 +170,43 @@ export function registerAuthorScene( }) } } + // A face module needs its face, its openings cut and its rooms derived: compiled for it and + // written on the wall; a failure leaves the face its paint. + for (const { wallId, face, nodeId, code, toHeight } of authorSceneFaceScripts( + args.payload, + outcome.result, + bridge.getNodes() as SceneNodes, + )) { + try { + const inputs = wallFaceScriptInputs( + bridge.getNodes() as SceneNodes, + nodeId, + face, + toHeight, + ) + if (!inputs) throw new Error('The face has nothing to build on.') + const built = await compileAndStore( + host, + scene.id, + { code, language: 'three', params: undefined, inputs }, + 'wall-face', + { nodeId, metadata: { name: 'Wall finish' } }, + ) + const written = setWallFaceScript( + bridge.getNodes() as SceneNodes, + { wallId: nodeId, face, compiled: built, inputs, toHeight }, + context, + ) + if (written.changes) bridge.applyPatch(toPatches(written.changes)) + outcome.result.faceScripts++ + } catch (error) { + outcome.result.defects.push({ + id: `${wallId}/${face}`, + code: 'face_script_failed', + message: `${error instanceof Error ? error.message : String(error)} (the face keeps its paint)`, + }) + } + } const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building') if (building) { bridge.updateNode(building.id, { @@ -213,12 +252,14 @@ function withoutCode(payload: AuthorScenePayload) { ...payload, levels: payload.levels.map((level) => ({ ...level, - walls: (level.walls ?? []).map((wall) => ({ + walls: (level.walls ?? []).map(({ faceLeft, faceRight, ...wall }) => ({ ...wall, openings: (wall.openings ?? []).map(({ code: _, ...opening }) => opening), + ...(faceLeft ? { faceLeft: { toHeight: faceLeft.toHeight } } : {}), + ...(faceRight ? { faceRight: { toHeight: faceRight.toHeight } } : {}), })), items: (level.items ?? []).map(({ code: _, ...item }) => item), - rooms: level.rooms.map(({ floorCode: _, ...room }) => room), + rooms: level.rooms.map(({ floorCode: _, wallCode: _w, ...room }) => room), })), } } diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 8c8c92bac2..10b9ab9589 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -73,7 +73,7 @@ Stair `targets` are design preferences in meters (`maxRiserHeight`, `minimumGoin When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, or with `language: 'bpy'` a Blender module when the shape needs Blender's modelling (bevels, smooth organic forms, booleans, curves), with the naming conventions its description lists for paint slots, parts, lights, cutouts and clips. Edit it by passing its `nodeId`. A new object needs a `reason` (why no Pascal tool or catalog item builds it); `verify_scene` lists every authored object with its reason. On a floor, a plain box with a wall's size or a floor plate is refused with the tool to use (`use_walls`, `use_slab`); a detailed object with a wall's size (a bookcase, a screen) is built with a hint naming `add_wall`. Servers without a script host answer `scripts_unavailable`. The hosted server builds three.js scripts in the user's open Pascal editor tab (Blender modules run in its Blender service and need no tab); when no tab has the project open it answers `editor_tab_required` with an `editorUrl`: give the user that link, wait until they say it is open, then repeat the same call. `editor_tab_busy` means a tab is open but busy or still loading: wait a few seconds and repeat the same call; the user need not be asked. Whether you may open the link yourself is the person's setting ("Agents may open the editor for me"; Ask first: ask the user to open the link (or bring the tab forward), or ask their permission before opening it yourself; Allow: you may open the link yourself, in a tab in front; Never: ask the user, and do not open or switch windows yourself); the refusal's words say which, so follow them. It applies to `editor_tab_required` and `editor_tab_hidden`; `editor_tab_busy` carries no link, and an autonomous agent account, which has no editor login, is refused with `editor_host_identity_required` whatever the setting says. To count or locate things by what they are, including typed parts inside authored objects, use `find_by_type`. To group elements of any kind into a named set the editor lists (all the lights, the windows, the kitchen cabinetry), use `edit_collection`, starting from a template when one fits; `list_collections` reads them back. -When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their colour (`color`, or `colorLeft` / `colorRight` per face; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open), rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module (`host` names the item it rests on, or the wall a casing or mirror is fixed to), and lights. A wall's baseboard is its `skirting` (`height`, `depth`, `color`), native on the wall along the faces its rooms stand on, never an item. A plank, tile or board floor is the room's `floorCode`, a module that receives `params.polygon`, `params.holes` and `params.elevation` and builds the finish for exactly that floor; Pascal rebuilds it when the room changes, keeps `floor` as the fallback, and `get_source` on the room reads it back. A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. Pascal compiles and stores the modules, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it filled in (`defaults`: a window written fixed, a handle set opposite its hinge, a ceiling point for a light without a fixture), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions), what it could not place and why (`defects`: a floor module that failed is `floor_script_failed`, the room keeping its `floor`), what it corrected (a wall end snapped onto the centerline it meets). Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and individual floor planks into the native wall, the floor finish and the room, and say so in `notes`; a slab over a wall's top is refused. Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. +When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their colour (`color`, or `colorLeft` / `colorRight` per face; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open), rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module (`host` names the item it rests on, or the wall a casing or mirror is fixed to), and lights. A wall's baseboard is its `skirting` (`height`, `depth`, `color`), native on the wall along the faces its rooms stand on, never an item. A plank, tile or board floor is the room's `floorCode`, a module that receives `params.polygon`, `params.holes` and `params.elevation` and builds the finish for exactly that floor; Pascal rebuilds it when the room changes, keeps `floor` as the fallback, and `get_source` on the room reads it back. Tiles with joints, panelling to a dado or boards on a wall are the wall's `faceLeft` / `faceRight` (`code`, `toHeight` from the face's base, default the whole face), a module that receives `params.outline`, `params.holes` (its doors and windows), `params.base` and `params.thickness` in wall-local metres and builds the finish for exactly that face, or the room's `wallCode` (`wallToHeight`) for every face the room looks into; Pascal rebuilds it when the wall or its openings change, `colorLeft` / `colorRight` stay the paint under it, and `get_source` with the wall's id and `face` reads it back. A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. Pascal compiles and stores the modules, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it filled in (`defaults`: a window written fixed, a handle set opposite its hinge, a ceiling point for a light without a fixture), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions), what it could not place and why (`defects`: a floor module that failed is `floor_script_failed`, the room keeping its `floor`; a face module that failed is `face_script_failed`, a face no room looks into `no_face`), what it corrected (a wall end snapped onto the centerline it meets). Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and individual floor planks into the native wall, the floor finish and the room, and say so in `notes`; a slab over a wall's top is refused. Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. Pascal uses meters. X and Z are floor-plan axes; Y is vertical. Tool fields that accept measurements may also accept strings such as `"6 ft"` or `"180cm"`, but report final spatial values in meters and retain the user's original units when useful. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 197419f763..37ec4b65e3 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -56,8 +56,8 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra `author_scene` turns a whole design you wrote as one three.js module into native, editable Pascal nodes in one call. The reading is yours; the writing is Pascal's. 1. Read your module: its source for intent (helper names, declared opening types), the executed graph for truth (world transforms, deleted objects gone). Most designs make walls with a helper such as `wall(x1, z1, x2, z2, height, thickness, openings)`: those arguments are the payload's walls. -2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour; `skirting` with `height` (default 0.1 m), `depth` (default 0.012 m) and `color` for the baseboard, native on the wall along the faces its rooms stand on), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`; `floorCode` for planks, tiles or boards, a module that receives `params.polygon`, `params.holes` and `params.elevation`, level coordinates in metres, and builds the finish clipped to that floor: Pascal rebuilds it when the room changes, `floor` stays the fallback, `get_source` with the room's id reads it back), `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a mirror on a wall), `lights` (`position`, `fixture`). A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. -3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `slab_over_wall`, `use_slab`, `skirting_refused` for a wall with no room on either face, `floor_script_failed` for a floor module that did not build…), `floorScripts` (rooms whose floor module was built), `warnings` (written, but doubtful: `thin_slab`, `many_slabs`, `many_regions`), `defaults` (what Pascal filled in or dropped: a window with no `type` written fixed, a handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. +2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour; `skirting` with `height` (default 0.1 m), `depth` (default 0.012 m) and `color` for the baseboard, native on the wall along the faces its rooms stand on), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`; `floorCode` for planks, tiles or boards, a module that receives `params.polygon`, `params.holes` and `params.elevation`, level coordinates in metres, and builds the finish clipped to that floor: Pascal rebuilds it when the room changes, `floor` stays the fallback, `get_source` with the room's id reads it back; `wallCode`, with `wallToHeight`, for the finish of every wall face the room looks into), each wall's `faceLeft` / `faceRight` (`code`, `toHeight` from the face's base, default the whole face) for tiles, panelling or boards on that face, a module that receives `params.outline`, `params.holes`, `params.base` and `params.thickness` in wall-local metres (x along the wall from its start, y up from the face's base, z out of the face) and builds the finish clipped to the outline, over the room's `wallCode`, `colorLeft` / `colorRight` staying the paint under it, `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a mirror on a wall), `lights` (`position`, `fixture`). A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. +3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `slab_over_wall`, `use_slab`, `skirting_refused` for a wall with no room on either face, `floor_script_failed` for a floor module that did not build, `face_script_failed` (`/`) for a face module that did not build, `no_face` for a face no room looks into…), `floorScripts` (rooms whose floor module was built), `faceScripts` (wall faces whose module was built), `warnings` (written, but doubtful: `thin_slab`, `many_slabs`, `many_regions`, `face_shared` for a face two rooms' `wallCode` claim), `defaults` (what Pascal filled in or dropped: a window with no `type` written fixed, a handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. 4. Fix the program, not the payload by hand, and call again with `replace: true`. Stop when `green` is true and the scene looks like the design. Report what you could not convert in `notes`. ## Wrap a window around a corner diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 8e8c31eecb..b07e29db82 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -140,7 +140,7 @@ Item slots keep their authored colours as exact materials. Opening `color` paint A frontier model builds a richer room or house as free three.js than through the scene tools one call at a time, and the test that settled it (four fresh agents converting the same flat from a contract and a gate report alone, none of them using the reading helpers offered) also settled the division of work: **the agent reads its own module, Pascal writes.** `author_scene` (`core/agent-tools/author-scene.ts`, operation in `core/agent-operations/author-scene.ts`; registered on the MCP first and not yet in `AGENT_TOOL_CONTRACTS`, since the hosted chat's agent cannot run its own program until its sandbox can emit the payload: a reviewed `gap` in the parity test) takes a conversion payload, levels with walls (a colour for both faces or `colorLeft` / `colorRight` for one: the left, looking from start to end, is the wall's front, role `a`; a `skirting` with height, depth and colour) and their openings (`type` as `DoorType` / `WindowType`, `style` as the door and window style presets, `leaves`, or `code` for a module the host compiles like an item's, keyed `/`), rooms by seed with a floor material and regions and an optional `floorCode`, slabs, columns, railings, items as authored-object modules placed at their bottom centre (hosted on another item when they rest on it, or on a wall when `host` names one: a wall-side item on the face nearest its point, as `place_items` writes one), lights, and writes native nodes: `WallNode`s, `DoorNode`/`WindowNode`s on their wall (`doorTypeFields` / `windowTypeFields`, `doorStyleLook` / `getWindowStyleOverrides`, the handle opposite the hinge, a scripted opening's `source` and `slots` as `add_door` with code writes them), zones through Pascal's own room derivation (`migrateRoomZones`) with the payload's names and finishes, floors and ceilings through `reconcileSceneStructure`, each wall's skirting through `setWallSkirting` once the rooms exist (on the faces a room stands on, its colour through the same exact materials; a refusal is a `skirting_refused` defect), items through `addObject`, lights as light effects on their fixture (or a compiled 60 mm × 8 mm ceiling point hosted on its room’s ceiling when no fixture is given, or the nearest item on the level outside rooms: Pascal keeps lights inside objects) in the host's frame, paint through `paint`. A hex colour is one exact scene material per distinct colour across the payload (`resolveInlinePaintMaterial`, roughness 0.9, metalness 0, named by the hex), reused by item slots, opening panels and frames, walls, floors, regions and slabs alike; a host without scene materials gets the nearest library colour and a `defaults` sentence saying so. Ids derive from the payload's hash, so the same payload writes the same scene. -The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and its join rule corrects wall ends (an end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. The report also says what Pascal filled in or dropped (`defaults`, one sentence per fact: windows with no type written fixed, a door's handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture) and what it wrote but doubts (`warnings` with a code: `thin_slab` under 2 cm, `many_slabs` over 20 on a level, `many_regions` over 24 in a room, since regions are fixed polygons that do not follow a resize). One structural defect refuses the node: `slab_over_wall`, a slab over a wall's footprint on its level with its top above half the wall's height, which would lift the wall onto it (an acceptance run wrote 43 twelve-millimetre "wall cap" slabs at 2.662 m). Pascal builds a wall's cap, the floor plate and the ceiling itself; the skill tells the agent to fold a module's caps, underlays and planks into the wall, the floor finish and the room. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host also compiles the candidate ceiling-point modules listed by `authorSceneCeilingPoints`; after reconciliation the operation uses the ceiling belonging to the room containing each light’s plan position. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. A room's `floorCode` is its floor script (`authored-objects.md`, "Room floor scripts"): core cannot compile, so the operation lists the rooms that have one by zone (`authorSceneFloorScripts`), and the host, once the write is applied, reads each room's floor (`floorScriptInputs`), compiles the module for it through the `GeometryScriptHost` with `inputs` (the hosted server's editor tab and its server compile carry them too) and writes it with `setRoomFloorScript`, counting `floorScripts`; a failure is a `floor_script_failed` defect and the room keeps its `floor`. `get_source` reads a room's floor script by its zone id, and a wall face's script by the wall's id and `face` (`authored-objects.md`, "Wall face scripts"). The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items', openings' and floors' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. +The policy is accept and report: everything valid is written, every defect comes back by the payload's own id with a code, and its join rule corrects wall ends (an end within 0.3 m of another wall's centerline snaps onto the line, extending the short end rather than sliding the wall it meets), listed per wall. The report also says what Pascal filled in or dropped (`defaults`, one sentence per fact: windows with no type written fixed, a door's handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture) and what it wrote but doubts (`warnings` with a code: `thin_slab` under 2 cm, `many_slabs` over 20 on a level, `many_regions` over 24 in a room, since regions are fixed polygons that do not follow a resize). One structural defect refuses the node: `slab_over_wall`, a slab over a wall's footprint on its level with its top above half the wall's height, which would lift the wall onto it (an acceptance run wrote 43 twelve-millimetre "wall cap" slabs at 2.662 m). Pascal builds a wall's cap, the floor plate and the ceiling itself; the skill tells the agent to fold a module's caps, underlays and planks into the wall, the floor finish and the room. A scene already holding a conversion is refused unless `replace` is set; replacing deletes the levels and writes the payload's. The host also compiles the candidate ceiling-point modules listed by `authorSceneCeilingPoints`; after reconciliation the operation uses the ceiling belonging to the room containing each light’s plan position. The host compiles each item's module first (the MCP on its server through the `GeometryScriptHost`, the chat in its worker) and passes the results keyed by item id; a failed compile is a `compile_failed` defect. A room's `floorCode` is its floor script (`authored-objects.md`, "Room floor scripts"): core cannot compile, so the operation lists the rooms that have one by zone (`authorSceneFloorScripts`), and the host, once the write is applied, reads each room's floor (`floorScriptInputs`), compiles the module for it through the `GeometryScriptHost` with `inputs` (the hosted server's editor tab and its server compile carry them too) and writes it with `setRoomFloorScript`, counting `floorScripts`; a failure is a `floor_script_failed` defect and the room keeps its `floor`. A wall's `faceLeft` / `faceRight` (face `a` / `b`) and a room's `wallCode` (on every face its floor meets, the spans `setWallSkirting` runs along, a wall's own module winning) are face scripts (`authored-objects.md`, "Wall face scripts") wired the same way: `authorSceneFaceScripts` lists them by face once walls and zones exist, the operation reporting a face no room looks into as `no_face` (and a face two rooms' `wallCode`s claim as a `face_shared` warning), and the host reads each face (`wallFaceScriptInputs`, with `toHeight`), compiles it as a `wall-face` and writes it with `setWallFaceScript`, counting `faceScripts`; a failure is a `face_script_failed` defect named `/` and the face keeps its paint. `get_source` reads a room's floor script by its zone id, and a wall face's script by the wall's id and `face` (`authored-objects.md`, "Wall face scripts"). The MCP tool (`mcp/tools/author-scene.ts`) also keeps the design's module and the agent's program as scripts of the scene and records the payload (without the items', openings', floors' and faces' code, which are their own artifacts) and the report on the building's `metadata.authorScene`, then answers the gate report with `verify_scene`'s issues: replay is the stored payload through the same operation, with no model. What the converting agents asked of `verify_scene` is in the same PR: an item hosted by another item is read through its host (`item-space.ts`), a thing resting on its host is stacked, not colliding, a rug or a pendant does not block a door, and a door's keep-out is the full clear depth on its swing side and a walkway on the other. From 01578cdd5ed64aaf58db800bc93463c271032c67 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 14:27:55 -0400 Subject: [PATCH 23/26] geometry-script: Pascal cuts a floor or face script's build to its region and openings A finish module was asked to clip itself to the floor polygon and skip the door and window holes; the first one written against that contract dropped whole tiles touching an opening and left whole planks out of a staggered row. Every module gets this slightly wrong, so the compiler now does it: a build with host inputs (a floor's polygon and holes, a face's outline and hole rectangles) is cut after the module runs, before the merge and export. A mesh wholly inside and touching no hole is kept as built (no CSG), one wholly outside or in a hole is dropped, and one across the boundary is intersected with the region's prism (three-bvh-csg; closed meshes gain the cut faces, open ones are trimmed), keeping its material and part. Triangles and bounds are measured after the cut; output stays byte-deterministic. The author_scene contract (floorCode, faceLeft/faceRight, wallCode), the skill reference and the wiki now say the module lays out and Pascal cuts; holes stay in the inputs for layouts that want them. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/agent-tools/author-scene.ts | 4 +- packages/core/src/lib/floor-script.ts | 3 +- packages/geometry-script/src/compile.test.ts | 195 +++++++++++++++ packages/geometry-script/src/compile.ts | 58 +++-- packages/geometry-script/src/cut.ts | 223 ++++++++++++++++++ packages/mcp/src/tools/author-scene.test.ts | 96 ++++++-- skills/pascal-3d/references/tool-workflows.md | 2 +- wiki/architecture/authored-objects.md | 4 +- 8 files changed, 548 insertions(+), 37 deletions(-) create mode 100644 packages/geometry-script/src/cut.ts diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index 3bd2f7fbba..b2a883e74b 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -83,7 +83,7 @@ export const authorSceneOpening = z.object({ }) const WALL_FACE_MODULE = - "a three.js module that builds this face's finish (tiles with joints, panelling, boards) for exactly the face: it receives params.outline, params.holes (its doors and windows), params.base and params.thickness in wall-local metres (x along the wall from its start, y up from the face's base, z out of the face) and returns the finish clipped to the outline; Pascal rebuilds it when the wall or its openings change" + "a three.js module that builds this face's finish (tiles with joints, panelling, boards) for exactly the face: it receives params.outline, params.holes (its doors and windows), params.base and params.thickness in wall-local metres (x along the wall from its start, y up from the face's base, z out of the face) and lays the finish out: Pascal cuts the build to the outline and its openings, so tiles and boards may run past the edges and over the doors and windows (the holes are there for a layout that wants them, tiles centred on a window); Pascal rebuilds it when the wall or its openings change" const faceToHeight = (whose: string) => length(`Where ${whose} stops, metres up from the face's base (default: the full face).`, { @@ -180,7 +180,7 @@ export const authorSceneRoom = z.object({ .max(48_000) .optional() .describe( - "A three.js module that builds the floor finish for the room's polygon: it receives params.polygon, params.holes and params.elevation (level coordinates, metres) and returns the planks, tiles or boards clipped to that polygon. Pascal rebuilds it when the room changes; floor stays the fallback finish.", + "A three.js module that builds the floor finish for the room's polygon: it receives params.polygon, params.holes and params.elevation (level coordinates, metres) and lays out the planks, tiles or boards: Pascal cuts the build to the polygon and its holes, so a row may run past the edges (the holes are there for a layout that wants them). Pascal rebuilds it when the room changes; floor stays the fallback finish.", ), wallCode: z .string() diff --git a/packages/core/src/lib/floor-script.ts b/packages/core/src/lib/floor-script.ts index 0f3c379a6f..ec07cdb971 100644 --- a/packages/core/src/lib/floor-script.ts +++ b/packages/core/src/lib/floor-script.ts @@ -18,7 +18,8 @@ type SceneNodes = Readonly> /** * What a room's floor script is built for, in level coordinates and metres: the room's walking * floor (its outline inside the wall faces, on its floor plate) as `polygon` with its `holes`, and - * the plate's top, `elevation`. The module lays its boards on exactly that; clipping is its job. + * the plate's top, `elevation`. The module lays its boards out over it; the compiler cuts the build + * to the polygon less its holes. */ export type FloorScriptInputs = { polygon: [number, number][] diff --git a/packages/geometry-script/src/compile.test.ts b/packages/geometry-script/src/compile.test.ts index cf4054a161..e1cd8f1c38 100644 --- a/packages/geometry-script/src/compile.test.ts +++ b/packages/geometry-script/src/compile.test.ts @@ -733,3 +733,198 @@ export default function build({ params, inputs }) { expect(compiled.manifest.bounds.min[1]).toBeCloseTo(0, 5) }) }) + +describe("a build cut to its host's floor or face", () => { + /** Every triangle of a compiled GLB, in the build's frame. */ + const trianglesOf = async (glb: ArrayBuffer) => { + const gltf = await new GLTFLoader().parseAsync(glb, '') + gltf.scene.updateWorldMatrix(true, true) + const triangles: [Vector3, Vector3, Vector3][] = [] + gltf.scene.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const position = mesh.geometry.getAttribute('position') + const index = mesh.geometry.getIndex() + const corner = (i: number) => + new Vector3() + .fromBufferAttribute(position, index ? index.getX(i) : i) + .applyMatrix4(mesh.matrixWorld) + for (let i = 0; i < (index?.count ?? position.count); i += 3) { + triangles.push([corner(i), corner(i + 1), corner(i + 2)]) + } + }) + return triangles + } + const normalOf = ([a, b, c]: [Vector3, Vector3, Vector3]) => + new Vector3().subVectors(b, a).cross(new Vector3().subVectors(c, a)) + /** Area of the triangles facing `axis` (their normal within ~18° of it). */ + const facingArea = (triangles: [Vector3, Vector3, Vector3][], axis: Vector3) => + triangles.reduce((sum, triangle) => { + const normal = normalOf(triangle) + const area = normal.length() / 2 + return area > 0 && normal.normalize().dot(axis) > 0.95 ? sum + area : sum + }, 0) + const centroid = ([a, b, c]: [Vector3, Vector3, Vector3]) => + new Vector3().add(a).add(b).add(c).divideScalar(3) + + const PLANKS = (planks: [number, number][]) => ` +export default function build({ THREE, params }) { + const m = new THREE.MeshStandardMaterial({ color: '#b08060' }); m.name = 'slot_oak' + const g = new THREE.Group() + for (const [from, to] of ${JSON.stringify(planks)}) { + const plank = new THREE.Mesh(new THREE.BoxGeometry(to - from, 0.02, 0.2), m) + plank.position.set((from + to) / 2, params.elevation + 0.01, 1.5) + g.add(plank) + } + return g +} +` + const ROOM = { + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [], + elevation: 0, + } + + test('a plank across the floor edge is cut at the edge, one wholly outside is dropped', async () => { + const compiled = await compileGeometryScript({ + code: PLANKS([ + [3, 5], + [6, 7], + ]), + inputs: ROOM, + }) + const { min, max } = compiled.manifest.bounds + expect(min[0]).toBeCloseTo(3, 5) + expect(max[0]).toBeCloseTo(4, 5) + const triangles = await trianglesOf(compiled.glb) + expect(compiled.manifest.triangles).toBe(triangles.length) + // The cut plank is closed again: an end face stands on the edge. + expect(facingArea(triangles, new Vector3(1, 0, 0))).toBeCloseTo(0.02 * 0.2, 6) + expect(facingArea(triangles, new Vector3(0, 1, 0))).toBeCloseTo(1 * 0.2, 6) + }) + + test('a tile over a window is cut to its rectangle, one wholly in the window is dropped', async () => { + const TILES = ` +export default function build({ THREE }) { + const m = new THREE.MeshStandardMaterial({ color: '#ffffff' }); m.name = 'slot_tile' + const g = new THREE.Group() + for (const [size, x, y] of [[1, 1, 1], [0.5, 1.5, 1.5]]) { + const tile = new THREE.Mesh(new THREE.BoxGeometry(size, size, 0.01), m) + tile.position.set(x, y, 0.005) + g.add(tile) + } + return g +} +` + const window = { x0: 1, y0: 1, x1: 2, y1: 2 } + const compiled = await compileGeometryScript({ + code: TILES, + inputs: { + outline: { x0: 0, y0: 0, x1: 4, y1: 2.5 }, + holes: [window], + base: [], + thickness: 0.1, + }, + }) + const triangles = await trianglesOf(compiled.glb) + // The 1 m tile less the window's quarter of it; the small tile inside the window is gone. + expect(facingArea(triangles, new Vector3(0, 0, 1))).toBeCloseTo(0.75, 6) + for (const triangle of triangles) { + const { x, y } = centroid(triangle) + expect( + x > window.x0 + 1e-6 && + x < window.x1 - 1e-6 && + y > window.y0 + 1e-6 && + y < window.y1 - 1e-6, + ).toBe(false) + } + expect(compiled.manifest.bounds.max[0]).toBeCloseTo(1.5, 5) + expect(compiled.manifest.triangles).toBe(triangles.length) + }) + + test('a plank wholly inside and away from the holes is exported as built, uncut', async () => { + const code = PLANKS([[1, 2.5]]).replace('params.elevation', '0') + const cut = await compileGeometryScript({ + code, + inputs: { + ...ROOM, + holes: [ + [ + [3, 0.2], + [3.5, 0.2], + [3.5, 0.7], + [3, 0.7], + ], + ], + }, + }) + const uncut = await compileGeometryScript({ code, inputs: {} }) + expect(Buffer.from(cut.glb).equals(Buffer.from(uncut.glb))).toBe(true) + }) + + test('a staggered floor laid past its edges covers the floor to the edge, the same every time', async () => { + // Odd rows start 0.4 m in: their first plank begins 0.8 m outside the floor and is cut to its + // edge; every row runs its last plank past the far edge and the notch of the L. + const STAGGERED = ` +export default function build({ THREE, params }) { + const xs = params.polygon.map((p) => p[0]), zs = params.polygon.map((p) => p[1]) + const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)] + const m = new THREE.MeshStandardMaterial({ color: '#b08060' }); m.name = 'slot_oak' + const g = new THREE.Group() + const length = 1.2, width = 0.2, thickness = 0.01 + for (let row = 0; z0 + row * width < z1; row++) { + for (let x = x0 + (row % 2 ? 0.4 - length : 0); x < x1; x += length) { + const plank = new THREE.Mesh(new THREE.BoxGeometry(length, thickness, width), m) + plank.position.set(x + length / 2, params.elevation + thickness / 2, z0 + (row + 0.5) * width) + g.add(plank) + } + } + return g +} +` + const column: [number, number][] = [ + [1, 1], + [1.5, 1], + [1.5, 1.5], + [1, 1.5], + ] + const floor = { + polygon: [ + [0, 0], + [5, 0], + [5, 2], + [3, 2], + [3, 4], + [0, 4], + ], + holes: [column], + elevation: 0.2, + } + const compiled = await compileGeometryScript({ code: STAGGERED, inputs: floor }) + const top = (await trianglesOf(compiled.glb)).filter( + (triangle) => normalOf(triangle).normalize().y > 0.95, + ) + expect(facingArea(top, new Vector3(0, 1, 0))).toBeCloseTo(5 * 2 + 3 * 2 - 0.25, 4) + const onFloor = (x: number, z: number) => + (z < 2 ? x < 5 : x < 3) && !(x > 1 && x < 1.5 && z > 1 && z < 1.5) + const covers = ([a, b, c]: [Vector3, Vector3, Vector3], x: number, z: number) => { + const side = (p: Vector3, q: Vector3) => (q.x - p.x) * (z - p.z) - (q.z - p.z) * (x - p.x) + const [ab, bc, ca] = [side(a, b), side(b, c), side(c, a)] + return (ab >= -1e-9 && bc >= -1e-9 && ca >= -1e-9) || (ab <= 1e-9 && bc <= 1e-9 && ca <= 1e-9) + } + const bare: [number, number][] = [] + for (let x = 0.05; x < 5; x += 0.1) { + for (let z = 0.05; z < 4; z += 0.1) { + if (onFloor(x, z) && !top.some((triangle) => covers(triangle, x, z))) bare.push([x, z]) + } + } + expect(bare).toEqual([]) + const again = await compileGeometryScript({ code: STAGGERED, inputs: floor }) + expect(again.sha256).toBe(compiled.sha256) + }) +}) diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index 0ef96d3838..ac4ffc2d75 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -17,6 +17,7 @@ import { ParametricGeometry } from 'three/examples/jsm/geometries/ParametricGeom import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, DIFFERENCE, Evaluator, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' +import { cutToRegion, hostRegion } from './cut' import { type ModuleTable, transformModule } from './transform' export type { GeometryScriptMount } @@ -25,10 +26,11 @@ export type GeometryScriptCompileInput = { code: string params?: Record /** - * What the host builds the module for (a room floor's `polygon` and `elevation`): the build reads - * them in `params` beside its declared ones, and as `inputs`; they are not among the returned - * params, which the scene keeps. A build given them is in the host's frame, so it is not - * re-origined for its mount. + * What the host builds the module for (a room floor's `polygon`, `holes` and `elevation`, a wall + * face's `outline` and `holes`): the build reads them in `params` beside its declared ones, and + * as `inputs`; they are not among the returned params, which the scene keeps. A build given them + * is in the host's frame, so it is not re-origined for its mount, and it is cut to the floor's + * polygon or the face's outline, less their holes: the module lays out, Pascal clips. */ inputs?: Record } @@ -211,21 +213,28 @@ function unmeasurable(root: THREE.Object3D): Error { return new Error('The object cannot be measured: its size is not a finite number.') } +/** The meshes that draw: helpers (cutout, collider), and anything under one, do not. */ +function visibleMeshes(root: THREE.Object3D): THREE.Mesh[] { + const meshes: THREE.Mesh[] = [] + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh || isHelper(mesh)) return + for (let p: THREE.Object3D | null = mesh; p; p = p.parent) if (isHelper(p)) return + meshes.push(mesh) + }) + return meshes +} + /** Bounds of the visible geometry: helpers (cutout, collider) and lights do not count. */ function visibleBounds(root: THREE.Object3D): THREE.Box3 { const box = new THREE.Box3() const meshBox = new THREE.Box3() root.updateWorldMatrix(true, true) - root.traverse((object) => { - const mesh = object as THREE.Mesh - if (!mesh.isMesh || isHelper(mesh)) return - let hidden = false - for (let p: THREE.Object3D | null = mesh; p; p = p.parent) if (isHelper(p)) hidden = true - if (hidden) return + for (const mesh of visibleMeshes(root)) { mesh.geometry.computeBoundingBox() meshBox.copy(mesh.geometry.boundingBox!).applyMatrix4(mesh.matrixWorld) box.union(meshBox) - }) + } return box } @@ -869,8 +878,9 @@ async function digest(bytes: BufferSource): Promise { /** * Everything after a module has built its object, whatever the language: - * re-origins it for its mount, reads the conventions into the manifest, samples - * its clips and exports the GLB. `built.animations` holds its clips. + * re-origins it for its mount (or, built for a host's inputs, cuts it to the + * floor or face they describe), reads the conventions into the manifest, + * samples its clips and exports the GLB. `built.animations` holds its clips. */ export async function finishBuild({ built, @@ -879,7 +889,7 @@ export async function finishBuild({ params, code, language, - hostFrame = false, + inputs, }: { built: THREE.Object3D mount: GeometryScriptMount @@ -887,8 +897,11 @@ export async function finishBuild({ params: Record code: string language: GeometryScriptLanguage - /** Keep the build where it stands: its host placed it, so its mount does not re-origin it. */ - hostFrame?: boolean + /** + * What the host built it for (`compileGeometryScript`'s `inputs`): the build stays where it + * stands, in the host's frame, and is cut to the floor or face they describe. + */ + inputs?: Record }): Promise { const root = new THREE.Group() root.name = 'pascal_script_root' @@ -904,12 +917,21 @@ export async function finishBuild({ `The object is ${vec(size).join(' × ')} m; the limit is ${GEOMETRY_SCRIPT_LIMITS.extent} m per side. Units are metres.`, ) } - if (!hostFrame) built.position.sub(originFor(box, mount)) + if (!inputs) built.position.sub(originFor(box, mount)) root.updateWorldMatrix(true, true) const read = readAnimations(built, root) const clips = sampleTransformClips(root, read.clips) const animated = read.animated + const region = inputs && hostRegion(inputs) + if (region) { + cutToRegion(visibleMeshes(root), region, box) + if (visibleBounds(root).isEmpty()) { + throw new Error( + `Nothing the build made lies on its ${region.axis === 'y' ? 'floor' : 'face'}: everything is outside it or in its openings.`, + ) + } + } bakeBackFaces(root) const conventions = readConventions(root) if (conventions.triangles > GEOMETRY_SCRIPT_LIMITS.triangles) { @@ -1008,6 +1030,6 @@ export async function compileGeometryScript( params, code: input.code, language: 'three', - hostFrame: input.inputs !== undefined, + inputs: input.inputs, }) } diff --git a/packages/geometry-script/src/cut.ts b/packages/geometry-script/src/cut.ts new file mode 100644 index 0000000000..eace8c1124 --- /dev/null +++ b/packages/geometry-script/src/cut.ts @@ -0,0 +1,223 @@ +import { difference, type MultiPolygon, type Ring } from '@pascal-app/core/polygon-boolean' +import * as THREE from 'three' +import { Brush, Evaluator, HOLLOW_INTERSECTION, INTERSECTION } from 'three-bvh-csg' + +/** + * The finish a host's build is cut to, as an area in a plane and the axis its prism runs along: a + * room's floor in plan (u = x, v = z, the prism along y) or a wall face (u = x, v = y, the prism + * along z), each without its holes. + */ +export type HostRegion = { axis: 'y' | 'z'; area: MultiPolygon } + +type Rect = { x0: number; y0: number; x1: number; y1: number } + +const isPoint = (point: unknown): point is [number, number] => + Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]) +const isRing = (ring: unknown): ring is Ring => + Array.isArray(ring) && ring.length >= 3 && ring.every(isPoint) +const isRect = (rect: unknown): rect is Rect => + rect !== null && + typeof rect === 'object' && + (['x0', 'y0', 'x1', 'y1'] as const).every((key) => + Number.isFinite((rect as Record)[key]), + ) +const rectRing = ({ x0, y0, x1, y1 }: Rect): Ring => [ + [x0, y0], + [x1, y0], + [x1, y1], + [x0, y1], +] +const pieces = (rings: Ring[]): MultiPolygon => rings.map((outer) => ({ outer, holes: [] })) + +/** + * The region a build for these inputs is cut to: a floor's `polygon` less its `holes`, or a wall + * face's `outline` less its `holes` (rectangles); null when the inputs describe neither. + */ +export function hostRegion(inputs: Record): HostRegion | null { + const holes: unknown[] = Array.isArray(inputs.holes) ? inputs.holes : [] + if (isRing(inputs.polygon)) { + return { axis: 'y', area: difference(inputs.polygon, pieces(holes.filter(isRing))) } + } + if (isRect(inputs.outline)) { + const rings = holes.filter(isRect).map(rectRing) + return { axis: 'z', area: difference(rectRing(inputs.outline), pieces(rings)) } + } + return null +} + +/** A mesh this close inside the boundary, or touching it, is inside: no cut for a plank laid to the edge. */ +const EDGE = 1e-5 +/** The prism outreaches the build along its axis, so no face of the build lies on its ends. */ +const MARGIN = 1 + +type Span = { u0: number; u1: number; v0: number; v1: number } + +function planeSpan(box: THREE.Box3, axis: HostRegion['axis']): Span { + return axis === 'y' + ? { u0: box.min.x, u1: box.max.x, v0: box.min.z, v1: box.max.z } + : { u0: box.min.x, u1: box.max.x, v0: box.min.y, v1: box.max.y } +} + +/** Whether the segment a→b passes through the closed rectangle (Liang–Barsky). */ +function segmentMeetsSpan(a: [number, number], b: [number, number], span: Span): boolean { + let t0 = 0 + let t1 = 1 + const du = b[0] - a[0] + const dv = b[1] - a[1] + for (const [p, q] of [ + [-du, a[0] - span.u0], + [du, span.u1 - a[0]], + [-dv, a[1] - span.v0], + [dv, span.v1 - a[1]], + ] as const) { + if (p === 0) { + if (q < 0) return false + } else if (p < 0) { + t0 = Math.max(t0, q / p) + } else { + t1 = Math.min(t1, q / p) + } + if (t0 > t1) return false + } + return true +} + +/** Even–odd over every ring: the region's pieces are disjoint and their holes lie inside them. */ +function regionHolds(area: MultiPolygon, [u, v]: [number, number]): boolean { + let inside = false + for (const { outer, holes } of area) { + for (const ring of [outer, ...holes]) { + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [ui, vi] = ring[i]! + const [uj, vj] = ring[j]! + if (vi > v !== vj > v && u < ((uj - ui) * (v - vi)) / (vj - vi) + ui) inside = !inside + } + } + } + return inside +} + +/** + * Where a mesh whose bounds span `span` stands against the region. No boundary through its bounds + * (shrunk by EDGE) means it is wholly in or wholly out, which its centre tells; else it is cut. + */ +function placement(area: MultiPolygon, span: Span): 'inside' | 'outside' | 'boundary' { + const shrink = (lo: number, hi: number) => { + const by = Math.min(EDGE, (hi - lo) / 2) + return [lo + by, hi - by] as const + } + const [u0, u1] = shrink(span.u0, span.u1) + const [v0, v1] = shrink(span.v0, span.v1) + const inner = { u0, u1, v0, v1 } + for (const { outer, holes } of area) { + for (const ring of [outer, ...holes]) { + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + if (segmentMeetsSpan(ring[j]!, ring[i]!, inner)) return 'boundary' + } + } + } + return regionHolds(area, [(u0 + u1) / 2, (v0 + v1) / 2]) ? 'inside' : 'outside' +} + +/** The region as a closed prism spanning `extent` (plus MARGIN) along its axis, in the build's frame. */ +function regionPrism({ axis, area }: HostRegion, extent: THREE.Box3): THREE.BufferGeometry { + // A floor's plan v is world z; the shape is drawn at -z so turning it up (-90° about x) lands it at +z. + const flip = axis === 'y' ? -1 : 1 + const shapes = area.map(({ outer, holes }) => { + const shape = new THREE.Shape(outer.map(([u, v]) => new THREE.Vector2(u, flip * v))) + shape.holes = holes.map( + (hole) => new THREE.Path(hole.map(([u, v]) => new THREE.Vector2(u, flip * v))), + ) + return shape + }) + const from = (axis === 'y' ? extent.min.y : extent.min.z) - MARGIN + const to = (axis === 'y' ? extent.max.y : extent.max.z) + MARGIN + const prism = new THREE.ExtrudeGeometry(shapes, { depth: to - from, bevelEnabled: false }) + prism.clearGroups() + if (axis === 'y') prism.rotateX(-Math.PI / 2).translate(0, from, 0) + else prism.translate(0, 0, from) + return prism +} + +/** + * The prism as a brush carrying every attribute the mesh has, so the cut faces it adds keep the + * mesh's layout. Attributes the prism has no value for are filled: white for `color`, so the + * material's own colour shows on the cut, zero for the rest. + */ +function prismBrush( + prism: THREE.BufferGeometry, + geometry: THREE.BufferGeometry, + brushes: Map, +): Brush { + const names = Object.keys(geometry.attributes).sort() + const key = names.map((name) => `${name}:${geometry.getAttribute(name).itemSize}`).join(',') + const cached = brushes.get(key) + if (cached) return cached + const shaped = prism.clone() + for (const name of Object.keys(shaped.attributes)) { + if (!names.includes(name)) shaped.deleteAttribute(name) + } + const count = shaped.getAttribute('position').count + for (const name of names) { + if (shaped.getAttribute(name)) continue + const { itemSize } = geometry.getAttribute(name) + const values = new Float32Array(count * itemSize).fill(name === 'color' ? 1 : 0) + shaped.setAttribute(name, new THREE.BufferAttribute(values, itemSize)) + } + const brush = new Brush(shaped) + brushes.set(key, brush) + return brush +} + +const triangles = (geometry: THREE.BufferGeometry) => + Math.floor((geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0) / 3) + +/** + * Cuts each visible mesh of a host's build to its region: whole inside, it is kept untouched; + * whole outside or in a hole, it is dropped; across the boundary, it is intersected with the + * region's prism (a closed mesh gains the cut faces, an open one is trimmed), keeping its + * material, and dropped if nothing is left. `extent` is the build's visible bounds. + */ +export function cutToRegion(meshes: THREE.Mesh[], region: HostRegion, extent: THREE.Box3) { + const box = new THREE.Box3() + let prism: THREE.BufferGeometry | undefined + const brushes = new Map() + const evaluator = new Evaluator() + for (const mesh of meshes) { + mesh.geometry.computeBoundingBox() + box.copy(mesh.geometry.boundingBox!).applyMatrix4(mesh.matrixWorld) + const where = placement(region.area, planeSpan(box, region.axis)) + if (where === 'inside') continue + if (where === 'outside') { + mesh.removeFromParent() + continue + } + prism ??= regionPrism(region, extent) + const target = prismBrush(prism, mesh.geometry, brushes) + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + target.material = materials[0]! + const source = new Brush(mesh.geometry.clone(), mesh.material) + source.matrixWorld.copy(mesh.matrixWorld) + source.prepareGeometry() + // `prepareGeometry` maps its edges; its types leave the map out. + const { halfEdges } = source.geometry as unknown as { + halfEdges: { isFullyConnected(): boolean } + } + const closed = halfEdges.isFullyConnected() + evaluator.attributes = Object.keys(mesh.geometry.attributes) + const result = evaluator.evaluate(source, target, closed ? INTERSECTION : HOLLOW_INTERSECTION) + source.geometry.dispose() + if (triangles(result.geometry) === 0) { + mesh.removeFromParent() + continue + } + const kept = result.material as THREE.Material[] + mesh.geometry = result.geometry + if (kept.length === 1) { + mesh.geometry.clearGroups() + mesh.material = kept[0]! + } else mesh.material = kept + } + prism?.dispose() + for (const brush of brushes.values()) brush.geometry.dispose() +} diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts index 623bf2874d..d3314806de 100644 --- a/packages/mcp/src/tools/author-scene.test.ts +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -130,14 +130,17 @@ const payload = { notes: ['The rug was left out.'], } -/** A floor of four boards across the bounds of the room's floor, at its top. */ +/** + * A floor of four boards over the bounds of the room's floor, run 0.3 m past them, and one more + * wholly outside it: Pascal cuts them to the floor. + */ const BOARDS = `export default function build({ THREE, params }) { const xs = params.polygon.map((p) => p[0]), zs = params.polygon.map((p) => p[1]) - const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)] + const [x0, x1, z0, z1] = [Math.min(...xs) - 0.3, Math.max(...xs) + 0.3, Math.min(...zs) - 0.3, Math.max(...zs) + 0.3] const g = new THREE.Group() const m = new THREE.MeshStandardMaterial({ color: '#b08060' }); m.name = 'slot_wood' const w = (x1 - x0) / 4 - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 5; i++) { const b = new THREE.Mesh(new THREE.BoxGeometry(w, 0.01, z1 - z0), m) b.position.set(x0 + w * (i + 0.5), params.elevation + 0.005, (z0 + z1) / 2) g.add(b) @@ -145,15 +148,14 @@ const BOARDS = `export default function build({ THREE, params }) { return g }` -/** Half-metre tiles over a wall face, none over its doors and windows. */ +/** Half-metre tiles from past the start of a wall face to past its end and top, over its openings. */ const TILES = `export default function build({ THREE, params }) { - const { outline, holes } = params + const { outline } = params const g = new THREE.Group() const m = new THREE.MeshStandardMaterial({ color: '#ffffff' }); m.name = 'slot_tile' const size = 0.5 - for (let x = outline.x0; x + size <= outline.x1 + 1e-6; x += size) - for (let y = outline.y0; y + size <= outline.y1 + 1e-6; y += size) { - if (holes.some((h) => x < h.x1 && x + size > h.x0 && y < h.y1 && y + size > h.y0)) continue + for (let x = outline.x0 - 0.25; x < outline.x1 + size; x += size) + for (let y = outline.y0; y < outline.y1 + size; y += size) { const t = new THREE.Mesh(new THREE.BoxGeometry(size - 0.004, size - 0.004, 0.008), m) t.position.set(x + size / 2, y + size / 2, 0.004) g.add(t) @@ -161,6 +163,55 @@ const TILES = `export default function build({ THREE, params }) { return g }` +/** + * The triangles' centroids of a compiled GLB, read straight from its chunks: the compiler writes + * a host's build in the host's frame, with no node transforms to apply. + */ +function glbCentroids(bytes: Uint8Array): [number, number, number][] { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const jsonLength = view.getUint32(12, true) + const gltf = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLength))) + const bin = 20 + jsonLength + 8 + expect( + gltf.nodes.every( + (node: Record) => + !node.translation && !node.rotation && !node.scale && !node.matrix, + ), + ).toBe(true) + const read = (accessorIndex: number) => { + const accessor = gltf.accessors[accessorIndex] + const bufferView = gltf.bufferViews[accessor.bufferView] + const offset = bin + (bufferView.byteOffset ?? 0) + (accessor.byteOffset ?? 0) + const width = { SCALAR: 1, VEC3: 3 }[accessor.type as 'SCALAR' | 'VEC3'] + return Array.from({ length: accessor.count * width }, (_, i) => + accessor.componentType === 5126 + ? view.getFloat32(offset + i * 4, true) + : accessor.componentType === 5125 + ? view.getUint32(offset + i * 4, true) + : view.getUint16(offset + i * 2, true), + ) + } + const centroids: [number, number, number][] = [] + for (const mesh of gltf.meshes) + for (const primitive of mesh.primitives) { + const position = read(primitive.attributes.POSITION) + const count = position.length / 3 + const index = + primitive.indices === undefined + ? Array.from({ length: count }, (_, i) => i) + : read(primitive.indices) + for (let i = 0; i < index.length; i += 3) { + const corners = [index[i]!, index[i + 1]!, index[i + 2]!] + centroids.push( + [0, 1, 2].map( + (axis) => corners.reduce((sum, corner) => sum + position[corner * 3 + axis]!, 0) / 3, + ) as [number, number, number], + ) + } + } + return centroids +} + /** One panel over the whole outline of a wall face. */ const PANEL = `export default function build({ THREE, params }) { const { x0, y0, x1, y1 } = params.outline @@ -175,9 +226,12 @@ describe('author_scene (MCP)', () => { let client: Client const stored: string[] = [] const artifacts = new Map() + /** What each build was for, by the hash of its GLB. */ + const builtFor = new Map | undefined>() const host: GeometryScriptHost = { compile: async ({ code, inputs }) => { const { glb, ...compiled } = await compileGeometryScript({ code, inputs }) + builtFor.set(compiled.sha256, inputs) return { ...compiled, glb: new Uint8Array(glb) } }, storeArtifact: async ({ sha256, mimeType, bytes }) => { @@ -365,7 +419,8 @@ describe('author_scene (MCP)', () => { expect(room.floor.script.language).toBe('three') expect(room.floor.script.manifest.slots.map((s: { id: string }) => s.id)).toEqual(['wood']) expect(room.floor.script.inputs).toEqual(expect.any(String)) - // The boards span the room inside the walls' faces: 4 m − 0.12 m by 5 m − 0.12 m. + // The boards, laid past the floor, are cut to the room inside the walls' faces: + // 4 m − 0.12 m by 5 m − 0.12 m. const { min, max } = room.floor.script.manifest.bounds expect(max[0] - min[0]).toBeCloseTo(3.88, 2) expect(max[2] - min[2]).toBeCloseTo(4.88, 2) @@ -445,19 +500,32 @@ describe('author_scene (MCP)', () => { const got = await client.callTool({ name: 'get_node', arguments: { id } }) return JSON.parse((got.content as { text: string }[])[0]!.text).node as Record } - // The tiles stay on the east wall's room face, between its mitred ends and under its top. + // The tiles, laid past the east wall's room face and over its windows, are cut to the face + // between its mitred ends and under its top, and to each window's rectangle. const east = await read(report.ids.e!) const tiles = east.finishScripts.a expect(east.finishScripts.b).toBeUndefined() expect(tiles.language).toBe('three') expect(tiles.toHeight).toBeUndefined() expect(tiles.manifest.slots.map((s: { id: string }) => s.id)).toEqual(['tile']) + const face = builtFor.get(tiles.artifact) as { + outline: { x0: number; y0: number; x1: number; y1: number } + holes: { x0: number; y0: number; x1: number; y1: number }[] + } const { min, max } = tiles.manifest.bounds - expect(min[0]).toBeGreaterThanOrEqual(0) - expect(max[0]).toBeLessThanOrEqual(5) - expect(min[1]).toBeGreaterThanOrEqual(0) - expect(max[1]).toBeLessThanOrEqual(2.7) + expect(min[0]).toBeCloseTo(face.outline.x0, 4) + expect(max[0]).toBeCloseTo(face.outline.x1, 4) + // The first row stands half a joint (2 mm) off the floor; the top row is cut at the top. + expect(min[1]).toBeCloseTo(0.002, 4) + expect(max[1]).toBeCloseTo(2.7, 4) expect(max[2] - min[2]).toBeCloseTo(0.008, 3) + expect(face.holes).toHaveLength(2) + const inWindow = glbCentroids(artifacts.get(tiles.artifact)!).filter(([x, y]) => + face.holes.some( + (h) => x > h.x0 + 1e-4 && x < h.x1 - 1e-4 && y > h.y0 + 1e-4 && y < h.y1 - 1e-4, + ), + ) + expect(inWindow).toEqual([]) // The room's other walls are panelled to 1.2 m on the face it looks into. for (const id of ['n', 's', 'w']) { const wall = await read(report.ids[id]!) diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 37ec4b65e3..81f0b9b830 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -56,7 +56,7 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra `author_scene` turns a whole design you wrote as one three.js module into native, editable Pascal nodes in one call. The reading is yours; the writing is Pascal's. 1. Read your module: its source for intent (helper names, declared opening types), the executed graph for truth (world transforms, deleted objects gone). Most designs make walls with a helper such as `wall(x1, z1, x2, z2, height, thickness, openings)`: those arguments are the payload's walls. -2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour; `skirting` with `height` (default 0.1 m), `depth` (default 0.012 m) and `color` for the baseboard, native on the wall along the faces its rooms stand on), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`; `floorCode` for planks, tiles or boards, a module that receives `params.polygon`, `params.holes` and `params.elevation`, level coordinates in metres, and builds the finish clipped to that floor: Pascal rebuilds it when the room changes, `floor` stays the fallback, `get_source` with the room's id reads it back; `wallCode`, with `wallToHeight`, for the finish of every wall face the room looks into), each wall's `faceLeft` / `faceRight` (`code`, `toHeight` from the face's base, default the whole face) for tiles, panelling or boards on that face, a module that receives `params.outline`, `params.holes`, `params.base` and `params.thickness` in wall-local metres (x along the wall from its start, y up from the face's base, z out of the face) and builds the finish clipped to the outline, over the room's `wallCode`, `colorLeft` / `colorRight` staying the paint under it, `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a mirror on a wall), `lights` (`position`, `fixture`). A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. +2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour; `skirting` with `height` (default 0.1 m), `depth` (default 0.012 m) and `color` for the baseboard, native on the wall along the faces its rooms stand on), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`; `floorCode` for planks, tiles or boards, a module that receives `params.polygon`, `params.holes` and `params.elevation`, level coordinates in metres, and lays out the finish, which Pascal cuts to that floor and its holes: Pascal rebuilds it when the room changes, `floor` stays the fallback, `get_source` with the room's id reads it back; `wallCode`, with `wallToHeight`, for the finish of every wall face the room looks into), each wall's `faceLeft` / `faceRight` (`code`, `toHeight` from the face's base, default the whole face) for tiles, panelling or boards on that face, a module that receives `params.outline`, `params.holes`, `params.base` and `params.thickness` in wall-local metres (x along the wall from its start, y up from the face's base, z out of the face) and lays out the finish, which Pascal cuts to the outline and its openings, over the room's `wallCode`, `colorLeft` / `colorRight` staying the paint under it, `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a mirror on a wall), `lights` (`position`, `fixture`). A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. 3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `slab_over_wall`, `use_slab`, `skirting_refused` for a wall with no room on either face, `floor_script_failed` for a floor module that did not build, `face_script_failed` (`/`) for a face module that did not build, `no_face` for a face no room looks into…), `floorScripts` (rooms whose floor module was built), `faceScripts` (wall faces whose module was built), `warnings` (written, but doubtful: `thin_slab`, `many_slabs`, `many_regions`, `face_shared` for a face two rooms' `wallCode` claim), `defaults` (what Pascal filled in or dropped: a window with no `type` written fixed, a handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. 4. Fix the program, not the payload by hand, and call again with `replace: true`. Stop when `green` is true and the scene looks like the design. Report what you could not convert in `notes`. diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 28b02711b6..9360af527a 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -134,11 +134,13 @@ artifact or scene data is changed by these checks. A room's floor finish can be a script: `zone.floor.script` holds the same source record as an item's `source` (language, script and artifact hashes, params, manifest) plus `inputs`, the key of the floor it was built for. The host reads the room's floor with `floorScriptInputs(nodes, zoneId)` (core): `polygon` and `holes`, the room's outline inside the wall faces clipped to its floor plate, and `elevation`, the plate's top, all in level coordinates and metres. It compiles the module with geometry-script's `inputs` (the module reads them as `params.polygon`, `params.holes`, `params.elevation`; they are never stored as params, and the build is kept in the level's frame instead of being re-origined for its mount) and stores the artifacts; `setRoomFloorScript` writes the result, or takes it off with `remove`. Only three.js modules: a Blender build is not given the floor. +Pascal cuts the build to the floor or face and its openings; the module lays out, it does not have to clip: after the module runs, before the merge and export, each mesh is kept inside `polygon` and outside every hole, as a prism over the build's full height. A mesh wholly inside, touching no hole, is kept as built (the common plank, no CSG); one wholly outside or in a hole is dropped; one across the boundary is intersected with the region's prism (`three-bvh-csg`, a closed mesh gaining the cut faces, an open one trimmed), keeping its material and part. The manifest's triangles and bounds are those of the cut build, and the same module and floor give the same bytes. The module still gets `holes` for a layout that wants them (a pattern centred on a column). The cut is the compiler's (`geometry-script/src/cut.ts`), so every host that compiles with `inputs` gets it. + The zone renderer draws the artifact 1 mm over the plate, with no pointer events of its own, so a click reaches the floor beneath. The script owns the floor's colours: the plate gives a scripted room its own top cell, and `floorPlatePaintRefusal` (the editor's Paint) and `set_zone_intent` (`floor.finish`, `floor.regions`) refuse paint there with a sentence until it is removed. When the structure reconcile re-derives the room (a wall moved, an edge pushed, the floor raised), the key no longer matches (`staleFinishScripts`) and `refreshFinishScripts(nodes, host)` runs the stored module and params again for the new floor; `finishScriptWrites` writes each rebuild only while the floor is still the one it was rebuilt for. The editor tab does this after each settled change (`initFinishScriptRefresh`), writing the rebuild outside history like the reconcile's derived writes. The room panel shows "Scripted finish" with its code and Remove. ## Wall face scripts -The twin of a floor script for one face of one wall: tiles with real joints, panelling to a dado, boards. `wall.finishScripts.a` / `.b` hold the same source record plus `inputs`, the key of the face it was built for, and `toHeight`, metres above the face's base where it stops (absent: the whole face). `wallFaceScriptInputs(nodes, wallId, face, toHeight?)` (core) reads the face in its own frame, in metres: x along the wall from its start, y up from the face's base (its lowest floor), z out of the face. The module gets `params.outline`, the face's rectangle `{ x0, y0, x1, y1 }` between its mitred ends and up to the wall's top or `toHeight`; `params.holes`, the doors and windows cutting it as rectangles clipped to the outline (a door from the floor under it, a window from its sill); `params.base`, the face's floor as `[x, y]` points from end to end (one level run, or a jump where a raised room meets it); and `params.thickness`. Curved and curtain walls have no face to build on. `setWallFaceScript` writes a compile on the face (`face`, `compiled`, `inputs`, `toHeight`) or takes it off with `remove`; three.js modules only. +The twin of a floor script for one face of one wall: tiles with real joints, panelling to a dado, boards. `wall.finishScripts.a` / `.b` hold the same source record plus `inputs`, the key of the face it was built for, and `toHeight`, metres above the face's base where it stops (absent: the whole face). `wallFaceScriptInputs(nodes, wallId, face, toHeight?)` (core) reads the face in its own frame, in metres: x along the wall from its start, y up from the face's base (its lowest floor), z out of the face. The module gets `params.outline`, the face's rectangle `{ x0, y0, x1, y1 }` between its mitred ends and up to the wall's top or `toHeight`; `params.holes`, the doors and windows cutting it as rectangles clipped to the outline (a door from the floor under it, a window from its sill); `params.base`, the face's floor as `[x, y]` points from end to end (one level run, or a jump where a raised room meets it); and `params.thickness`. Pascal cuts the build to the floor or face and its openings; the module lays out, it does not have to clip: as for a floor, the build is kept inside `outline` and outside every hole rectangle, as a prism along z over the build's depth, so tiles may run past the face's ends and over a window, and `holes` remain for a layout centred on one. Curved and curtain walls have no face to build on. `setWallFaceScript` writes a compile on the face (`face`, `compiled`, `inputs`, `toHeight`) or takes it off with `remove`; three.js modules only. The wall renderer draws the artifact under the wall mesh, like the trims: `wallFaceScriptPlacement` puts the build's origin at the face's base, 1 mm proud of the face (`getWallLocalFaceZ`), and mirrors face b's build through the wall (scale z −1, which three.js renders with the winding flipped) so x still runs from the wall's start and z points out of face b. It has no pointer events, so clicks reach the wall. The skirting keeps running along the base; where the build comes down into the skirting's band (its bounds' bottom under the skirting's top on that face) the skirting stops over the build's span, as at a door, so the script wins visually; a splashback above it leaves it whole. A script to the top owns the face's colours: `wallFaceScriptPaintRefusal` refuses the face, its paint regions and the room's finish on it in the editor's Paint and the wall's paint commit, and `paint` refuses the face, until the script is removed; a script stopping at `toHeight` leaves the face paintable, since the paint shows above it. The other face is untouched. Stale face scripts (the wall lengthened, an opening added or moved, a floor raised) rebuild through the same `staleFinishScripts` / `refreshFinishScripts` as floors. Turning a wall around swaps its face scripts; a split copies them onto both pieces, which then rebuild for their own faces. `get_source` reads a face script with the wall's id and `face` (optional when one face has one). The wall panel shows "Scripted finish" per face with its code and Remove. From db0371487e6f2fd873f44ac4bd4d8d4519b267d6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 14:48:11 -0400 Subject: [PATCH 24/26] geometry-script + author_scene: Pascal measures how much of a floor or face a finish script covers The cut trims what a floor or face module builds past its region, but cannot add what it never built: odd plank rows started 40 cm in left a bare strip along the wall and the report was green. After the cut the compiler now measures the build seen square-on to its region (strips cut at every corner's height, exact for boards and tiles laid square, about 5 ms for a 20 m2 plank floor) and writes `coverage` (0..1, joints count as bare) and `uncovered` (up to four rectangles bounding the largest bare patches, in the host's frame; a joint is never a patch) in the manifest. author_scene warns `finish_gaps` with the share and where each patch lies when coverage is under 0.98 and a patch exists, so tile joints alone stay silent. Co-Authored-By: Claude Fable 5.1 --- .../core/src/agent-operations/author-scene.ts | 66 ++++ packages/core/src/agent-tools/author-scene.ts | 8 +- packages/core/src/schema/geometry-source.ts | 14 + packages/geometry-script/src/compile.test.ts | 86 +++++ packages/geometry-script/src/compile.ts | 6 +- packages/geometry-script/src/coverage.ts | 305 ++++++++++++++++++ packages/mcp/src/tools/author-scene.test.ts | 60 ++++ packages/mcp/src/tools/author-scene.ts | 10 + wiki/architecture/authored-objects.md | 4 + 9 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 packages/geometry-script/src/coverage.ts diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 40372d69bd..e9c8e26855 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -28,6 +28,7 @@ import { ColumnNode, type CompiledGeometryScript, DoorNode, + type GeometryArtifactManifest, LevelNode, type SceneMaterial, type SceneMaterialId, @@ -413,6 +414,71 @@ function resolveFaceScripts( return { scripts, defects, warnings } } +/** Below this share a floor or face finish leaves enough of its host bare to be worth a warning. */ +const FINISH_COVERED = 0.98 + +/** Where a floor or face script's build was compiled: the room's floor outline, or the wall's face. */ +export type AuthorSceneFinishHost = + | { room: string; polygon: [number, number][] } + | { wall: string; face: WallFace } + +const figure = (value: number) => String(Math.round(value * 100) / 100) + +/** A bare patch on a floor, by the edges of the floor's bounds it runs along, and its extent. */ +function floorPatch( + { min, max }: { min: [number, number]; max: [number, number] }, + polygon: [number, number][], +): string { + const reach = 0.05 + const xs = polygon.map(([x]) => x) + const zs = polygon.map(([, z]) => z) + const touches = { + north: min[1] <= Math.min(...zs) + reach, + south: max[1] >= Math.max(...zs) - reach, + west: min[0] <= Math.min(...xs) + reach, + east: max[0] >= Math.max(...xs) - reach, + } + const sides = (Object.keys(touches) as (keyof typeof touches)[]).filter((side) => touches[side]) + const ns = sides.filter((side) => side === 'north' || side === 'south') + const ew = sides.filter((side) => side === 'west' || side === 'east') + const where = + sides.length === 0 + ? 'inside the floor' + : sides.length === 1 + ? `along the ${sides[0]} edge` + : sides.length > 2 + ? 'over most of the floor' + : ns.length === 1 + ? `in the ${ns[0]}-${ew[0]} corner` + : `across the floor from ${sides[0]} to ${sides[1]}` + return `${where} (${figure(max[0] - min[0])} × ${figure(max[1] - min[1])} m, x ${figure(min[0])} to ${figure(max[0])}, z ${figure(min[1])} to ${figure(max[1])})` +} + +/** + * The `finish_gaps` warning for a floor or face script whose build leaves part of its host bare: + * its `coverage` under FINISH_COVERED with at least one bare patch, each named by where it lies. + * Joints make no patch, so a tiled finish is never warned for its joints alone. Null otherwise. + */ +export function authorSceneFinishGaps( + id: string, + manifest: GeometryArtifactManifest, + host: AuthorSceneFinishHost, +): AuthorSceneDefect | null { + const { coverage, uncovered } = manifest + if (coverage === undefined || coverage >= FINISH_COVERED || !uncovered?.length) return null + const share = `${Math.round(coverage * 100)}%` + const message = + 'room' in host + ? `The floor finish of "${host.room}" covers ${share} of its floor: uncovered ${uncovered.map((patch) => floorPatch(patch, host.polygon)).join('; ')}.` + : `The finish on the ${host.face === 'a' ? 'left' : 'right'} face of wall ${host.wall} covers ${share} of the face: uncovered ${uncovered + .map( + ({ min, max }) => + `${figure(min[0])} to ${figure(max[0])} m along the wall from its start, ${figure(min[1])} to ${figure(max[1])} m up (${figure(max[0] - min[0])} × ${figure(max[1] - min[1])} m)`, + ) + .join('; ')}.` + return { id, code: 'finish_gaps', message } +} + /** * The wall faces whose finish is a module, once the write made the walls and the rooms: the host * reads each face (`wallFaceScriptInputs`), compiles the module for it and writes it with diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index b2a883e74b..d7edd9494b 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -144,12 +144,12 @@ export const authorSceneWall = z.object({ faceLeft: authorSceneWallFace .optional() .describe( - `The face on the left looking from start to end (the front, colorLeft's face), built by ${WALL_FACE_MODULE}; colorLeft stays the paint under it. Over the room's wallCode.`, + `The face on the left looking from start to end (the front, colorLeft's face), built by ${WALL_FACE_MODULE}; colorLeft stays the paint under it. Over the room's wallCode. The report warns when the build leaves part of the face uncovered.`, ), faceRight: authorSceneWallFace .optional() .describe( - `The face on the right looking from start to end (colorRight's face), built by ${WALL_FACE_MODULE}; colorRight stays the paint under it. Over the room's wallCode.`, + `The face on the right looking from start to end (colorRight's face), built by ${WALL_FACE_MODULE}; colorRight stays the paint under it. Over the room's wallCode. The report warns when the build leaves part of the face uncovered.`, ), }) @@ -180,7 +180,7 @@ export const authorSceneRoom = z.object({ .max(48_000) .optional() .describe( - "A three.js module that builds the floor finish for the room's polygon: it receives params.polygon, params.holes and params.elevation (level coordinates, metres) and lays out the planks, tiles or boards: Pascal cuts the build to the polygon and its holes, so a row may run past the edges (the holes are there for a layout that wants them). Pascal rebuilds it when the room changes; floor stays the fallback finish.", + "A three.js module that builds the floor finish for the room's polygon: it receives params.polygon, params.holes and params.elevation (level coordinates, metres) and lays out the planks, tiles or boards: Pascal cuts the build to the polygon and its holes, so a row may run past the edges (the holes are there for a layout that wants them). Pascal rebuilds it when the room changes; floor stays the fallback finish. The report warns when the build leaves part of the floor uncovered.", ), wallCode: z .string() @@ -188,7 +188,7 @@ export const authorSceneRoom = z.object({ .max(48_000) .optional() .describe( - `The finish of every wall face that looks into this room (the faces its floor meets, where the skirting runs), each built by ${WALL_FACE_MODULE}. A wall's own faceLeft / faceRight wins over it.`, + `The finish of every wall face that looks into this room (the faces its floor meets, where the skirting runs), each built by ${WALL_FACE_MODULE}. A wall's own faceLeft / faceRight wins over it. The report warns when the build leaves part of a face uncovered.`, ), wallToHeight: faceToHeight("the room's wallCode"), }) diff --git a/packages/core/src/schema/geometry-source.ts b/packages/core/src/schema/geometry-source.ts index 9045e4024a..5055961714 100644 --- a/packages/core/src/schema/geometry-source.ts +++ b/packages/core/src/schema/geometry-source.ts @@ -126,6 +126,20 @@ export const GeometryArtifactManifest = z.object({ cutout: z.boolean().default(false), collider: z.boolean().default(false), triangles: z.number().int().nonnegative(), + /** + * Built for a room's floor or a wall face: the share of it (less its holes) the build covers, + * seen square-on, two decimals. Joints count as bare. + */ + coverage: finite.min(0).max(1).optional(), + /** + * With `coverage`, up to four rectangles bounding its largest bare patches, largest first, in + * the host's frame: a floor's plan (x, z), a face's (x along the wall from its start, y up). + * Gaps a joint's width (under 2 cm) are not patches. + */ + uncovered: z + .array(z.object({ min: z.tuple([finite, finite]), max: z.tuple([finite, finite]) })) + .max(4) + .optional(), }) export type GeometryArtifactManifest = z.infer diff --git a/packages/geometry-script/src/compile.test.ts b/packages/geometry-script/src/compile.test.ts index e1cd8f1c38..92867ea561 100644 --- a/packages/geometry-script/src/compile.test.ts +++ b/packages/geometry-script/src/compile.test.ts @@ -731,6 +731,7 @@ export default function build({ params, inputs }) { }) expect(compiled.manifest.bounds.min[0]).toBeCloseTo(-2, 5) expect(compiled.manifest.bounds.min[1]).toBeCloseTo(0, 5) + expect(compiled.manifest.coverage).toBeUndefined() }) }) @@ -928,3 +929,88 @@ export default function build({ THREE, params }) { expect(again.sha256).toBe(compiled.sha256) }) }) + +describe('how much of its floor or face a build covers', () => { + /** Rows of 1.2 × 0.2 m boards over the floor's bounds; odd rows start `oddStart` from its west edge. */ + const BOARDS = (oddStart: number) => ` +export default function build({ THREE, params }) { + const xs = params.polygon.map((p) => p[0]), zs = params.polygon.map((p) => p[1]) + const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)] + const m = new THREE.MeshStandardMaterial({ color: '#b08060' }); m.name = 'slot_oak' + const g = new THREE.Group() + const length = 1.2, width = 0.2, thickness = 0.01 + for (let row = 0; z0 + row * width < z1; row++) { + for (let x = x0 + (row % 2 ? ${oddStart} : 0); x < x1; x += length) { + const plank = new THREE.Mesh(new THREE.BoxGeometry(length, thickness, width), m) + plank.position.set(x + length / 2, params.elevation + thickness / 2, z0 + (row + 0.5) * width) + g.add(plank) + } + } + return g +} +` + const ROOM = { + polygon: [ + [0, 0], + [3, 0], + [3, 4.9], + [0, 4.9], + ], + holes: [], + elevation: 0, + } + + test('a plank floor laid to every edge covers all of it, with no patch', async () => { + const compiled = await compileGeometryScript({ code: BOARDS(0.4 - 1.2), inputs: ROOM }) + expect(compiled.manifest.coverage).toBe(1) + expect(compiled.manifest.uncovered).toEqual([]) + }) + + test('odd rows started 0.4 m in leave a strip bare along the west edge, measured and bounded', async () => { + const compiled = await compileGeometryScript({ code: BOARDS(0.4), inputs: ROOM }) + // Every odd row leaves 0.4 m of its own width bare at x = 0; the far ends are cut, not short. + let bare = 0 + for (let row = 1; row * 0.2 < 4.9; row += 2) + bare += 0.4 * (Math.min(4.9, row * 0.2 + 0.2) - row * 0.2) + expect(compiled.manifest.coverage).toBeCloseTo(1 - bare / (3 * 4.9), 2) + expect(compiled.manifest.uncovered).toHaveLength(1) + const [strip] = compiled.manifest.uncovered! + expect(strip!.min[0]).toBeCloseTo(0, 2) + expect(strip!.max[0]).toBeCloseTo(0.4, 2) + expect(strip!.min[1]).toBeCloseTo(0.2, 2) + expect(strip!.max[1]).toBeCloseTo(4.8, 2) + }) + + test('tile joints count as bare but make no patch', async () => { + const [size, joint] = [0.25, 0.004] + const TILES = ` +export default function build({ THREE, params }) { + const { outline } = params + const m = new THREE.MeshStandardMaterial({ color: '#ffffff' }); m.name = 'slot_tile' + const g = new THREE.Group() + const pitch = ${size + joint} + for (let x = outline.x0; x < outline.x1; x += pitch) + for (let y = outline.y0; y < outline.y1; y += pitch) { + const tile = new THREE.Mesh(new THREE.BoxGeometry(${size}, ${size}, 0.008), m) + tile.position.set(x + ${size / 2}, y + ${size / 2}, 0.004) + g.add(tile) + } + return g +} +` + const outline = { x0: 0, y0: 0, x1: 3, y1: 2.4 } + const compiled = await compileGeometryScript({ + code: TILES, + inputs: { outline, holes: [], base: [], thickness: 0.1 }, + }) + const tiled = (length: number) => { + let covered = 0 + for (let at = 0; at < length; at += size + joint) covered += Math.min(length, at + size) - at + return covered / length + } + const expected = tiled(outline.x1) * tiled(outline.y1) + expect(Math.abs(expected - 0.97)).toBeLessThan(0.01) + expect(Math.abs(compiled.manifest.coverage! - expected)).toBeLessThanOrEqual(0.01) + expect(compiled.manifest.uncovered).toEqual([]) + }) +}) diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index ac4ffc2d75..87278b78eb 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -17,6 +17,7 @@ import { ParametricGeometry } from 'three/examples/jsm/geometries/ParametricGeom import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, DIFFERENCE, Evaluator, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' +import { type Coverage, measureCoverage } from './coverage' import { cutToRegion, hostRegion } from './cut' import { type ModuleTable, transformModule } from './transform' @@ -879,7 +880,7 @@ async function digest(bytes: BufferSource): Promise { /** * Everything after a module has built its object, whatever the language: * re-origins it for its mount (or, built for a host's inputs, cuts it to the - * floor or face they describe), reads the conventions into the manifest, + * floor or face they describe and measures how much of it the build covers), reads the conventions into the manifest, * samples its clips and exports the GLB. `built.animations` holds its clips. */ export async function finishBuild({ @@ -924,6 +925,7 @@ export async function finishBuild({ const clips = sampleTransformClips(root, read.clips) const animated = read.animated const region = inputs && hostRegion(inputs) + let coverage: Coverage | null = null if (region) { cutToRegion(visibleMeshes(root), region, box) if (visibleBounds(root).isEmpty()) { @@ -931,6 +933,7 @@ export async function finishBuild({ `Nothing the build made lies on its ${region.axis === 'y' ? 'floor' : 'face'}: everything is outside it or in its openings.`, ) } + coverage = measureCoverage(visibleMeshes(root), region) } bakeBackFaces(root) const conventions = readConventions(root) @@ -973,6 +976,7 @@ export async function finishBuild({ cutout: conventions.cutout, collider: conventions.collider, triangles: conventions.triangles, + ...coverage, }), } } diff --git a/packages/geometry-script/src/coverage.ts b/packages/geometry-script/src/coverage.ts new file mode 100644 index 0000000000..f105560d45 --- /dev/null +++ b/packages/geometry-script/src/coverage.ts @@ -0,0 +1,305 @@ +import type { MultiPolygon, Ring } from '@pascal-app/core/polygon-boolean' +import * as THREE from 'three' +import type { HostRegion } from './cut' + +const CELL = 0.02 +const MAX_CELLS = 250_000 +/** + * Strips between the heights where a triangle or the region has a corner hold no corner, so the + * covered length along each strip's middle line is its exact mean. Beyond this many corners per + * cell row (a rotated mosaic), strips are a fixed quarter of a row instead. + */ +const MAX_CORNERS_PER_ROW = 8 +const LINES = 4 +/** Patches nearer than this (about a board's width) are one place for the report. */ +const NEAR = 0.3 +const MAX_PATCHES = 4 +const EPS = 1e-6 + +/** A rectangle in the region's plane: a floor's (x, z), a face's (x along the wall, y up). */ +export type UncoveredPatch = { min: [number, number]; max: [number, number] } + +export type Coverage = { coverage: number; uncovered: UncoveredPatch[] } + +/** Cells of `cell` metres from the region's near corner (u0, v0); the last ones may run past (u1, v1). */ +type Grid = { u0: number; v0: number; u1: number; v1: number; cell: number; nu: number; nv: number } + +function gridOver(area: MultiPolygon): Grid | null { + let [u0, v0, u1, v1] = [Infinity, Infinity, -Infinity, -Infinity] + for (const { outer } of area) { + for (const [u, v] of outer) { + u0 = Math.min(u0, u) + v0 = Math.min(v0, v) + u1 = Math.max(u1, u) + v1 = Math.max(v1, v) + } + } + const [w, h] = [u1 - u0, v1 - v0] + if (!(w > EPS && h > EPS)) return null + const cell = Math.max(CELL, Math.sqrt((w * h) / MAX_CELLS)) + return { u0, v0, u1, v1, cell, nu: Math.ceil(w / cell - EPS), nv: Math.ceil(h / cell - EPS) } +} + +/** The build's triangles seen square-on to the plane, (u, v) × 3 each; those edge-on are left out. */ +function projectedTriangles(meshes: THREE.Mesh[], axis: HostRegion['axis']): Float64Array { + const out: number[] = [] + const point = new THREE.Vector3() + const corner: number[] = [0, 0, 0, 0, 0, 0] + for (const mesh of meshes) { + const position = mesh.geometry.getAttribute('position') + const index = mesh.geometry.getIndex() + const count = index ? index.count : position.count + for (let t = 0; t + 2 < count; t += 3) { + for (let k = 0; k < 3; k++) { + point.fromBufferAttribute(position, index ? index.getX(t + k) : t + k) + point.applyMatrix4(mesh.matrixWorld) + corner[2 * k] = point.x + corner[2 * k + 1] = axis === 'y' ? point.z : point.y + } + const [ua, va, ub, vb, uc, vc] = corner as [number, number, number, number, number, number] + if (Math.abs((ub - ua) * (vc - va) - (uc - ua) * (vb - va)) < 1e-12) continue + out.push(ua, va, ub, vb, uc, vc) + } + } + return Float64Array.from(out) +} + +type Strips = { mid: Float64Array; height: Float64Array } + +/** The strips the region is read in: cell rows cut at every corner's height, or in LINES. */ +function stripsOf(grid: Grid, triangles: Float64Array, rings: Ring[]): Strips { + const { v0, v1, cell, nv } = grid + const inside = (v: number) => v > v0 && v < v1 + const corners: number[] = [] + for (let k = 1; k < triangles.length; k += 2) + if (inside(triangles[k]!)) corners.push(triangles[k]!) + for (const ring of rings) for (const [, v] of ring) if (inside(v)) corners.push(v) + const cuts = Array.from({ length: nv }, (_, j) => v0 + j * cell) + cuts.push(v1) + const sorted = Float64Array.from(corners).sort() + let distinct = 0 + for (let k = 0; k < sorted.length; k++) if (!k || sorted[k]! - sorted[k - 1]! > EPS) distinct++ + if (distinct <= nv * MAX_CORNERS_PER_ROW) cuts.push(...sorted) + else + for (let j = 0; j < nv; j++) + for (let k = 1; k < LINES; k++) cuts.push(v0 + (j + k / LINES) * cell) + const at = Float64Array.from(cuts).sort() + const mid: number[] = [] + const height: number[] = [] + let from = at[0]! + for (let k = 1; k < at.length; k++) { + if (at[k]! - from <= EPS) continue + mid.push((from + at[k]!) / 2) + height.push(at[k]! - from) + from = at[k]! + } + return { mid: Float64Array.from(mid), height: Float64Array.from(height) } +} + +/** What the triangles cover along each strip's middle line, as unsorted [from, to] pairs. */ +function coveredSpans(triangles: Float64Array, { mid }: Strips): number[][] { + const spans: number[][] = Array.from({ length: mid.length }, () => []) + for (let t = 0; t < triangles.length; t += 6) { + const u = [triangles[t]!, triangles[t + 2]!, triangles[t + 4]!] + const v = [triangles[t + 1]!, triangles[t + 3]!, triangles[t + 5]!] + const vMin = Math.min(v[0]!, v[1]!, v[2]!) - EPS + const vMax = Math.max(v[0]!, v[1]!, v[2]!) + EPS + let first = 0 + let last = mid.length + while (first < last) { + const half = (first + last) >> 1 + if (mid[half]! < vMin) first = half + 1 + else last = half + } + for (let s = first; s < mid.length && mid[s]! <= vMax; s++) { + const at = mid[s]! + let lo = Infinity + let hi = -Infinity + for (let a = 0, b = 2; a < 3; b = a++) { + const [ua, va, ub, vb] = [u[a]!, v[a]!, u[b]!, v[b]!] + if (at < Math.min(va, vb) - EPS || at > Math.max(va, vb) + EPS) continue + if (Math.abs(vb - va) < EPS) { + lo = Math.min(lo, ua, ub) + hi = Math.max(hi, ua, ub) + } else { + const cross = ua + ((at - va) / (vb - va)) * (ub - ua) + lo = Math.min(lo, cross) + hi = Math.max(hi, cross) + } + } + if (lo <= hi) spans[s]!.push(lo - EPS, hi + EPS) + } + } + return spans +} + +/** The region along line `v`, as sorted [from, to] pairs, even–odd over every ring. */ +function regionSpans(rings: Ring[], v: number): number[] { + const crossings: number[] = [] + for (const ring of rings) { + for (let i = 0, k = ring.length - 1; i < ring.length; k = i++) { + const [ua, va] = ring[k]! + const [ub, vb] = ring[i]! + if (va > v !== vb > v) crossings.push(ua + ((v - va) * (ub - ua)) / (vb - va)) + } + } + return crossings.sort((a, b) => a - b) +} + +/** Adds the area [a, b) × `height` lays on each cell of row `j` to `bare`. */ +function addToCells( + bare: Float64Array, + grid: Grid, + j: number, + a: number, + b: number, + height: number, +) { + const first = Math.max(0, Math.floor((a - grid.u0) / grid.cell)) + const last = Math.min(grid.nu - 1, Math.floor((b - grid.u0) / grid.cell)) + for (let i = first; i <= last; i++) { + const length = Math.min(b, grid.u0 + (i + 1) * grid.cell) - Math.max(a, grid.u0 + i * grid.cell) + if (length > 0) bare[j * grid.nu + i]! += length * height + } +} + +/** The cells of `mask` that belong to a 2 × 2 block of them: a gap one cell wide is not a patch. */ +function withoutThin(mask: Uint8Array, { nu, nv }: Grid): Uint8Array { + const block = new Uint8Array(nu * nv) + for (let j = 0; j + 1 < nv; j++) { + for (let i = 0; i + 1 < nu; i++) { + const c = j * nu + i + block[c] = mask[c]! & mask[c + 1]! & mask[c + nu]! & mask[c + nu + 1]! + } + } + const open = new Uint8Array(nu * nv) + for (let j = 0; j < nv; j++) { + for (let i = 0; i < nu; i++) { + const c = j * nu + i + open[c] = + block[c]! | + (i > 0 ? block[c - 1]! : 0) | + (j > 0 ? block[c - nu]! : 0) | + (i > 0 && j > 0 ? block[c - nu - 1]! : 0) + } + } + return open +} + +/** `mask` grown by `r` cells each way (a square), through running sums along rows then columns. */ +function grown(mask: Uint8Array, { nu, nv }: Grid, r: number): Uint8Array { + const rows = new Uint8Array(nu * nv) + const sums = new Int32Array(Math.max(nu, nv) + 1) + for (let j = 0; j < nv; j++) { + for (let i = 0; i < nu; i++) sums[i + 1] = sums[i]! + mask[j * nu + i]! + for (let i = 0; i < nu; i++) + rows[j * nu + i] = sums[Math.min(nu, i + r + 1)]! > sums[Math.max(0, i - r)]! ? 1 : 0 + } + const out = new Uint8Array(nu * nv) + for (let i = 0; i < nu; i++) { + for (let j = 0; j < nv; j++) sums[j + 1] = sums[j]! + rows[j * nu + i]! + for (let j = 0; j < nv; j++) + out[j * nu + i] = sums[Math.min(nv, j + r + 1)]! > sums[Math.max(0, j - r)]! ? 1 : 0 + } + return out +} + +/** Bounds of the patches, gathered by the places they grow into, largest first. */ +function patchesOf(patches: Uint8Array, grid: Grid): UncoveredPatch[] { + const { nu, nv, u0, v0, u1, v1, cell } = grid + const places = grown(patches, grid, Math.ceil(NEAR / 2 / cell)) + const label = new Int32Array(nu * nv).fill(-1) + const queue = new Int32Array(nu * nv) + const found: { cells: number; i0: number; j0: number; i1: number; j1: number }[] = [] + for (let start = 0; start < places.length; start++) { + if (!places[start] || label[start] !== -1) continue + const id = found.length + const place = { cells: 0, i0: nu, j0: nv, i1: -1, j1: -1 } + found.push(place) + let head = 0 + let tail = 0 + queue[tail++] = start + label[start] = id + while (head < tail) { + const c = queue[head++]! + const i = c % nu + const j = (c - i) / nu + if (patches[c]) { + place.cells++ + place.i0 = Math.min(place.i0, i) + place.j0 = Math.min(place.j0, j) + place.i1 = Math.max(place.i1, i) + place.j1 = Math.max(place.j1, j) + } + for (const n of [i > 0 ? c - 1 : -1, i < nu - 1 ? c + 1 : -1, c - nu, c + nu]) { + if (n >= 0 && n < places.length && places[n] && label[n] === -1) { + label[n] = id + queue[tail++] = n + } + } + } + } + const mm = (value: number) => Math.round(value * 1000) / 1000 + return found + .sort((a, b) => b.cells - a.cells) + .slice(0, MAX_PATCHES) + .map(({ i0, j0, i1, j1 }) => ({ + min: [mm(u0 + i0 * cell), mm(v0 + j0 * cell)], + max: [mm(Math.min(u1, u0 + (i1 + 1) * cell)), mm(Math.min(v1, v0 + (j1 + 1) * cell))], + })) +} + +/** + * How much of the region the build covers, seen square-on to its plane (a floor from above, a + * face from the room). `coverage` is the covered share of the region's area, two decimals, + * joints included: read along strips between the heights of every corner, exact for boards and + * tiles laid square. On a grid of 2 cm cells (coarser on a region too big for 250k of them), a + * cell more than half bare is bare; `uncovered` bounds the largest bare patches (two cells across + * at least, so no joint is one; patches nearer than NEAR are one place), largest first. Null + * when the region has no area. + */ +export function measureCoverage(meshes: THREE.Mesh[], region: HostRegion): Coverage | null { + const grid = gridOver(region.area) + if (!grid) return null + const rings = region.area.flatMap(({ outer, holes }) => [outer, ...holes]) + const triangles = projectedTriangles(meshes, region.axis) + const strips = stripsOf(grid, triangles, rings) + const spans = coveredSpans(triangles, strips) + const bareArea = new Float64Array(grid.nu * grid.nv) + let inside = 0 + let bare = 0 + for (let s = 0; s < strips.mid.length; s++) { + const [at, height] = [strips.mid[s]!, strips.height[s]!] + const row = Math.min(grid.nv - 1, Math.floor((at - grid.v0) / grid.cell)) + const exposed = (from: number, to: number) => { + bare += (to - from) * height + addToCells(bareArea, grid, row, from, to, height) + } + const area = regionSpans(rings, at) + const pairs = spans[s]! + const order = Array.from({ length: pairs.length / 2 }, (_, p) => p).sort( + (p, q) => pairs[2 * p]! - pairs[2 * q]!, + ) + for (let r = 0; r + 1 < area.length; r += 2) { + const [from, to] = [area[r]!, area[r + 1]!] + inside += (to - from) * height + let cursor = from + for (const p of order) { + const [lo, hi] = [pairs[2 * p]!, pairs[2 * p + 1]!] + if (hi <= cursor) continue + if (lo >= to) break + if (lo > cursor) exposed(cursor, lo) + cursor = hi + if (cursor >= to) break + } + if (cursor < to) exposed(cursor, to) + } + } + if (inside <= EPS * EPS) return null + const half = (grid.cell * grid.cell) / 2 + const bareCells = bareArea.map((area) => (area > half ? 1 : 0)) + return { + coverage: Math.round((1 - bare / inside) * 100) / 100, + uncovered: patchesOf(withoutThin(Uint8Array.from(bareCells), grid), grid), + } +} diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts index d3314806de..be0e3dd306 100644 --- a/packages/mcp/src/tools/author-scene.test.ts +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -148,6 +148,21 @@ const BOARDS = `export default function build({ THREE, params }) { return g }` +/** Rows of 1.2 × 0.2 m boards whose odd rows start 0.4 m in from the floor's west edge. */ +const GAPPED = `export default function build({ THREE, params }) { + const xs = params.polygon.map((p) => p[0]), zs = params.polygon.map((p) => p[1]) + const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)] + const g = new THREE.Group() + const m = new THREE.MeshStandardMaterial({ color: '#b08060' }); m.name = 'slot_wood' + for (let row = 0; z0 + row * 0.2 < z1; row++) + for (let x = x0 + (row % 2 ? 0.4 : 0); x < x1; x += 1.2) { + const b = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.01, 0.2), m) + b.position.set(x + 0.6, params.elevation + 0.005, z0 + (row + 0.5) * 0.2) + g.add(b) + } + return g +}` + /** Half-metre tiles from past the start of a wall face to past its end and top, over its openings. */ const TILES = `export default function build({ THREE, params }) { const { outline } = params @@ -405,11 +420,13 @@ describe('author_scene (MCP)', () => { const report = response.structuredContent as { floorScripts: number defects: unknown[] + warnings: { code: string }[] ids: Record payload?: unknown } expect(report.defects).toEqual([]) expect(report.floorScripts).toBe(1) + expect(report.warnings.filter((w) => w.code === 'finish_gaps')).toEqual([]) const read = async (id: string) => { const got = await client.callTool({ name: 'get_node', arguments: { id } }) return JSON.parse((got.content as { text: string }[])[0]!.text).node as Record @@ -438,6 +455,49 @@ describe('author_scene (MCP)', () => { }) }) + test('a floor module that leaves a strip bare is written, with a warning that says how much and where', async () => { + const level = payload.levels[0]! + const design = { + ...payload, + levels: [ + { ...level, items: [], lights: [], rooms: [{ ...level.rooms[0]!, floorCode: GAPPED }] }, + ], + } + const response = await client.callTool({ name: 'author_scene', arguments: { payload: design } }) + const report = response.structuredContent as { + floorScripts: number + defects: unknown[] + warnings: { id: string; code: string; message: string }[] + ids: Record + } + expect(report.defects).toEqual([]) + expect(report.floorScripts).toBe(1) + const got = await client.callTool({ + name: 'get_node', + arguments: { id: report.ids['room/0/0']! }, + }) + const script = JSON.parse((got.content as { text: string }[])[0]!.text).node.floor.script + const { polygon } = builtFor.get(script.artifact) as { polygon: [number, number][] } + const xs = polygon.map(([x]) => x) + const zs = polygon.map(([, z]) => z) + const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)] + // Each odd row leaves 0.4 m of its width bare at the west edge. + let bare = 0 + for (let row = 1; z0 + row * 0.2 < z1; row += 2) + bare += 0.4 * Math.min(0.2, z1 - (z0 + row * 0.2)) + const coverage = Math.round((1 - bare / ((x1 - x0) * (z1 - z0))) * 100) / 100 + expect(script.manifest.coverage).toBe(coverage) + expect(report.warnings.filter((w) => w.code === 'finish_gaps')).toEqual([ + { + id: 'room/0/0', + code: 'finish_gaps', + message: expect.stringContaining( + `covers ${Math.round(coverage * 100)}% of its floor: uncovered along the west edge (0.4 × `, + ), + }, + ]) + }) + test('a floor script that fails is a defect and the room keeps its floor finish', async () => { const level = payload.levels[0]! const design = { diff --git a/packages/mcp/src/tools/author-scene.ts b/packages/mcp/src/tools/author-scene.ts index 2ad25eddcd..8b6adf8056 100644 --- a/packages/mcp/src/tools/author-scene.ts +++ b/packages/mcp/src/tools/author-scene.ts @@ -7,6 +7,7 @@ import { authorScene, authorSceneCeilingPoints, authorSceneFaceScripts, + authorSceneFinishGaps, authorSceneFloorScripts, type SceneNodes, setRoomFloorScript, @@ -162,6 +163,12 @@ export function registerAuthorScene( ) if (written.changes) bridge.applyPatch(toPatches(written.changes)) outcome.result.floorScripts++ + const room = (bridge.getNodes() as SceneNodes)[zoneId]?.name ?? roomId + const gaps = authorSceneFinishGaps(roomId, built.manifest, { + room, + polygon: inputs.polygon, + }) + if (gaps) outcome.result.warnings.push(gaps) } catch (error) { outcome.result.defects.push({ id: roomId, @@ -199,6 +206,9 @@ export function registerAuthorScene( ) if (written.changes) bridge.applyPatch(toPatches(written.changes)) outcome.result.faceScripts++ + const id = `${wallId}/${face}` + const gaps = authorSceneFinishGaps(id, built.manifest, { wall: wallId, face }) + if (gaps) outcome.result.warnings.push(gaps) } catch (error) { outcome.result.defects.push({ id: `${wallId}/${face}`, diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 9360af527a..910424407e 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -136,12 +136,16 @@ A room's floor finish can be a script: `zone.floor.script` holds the same source Pascal cuts the build to the floor or face and its openings; the module lays out, it does not have to clip: after the module runs, before the merge and export, each mesh is kept inside `polygon` and outside every hole, as a prism over the build's full height. A mesh wholly inside, touching no hole, is kept as built (the common plank, no CSG); one wholly outside or in a hole is dropped; one across the boundary is intersected with the region's prism (`three-bvh-csg`, a closed mesh gaining the cut faces, an open one trimmed), keeping its material and part. The manifest's triangles and bounds are those of the cut build, and the same module and floor give the same bytes. The module still gets `holes` for a layout that wants them (a pattern centred on a column). The cut is the compiler's (`geometry-script/src/cut.ts`), so every host that compiles with `inputs` gets it. +The cut removes what hangs outside the floor; it cannot add what the module never built (odd rows started 40 cm in leave a strip bare along the wall). So after the cut the compiler measures what the build covers (`geometry-script/src/coverage.ts`) and writes it in the manifest: `coverage`, the share of the polygon less its holes the build covers seen from above (0..1, two decimals; joints count as bare), and `uncovered`, up to four rectangles `{ min: [x, z], max: [x, z] }` in level coordinates bounding the largest bare patches, largest first. The floor is read along strips cut at every corner's height, exact for boards and tiles laid square; a 2 cm cell more than half bare is bare, a patch is at least two cells across (no joint is one), and patches within 0.3 m of each other are one. A 20 m² plank floor measures in about 5 ms. `author_scene` warns `finish_gaps` (id `room//`) when `coverage` is under 0.98 and a patch exists, naming each by the floor's edge it runs along and its extent: "covers 93% of its floor: uncovered along the west edge (0.4 × 4.6 m, x 0 to 0.4, z 0.2 to 4.8)". A tiled floor is never warned for its joints alone. + The zone renderer draws the artifact 1 mm over the plate, with no pointer events of its own, so a click reaches the floor beneath. The script owns the floor's colours: the plate gives a scripted room its own top cell, and `floorPlatePaintRefusal` (the editor's Paint) and `set_zone_intent` (`floor.finish`, `floor.regions`) refuse paint there with a sentence until it is removed. When the structure reconcile re-derives the room (a wall moved, an edge pushed, the floor raised), the key no longer matches (`staleFinishScripts`) and `refreshFinishScripts(nodes, host)` runs the stored module and params again for the new floor; `finishScriptWrites` writes each rebuild only while the floor is still the one it was rebuilt for. The editor tab does this after each settled change (`initFinishScriptRefresh`), writing the rebuild outside history like the reconcile's derived writes. The room panel shows "Scripted finish" with its code and Remove. ## Wall face scripts The twin of a floor script for one face of one wall: tiles with real joints, panelling to a dado, boards. `wall.finishScripts.a` / `.b` hold the same source record plus `inputs`, the key of the face it was built for, and `toHeight`, metres above the face's base where it stops (absent: the whole face). `wallFaceScriptInputs(nodes, wallId, face, toHeight?)` (core) reads the face in its own frame, in metres: x along the wall from its start, y up from the face's base (its lowest floor), z out of the face. The module gets `params.outline`, the face's rectangle `{ x0, y0, x1, y1 }` between its mitred ends and up to the wall's top or `toHeight`; `params.holes`, the doors and windows cutting it as rectangles clipped to the outline (a door from the floor under it, a window from its sill); `params.base`, the face's floor as `[x, y]` points from end to end (one level run, or a jump where a raised room meets it); and `params.thickness`. Pascal cuts the build to the floor or face and its openings; the module lays out, it does not have to clip: as for a floor, the build is kept inside `outline` and outside every hole rectangle, as a prism along z over the build's depth, so tiles may run past the face's ends and over a window, and `holes` remain for a layout centred on one. Curved and curtain walls have no face to build on. `setWallFaceScript` writes a compile on the face (`face`, `compiled`, `inputs`, `toHeight`) or takes it off with `remove`; three.js modules only. +The manifest carries the same `coverage` and `uncovered` as a floor's, measured on the outline less its holes seen from the room, with patches as `{ min: [x, y], max: [x, y] }` in the face's frame. `author_scene` warns `finish_gaps` (id `/a` or `/b`) under the same rule, naming each patch along the wall from its start and up from the base: "covers 85% of the face: uncovered 0 to 0.4 m along the wall from its start, 0 to 2.7 m up (0.4 × 2.7 m)". Tile joints lower `coverage` (4 mm joints on 25 cm tiles: 0.97) but make no patch and no warning. + The wall renderer draws the artifact under the wall mesh, like the trims: `wallFaceScriptPlacement` puts the build's origin at the face's base, 1 mm proud of the face (`getWallLocalFaceZ`), and mirrors face b's build through the wall (scale z −1, which three.js renders with the winding flipped) so x still runs from the wall's start and z points out of face b. It has no pointer events, so clicks reach the wall. The skirting keeps running along the base; where the build comes down into the skirting's band (its bounds' bottom under the skirting's top on that face) the skirting stops over the build's span, as at a door, so the script wins visually; a splashback above it leaves it whole. A script to the top owns the face's colours: `wallFaceScriptPaintRefusal` refuses the face, its paint regions and the room's finish on it in the editor's Paint and the wall's paint commit, and `paint` refuses the face, until the script is removed; a script stopping at `toHeight` leaves the face paintable, since the paint shows above it. The other face is untouched. Stale face scripts (the wall lengthened, an opening added or moved, a floor raised) rebuild through the same `staleFinishScripts` / `refreshFinishScripts` as floors. Turning a wall around swaps its face scripts; a split copies them onto both pieces, which then rebuild for their own faces. `get_source` reads a face script with the wall's id and `face` (optional when one face has one). The wall panel shows "Scripted finish" per face with its code and Remove. ## Cutter binding From f87c929fb0415a89fbc9785fd7cb55a6c738ff03 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 16:26:07 -0400 Subject: [PATCH 25/26] core + mcp: author_scene keeps authored glass, glow and surface on exact slots A converted house showed its window glass and a pendant's bulb opaque, and two agents painted the originals back by hand: every authored slot with a colour was written as an exact matte material (roughness 0.9, metalness 0, opaque), over the glass the library match had given it and over the module's own emissive material. A slot add_object writes as glass (named glass, or transparent in the manifest) now stays library:preset-glass, on items, scripted doors and scripted windows alike. A scene material has no emission (MaterialProperties is colour, roughness, metalness, opacity, transparent, side), so an emissive slot keeps the module's own material and a bulb stays lit, its light effect unchanged. Every other exact slot carries the module's roughness and metalness (0.9 / 0 only when absent), one scene material per colour and surface, named by them when not matte. Co-Authored-By: Claude Fable 5.1 --- .../src/agent-operations/author-scene.test.ts | 170 ++++++++++++++++++ .../core/src/agent-operations/author-scene.ts | 38 ++-- packages/mcp/src/tools/author-scene.test.ts | 88 ++++++++- skills/pascal-3d/SKILL.md | 2 +- skills/pascal-3d/references/tool-workflows.md | 2 +- 5 files changed, 287 insertions(+), 13 deletions(-) diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 4abde2d860..05fb74dfb6 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -553,6 +553,176 @@ describe('author_scene', () => { expect(outcome.result.defects).toEqual([]) }) + // The living room's glazed balcony door and the house's casements and privacy glass (opacity + // 0.64, under the compiler's transparent cut-off but named glass) rendered opaque once exact. + test('a glass slot, by its name or a transparent hint, stays glass on items, scripted doors and scripted windows', () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + lights: [], + items: [bedroom.levels[0]!.items![0]], + walls: bedroom.levels[0]!.walls!.map((w) => + w.id === 'south' + ? { + ...w, + openings: [ + { + kind: 'door', + along: 1, + width: 1, + height: 2.1, + code: '// glazed leaves', + color: '#f5f4ef', + }, + { + kind: 'window', + along: 3, + width: 0.9, + height: 1.2, + sill: 0.9, + code: '// casement', + }, + ], + } + : w, + ), + }, + ], + }) + const withSlots = (base: CompiledGeometryScript, slots: object[]) => + ({ ...base, manifest: { ...base.manifest, slots } }) as CompiledGeometryScript + const casement = { + ...arch, + manifest: { ...arch.manifest, bounds: { min: [-0.45, 0, -0.1], max: [0.45, 1.2, 0.1] } }, + } as CompiledGeometryScript + const outcome = run( + payload, + { + bed: withSlots(bed, [ + { id: 'glass', color: '#d8e4dc', roughness: 0.55, metalness: 0, transparent: false }, + { id: 'pane', color: '#bfd6d3', roughness: 0.12, metalness: 0, transparent: true }, + { id: 'wood', color: '#8a6a4a' }, + ]), + 'south/0': withSlots(arch, [ + { id: 'glass', color: '#bfd6d3', roughness: 0.12, metalness: 0, transparent: true }, + { id: 'f5f4ef', color: '#f5f4ef' }, + ]), + 'south/1': withSlots(casement, [ + { id: 'frame', color: '#f1ece4' }, + { id: 'glass', color: '#bfd6d3', roughness: 0.12, metalness: 0, transparent: true }, + ]), + }, + {}, + ) + expect(outcome.result.defects).toEqual([]) + const item = byAuthorId(outcome, 'bed') + const door = byAuthorId(outcome, 'south/0') + const window = byAuthorId(outcome, 'south/1') + expect(item.slots.glass).toBe('library:preset-glass') + expect(item.slots.pane).toBe('library:preset-glass') + expect(door.slots.glass).toBe('library:preset-glass') + expect(window.slots.glass).toBe('library:preset-glass') + expect([item.slots.wood, door.slots.f5f4ef, window.slots.frame]).toEqual([ + expect.stringMatching(/^scene:/), + expect.stringMatching(/^scene:/), + expect.stringMatching(/^scene:/), + ]) + const colours = outcome.changes!.materials!.map((m) => m.material.properties!.color) + expect(colours).not.toContain('#bfd6d3') + expect(colours).not.toContain('#d8e4dc') + expect(outcome.changes!.materials!.every((m) => !m.material.properties!.transparent)).toBe(true) + }) + + test("an exact slot keeps its module's roughness and metalness, one material per colour and surface", () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [{ ...bedroom.levels[0], lights: [], items: [bedroom.levels[0]!.items![0]] }], + }) + const compiled = { + ...bed, + manifest: { + ...bed.manifest, + slots: [ + { id: 'brass', color: '#ba9860', roughness: 0.34, metalness: 0.72 }, + { id: 'oak', color: '#8a6a4a', roughness: 1, metalness: 0 }, + { id: 'oakSatin', color: '#8a6a4a', roughness: 0.4, metalness: 0 }, + { id: 'oakTwin', color: '#8a6a4a', roughness: 1, metalness: 0 }, + { id: 'plain', color: '#f1ece4' }, + ], + }, + } as CompiledGeometryScript + const outcome = run(payload, { bed: compiled }, {}) + const item = byAuthorId(outcome, 'bed') + const material = (ref: string) => + outcome.changes!.materials!.find((m) => `scene:${m.id}` === ref)! + expect(material(item.slots.brass)).toMatchObject({ + name: '#ba9860, roughness 0.34, metalness 0.72', + material: { properties: { color: '#ba9860', roughness: 0.34, metalness: 0.72 } }, + }) + expect(material(item.slots.oak).material.properties).toMatchObject({ + roughness: 1, + metalness: 0, + }) + expect(material(item.slots.oakSatin).material.properties).toMatchObject({ roughness: 0.4 }) + expect(item.slots.oak).not.toBe(item.slots.oakSatin) + expect(item.slots.oakTwin).toBe(item.slots.oak) + // No hint: the matte paint a payload colour gets, shared with the wall in that colour. + expect(material(item.slots.plain).material.properties).toMatchObject({ + roughness: 0.9, + metalness: 0, + }) + expect(item.slots.plain).toBe(byAuthorId(outcome, 'north').slots.a) + }) + + // A scene material has no emission: an emissive slot keeps the module's own material. + test("a bulb's emissive slot keeps the module's lit material and its fixture keeps the light", () => { + const payload = authorScenePayload.parse({ + ...bedroom, + levels: [ + { + ...bedroom.levels[0], + items: [ + { + id: 'pendant', + name: 'Pendant', + category: 'lamp', + code: 'export default function build() {}', + position: [0, 2, 0], + }, + ], + lights: [{ position: [0, 2.1, 0], fixture: 'pendant', color: '#ffd27a', intensity: 1.5 }], + }, + ], + }) + const pendant = { + ...bed, + manifest: { + ...bed.manifest, + bounds: { min: [-0.1, 0, -0.1], max: [0.1, 0.3, 0.1] }, + slots: [ + { id: 'filament', color: '#ffd27a', roughness: 0.4, metalness: 0, emissive: true }, + { id: 'cord', color: '#222222', roughness: 0.5, metalness: 0 }, + ], + }, + } as CompiledGeometryScript + const outcome = run(payload, { pendant }, {}) + expect(outcome.result.defects).toEqual([]) + const item = byAuthorId(outcome, 'pendant') + expect(item.slots.filament).toBeUndefined() + expect(item.slots.cord).toStartWith('scene:') + expect( + item.source.manifest.slots.find((s: { id: string }) => s.id === 'filament').emissive, + ).toBe(true) + expect(outcome.changes!.materials!.map((m) => m.material.properties!.color)).not.toContain( + '#ffd27a', + ) + expect(item.asset.interactive.effects).toEqual([ + expect.objectContaining({ kind: 'light', color: '#ffd27a', intensityRange: [0, 1.5] }), + ]) + }) + test('opening colours paint panel and frame exactly, with an optional different frame', () => { const payload = authorScenePayload.parse({ levels: [ diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index e9c8e26855..03fca45453 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -108,6 +108,7 @@ const THIN_SLAB = 0.02 const MANY_SLABS = 20 const MANY_REGIONS = 24 const HEX = /^#[0-9a-f]{6}$/i +const GLASS = 'library:preset-glass' type Vec3 = [number, number, number] type AuthorSceneWall = NonNullable[number] @@ -202,10 +203,15 @@ function toLocal(frame: Frame, world: Vec3): Vec3 { return [c * dx - s * dz, dy, s * dx + c * dz] } +/** How a colour's surface reflects: a module's own values, or a matte paint for a payload colour. */ +type Surface = { roughness: number; metalness: number } +const MATTE: Surface = { roughness: 0.9, metalness: 0 } + /** * The finish a payload value writes. A library or scene ref passes through; a hex colour is kept - * as an exact native material, one scene material per distinct colour across the payload, named - * by the colour. A host that keeps no scene materials gets the nearest library colour, said once. + * as an exact native material, one scene material per distinct colour and surface across the + * payload, named by them. A host that keeps no scene materials gets the nearest library colour, + * said once. */ function finishPool(context: AgentContext, say: (sentence: string) => void) { const materials: Record | undefined = context.materials @@ -216,7 +222,7 @@ function finishPool(context: AgentContext, say: (sentence: string) => void) { return { materials, created, - ref(value: string): { ref: string } | { error: string } { + ref(value: string, surface: Surface = MATTE): { ref: string } | { error: string } { if (value.startsWith('library:') || value.startsWith('scene:')) return { ref: value } if (!HEX.test(value)) return { error: `${value} is no colour: give #rrggbb or library:.` } const hex = value.toLowerCase() @@ -231,14 +237,16 @@ function finishPool(context: AgentContext, say: (sentence: string) => void) { { properties: { color: hex, - roughness: 0.9, - metalness: 0, + roughness: surface.roughness, + metalness: surface.metalness, opacity: 1, transparent: false, side: 'front', }, }, - hex, + surface.roughness === MATTE.roughness && surface.metalness === MATTE.metalness + ? hex + : `${hex}, roughness ${+surface.roughness.toFixed(2)}, metalness ${+surface.metalness.toFixed(2)}`, ) if (resolved.created) { materials[resolved.sceneMaterial.id as SceneMaterialId] = resolved.sceneMaterial @@ -542,9 +550,13 @@ export function authorScene( report.warnings.push({ id, code, message }) const say = (sentence: string) => report.defaults.push(sentence) const finishes = finishPool(context, say) - const finishRef = (value: string | undefined, id: string): string | undefined => { + const finishRef = ( + value: string | undefined, + id: string, + surface?: Surface, + ): string | undefined => { if (!value) return undefined - const got = finishes.ref(value) + const got = finishes.ref(value, surface) if ('error' in got) { defect(id, 'invalid_color', got.error) return undefined @@ -554,8 +566,14 @@ export function authorScene( const exactSlots = (compiled: CompiledGeometryScript, id: string) => { const slots = matchScriptSlotsToLibrary(compiled.manifest) for (const slot of compiled.manifest.slots) { - if (!slot.color) continue - const ref = finishRef(slot.color, id) + // Glass keeps the glass add_object writes, drawn see-through and letting light pass. A + // scene material carries no emission, so an emissive slot keeps the module's own material + // (the library match leaves it alone) and a bulb stays lit. + if (!slot.color || slot.emissive || slots[slot.id] === GLASS) continue + const ref = finishRef(slot.color, id, { + roughness: slot.roughness ?? MATTE.roughness, + metalness: slot.metalness ?? MATTE.metalness, + }) if (ref) slots[slot.id] = ref } return slots diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts index be0e3dd306..2faee096f6 100644 --- a/packages/mcp/src/tools/author-scene.test.ts +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -331,7 +331,93 @@ describe('author_scene (MCP)', () => { expect(door.slots.panel).toBe(north.slots.a) expect(door.slots.frame).toBe(north.slots.b) expect(round.slots.frame).toBe(north.slots.b) - expect(trim.slots.body).toBe(north.slots.b) + // The baseboard's module material is rough 1, not the payload colour's matte 0.9: its own. + expect(trim.slots.body).toMatch(/^scene:/) + expect(trim.slots.body).not.toBe(north.slots.b) + }) + + test('authored glass stays glass, a bulb stays lit and a metal keeps its sheen, through the real compiler', async () => { + const LAMP = `export const mount = 'floor' +export default function build({ THREE }) { + const g = new THREE.Group() + const brass = new THREE.MeshStandardMaterial({ color: '#ba9860', metalness: 0.72, roughness: 0.34 }); brass.name = 'slot_brass' + const glass = new THREE.MeshPhysicalMaterial({ color: '#bfd6d3', transparent: true, opacity: 0.27, roughness: 0.12 }); glass.name = 'glass' + const bulb = new THREE.MeshStandardMaterial({ color: '#fff4e0', emissive: '#ffd27a', emissiveIntensity: 2 }); bulb.name = 'slot_fff4e0' + const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.4), brass); stem.position.y = 0.2; g.add(stem) + const shade = new THREE.Mesh(new THREE.SphereGeometry(0.12), glass); shade.position.y = 0.45; g.add(shade) + const filament = new THREE.Mesh(new THREE.SphereGeometry(0.03), bulb); filament.position.y = 0.45; g.add(filament) + return g +}` + const CASEMENT = `export const mount = 'wall' +export default function build({ THREE }) { + const g = new THREE.Group() + const frame = new THREE.MeshStandardMaterial({ color: '#f1ece4' }); frame.name = 'slot_frame' + const glass = new THREE.MeshPhysicalMaterial({ color: '#d8e4dc', transparent: true, opacity: 0.64, roughness: 0.55 }); glass.name = 'glass' + const sash = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.2, 0.06), frame); sash.position.y = 0.6; g.add(sash) + const pane = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.1, 0.01), glass); pane.position.y = 0.6; g.add(pane) + const hole = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.2, 0.3)); hole.position.y = 0.6; hole.name = 'cutout'; g.add(hole) + return g +}` + const level = payload.levels[0]! + const design = { + ...payload, + levels: [ + { + ...level, + walls: level.walls.map((w) => + w.id === 'e' + ? { + ...w, + openings: [ + { + kind: 'window', + along: 2.5, + width: 0.9, + height: 1.2, + sill: 0.9, + code: CASEMENT, + }, + ], + } + : w, + ), + items: [ + { id: 'lamp', name: 'Glass lamp', category: 'lamp', code: LAMP, position: [1, 0, 1] }, + ], + lights: [{ position: [1, 0.45, 1], fixture: 'lamp', color: '#ffd27a', intensity: 1.5 }], + }, + ], + } + const response = await client.callTool({ name: 'author_scene', arguments: { payload: design } }) + expect(response.isError, JSON.stringify(response)).not.toBe(true) + const report = response.structuredContent as { defects: unknown[]; ids: Record } + expect(report.defects).toEqual([]) + const read = async (id: string) => { + const got = await client.callTool({ name: 'get_node', arguments: { id } }) + return JSON.parse((got.content as { text: string }[])[0]!.text) as { + node: Record + materials?: Record< + string, + { name: string; material: { properties: Record } } + > + } + } + const lamp = await read(report.ids.lamp!) + expect(lamp.node.slots.glass).toBe('library:preset-glass') + // The bulb keeps the module's emissive material: no override, and its hint and light survive. + expect(lamp.node.slots.fff4e0).toBeUndefined() + expect(lamp.node.source.manifest.slots).toContainEqual( + expect.objectContaining({ id: 'fff4e0', emissive: true }), + ) + expect(lamp.node.asset.interactive.effects).toEqual([ + expect.objectContaining({ kind: 'light', color: '#ffd27a' }), + ]) + const brass = lamp.materials![lamp.node.slots.brass.slice('scene:'.length)]! + expect(brass.material.properties).toMatchObject({ color: '#ba9860', metalness: 0.72 }) + expect(brass.material.properties.roughness).toBeCloseTo(0.34, 6) + const casement = await read(report.ids['e/0']!) + expect(casement.node.slots.glass).toBe('library:preset-glass') + expect(casement.node.slots.frame).toMatch(/^scene:/) }) test('compiles a 60 mm ceiling point and persists its room ceiling host and light effect', async () => { diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 10b9ab9589..869dc4bdb6 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -73,7 +73,7 @@ Stair `targets` are design preferences in meters (`maxRiserHeight`, `minimumGoin When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, or with `language: 'bpy'` a Blender module when the shape needs Blender's modelling (bevels, smooth organic forms, booleans, curves), with the naming conventions its description lists for paint slots, parts, lights, cutouts and clips. Edit it by passing its `nodeId`. A new object needs a `reason` (why no Pascal tool or catalog item builds it); `verify_scene` lists every authored object with its reason. On a floor, a plain box with a wall's size or a floor plate is refused with the tool to use (`use_walls`, `use_slab`); a detailed object with a wall's size (a bookcase, a screen) is built with a hint naming `add_wall`. Servers without a script host answer `scripts_unavailable`. The hosted server builds three.js scripts in the user's open Pascal editor tab (Blender modules run in its Blender service and need no tab); when no tab has the project open it answers `editor_tab_required` with an `editorUrl`: give the user that link, wait until they say it is open, then repeat the same call. `editor_tab_busy` means a tab is open but busy or still loading: wait a few seconds and repeat the same call; the user need not be asked. Whether you may open the link yourself is the person's setting ("Agents may open the editor for me"; Ask first: ask the user to open the link (or bring the tab forward), or ask their permission before opening it yourself; Allow: you may open the link yourself, in a tab in front; Never: ask the user, and do not open or switch windows yourself); the refusal's words say which, so follow them. It applies to `editor_tab_required` and `editor_tab_hidden`; `editor_tab_busy` carries no link, and an autonomous agent account, which has no editor login, is refused with `editor_host_identity_required` whatever the setting says. To count or locate things by what they are, including typed parts inside authored objects, use `find_by_type`. To group elements of any kind into a named set the editor lists (all the lights, the windows, the kitchen cabinetry), use `edit_collection`, starting from a template when one fits; `list_collections` reads them back. -When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their colour (`color`, or `colorLeft` / `colorRight` per face; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open), rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module (`host` names the item it rests on, or the wall a casing or mirror is fixed to), and lights. A wall's baseboard is its `skirting` (`height`, `depth`, `color`), native on the wall along the faces its rooms stand on, never an item. A plank, tile or board floor is the room's `floorCode`, a module that receives `params.polygon`, `params.holes` and `params.elevation` and builds the finish for exactly that floor; Pascal rebuilds it when the room changes, keeps `floor` as the fallback, and `get_source` on the room reads it back. Tiles with joints, panelling to a dado or boards on a wall are the wall's `faceLeft` / `faceRight` (`code`, `toHeight` from the face's base, default the whole face), a module that receives `params.outline`, `params.holes` (its doors and windows), `params.base` and `params.thickness` in wall-local metres and builds the finish for exactly that face, or the room's `wallCode` (`wallToHeight`) for every face the room looks into; Pascal rebuilds it when the wall or its openings change, `colorLeft` / `colorRight` stay the paint under it, and `get_source` with the wall's id and `face` reads it back. A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. Pascal compiles and stores the modules, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it filled in (`defaults`: a window written fixed, a handle set opposite its hinge, a ceiling point for a light without a fixture), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions), what it could not place and why (`defects`: a floor module that failed is `floor_script_failed`, the room keeping its `floor`; a face module that failed is `face_script_failed`, a face no room looks into `no_face`), what it corrected (a wall end snapped onto the centerline it meets). Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and individual floor planks into the native wall, the floor finish and the room, and say so in `notes`; a slab over a wall's top is refused. Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. +When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines with their colour (`color`, or `colorLeft` / `colorRight` per face; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open), rooms with a seed point and a floor material, slabs, columns, railings, every piece of furniture as an authored object with its own module (`host` names the item it rests on, or the wall a casing or mirror is fixed to), and lights. A wall's baseboard is its `skirting` (`height`, `depth`, `color`), native on the wall along the faces its rooms stand on, never an item. A plank, tile or board floor is the room's `floorCode`, a module that receives `params.polygon`, `params.holes` and `params.elevation` and builds the finish for exactly that floor; Pascal rebuilds it when the room changes, keeps `floor` as the fallback, and `get_source` on the room reads it back. Tiles with joints, panelling to a dado or boards on a wall are the wall's `faceLeft` / `faceRight` (`code`, `toHeight` from the face's base, default the whole face), a module that receives `params.outline`, `params.holes` (its doors and windows), `params.base` and `params.thickness` in wall-local metres and builds the finish for exactly that face, or the room's `wallCode` (`wallToHeight`) for every face the room looks into; Pascal rebuilds it when the wall or its openings change, `colorLeft` / `colorRight` stay the paint under it, and `get_source` with the wall's id and `face` reads it back. A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item and scripted-opening slots keep their authored colour, roughness and metalness as exact materials; a material named `glass` or made transparent stays Pascal’s glass, and an emissive one keeps its own glow. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. Pascal compiles and stores the modules, derives the rooms and floors, paints, runs its checks and answers with a gate report: what it wrote, what it filled in (`defaults`: a window written fixed, a handle set opposite its hinge, a ceiling point for a light without a fixture), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions), what it could not place and why (`defects`: a floor module that failed is `floor_script_failed`, the room keeping its `floor`; a face module that failed is `face_script_failed`, a face no room looks into `no_face`), what it corrected (a wall end snapped onto the centerline it meets). Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and individual floor planks into the native wall, the floor finish and the room, and say so in `notes`; a slab over a wall's top is refused. Fix your program, call again with `replace: true`, until the report is clean and the editor shows the design. Pass the module and your program as `module` and `adapter`: they are kept with the scene. Build the building as it is built: a wall at its real height (a half-height partition is fine), nothing lowered or removed for viewing. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. Pascal uses meters. X and Z are floor-plan axes; Y is vertical. Tool fields that accept measurements may also accept strings such as `"6 ft"` or `"180cm"`, but report final spatial values in meters and retain the user's original units when useful. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 81f0b9b830..97a716b560 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -56,7 +56,7 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra `author_scene` turns a whole design you wrote as one three.js module into native, editable Pascal nodes in one call. The reading is yours; the writing is Pascal's. 1. Read your module: its source for intent (helper names, declared opening types), the executed graph for truth (world transforms, deleted objects gone). Most designs make walls with a helper such as `wall(x1, z1, x2, z2, height, thickness, openings)`: those arguments are the payload's walls. -2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour; `skirting` with `height` (default 0.1 m), `depth` (default 0.012 m) and `color` for the baseboard, native on the wall along the faces its rooms stand on), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`; `floorCode` for planks, tiles or boards, a module that receives `params.polygon`, `params.holes` and `params.elevation`, level coordinates in metres, and lays out the finish, which Pascal cuts to that floor and its holes: Pascal rebuilds it when the room changes, `floor` stays the fallback, `get_source` with the room's id reads it back; `wallCode`, with `wallToHeight`, for the finish of every wall face the room looks into), each wall's `faceLeft` / `faceRight` (`code`, `toHeight` from the face's base, default the whole face) for tiles, panelling or boards on that face, a module that receives `params.outline`, `params.holes`, `params.base` and `params.thickness` in wall-local metres (x along the wall from its start, y up from the face's base, z out of the face) and lays out the finish, which Pascal cuts to the outline and its openings, over the room's `wallCode`, `colorLeft` / `colorRight` staying the paint under it, `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a mirror on a wall), `lights` (`position`, `fixture`). A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item slots keep their authored colours as exact materials. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. +2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `color` for both faces or `colorLeft` / `colorRight` for one, the left being on your left looking from start to end; a hex is written as an exact material, one per colour; `skirting` with `height` (default 0.1 m), `depth` (default 0.012 m) and `color` for the baseboard, native on the wall along the faces its rooms stand on), their `openings` (`kind`, `along` from the wall's start, `width`, `height`, `sill`, `type` for how it opens: hinged, double, french, sliding, pocket… for a door, fixed, casement, awning, double-hung… for a window; `style` for its look: glass, six-panel, modern… for a leaf, grid, transom, double-hung… for a fixed window's panes; `leaves`, `hinge`; or `code`, a module with mount `wall`, for a shape those cannot express), `rooms` (`name`, `seed`, `floor` as `library:` from `search_materials` or `#rrggbb`; `floorCode` for planks, tiles or boards, a module that receives `params.polygon`, `params.holes` and `params.elevation`, level coordinates in metres, and lays out the finish, which Pascal cuts to that floor and its holes: Pascal rebuilds it when the room changes, `floor` stays the fallback, `get_source` with the room's id reads it back; `wallCode`, with `wallToHeight`, for the finish of every wall face the room looks into), each wall's `faceLeft` / `faceRight` (`code`, `toHeight` from the face's base, default the whole face) for tiles, panelling or boards on that face, a module that receives `params.outline`, `params.holes`, `params.base` and `params.thickness` in wall-local metres (x along the wall from its start, y up from the face's base, z out of the face) and lays out the finish, which Pascal cuts to the outline and its openings, over the room's `wallCode`, `colorLeft` / `colorRight` staying the paint under it, `slabs` (terraces, platforms, balconies: Pascal builds floor plates, wall caps and ceilings itself, so fold a module's caps, underlays and floor planks into the wall, the floor finish and the room, and say so in `notes`), `columns`, `items` (`id`, `name`, `category`, `code` as its own module, `position` at its bottom centre, `rotation`, `host` for a lamp on a nightstand or a mirror on a wall), `lights` (`position`, `fixture`). A light without `fixture` gets a minimal ceiling point hosted on its room’s ceiling; outside every room it falls back to the nearest item on the level, reported in `defaults`. Item and scripted-opening slots keep their authored colour, roughness and metalness as exact materials; a material named `glass` or made transparent stays Pascal’s glass, and an emissive one keeps its own glow. Opening `color` paints the door’s panel and frame or the window’s frame; `frameColor` overrides the frame (default: `color`). Scripted openings keep their declared `type`; only `style` is ignored. Wall-hosted items keep their bottom centre’s outward distance from the nearest face; a point inside the wall moves to the face and is reported in `defaults`. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. 3. Call `author_scene` with `payload`, `module` and `adapter` (your program). Read the report: `defects` by your own ids with a code (`compile_failed`, `room_not_enclosed`, `host_missing`, `slab_over_wall`, `use_slab`, `skirting_refused` for a wall with no room on either face, `floor_script_failed` for a floor module that did not build, `face_script_failed` (`/`) for a face module that did not build, `no_face` for a face no room looks into…), `floorScripts` (rooms whose floor module was built), `faceScripts` (wall faces whose module was built), `warnings` (written, but doubtful: `thin_slab`, `many_slabs`, `many_regions`, `face_shared` for a face two rooms' `wallCode` claim), `defaults` (what Pascal filled in or dropped: a window with no `type` written fixed, a handle set opposite its hinge, a style dropped on an operable window, a ceiling point for a light without a fixture), `snapped` wall ends, `verify` issues, `itemsCompiled`, the editor link. 4. Fix the program, not the payload by hand, and call again with `replace: true`. Stop when `green` is true and the scene looks like the design. Report what you could not convert in `notes`. From e52c6e114bb2edccb63b3987eb87187d0e941872 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 19 Oct 2026 16:38:08 -0400 Subject: [PATCH 26/26] core: add_object's floor-plate rule from main covers rugs, so the name exemption goes Main refuses a thin wide box only where no floor lies under it; a rug on a room floor is built without naming it. The exemption author_scene added for rugs is no longer needed. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/agent-operations/add-object.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index 85db85495a..412e07851a 100644 --- a/packages/core/src/agent-operations/add-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -202,15 +202,10 @@ function refuseWallOrSlabShape( { label }, ) const base = input.position?.[1] ?? 0 - // A rug, carpet or mat is a thin wide box on the floor by nature, not a floor plate. - const covering = /\b(rug|carpet|mat|runner)\b/i.test( - `${input.name ?? ''} ${input.category ?? ''}`, - ) if ( height <= 0.35 && short >= 2 && base <= 0.05 && - !covering && shareOverFloor(nodes, levelId, compiled, input) < FLOOR_SHARE ) { // Its extent along x and z as it stands, so the numbers match the outline the agent drew.