diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index adebb221b9..412e07851a 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' @@ -616,11 +617,62 @@ export function scriptedFieldRefusal(node: AnyNode, data: Record & { + id: string + 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, 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 }) + 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, + ...(node.face ? { face: node.face } : {}), name: node.name, language: node.source.language, code, 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..05fb74dfb6 --- /dev/null +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -0,0 +1,1287 @@ +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 { CEILING_DRAW_OFFSET, resolveCeilingHeight } from '../services' +import { + authorScene, + authorSceneCeilingPoints, + authorSceneFaceScripts, + authorSceneFloorScripts, +} from './index' + +/** 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'], +} + +/** 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) +/** 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 + +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], fixture: 'bed' }, + ], + }, + ], + 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) + // 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') + 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 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', + ) as AnyNode & { slots?: Record } + 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(), + { 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) + // 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', () => { + 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', 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('scene:') + 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('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([]) + }) + + // 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: [ + { + 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, + 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.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, + 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'), + }, + ]) + }) + + 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([]) + }) + + 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 new file mode 100644 index 0000000000..03fca45453 --- /dev/null +++ b/packages/core/src/agent-operations/author-scene.ts @@ -0,0 +1,1309 @@ +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 { floorRoomFaces } from '../lib/floor-room-faces' +import { + matchScriptSlotsToLibrary, + requireMeasured, + 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 { wallFaceScriptInputs } from '../lib/wall-face-script' +import { nearestLibraryColorRef } from '../procedural-items/library-colors' +import { + type AnyNode, + type CeilingNode, + ColumnNode, + type CompiledGeometryScript, + DoorNode, + type GeometryArtifactManifest, + LevelNode, + type SceneMaterial, + type SceneMaterialId, + SlabNode, + type WallFace, + WallNode, + WindowNode, + 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 { roomFaceCover } from '../systems/wall/wall-room-sides' +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' +import { setWallSkirting } from './wall-skirting' + +/** + * `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 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 + * `/`. 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`. 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, keyed like `compiled`, 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 + /** 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. */ + defaults: string[] + /** Written, but doubtful: by the payload's id, with a code. */ + warnings: AuthorSceneDefect[] + defects: AuthorSceneDefect[] + notes: string[] + /** Payload ids, `wall/i` openings, `room/level/i`, and `light/level/i` ceiling points to nodes. */ + ids: Record +} + +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 +const GLASS = 'library:preset-glass' + +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 + * 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 `${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() + 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]!) + +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 + * 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) } +} + +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 +} + +/** 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] +} + +/** 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 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 + ? { ...context.materials } + : undefined + const created: SceneMaterial[] = [] + let snapped = false + return { + materials, + created, + 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() + 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: surface.roughness, + metalness: surface.metalness, + opacity: 1, + transparent: false, + side: 'front', + }, + }, + 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 + created.push(resolved.sceneMaterial) + } + return { ref: resolved.ref } + }, + } +} + +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) + +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 }], + ), + ) +} + +/** + * 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 }] : [] + }), + ) +} + +/** 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 } +} + +/** 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 + * `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, + 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.', + ) + + // 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)('level')]) revision++ + const mint = idFactory(payload, revision) + const report: AuthorSceneReport = { + levels: 0, + walls: 0, + doors: 0, + windows: 0, + rooms: 0, + roomsNamed: 0, + slabs: 0, + columns: 0, + items: 0, + lights: 0, + floorScripts: 0, + faceScripts: 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, + surface?: Surface, + ): string | undefined => { + if (!value) return undefined + const got = finishes.ref(value, surface) + if ('error' in got) { + defect(id, 'invalid_color', got.error) + return undefined + } + return got.ref + } + const exactSlots = (compiled: CompiledGeometryScript, id: string) => { + const slots = matchScriptSlotsToLibrary(compiled.manifest) + for (const slot of compiled.manifest.slots) { + // 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 + } + 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 = {} + 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 itemFrames = new Map() + const skirtings: { + id: string + nodeId: string + levelId: string + skirting: NonNullable + }[] = [] + let untypedWindows = 0 + + 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, + }) + 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 ---------- + const length = dist(w.start, w.end) + ;(w.spec.openings ?? []).forEach((o, i) => { + const id = `${w.spec.id}/${i}` + // A module supplies geometry; the declared type still describes how the opening works. + 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.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) { + defect( + id, + 'opening_wider_than_wall', + `${o.kind} ${width.toFixed(2)} m wide on a ${length.toFixed(2)} m wall.`, + ) + return + } + const x = Math.min(Math.max(o.along, width / 2), length - width / 2) + if (Math.abs(x - o.along) > 0.01) + say( + `${o.kind} ${id}: 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, + height, + rotation: [0, 0, 0], + ...(o.name ? { name: o.name } : {}), + ...(compiled + ? { + source: scriptSource(compiled, { category: o.kind }), + slots: exactSlots(compiled, id), + } + : {}), + } + let opening: AnyNode + if (o.kind === 'door') { + 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 + 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 + 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 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) + }) + } + + // ---------- slabs and columns ---------- + 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', + 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, + ...(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++ + }) + ;(level.columns ?? []).forEach((c, i) => { + 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]], + crossSection: 'square', + width: c.width, + depth: c.depth, + height: c.height, + baseHeight: 0, + capitalHeight: 0, + }) + place(node) + report.ids[`column/${index}/${i}`] = node.id + report.columns++ + }) + + // ---------- items, hosts first ---------- + const pending = [...(level.items ?? [])] + let progress = true + while (pending.length && progress) { + progress = false + for (const item of [...pending]) { + 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] + if (!compiled) { + defect( + item.id, + 'compile_failed', + input.compileErrors?.[item.id] ?? 'The module did not compile.', + ) + continue + } + 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 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] + 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: { ...placed.compiled, nodeId: mint('item') }, + name: item.name, + category: item.category, + reason: `author_scene: ${item.name}`, + 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, + 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) + itemFrames.set(item.id, placed.frame) + report.ids[item.id] = node.id + 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.`) + + // ---------- 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 + 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`, + ) + + // ---------- 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])) + 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++ + } + const claimed = new Map() + 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 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( + id, + 'room_shares_enclosure', + `"${room.name}" and "${claimed.get(zone.id)}" are one enclosure: no wall separates them.`, + ) + continue + } + if (!zone) { + defect( + id, + 'room_not_enclosed', + `No closed room around (${room.seed[0]}, ${room.seed[1]}) "${room.name}": its walls do not meet.`, + ) + continue + } + 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, id) ?? 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 } }, + }) + claimed.set(zone.id, room.name) + report.ids[id] = zone.id + 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 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)}`, + ) + } + } + + // ---------- 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]! + 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 { + result: report, + changes: { + ...(finishes.created.length ? { materials: finishes.created } : {}), + 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/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-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/index.ts b/packages/core/src/agent-operations/index.ts index c7297883e3..8bbb46d39a 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' @@ -71,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' @@ -82,8 +84,10 @@ 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' const SINGLE_OPERATIONS = { measure_stair: measureStairOperation, 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, 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..d90c670085 100644 --- a/packages/core/src/agent-operations/paint.ts +++ b/packages/core/src/agent-operations/paint.ts @@ -1,10 +1,12 @@ 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' import { type AnyNode, getEffectiveSegmentSurfaceMaterial, + getWallTrimSlotId, isGlazedRoofSegment, type MaterialSchema, type RoofNode, @@ -12,6 +14,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 +42,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 +330,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(', ')}.`, { @@ -343,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 new file mode 100644 index 0000000000..8cacc2ef58 --- /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 + * `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) => { + 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/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-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/add-object.ts b/packages/core/src/agent-tools/add-object.ts index 9aa722e7c3..a3996906f5 100644 --- a/packages/core/src/agent-tools/add-object.ts +++ b/packages/core/src/agent-tools/add-object.ts @@ -104,8 +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. 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.'), + nodeId: NodeId.describe( + '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/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts new file mode 100644 index 0000000000..d7edd9494b --- /dev/null +++ b/packages/core/src/agent-tools/author-scene.ts @@ -0,0 +1,321 @@ +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; 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). + +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(), + 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 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( + '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() + .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() + .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 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).`, { + 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'), + end: plan('end'), + height: length('Wall height.', { positive: true }), + thickness: length('Wall thickness.', { positive: true }), + color: z + .string() + .optional() + .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(), + 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.", + ), + 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 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 report warns when the build leaves part of the face uncovered.`, + ), +}) + +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 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), 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 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() + .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. The report warns when the build leaves part of a face uncovered.`, + ), + wallToHeight: faceToHeight("the room's wallCode"), +}) + +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 (an exact colour).'), + 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), 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.', + ), +}) + +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). 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.', + ), +}) + +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(1200)) + .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..75d704a1e6 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -39,6 +39,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' 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/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 } : {}), 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/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') diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 60f8c18879..1651e27ab4 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -745,27 +745,30 @@ 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, a wall face's script on its wall. + ...( + [ + ['item', 'source'], + ['window', 'source'], + ['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) => ({ 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.' }, @@ -919,6 +922,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/index.ts b/packages/core/src/index.ts index c7fc1391e9..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 { @@ -189,7 +199,17 @@ 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 FloorScriptInputs, + floorScriptInputs, + floorScriptInputsKey, + floorScriptRecord, +} from './lib/floor-script' export { type FloorStepOverride, floorStepOverrideFor, @@ -524,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, @@ -1006,6 +1038,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/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-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..44762d3afa --- /dev/null +++ b/packages/core/src/lib/floor-script.test.ts @@ -0,0 +1,214 @@ +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 { + 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' +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: FinishScriptHost = { + readScript: async (sha) => (sha === 'a'.repeat(64) ? 'export default () => {}' : null), + 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) + 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: FinishScriptHost) { + const nodes = room() + const zone = zoneOf(nodes) + const inputs = floorScriptInputs(nodes, zone.id)! + const compiled = await host.build({ nodeId: 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(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(staleFinishScripts(nodes).map((stale) => stale.nodeId)).toEqual([zone.id]) + + 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, { + 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(staleFinishScripts(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({ nodeId: 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(staleFinishScripts({ ...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..ec07cdb971 --- /dev/null +++ b/packages/core/src/lib/floor-script.ts @@ -0,0 +1,80 @@ +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' +import { + area, + difference, + intersection, + type MultiPolygon, + type Ring, + union, +} from './polygon-boolean' +import { roomFloorPlate } from './room-floor-plate' +import { scriptInputsKey } from './script-inputs-key' + +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 out over it; the compiler cuts the build + * to the polygon less its holes. + */ +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 +} + +/** The key a floor script keeps of the floor it was built for (`ZoneFloorScript.inputs`). */ +export function floorScriptInputsKey(inputs: FloorScriptInputs): string { + 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. */ +export function floorScriptRecord( + compiled: CompiledGeometryScript, + inputs: FloorScriptInputs, + previous?: ZoneFloorScript, +): ZoneFloorScript { + return { ...scriptSource(compiled, {}, previous), inputs: floorScriptInputsKey(inputs) } +} 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/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/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: 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/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 509c379b71..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, @@ -445,7 +446,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/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/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/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 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/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/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 6664b51bab..18144109b7 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 { initFinishScriptRefresh } from '../../lib/geometry-script/finish-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 stopFinishScriptRefresh = initFinishScriptRefresh() initSFXBus() return () => { unsubscribeSpatialGrid() unsubscribeSpaceDetection?.() + 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 3b0d8b1ba3..334c571cd3 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 { 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' @@ -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 + 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 } = 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..5292aca737 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 { + 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/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/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/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..520116124c 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -488,11 +488,17 @@ export async function pasteSystemEditorClipboardToLevel( return applyClipboardPayloadToLevel(payload, targetLevel.id, refused) } +/** 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 = '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/geometry-script/src/compile.test.ts b/packages/geometry-script/src/compile.test.ts index 2f4f298aaf..92867ea561 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 }) @@ -633,3 +681,336 @@ 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) + expect(compiled.manifest.coverage).toBeUndefined() + }) +}) + +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) + }) +}) + +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 255e74429a..87278b78eb 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -17,6 +17,8 @@ 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' export type { GeometryScriptMount } @@ -24,6 +26,14 @@ export type { GeometryScriptMount } export type GeometryScriptCompileInput = { code: string params?: Record + /** + * 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 } export type GeometryScriptCompileOutput = CompiledGeometryScript & { glb: ArrayBuffer } @@ -204,21 +214,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 } @@ -536,6 +553,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 @@ -821,8 +879,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 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({ built, @@ -831,6 +890,7 @@ export async function finishBuild({ params, code, language, + inputs, }: { built: THREE.Object3D mount: GeometryScriptMount @@ -838,6 +898,11 @@ export async function finishBuild({ params: Record code: string language: GeometryScriptLanguage + /** + * 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' @@ -853,12 +918,24 @@ 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 (!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) + let coverage: Coverage | null = null + 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.`, + ) + } + coverage = measureCoverage(visibleMeshes(root), region) + } + bakeBackFaces(root) const conventions = readConventions(root) if (conventions.triangles > GEOMETRY_SCRIPT_LIMITS.triangles) { throw new Error( @@ -899,6 +976,7 @@ export async function finishBuild({ cutout: conventions.cutout, collider: conventions.collider, triangles: conventions.triangles, + ...coverage, }), } } @@ -938,9 +1016,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, }) @@ -948,5 +1027,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', + inputs: input.inputs, + }) } 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/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/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/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 f8dae12c08..a78bb9dca7 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, a wall's face: `floorScriptInputs`, `wallFaceScriptInputs`), 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,17 @@ export type GeometryScriptHost = { }): Promise } -export type ScriptedKind = 'object' | 'window' | 'door' | 'column' +export type ScriptedKind = 'object' | 'window' | 'door' | 'column' | 'floor' | 'wall-face' + +/** 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. */ export async function compileAndStore( @@ -82,25 +97,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 +140,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 @@ -271,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', @@ -280,8 +308,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, face) + 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 new file mode 100644 index 0000000000..2faee096f6 --- /dev/null +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -0,0 +1,723 @@ +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 +}` + +/** 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: [ + { + name: 'Ground floor', + walls: [ + { + id: 'n', + start: [-2, -2.5], + end: [2, -2.5], + height: 2.7, + thickness: 0.12, + color: '#8f9e8a', + colorRight: '#f1ece4', + }, + { + id: 'e', + start: [2, -2.5], + end: [2, 2.5], + height: 2.7, + thickness: 0.12, + 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, + type: 'casement', + code: ROUND_WINDOW, + frameColor: '#f1ece4', + }, + ], + }, + { + 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, + style: 'glass', + hinge: 'end', + color: '#8f9e8a', + frameColor: '#f1ece4', + }, + ], + }, + { id: 'w', start: [-2, 2.5], end: [-2, -2.4], height: 2.7, thickness: 0.12 }, + ], + 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', + 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.'], +} + +/** + * 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) - 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 < 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) + } + 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 + 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 - 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) + } + 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 + 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[] = [] + 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 }) => { + stored.push(`${mimeType} ${sha256.slice(0, 8)}`) + artifacts.set(sha256, bytes) + return sha256 + }, + readArtifact: async ({ sha256 }) => artifacts.get(sha256) ?? null, + } + + 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) + 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 }[] + defaults: string[] + ids: Record + verify: { issues: number } + } + expect(report.walls).toBe(4) + expect(report.doors).toBe(1) + expect(report.windows).toBe(2) + expect(report.rooms).toBe(1) + expect(report.items).toBe(3) + expect(report.lights).toBe(1) + 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 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.windowType).toBe('casement') + 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) + expect(door.slots.panel).toBe(north.slots.a) + expect(door.slots.frame).toBe(north.slots.b) + expect(round.slots.frame).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 () => { + 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 () => { + 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) + }) + + 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[] + 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 + } + 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, 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) + 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 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 = { + ...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() + }) + + 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, 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]).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]!) + 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 new file mode 100644 index 0000000000..8b6adf8056 --- /dev/null +++ b/packages/mcp/src/tools/author-scene.ts @@ -0,0 +1,291 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { floorScriptInputs, wallFaceScriptInputs } from '@pascal-app/core' +import { + type AgentContext, + type AuthorSceneReport, + achievedChanges, + authorScene, + authorSceneCeilingPoints, + authorSceneFaceScripts, + authorSceneFinishGaps, + authorSceneFloorScripts, + type SceneNodes, + setRoomFloorScript, + setWallFaceScript, + 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, type ScriptedKind } 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', + }) + } + // 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 ?? []) + 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 }) + }) + } + modules.push( + ...authorSceneCeilingPoints(args.payload).map((point) => ({ + ...point, + kind: 'object' as const, + })), + ) + 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. + 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) } + 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 }, + context, + ) + } 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) + // 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++ + 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, + code: 'floor_script_failed', + message: `${error instanceof Error ? error.message : String(error)} (the room keeps its floor finish)`, + }) + } + } + // 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++ + 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}`, + 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, { + 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 module is already its own artifact. */ +function withoutCode(payload: AuthorScenePayload) { + return { + ...payload, + levels: payload.levels.map((level) => ({ + ...level, + 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: _, wallCode: _w, ...room }) => room), + })), + } +} + +/** 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/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/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/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-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..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, @@ -38,11 +40,14 @@ import { wallAssemblyPatch, wallAssemblyToLegacy, wallAssemblyUnverifiedNote, + wallTrimPatch, } from '@pascal-app/core' +import { setWallFaceScript } from '@pascal-app/core/agent-operations' import { ActionButton, ActionGroup, curveReshapeScope, + finishScriptCode, formatLinearMeasurement, getLinearUnitLabel, linearControlValueToMeters, @@ -58,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' @@ -444,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, @@ -512,12 +596,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 +650,7 @@ function WallTrimSection({ value={metersToLinearUnit(trimValue.height, unit)} /> 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 f4bdc4f482..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' @@ -220,4 +221,56 @@ 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) + }) + + 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 8200eead0f..66a607cdfc 100644 --- a/packages/nodes/src/zone/renderer.tsx +++ b/packages/nodes/src/zone/renderer.tsx @@ -5,6 +5,7 @@ import { useLiveNodeOverrides, useRegistry, useScene, + type ZoneFloorScript, type ZoneNode, } from '@pascal-app/core' import { @@ -19,6 +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 { FinishScriptModel } from '../shared/finish-script-model' import { owningUnitForZone } from './unit-membership' const Y_OFFSET = 0.01 @@ -71,9 +73,23 @@ 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, in the level's frame, just over the plate. */ +function FloorScriptModel({ node, script }: { node: ZoneNode; script: ZoneFloorScript }) { + return ( + + ) } 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), diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 69e6bd1fe2..869dc4bdb6 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 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. 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..97a716b560 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` 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`. + ## 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..b07e29db82 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -133,3 +133,14 @@ 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` + +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`; 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`. 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. + diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 89a2e7d786..910424407e 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -130,6 +130,24 @@ 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. + +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 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.