From 53bec51f04e21337dd65517accd42470e5421c83 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 28 Oct 2026 11:21:35 -0400 Subject: [PATCH] Agents: author_scene and MCP build more faithful finishes - author_scene columns take code (mount floor), rotation, and a color applied as an exact material on every native surface (it was stored, never applied). - author_scene slab railings are fences standing on the slab with their own height and color (the height was accepted but drawn at 1.1 m in the edge finish). - author_scene stairs take railing sides and style, and flight, tread and railing finishes. - paint color and add_object take exact: true to keep a colour as written instead of the nearest library finish; one shared exact-colour helper. - Native textures take offset, normalMap/normalScale and bumpMap/bumpScale; paint documents the UV convention for lining joints up. - Standalone slabs take a surface script (author_scene surfaceCode), as a room's floorCode: stored, rendered on the slab top, refreshed when reshaped, owning its colours, read back by get_source, shown in the slab panel. Co-Authored-By: Claude Opus 5.5 --- .../__fixtures__/notation-cases.ts | 2 + .../src/agent-operations/add-object.test.ts | 60 +++- .../core/src/agent-operations/add-object.ts | 47 ++- .../src/agent-operations/author-scene.test.ts | 163 ++++++++++ .../core/src/agent-operations/author-scene.ts | 207 +++++++++---- packages/core/src/agent-operations/index.ts | 1 + .../agent-operations/native-materials.test.ts | 53 +++- packages/core/src/agent-operations/paint.ts | 19 +- .../src/agent-operations/scene-materials.ts | 58 ++++ .../agent-operations/slab-surface-script.ts | 95 ++++++ packages/core/src/agent-tools/add-object.ts | 12 +- packages/core/src/agent-tools/author-scene.ts | 47 ++- packages/core/src/agent-tools/paint.ts | 14 +- .../core/src/contracts/reference-discovery.ts | 6 +- .../core/src/contracts/reference-inventory.ts | 33 ++- .../floor-placed-elevation.test.ts | 43 +++ .../spatial-grid/spatial-grid-sync.test.ts | 39 +++ .../hooks/spatial-grid/spatial-grid-sync.ts | 3 +- packages/core/src/index.ts | 3 + packages/core/src/lib/finish-script.ts | 20 +- packages/core/src/lib/floor-plate-paint.ts | 15 + packages/core/src/lib/floor-script.ts | 61 +++- .../core/src/lib/slab-surface-script.test.ts | 280 ++++++++++++++++++ packages/core/src/schema/material.ts | 6 + packages/core/src/schema/nodes/slab.ts | 3 + .../src/components/ui/panels/room-panel.tsx | 48 ++- packages/editor/src/index.tsx | 2 + .../src/lib/geometry-script/finish-scripts.ts | 14 +- .../editor/src/lib/scene-clipboard.test.ts | 119 +++++--- packages/editor/src/lib/scene-clipboard.ts | 13 +- packages/geometry-script/src/author-scene.ts | 49 ++- packages/mcp/src/tools/add-object.ts | 9 +- packages/mcp/src/tools/author-scene.test.ts | 57 ++++ .../src/slab/__tests__/plate-paint.test.ts | 23 ++ packages/nodes/src/slab/definition.ts | 7 +- packages/nodes/src/slab/panel.tsx | 27 ++ packages/nodes/src/slab/renderer.tsx | 36 +++ packages/viewer/src/index.ts | 3 + packages/viewer/src/lib/materials.test.ts | 54 +++- packages/viewer/src/lib/materials.ts | 70 +++-- skills/pascal-3d/SKILL.md | 6 +- skills/pascal-3d/references/tool-workflows.md | 2 +- wiki/architecture/authored-objects.md | 2 + 43 files changed, 1608 insertions(+), 223 deletions(-) create mode 100644 packages/core/src/agent-operations/slab-surface-script.ts create mode 100644 packages/core/src/lib/slab-surface-script.test.ts create mode 100644 packages/nodes/src/slab/renderer.tsx diff --git a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts index 3bb65495c3..95ad78c120 100644 --- a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts @@ -360,6 +360,8 @@ export const NOT_LENGTHS = new Set([ 'create_roof.glazing.glassOpacity', 'create_roof.glazing.glassRoughness', 'paint.material.texture.scale', + 'paint.material.texture.normalScale', + 'paint.material.texture.bumpScale', 'generate_studio_media.settings.durationSeconds', // Design parameter values inherit their recipe's units, not one fixed physical length unit. 'place_design.parameters{}', diff --git a/packages/core/src/agent-operations/add-object.test.ts b/packages/core/src/agent-operations/add-object.test.ts index 727c58545a..763b74c6b4 100644 --- a/packages/core/src/agent-operations/add-object.test.ts +++ b/packages/core/src/agent-operations/add-object.test.ts @@ -3,7 +3,11 @@ import { z } from 'zod' import { addObjectTool } from '../agent-tools' import { type AgentRefusal, isAgentRefusal } from '../agent-tools/refusal' import { type AnyNode, DoorNode, LevelNode, WallNode } from '../schema' -import { ADD_OBJECT_CASES, compiledUnmeasured } from './__fixtures__/add-object-cases' +import { + ADD_OBJECT_CASES, + compiledSolid, + compiledUnmeasured, +} from './__fixtures__/add-object-cases' import { addColumn, addObject, @@ -43,6 +47,60 @@ describe('add_object', () => { } }) +// A module's colours are shown as the library finishes they match unless exact keeps them as written. +describe('add_object exact', () => { + const level = LevelNode.parse({ id: 'level_0', parentId: 'building_main', level: 0 }) + const solid = compiledSolid([0.8, 0.75, 0.8]) + const compiled = { + ...solid, + manifest: { + ...solid.manifest, + slots: [ + { id: 'top', color: '#2e5e4e', roughness: 0.3, metalness: 0 }, + { id: 'legs', color: '#c0c0c0', roughness: 0.2, metalness: 1 }, + { id: 'glass', color: '#ffffff', transparent: true }, + { id: 'bulb', color: '#fff4d6', emissive: true }, + ], + }, + } as typeof solid + const input = (extra: Record) => ({ + ...z + .object(addObjectTool.input) + .parse({ code: 'x', name: 'Table', reason: 'Pascal has no lacquered table.', ...extra }), + compiled, + }) + + test('keeps each colour with its surface as a scene material; glass and a glowing slot keep theirs', () => { + const nodes = { [level.id]: level } as Record + const outcome = addObject(nodes, input({ exact: true }), { + activeLevelId: level.id, + materials: {}, + }) + const materials = outcome.changes!.materials! + expect(materials.map((m) => m.name).sort()).toEqual([ + '#2e5e4e, roughness 0.3, metalness 0', + '#c0c0c0, roughness 0.2, metalness 1', + ]) + const item = outcome.changes!.create![0]!.node as AnyNode & { slots: Record } + const ref = (name: string) => `scene:${materials.find((m) => m.name.startsWith(name))!.id}` + expect(item.slots).toEqual({ + top: ref('#2e5e4e'), + legs: ref('#c0c0c0'), + glass: 'library:preset-glass', + }) + // Without exact the colours are the library's; a host without scene materials refuses exact. + const matched = addObject(nodes, input({}), { activeLevelId: level.id, materials: {} }) + expect(matched.changes!.materials).toBeUndefined() + let error: unknown + try { + addObject(nodes, input({ exact: true }), { activeLevelId: level.id }) + } catch (thrown) { + error = thrown + } + expect((error as AgentRefusal).code).toBe('materials_unavailable') + }) +}) + // The compile's bounds are the size of everything a script builds: a door, a window or a column // whose bounds are not numbers is refused with the object's own code, not built with a NaN width. describe('a scripted door, window or column that cannot be measured', () => { diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index f16cd51495..aa2f53cd68 100644 --- a/packages/core/src/agent-operations/add-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -22,12 +22,15 @@ import { type GeometryScriptParamValue, generateId, ItemNode, + type SceneMaterial, + type SceneMaterialId, type SlabNode, type WallFace, } from '../schema' import { achievedChanges } from './achieved' import { targetLevel } from './level-target' import { roofLevelRefusal } from './roof-writes' +import { exactColorMaterial, exactScriptSlots } from './scene-materials' import { queryNodes } from './scene-queries' import type { AgentOperation, BatchedObject, SceneNodes } from './types' @@ -43,6 +46,8 @@ export type AddObjectInput = { /** Degrees about Y, as the contract parses it. */ rotation?: number side?: 'front' | 'back' + /** The module's slot colours as written, not their library matches. */ + exact?: boolean name?: string description?: string tags?: string[] @@ -308,6 +313,19 @@ export const addObject: AgentOperation = (nodes, input, context) requireMeasured(compiled, { label: input.name, nodeId: input.nodeId, tool: 'add_object' }) const rotation: Vec3 | undefined = input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] + const createdMaterials: SceneMaterial[] = [] + const slotsOf = (matched: Record) => { + if (!input.exact) return matched + const materials = { ...context.materials } + return exactScriptSlots(compiled.manifest, matched, (hex, surface) => { + const resolved = exactColorMaterial(context.materials && materials, hex, surface) + if (resolved.created) { + materials[resolved.sceneMaterial.id as SceneMaterialId] = resolved.sceneMaterial + createdMaterials.push(resolved.sceneMaterial) + } + return resolved.ref + }) + } if (input.nodeId) { if (nodes[input.nodeId]?.type === 'column') @@ -331,10 +349,8 @@ export const addObject: AgentOperation = (nodes, input, context) rotation: rotation ?? previous.rotation, ...(input.side === undefined ? {} : { side: input.side }), source: scriptSource(compiled, itemSourceMeta(input), previous.source), - slots: matchScriptSlotsToLibrary( - compiled.manifest, - previous.slots, - previous.source?.manifest, + slots: slotsOf( + matchScriptSlotsToLibrary(compiled.manifest, previous.slots, previous.source?.manifest), ), asset: scriptAsset(compiled, input, previous.asset), ...(input.reason ? { metadata: { ...previous.metadata, reason: input.reason } } : {}), @@ -358,6 +374,7 @@ export const addObject: AgentOperation = (nodes, input, context) { id: next.id, data: next }, ...resettled.map(({ id, position }) => ({ id, data: { position } })), ], + ...(createdMaterials.length ? { materials: createdMaterials } : {}), }, } } @@ -399,7 +416,7 @@ export const addObject: AgentOperation = (nodes, input, context) 0, ], source: scriptSource(compiled, itemSourceMeta(input)), - slots: matchScriptSlotsToLibrary(compiled.manifest), + slots: slotsOf(matchScriptSlotsToLibrary(compiled.manifest)), asset, metadata: { reason: input.reason, ...(input.role ? { role: input.role } : {}) }, }) @@ -413,7 +430,10 @@ export const addObject: AgentOperation = (nodes, input, context) .join(' ') return { result: { ...summary(node, compiled, []), ...(hint ? { hint } : {}) }, - changes: { create: [{ node, parentId: parent.id }] }, + changes: { + create: [{ node, parentId: parent.id }], + ...(createdMaterials.length ? { materials: createdMaterials } : {}), + }, } } @@ -653,17 +673,21 @@ export function scriptedFieldRefusal(node: AnyNode, data: Record & { id: string - type: ScriptedNode['type'] | 'zone' | 'wall' + type: ScriptedNode['type'] | 'zone' | 'slab' | '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). + * zone id, a slab's surface script by its slab id, or a wall's face script (`face`, which a wall + * with one scripted face may leave out). */ export function readableScript( nodes: Record, @@ -697,6 +721,11 @@ export function readableScript( ) return { id: node.id, type: 'wall', name: node.name, source, face: read } } + if (node?.type === 'slab') { + if (!node.surfaceScript) + refuse('not_authored', `${node.name || nodeId} has no surface script.`, { id: nodeId }) + return { id: node.id, type: 'slab', name: node.name, source: node.surfaceScript } + } if (node?.type !== 'zone') return authoredObject(nodes, nodeId) if (!node.floor?.script) refuse('not_authored', `${node.name || nodeId} has no floor script.`, { id: nodeId }) diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 5ac30aa968..507924d4ec 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -17,6 +17,7 @@ import { authorSceneChecks, authorSceneFaceScripts, authorSceneFloorScripts, + authorSceneSlabScripts, finalizeAuthorScene, setRoomFloorScript, verifyScene, @@ -1584,6 +1585,35 @@ describe('author_scene', () => { expect(authorSceneFloorScripts(bedroom, outcome.result)).toEqual([]) }) + test('slabs with a surface module are listed by their node for the host to compile', () => { + const decking = 'export default function build({ THREE }) { return new THREE.Group() }' + const terrace = { + polygon: [ + [3, -2], + [6, -2], + [6, 2], + [3, 2], + ], + } + const payload = authorScenePayload.parse({ + levels: [ + { + name: 'Ground floor', + slabs: [{ ...terrace, name: 'Deck', surfaceCode: decking }, terrace], + }, + ], + }) + const outcome = run(payload, {}) + expect(authorSceneSlabScripts(payload, outcome.result)).toEqual([ + { + slabId: 'slab/0/0', + nodeId: outcome.result.ids['slab/0/0']!, + name: 'Deck', + code: decking, + }, + ]) + }) + 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() }' @@ -2684,6 +2714,139 @@ describe('author_scene', () => { width: 0.3, }) }) + + test('a column takes its colour exactly on every surface, its rotation, or its own module and its colours', () => { + const post: CompiledGeometryScript = { + ...bed, + manifest: { + ...bed.manifest, + bounds: { min: [-0.1, 0, -0.15], max: [0.1, 2.6, 0.15] }, + slots: [{ id: 'timber', color: '#6b4a2f' }], + } as unknown as CompiledGeometryScript['manifest'], + } + const payload = authorScenePayload.parse({ + levels: [ + { + name: 'Ground floor', + columns: [ + { position: [0, 0], width: 0.3, height: 2.7, color: '#b85c38', rotation: 90 }, + { + position: [2, 0], + width: 0.3, + height: 2.7, + code: 'export default () => {}', + color: '#ffffff', + }, + ], + }, + ], + }) + const outcome = run(payload, { 'column/0/1': post }, {}) + expect(outcome.result.defects).toEqual([]) + const terracotta = outcome.changes!.materials!.find((m) => m.name === '#b85c38')! + const plain = byAuthorId(outcome, 'column/0/0') + expect(plain.rotation).toBeCloseTo(Math.PI / 2, 9) + expect(Object.keys(plain.slots).sort()).toEqual(['base', 'capital', 'shaft']) + for (const ref of Object.values(plain.slots)) expect(ref).toBe(`scene:${terracotta.id}`) + const scripted = byAuthorId(outcome, 'column/0/1') + expect(scripted.source.manifest.slots).toEqual([{ id: 'timber', color: '#6b4a2f' }]) + expect([scripted.width, scripted.height, scripted.depth]).toEqual([0.2, 2.6, 0.3]) + const timber = outcome.changes!.materials!.find((m) => m.name.startsWith('#6b4a2f'))! + expect(scripted.slots).toEqual({ timber: `scene:${timber.id}` }) + expect(outcome.result.defaults).toContain( + 'column column/0/1: built by its module, which keeps its own colours; color ignored', + ) + // A module that did not compile is a defect, by the column's id. + expect(run(payload, {}, {}).result.defects.map((d) => [d.id, d.code])).toEqual([ + ['column/0/1', 'compile_failed'], + ]) + }) + + test("a slab's railing stands on it at its own height and in its own colour", () => { + const payload = authorScenePayload.parse({ + levels: [ + { + name: 'Ground floor', + slabs: [ + { + name: 'Deck', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + top: 0.6, + thickness: 0.3, + railing: [{ start: [0, 3], end: [4, 3], height: 0.9, color: '#20262b' }], + }, + ], + }, + ], + }) + const outcome = run(payload, {}, {}) + expect(outcome.result.defects).toEqual([]) + const slab = byAuthorId(outcome, 'slab/0/0') + expect(slab.railing).toBeUndefined() + const fence = nodesOf(outcome).find((n) => n.type === 'fence') as AnyNode & Record + expect(fence).toMatchObject({ start: [0, 3], end: [4, 3], height: 0.9, supportSlabId: slab.id }) + const iron = outcome.changes!.materials!.find((m) => m.name === '#20262b')! + expect(Object.values(fence.slots).length).toBeGreaterThan(0) + for (const ref of Object.values(fence.slots)) expect(ref).toBe(`scene:${iron.id}`) + }) + + test("a stair takes its railing sides and style, and its flight's, treads' and railings' finishes", () => { + const storey = (prefix: string) => + bedroom.levels[0]!.walls!.map((w) => ({ ...w, id: `${prefix}${w.id}`, openings: [] })) + const payload = authorScenePayload.parse({ + levels: [ + { + name: 'Ground floor', + walls: storey(''), + rooms: [{ name: 'Hall', seed: [0, 0] }], + stairs: [ + { + start: [-1.2, -2.3], + width: 1, + railing: 'right', + railingStyle: 'glass', + color: '#f4f1ea', + treads: 'library:wood-woodplank48', + railingColor: '#111111', + }, + ], + }, + { + name: 'First floor', + elevation: 3, + walls: storey('up-'), + rooms: [{ name: 'Landing', seed: [0, 0] }], + }, + ], + }) + const outcome = run(payload, {}, {}) + expect(outcome.result.defects).toEqual([]) + const stair = byAuthorId(outcome, 'stair/0/0') + expect(stair).toMatchObject({ railingMode: 'right', railingStyle: 'glass' }) + const named = (name: string) => + `scene:${outcome.changes!.materials!.find((m) => m.name === name)!.id}` + expect(stair.slots).toMatchObject({ + body: named('#f4f1ea'), + treads: 'library:wood-woodplank48', + railing: named('#111111'), + }) + // No railing: its colour has nothing to paint, and is said. + const bare = authorScenePayload.parse({ + ...payload, + levels: [ + { ...payload.levels[0]!, stairs: [{ ...payload.levels[0]!.stairs![0]!, railing: 'none' }] }, + payload.levels[1]!, + ], + }) + expect(run(bare, {}, {}).result.defaults).toContain( + 'stair/0/0: it has no railing, its railingColor ignored', + ) + }) }) describe('author_scene units', () => { diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 17a7cf22b1..934e119f63 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -32,6 +32,7 @@ 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 { nodeSlotDeclarations } from '../node-slots' import { nearestLibraryColorRef } from '../procedural-items/library-colors' import { type AnyNode, @@ -84,7 +85,7 @@ import { authoredSceneChanges } from './authored-scene-merge' import { createStair } from './create-stair' import { paint } from './paint' import { pointInPolygon, projectWorldPointToWallLocalX, type Vec2 } from './plan-geometry' -import { resolveInlinePaintMaterial } from './scene-materials' +import { type ColorSurface, exactColorMaterial, exactScriptSlots, MATTE } from './scene-materials' import { queryNodes } from './scene-queries' import type { AgentContext, AgentOperationOutcome, SceneChanges, SceneNodes } from './types' import { setWallSkirting } from './wall-skirting' @@ -198,7 +199,10 @@ export type AuthorSceneReport = { /** Units under the building, one per distinct name the rooms and the payload give. */ units: number lights: number - /** Rooms whose floor finish is their module, counted by the host that compiles them after the write. */ + /** + * Rooms whose floor finish and slabs whose surface 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 @@ -224,7 +228,6 @@ 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] @@ -382,10 +385,6 @@ function toLocal(frame: Frame, world: Vec3): Vec3 { return [c * dx - s * dz, dy, s * dx + c * dz] } -/** How a colour's surface reflects: a module's own values, or a matte paint for a payload colour. */ -type Surface = { roughness: number; metalness: number } -const MATTE: Surface = { roughness: 0.9, metalness: 0 } - /** * The finish a payload value writes. A library or scene ref passes through; a hex colour is kept * as an exact native material, one scene material per distinct colour and surface across the @@ -401,32 +400,16 @@ function finishPool(context: AgentContext, say: (sentence: string) => void) { return { materials, created, - ref(value: string, surface: Surface = MATTE): { ref: string } | { error: string } { + ref(value: string, surface: ColorSurface = 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 } + return { ref: nearestLibraryColorRef(value)!.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)}`, - ) + const resolved = exactColorMaterial(materials, value, surface) if (resolved.created) { materials[resolved.sceneMaterial.id as SceneMaterialId] = resolved.sceneMaterial created.push(resolved.sceneMaterial) @@ -485,6 +468,26 @@ export function authorSceneFloorScripts( ) } +/** + * The slabs whose surface finish is a module, by their node once the write made them: the host + * reads each slab's surface (`slabSurfaceScriptInputs`), compiles the module for it and writes it + * with `setSlabSurfaceScript`, as it does a room's floor module. + */ +export function authorSceneSlabScripts( + payload: AuthorScenePayload, + report: Pick, +): { slabId: string; nodeId: string; name: string; code: string }[] { + return payload.levels.flatMap((level, index) => + (level.slabs ?? []).flatMap((slab, i) => { + const slabId = `slab/${index}/${i}` + const nodeId = report.ids[slabId] + return slab.surfaceCode && nodeId + ? [{ slabId, nodeId, name: slab.name ?? slabId, code: slab.surfaceCode }] + : [] + }), + ) +} + /** 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. */ @@ -815,7 +818,7 @@ export function authorScene( const finishRef = ( value: string | undefined, id: string, - surface?: Surface, + surface?: ColorSurface, ): string | undefined => { if (!value) return undefined const got = finishes.ref(value, surface) @@ -825,21 +828,12 @@ export function authorScene( } 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 exactSlots = (compiled: CompiledGeometryScript, id: string) => + exactScriptSlots( + compiled.manifest, + matchScriptSlotsToLibrary(compiled.manifest), + (hex, surface) => finishRef(hex, id, surface), + ) const paintContext = (levelId: string): AgentContext => ({ ...context, activeLevelId: levelId, @@ -1209,34 +1203,106 @@ export function authorScene( 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++ + // Railings are fences standing on the slab, so each keeps its height and its own finish. + ;(s.railing ?? []).forEach((r, k) => { + const railId = `${id}/railing/${k}` + let fence: AnyNode = FenceNode.parse({ + object: 'node', + id: mint('fence', railId), + parentId: levelNode.id, + visible: true, + metadata: { authorScene: { id: railId } }, + name: 'Railing', + start: r.start, + end: r.end, + height: r.height, + style: 'rail', + baseStyle: 'floating', + baseHeight: 0, + postCap: 'none', + supportSlabId: node.id, + }) + const railFinish = finishRef(r.color, railId) + if (railFinish) + fence = { + ...fence, + slots: Object.fromEntries( + (nodeSlotDeclarations(fence) ?? []).map((slot) => [slot.slotId, railFinish]), + ), + } as AnyNode + place(fence) + }) }) ;(level.columns ?? []).forEach((c, i) => { - const node = ColumnNode.parse({ + const id = `column/${index}/${i}` + let compiled: CompiledGeometryScript | undefined + if (c.code) { + compiled = input.compiled[id] + if (!compiled) { + defect(id, 'compile_failed', input.compileErrors?.[id] ?? 'The module did not compile.') + return + } + if (compiled.mount !== 'floor') { + defect( + id, + 'wrong_mount', + `A column's module uses mount 'floor', not '${compiled.mount}'.`, + ) + return + } + try { + requireMeasured(compiled, { label: c.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 (c.color) + say(`column ${id}: built by its module, which keeps its own colours; color ignored`) + } + const size = compiled && scriptedSize(compiled.manifest) + let node = ColumnNode.parse({ object: 'node', type: 'column', - id: mint('column', `column/${index}/${i}`), + id: mint('column', id), parentId: levelNode.id, visible: true, - metadata: { authorScene: { color: c.color ?? null } }, + metadata: { authorScene: { id } }, ...(c.name ? { name: c.name } : {}), position: [c.position[0], 0, c.position[1]], - crossSection: c.shape, - ...(c.shape === 'round' ? { radius: c.width / 2 } : {}), - width: c.width, - depth: c.shape === 'round' ? c.width : (c.depth ?? c.width), - height: c.height, - baseHeight: 0, - capitalHeight: 0, + rotation: (c.rotation * Math.PI) / 180, + ...(compiled && size + ? { + source: scriptSource(compiled, { category: 'column' }), + slots: exactSlots(compiled, id), + width: size[0], + height: size[1], + depth: size[2], + } + : { + crossSection: c.shape, + ...(c.shape === 'round' ? { radius: c.width / 2 } : {}), + width: c.width, + depth: c.shape === 'round' ? c.width : (c.depth ?? c.width), + height: c.height, + baseHeight: 0, + capitalHeight: 0, + }), }) + const finish = !compiled && finishRef(c.color, id) + if (finish) + node = { + ...node, + slots: Object.fromEntries( + (nodeSlotDeclarations(node) ?? []).map((slot) => [slot.slotId, finish]), + ), + } place(node) - report.ids[`column/${index}/${i}`] = node.id + report.ids[id] = node.id report.columns++ }) @@ -1500,6 +1566,8 @@ export function authorScene( ...(stair.length === undefined ? {} : { length: stair.length }), ...(stair.steps === undefined ? {} : { steps: stair.steps }), ...(stair.lowerSteps === undefined ? {} : { lowerSteps: stair.lowerSteps }), + ...(stair.railing ? { railingMode: stair.railing } : {}), + ...(stair.railingStyle ? { railingStyle: stair.railingStyle } : {}), ...(stair.name ? { name: stair.name } : {}), levelId: levelNode.id, toLevelId: upper.id, @@ -1526,6 +1594,33 @@ export function authorScene( ...rest, ] written = applySceneChanges(written, changes) + const finishes = [ + ['body', stair.color], + ['treads', stair.treads ?? stair.color], + ['railing', stair.railingColor], + ] as const + for (const [role, color] of finishes) { + const finish = finishRef(color, id) + if (!finish) continue + if ( + !nodeSlotDeclarations(written[made!.node.id]!)?.some((slot) => slot.slotId === role) + ) { + say( + `${id}: it has no ${role}, its ${role === 'railing' ? 'railingColor' : 'color'} ignored`, + ) + continue + } + try { + const { changes } = paint( + written, + { targets: [made!.node.id], material: finish, role }, + paintContext(levelNode.id), + ) + written = applySceneChanges(written, changes!) + } catch (error) { + defect(id, 'paint_refused', (error as Error).message) + } + } report.ids[id] = made!.node.id report.stairs++ stairsWritten = true diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index 882e8660a6..1383130e58 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -85,6 +85,7 @@ export * from './scene-view' export * from './search-assets' export * from './search-materials' export * from './site' +export * from './slab-surface-script' export * from './stairs' export * from './types' export * from './units' diff --git a/packages/core/src/agent-operations/native-materials.test.ts b/packages/core/src/agent-operations/native-materials.test.ts index db994101e3..e275644569 100644 --- a/packages/core/src/agent-operations/native-materials.test.ts +++ b/packages/core/src/agent-operations/native-materials.test.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { AGENT_TOOL_CONTRACTS } from '../agent-tools' import { isAgentRefusal } from '../agent-tools/refusal' import { slotPaintMaterial } from '../lib/slots' -import { ColumnNode, RoofNode, RoofSegmentNode, type SceneMaterial } from '../schema' +import { type AnyNode, ColumnNode, RoofNode, RoofSegmentNode, type SceneMaterial } from '../schema' import { EXPECTED_MOUNTAIN_MATERIAL, expectPreserved, @@ -185,6 +185,57 @@ describe('native custom paint: contract v1.1', () => { expect(serialized[outcome.changes!.materials![0]!.id].material.texture).toEqual(texture) }) + test('texture offset, normal and bump maps are kept, and another offset is another material', () => { + const { nodes } = nativeMaterialScene() + const texture = { + url: '/material/stone/slate.jpg', + repeat: [2, 2], + offset: [0.25, 0], + normalMap: '/material/stone/slate-normal.jpg', + normalScale: 0.8, + bumpMap: '/material/stone/slate-height.jpg', + bumpScale: 0.3, + } + const outcome = run( + 'paint', + nodes, + { ...mountainPaint(), material: { ...MOUNTAIN_MATERIAL, texture } }, + {}, + ) + expect(outcome.changes!.materials![0]!.material.texture).toEqual(texture) + const materials = registryAfter({}, outcome.changes) + const second = run( + 'paint', + applySceneChanges(nodes, outcome.changes!), + { + ...mountainPaint(['wall_material_east']), + material: { ...MOUNTAIN_MATERIAL, texture: { ...texture, offset: [0.5, 0] } }, + }, + materials, + ) + expect(Object.keys(registryAfter(materials, second.changes))).toHaveLength(2) + }) + + test('an exact colour is that colour as a matte scene material, reused; exact without a colour is refused', () => { + const { nodes } = nativeMaterialScene() + const exact = { targets: ['wall_material_south'], color: '#3A5F8C', exact: true } + const outcome = run('paint', nodes, exact, {}) + const [material] = outcome.changes!.materials! + expect(material).toMatchObject({ + name: '#3a5f8c', + material: { properties: { color: '#3a5f8c', roughness: 0.9, metalness: 0 } }, + }) + const wall = applySceneChanges(nodes, outcome.changes!).wall_material_south as AnyNode & { + slots: Record + } + expect(wall.slots.a).toBe(`scene:${material!.id}`) + const again = run('paint', nodes, exact, registryAfter({}, outcome.changes)) + expect(again.changes!.materials).toBeUndefined() + expect(refusal(() => run('paint', nodes, { ...mountainPaint(), exact: true }, {})).code).toBe( + 'invalid_exact', + ) + }) + test('undefined registry is an unsupported host, not permission to author an orphan', () => { const { nodes } = nativeMaterialScene() const before = structuredClone(nodes) diff --git a/packages/core/src/agent-operations/paint.ts b/packages/core/src/agent-operations/paint.ts index d90c670085..d74215b797 100644 --- a/packages/core/src/agent-operations/paint.ts +++ b/packages/core/src/agent-operations/paint.ts @@ -1,4 +1,5 @@ import { refuse } from '../agent-tools/refusal' +import { slabSurfaceScriptPaintRefusal } from '../lib/floor-plate-paint' import { wallFaceScriptPaintRefusal } from '../lib/wall-face-script' import type { MaterialSurface } from '../material-kinds' import { GLAZED_ROOF_SLOTS, nodeSlotDeclarations } from '../node-slots' @@ -20,14 +21,15 @@ import { buildWallFinishLayout, getWallLevelZones } from '../systems/wall/wall-f import { planWallPaint } from '../systems/wall/wall-paint-plan' import { roomFaceCover } from '../systems/wall/wall-room-sides' import { finishSurface, requireMaterialRef } from './material-refs' -import { resolveInlinePaintMaterial } from './scene-materials' +import { exactColorMaterial, resolveInlinePaintMaterial } from './scene-materials' import type { AgentContext, AgentOperation, SceneNodes } from './types' /** * `paint`: a finish in one call, as the editor's Paint gives it. Through patches an agent once took * five tries to paint a pier grey: `material: {color}` was stored as `{}`, `materialPreset: null` * was refused and an unknown preset fell back to grey. A colour is the nearest of the library's - * flat colours, said with how near; a native material is kept as a scene material. Slots are + * flat colours, said with how near, or kept as given with exact; a native material is kept as a + * scene material. Slots are * written as the editor's slot paint writes them, a wall's faces through its paint plan, a roof's * role through its role field, on the roof: a roof shows one finish per surface, so a segment's * finish goes on its roof. @@ -39,6 +41,7 @@ type PaintInput = { color?: string material?: string | MaterialSchema materialName?: string + exact?: boolean erase?: true } @@ -92,7 +95,13 @@ function finishOf( ) if (input.materialName !== undefined && typeof input.material !== 'object') refuse('invalid_material_name', 'materialName names a native material only.') + if (input.exact && !input.color) + refuse('invalid_exact', 'exact keeps a color as given: give color.') if (input.erase) return { ref: undefined } + if (input.color && input.exact) { + const { ref, sceneMaterial, created } = exactColorMaterial(context.materials, input.color) + return { ref, sceneMaterial, created } + } if (input.color) { const match = nearestLibraryColorRef(input.color) if (!match) refuse('invalid_color', `${input.color} is no colour: give #rrggbb.`) @@ -376,6 +385,12 @@ export const paint: AgentOperation = (nodes, input, context) => { keptParts.push(`${id} ${face}: ${parts.map((region) => region.id).join(', ')}`) continue } + const scripted = slabSurfaceScriptPaintRefusal(target, role) + if (scripted) + refuse('surface_built_by_script', `${scripted.message} Nothing was painted.`, { + target: id, + role, + }) const next = { ...((target as AnyNode & { slots?: Record }).slots ?? {}) } if (ref) next[role] = ref else delete next[role] diff --git a/packages/core/src/agent-operations/scene-materials.ts b/packages/core/src/agent-operations/scene-materials.ts index 7c7b5a7204..4c7e131219 100644 --- a/packages/core/src/agent-operations/scene-materials.ts +++ b/packages/core/src/agent-operations/scene-materials.ts @@ -1,5 +1,6 @@ import { refuse } from '../agent-tools/refusal' import { parseMaterialRef, toSceneMaterialRef } from '../material-library' +import type { CompiledGeometryScript } from '../schema' import { MaterialSchema } from '../schema/material' import { generateSceneMaterialId, @@ -58,6 +59,63 @@ export function resolveInlinePaintMaterial( return { ref: toSceneMaterialRef(id), sceneMaterial, created: true } } +const GLASS_REF = 'library:preset-glass' + +/** How a colour's surface reflects: a module's own values, or a matte paint. */ +export type ColorSurface = { roughness: number; metalness: number } +export const MATTE: ColorSurface = { roughness: 0.9, metalness: 0 } + +/** + * A colour kept exactly rather than snapped to the library: a native material named by its hex + * (and its surface, when not matte), so the same colour and surface reuse one scene material. + */ +export function exactColorMaterial( + materials: Readonly> | undefined, + hex: string, + surface: ColorSurface = MATTE, +) { + const color = hex.toLowerCase() + return resolveInlinePaintMaterial( + materials, + { + properties: { + color, + roughness: surface.roughness, + metalness: surface.metalness, + opacity: 1, + transparent: false, + side: 'front', + }, + }, + surface.roughness === MATTE.roughness && surface.metalness === MATTE.metalness + ? color + : `${color}, roughness ${+surface.roughness.toFixed(2)}, metalness ${+surface.metalness.toFixed(2)}`, + ) +} + +/** + * A compiled module's slots with each of its own colours written exactly, over the library + * matches: glass keeps the library glass (drawn see-through, letting light pass) and an emissive + * slot its module's material (a scene material carries no emission, so a bulb stays lit). + * `exact` answers a colour's ref, or undefined to keep the match. + */ +export function exactScriptSlots( + manifest: CompiledGeometryScript['manifest'], + matched: Record, + exact: (hex: string, surface: ColorSurface) => string | undefined, +): Record { + const slots = { ...matched } + for (const slot of manifest.slots) { + if (!slot.color || slot.emissive || slots[slot.id] === GLASS_REF) continue + const ref = exact(slot.color, { + roughness: slot.roughness ?? MATTE.roughness, + metalness: slot.metalness ?? MATTE.metalness, + }) + if (ref) slots[slot.id] = ref + } + return slots +} + /** The scene materials a node points at, in its slots and its role fields alike. */ export function referencedSceneMaterialIds(value: unknown): Set { const ids = new Set() diff --git a/packages/core/src/agent-operations/slab-surface-script.ts b/packages/core/src/agent-operations/slab-surface-script.ts new file mode 100644 index 0000000000..81eb053290 --- /dev/null +++ b/packages/core/src/agent-operations/slab-surface-script.ts @@ -0,0 +1,95 @@ +import { refuse } from '../agent-tools/refusal' +import { + type FloorScriptInputs, + floorScriptRecord, + slabSurfaceScriptInputs, +} from '../lib/floor-script' +import { scriptedSize } from '../lib/geometry-script-node' +import { isFloorPlate } from '../lib/plate-surface' +import { type CompiledGeometryScript, ZoneFloorScript } from '../schema' +import type { AgentOperation } from './types' + +export type SetSlabSurfaceScriptInput = + | { + slabId: string + /** The module compiled for `inputs` (`slabSurfaceScriptInputs` of the slab, read before compiling). */ + compiled: CompiledGeometryScript + inputs: FloorScriptInputs + } + | { slabId: string; remove: true } + +const round = (value: number) => Math.round(value * 1000) / 1000 + +/** + * `setSlabSurfaceScript`: a standalone slab's surface finish (a terrace's decking, a platform's + * tiles, a plinth's paving) built by a three.js module, or the module taken off. The host reads the + * slab's outline (`slabSurfaceScriptInputs`), compiles the module for it (the module reads + * `params.polygon` and `params.holes` in the level's plan x/z, and lays its finish from y = 0, the + * slab's top) and stores the artifacts; this writes the result on the slab with the key of the + * outline it was built for, and the renderer stands it on the slab. When the slab is reshaped the + * script is stale and `refreshFinishScripts` runs it again. The script owns the surface's colours: + * paint on it is refused while it is there. A room's floor plate has none: the room's floor script + * (`setRoomFloorScript`) builds there. + */ +export const setSlabSurfaceScript: AgentOperation = (nodes, input) => { + const slab = nodes[input.slabId] + if (slab?.type !== 'slab') + refuse('target_not_found', `${input.slabId} is not a slab in the scene: nothing was changed.`, { + target: input.slabId, + }) + const name = slab.name?.trim() || slab.id + + if ('remove' in input) { + if (!slab.surfaceScript) return { result: { ok: true, slabId: slab.id, removed: false } } + return { + result: { ok: true, slabId: slab.id, removed: true }, + changes: { update: [{ id: slab.id, data: { surfaceScript: undefined } }] }, + } + } + + const { compiled } = input + if (compiled.language !== 'three') + refuse( + 'three_only', + `A slab's surface script is a three.js module: it is given the slab's outline, which a Blender build is not. Nothing was changed on ${name}.`, + { target: slab.id }, + ) + if (isFloorPlate(slab)) { + const rooms = (slab.zoneIds ?? []).flatMap((id) => { + const zone = nodes[id] + return zone?.type === 'zone' ? [`${zone.name?.trim() || zone.id} (${zone.id})`] : [] + }) + refuse( + 'room_floor_plate', + `${name} is the floor plate Pascal builds under ${rooms.length ? rooms.join(', ') : 'its rooms'}: a room's floor finish is built by that room's floor script, not by the plate. Nothing was changed.`, + { target: slab.id, zoneIds: slab.zoneIds ?? [] }, + ) + } + if (!slabSurfaceScriptInputs(nodes, slab.id)) + refuse( + 'no_surface', + `${name} has no surface to build on (its outline has fewer than three corners): nothing was changed.`, + { target: slab.id }, + ) + const { min, max } = compiled.manifest.bounds + if (![...min, ...max].every(Number.isFinite)) + refuse( + 'unmeasurable_object', + `${name}'s surface 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: slab.id }, + ) + const surfaceScript = ZoneFloorScript.parse( + floorScriptRecord(compiled, input.inputs, slab.surfaceScript), + ) + return { + result: { + ok: true, + slabId: slab.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 surface colours: painting that surface is refused until the script is removed. It runs again whenever the slab's outline or holes change.`, + }, + changes: { update: [{ id: slab.id, data: { surfaceScript } }] }, + } +} diff --git a/packages/core/src/agent-tools/add-object.ts b/packages/core/src/agent-tools/add-object.ts index bb8298be25..d6382f7b04 100644 --- a/packages/core/src/agent-tools/add-object.ts +++ b/packages/core/src/agent-tools/add-object.ts @@ -16,7 +16,7 @@ One object is one feature that changes together: a table, a bench, a porch, a ra Conventions (they make the object work in Pascal; follow them): - Metres, Y up, modelled in its installed orientation. Floor and wall mounts put the bottom-centre of the visible bounds at the placement point. Ceiling mounts put the top-centre there: visible geometry extends down in negative local Y, with its top at Y = 0. Wall-side mounts put the bottom-centre of the back face there (back at Z = 0, facing +Z). Normalization translates geometry; it does not flip it. -- Paint: name every material slot_ (slot_trim, slot_frame, slot_metal) and reuse one material per finish; a material named "glass" renders as glass. +- Paint: name every material slot_ (slot_trim, slot_frame, slot_metal) and reuse one material per finish; a material named "glass" renders as glass. A slot's colour is shown as the library finish it matches (wood, metal, the nearest flat colour); exact: true keeps each colour as written. - Parts: name the few groups a person would point at part: (part:column_left, part:canopy, part:landing), usually 2–24, a group per part, not every mesh. Set userData.type on parts someone would look for: column, beam, slab, roof, railing, panel, trim, step, light. - Lights: add a THREE.PointLight or SpotLight named light: where the bulb is; it becomes a switchable light. - Motion: put THREE.AnimationClips on the returned group's .animations, as in any three.js project; tracks target . or . and may use any transform property (position, rotation, rotation[y], quaternion, scale); material and visibility tracks do not animate. A clip named open becomes the object's open/close control (close plays a clip named close, or open reversed); a clip named loop runs continuously; every other clip gets its own play toggle labelled with its name (name it for the person: "Twirl", "Music"). Write as many clips as the object needs. @@ -129,6 +129,12 @@ export const addObjectTool = { .enum(['front', 'back']) .optional() .describe('Which wall face a wall-side object sits on.'), + exact: z + .boolean() + .optional() + .describe( + "Keep the module's slot colours exactly as written, with their roughness and metalness, instead of the library finishes they match (glass stays glass, a glowing slot its own material). On an edit, the module's colours replace the slots' paint.", + ), ...geometryMetaFields, name: geometryMetaFields.name.describe('What the user would call it ("Low oak table").'), category: geometryMetaFields.category.describe( @@ -149,10 +155,10 @@ export const getSourceTool = { name: 'get_source', title: 'Read object script', description: - "The module (three.js or Blender bpy, named by language) an object (or a window, door or column built from code) runs, with its params and their current values; 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.", + "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 slab's id reads its surface script, the module that builds its top's 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, a room with a floor script, or a wall with a face script.', + 'An object, window, door or column built from code, a room with a floor script, a slab with a surface script, or a wall with a face script.', ), face: z .enum(['a', 'b']) diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index 93fcaa1979..2ab59a954d 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -2,13 +2,14 @@ import { z } from 'zod' import { DOOR_STYLES, WINDOW_STYLES } from '../building/opening-style-presets' import { DoorType, WindowType } from '../schema/nodes/opening-types' import { RoofType } from '../schema/nodes/roof-types' +import { StairRailingMode, StairRailingStyle } from '../schema/nodes/stair-types' import { measurement, roofPitch } from './measurement' import { NodeId } from './node-id' import { unitKind } from './units' 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 (straight, curved or glass) with their openings, rooms (each naming its unit in a building of several dwellings or tenancies: a condo, an apartment building, a hotel; a room naming none is common area), slabs, voids, columns, railings, stairs, 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, except a ceiling item's (mount 'ceiling': a pendant, a ceiling light): it hangs from the ceiling of the room containing its plan position, its top on that ceiling, and its y is not read. A rug, a mat or a runner is an item lying on the room's floor. What stands on a floor stands on its finish: on a room with a floorCode, on the top of its planks or tiles, so its y stays 0. 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), cladding, brick or battens outside its faceOutside; a slab is a terrace, a platform, a balcony; a stair is its level's stairs, which open the floor above; the roof over the top level is roof, Pascal's own segments when its shape is one of theirs, else your geometry as roof items, both on the building's roof level above the top storey. Pascal draws what bold architecture needs natively: a curved wall is one wall with the point its arc passes through, through (never a run of short straight ones), a glass front a curtain wall, a double-height room a void in the floor of the level above it (its ceiling opens with it, a railing along its edges), an archway a passage with an arch shape. Paint a wall by what its faces look onto, colorInside and colorOutside: which face is inside is Pascal's to find. Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. +Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre; give each opening its type (how it opens) and style (its look), or its own module for a shape they cannot express. An item's position is where its bottom centre stands, except a ceiling item's (mount 'ceiling': a pendant, a ceiling light): it hangs from the ceiling of the room containing its plan position, its top on that ceiling, and its y is not read. A rug, a mat or a runner is an item lying on the room's floor. What stands on a floor stands on its finish: on a room with a floorCode, on the top of its planks or tiles, so its y stays 0. Its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is). A hex colour is written as an exact material, one per distinct colour. Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and floor planks into the wall, the room's floor finish and the room (say so in notes), never into slabs or regions: baseboards are the wall's skirting, a plank or tile floor is the room's floorCode (a terrace's decking or paving its slab's surfaceCode), tiles, panelling or boards on a wall are its faceLeft / faceRight (or the room's wallCode), cladding, brick or battens outside its faceOutside; a slab is a terrace, a platform, a balcony; a stair is its level's stairs, which open the floor above; the roof over the top level is roof, Pascal's own segments when its shape is one of theirs, else your geometry as roof items, both on the building's roof level above the top storey. Pascal draws what bold architecture needs natively: a curved wall is one wall with the point its arc passes through, through (never a run of short straight ones), a glass front a curtain wall, a double-height room a void in the floor of the level above it (its ceiling opens with it, a railing along its edges), an archway a passage with an arch shape. Paint a wall by what its faces look onto, colorInside and colorOutside: which face is inside is Pascal's to find. Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. Hand over the payload, or your module and its adapter, the program that reads the module and returns the payload: Pascal runs the adapter (one that throws comes back with its error, and nothing is written). An item names its piece instead of code when add_object makes it from words; one still being made is written when it lands, with no call from you. Pieces made from words are written many at a time while you go on, so furniture made that way does not lengthen your turn the way pieces you write yourself do. @@ -276,21 +277,34 @@ export const authorSceneSlab = z.object({ }, ), finish: z.string().optional().describe('library: or #rrggbb (an exact colour).'), + surfaceCode: z + .string() + .min(1) + .max(48_000) + .optional() + .describe( + "A three.js module that builds the slab's surface finish (a terrace's decking or paving, a platform's boards): it receives params.polygon ([[x, z], …]) and params.holes ([[[x, z], …], …]), the slab's outline in level plan x/z, metres, and lays the boards or slabs out from y = 0, the slab's top. Pascal cuts the build to the polygon and its holes, so a row may run past the edges, and stands what is placed on the slab on the build's top. Pascal rebuilds it when the slab changes; finish stays the fallback and the sides keep theirs. The report warns when the build leaves part of the slab uncovered.", + ), railing: z .array( z.object({ start: plan('start'), end: plan('end'), height: length('Railing height (default 1.1 m).', { default: 1.1, positive: true }), + color: z.string().optional().describe('#rrggbb or library: (default white).'), }), ) .optional() - .describe('Railing runs on this slab (a balcony).'), + .describe('Railing runs standing on this slab (a balcony).'), }) export const authorSceneColumn = z.object({ name: z.string().max(80).optional(), position: plan('centre'), + rotation: measurement('angle', 'deg', { + default: 0, + description: 'Turn about the vertical axis (default 0): at 0 its width runs along x.', + }), shape: z .enum(['square', 'round']) .default('square') @@ -298,7 +312,20 @@ export const authorSceneColumn = z.object({ width: length('Column width.', { positive: true }), depth: length('Column depth (default: its width).', { positive: true }).optional(), height: length('Column height.', { positive: true }), - color: z.string().optional(), + color: z + .string() + .optional() + .describe( + "Its finish, #rrggbb written as an exact material, or library: (default: Pascal's plaster). A column built by code keeps its module's colours.", + ), + code: z + .string() + .min(1) + .max(48_000) + .optional() + .describe( + "A three.js module for a column shape and width cannot express (a timber post with brackets, a fluted or tapered pier, a steel section): the add_column code contract, mount 'floor', built standing from y = 0 with its bottom centre on position; what it builds sets its width, depth and height, so shape, width, depth and height are not read. Compiled and stored as an item's module is.", + ), }) export const authorSceneItem = z.object({ @@ -371,6 +398,20 @@ export const authorSceneStair = z.object({ .min(1) .optional() .describe("An L or U's risers up to its landing; the rest climb after it (default half)."), + railing: StairRailingMode.optional().describe( + 'Which sides have a railing, looking up the flight (default both): none, left, right or both.', + ), + railingStyle: StairRailingStyle.optional().describe( + 'How the railings are built (default balusters): post-and-rail, cable, boards, glass or metal.', + ), + color: z + .string() + .optional() + .describe( + "The flight's finish, treads and body, #rrggbb written as an exact material, or library: (default: wood treads on a light grey body).", + ), + treads: z.string().optional().describe("The treads' finish, over color; same forms."), + railingColor: z.string().optional().describe("The railings' finish; same forms."), }) export const authorSceneLight = z.object({ diff --git a/packages/core/src/agent-tools/paint.ts b/packages/core/src/agent-tools/paint.ts index ada62a55a4..0d305cdc09 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 (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.", + "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; with exact: true, that colour itself, a matte scene material); 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.'), @@ -14,12 +14,20 @@ export const paintTool = { .string() .regex(/^#[0-9a-fA-F]{6}$/) .optional() - .describe('A colour, #rrggbb: painted with the nearest library colour.'), + .describe( + 'A colour, #rrggbb: painted with the nearest library colour, or as given with exact.', + ), + exact: z + .boolean() + .optional() + .describe( + 'With color: paint that colour itself, a matte scene material, not the nearest library colour (a brand colour, a colour matched to a photo).', + ), material: z .union([z.string().min(1), MaterialSchema]) .optional() .describe( - 'A library: or scene: ref, or a native material (properties color, roughness, metalness, and an existing texture) kept as a scene material.', + "A library: or scene: ref, or a native material kept as a scene material: properties color, roughness, metalness; texture { url, repeat [u, v] (tiles per metre), offset [u, v] (in tiles), normalMap, normalScale, bumpMap, bumpScale }, its maps sharing the image's repeat and offset. Surfaces are laid in level metres: a floor, a slab's top or a ceiling at u = plan x, v = −z; a wall face at u = x for a wall running along x (−x on its face turned to −z), z for one running along z (−z on its face turned to −x), v = 1 − the height above the level's floor. A joint at c along u or v: offset = frac(−c × repeat).", ), materialName: z .string() diff --git a/packages/core/src/contracts/reference-discovery.ts b/packages/core/src/contracts/reference-discovery.ts index 04951e8a6e..b9243fce7f 100644 --- a/packages/core/src/contracts/reference-discovery.ts +++ b/packages/core/src/contracts/reference-discovery.ts @@ -1,11 +1,12 @@ import type { z } from 'zod' +import { AssetUrl } from '../schema/asset-url' /** * Walks a zod schema's AST and returns every persisted path that could hold a * reference (R3 coverage). A candidate is a leaf whose field name ends in * `Id`/`Ids`, is `id` below the root, `children` or `members`, or names a URL, - * `src`, a `slot` key, thumbnail or material preset; any typed-id (`template_literal`) or `custom` - * leaf; every record key; and every string-like record value. The inventory + * `src`, a `slot` key, thumbnail or material preset; any `AssetUrl`, typed-id (`template_literal`) + * or `custom` leaf; every record key; and every string-like record value. The inventory * must classify each candidate as a reference or a declared non-reference, so * a new id-like field cannot land without a policy. Beside a field ending in * `Id`, host-derived siblings (`side`, `wallT`, `offset`, `*Face`, `*UV`, @@ -113,6 +114,7 @@ function walk( if (!STRING_LIKE.has(def.type)) return const nested = path.includes('.') || path.includes('[') if ( + schema === AssetUrl || def.type === 'template_literal' || def.type === 'custom' || field === '*' || diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index b9a2974f3d..204b9908e1 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -91,13 +91,21 @@ const MATERIAL_FIELDS = [ 'wallMaterial', ] as const +/** A `MaterialSchema.texture`'s maps, each an asset URL. */ +const TEXTURE_MAPS = ['url', 'normalMap', 'bumpMap'] as const + +const textureRows = (material: string) => + TEXTURE_MAPS.map((map) => + row({ + kind: '*', + path: `${material}.texture.${map}`, + ...policy('asset', 'content', 'freeze', 'keep'), + note: 'Texture by URL; a dangling URL renders the material without that map.', + }), + ) + const materialRows = MATERIAL_FIELDS.flatMap((field) => [ - row({ - kind: '*', - path: `${field}.texture.url`, - ...policy('asset', 'content', 'freeze', 'keep'), - note: 'Texture by URL; a dangling URL renders the untextured material.', - }), + ...textureRows(field), row({ kind: '*', path: `${field}Preset`, @@ -602,13 +610,8 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [ note: 'Library material; `#rrggbb` values are literal colours.', }), ...materialRows, - ...(['legacyFaceMaterials.a', 'legacyFaceMaterials.b', 'foundation'] as const).map((prefix) => - row({ - kind: '*', - path: `${prefix}.material.texture.url`, - ...policy('asset', 'content', 'freeze', 'keep'), - note: 'Texture by URL; a dangling URL renders the untextured material.', - }), + ...(['legacyFaceMaterials.a', 'legacyFaceMaterials.b', 'foundation'] as const).flatMap((prefix) => + textureRows(`${prefix}.material`), ), ...(['legacyFaceMaterials.a', 'legacyFaceMaterials.b'] as const).map((prefix) => row({ @@ -754,7 +757,8 @@ export const NON_REFERENCES: readonly { kind: string; path: string; reason: stri }, { kind: 'procedural-item', path: 'parameters.@key', reason: 'Recipe parameter name.' }, // Scripted nodes (an authored item, or a window or door built from code) share one `source`; - // a room's floor script keeps the same record on its zone, a wall face's script on its wall. + // a room's floor script keeps the same record on its zone, a slab's surface script on its slab, + // a wall face's script on its wall. ...( [ ['item', 'source'], @@ -762,6 +766,7 @@ export const NON_REFERENCES: readonly { kind: string; path: string; reason: stri ['door', 'source'], ['column', 'source'], ['zone', 'floor.script'], + ['slab', 'surfaceScript'], ['wall', 'finishScripts.a'], ['wall', 'finishScripts.b'], ] as const diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index 9f18c731a6..8af61d72a8 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -256,6 +256,49 @@ describe('floor-placed elevation resolver', () => { ).toBeCloseTo(0.069, 9) }) + test("what stands on a slab's scripted surface stands on the build's top; off it, on the level", () => { + registerNode( + makeDefinition('item', { + floorPlaced: { footprint: () => ({ dimensions: [1, 0.01, 1], rotation: [0, 0, 0] }) }, + }), + ) + // 25 mm decking laid from the terrace's top, y = 0. + const terrace = { + id: 'slab_terrace', + type: 'slab', + parentId: LEVEL_ID, + polygon: [ + [-2, -2], + [2, -2], + [2, 2], + [-2, 2], + ], + holes: [], + elevation: 0.3, + thickness: 0.3, + surfaceScript: { manifest: { bounds: { min: [-2, 0, -2], max: [2, 0.025, 2] } } }, + } as unknown as SlabNode + const level = { ...makeLevel(), children: [terrace.id] } as AnyNode + spatialGridManager.handleNodeCreated(terrace as AnyNode, LEVEL_ID) + const chair = makeFloorNode() + const nodes = nodesFor(level, terrace as AnyNode, chair) + + // The chair stands on the boards (0.3 slab + 1 mm under the build + 25 mm), not in them. + expect(getFloorPlacedElevation({ node: chair, nodes, position: [0, 0, 0] })).toBeCloseTo( + 0.326, + 9, + ) + expect(getFloorPlacedElevation({ node: chair, nodes, position: [6, 0, 0] })).toBe(0) + const bare = { ...terrace, surfaceScript: undefined } as AnyNode + expect( + getFloorPlacedElevation({ + node: chair, + nodes: { ...nodes, [bare.id]: bare }, + position: [0, 0, 0], + }), + ).toBe(0.3) + }) + test('canPlaceOnFloorFootprints accepts an L-shaped draft in the open corner gap', () => { const baseAsset = (makeFloorNode() as { asset: Record }).asset registerNode( diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts index d19190d77e..d5a565748b 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts @@ -729,6 +729,45 @@ describe('temporal writes update slab support dependencies', () => { } }) + test("a slab's surface script laid, rebuilt or removed re-elevates what stands on it", () => { + const level = LevelNode.parse({ id: 'level_surface' }) + const terrace = makeSlab('slab_surface', level.id) + const chair = ItemNode.parse({ + parentId: level.id, + position: [2, 0, 2], + asset: { + id: 'chair', + name: 'Chair', + category: 'chair', + thumbnail: '', + src: '/chair.glb', + dimensions: [0.2, 1, 0.2], + }, + }) + const elsewhere = { ...chair, id: 'item_elsewhere', position: [20, 0, 20] } as AnyNode + useScene.setState({ + nodes: nodesFor(level, terrace, chair, elsewhere), + dirtyNodes: new Set(), + readOnly: false, + }) + stop = initSpatialGridSync() + const script = (height: number) => ({ + manifest: { bounds: { min: [0, 0, 0], max: [4, height, 4] } }, + }) + for (const surfaceScript of [script(0.025), script(0.02), undefined]) { + useScene.getState().dirtyNodes.clear() + const current = useScene.getState().nodes[terrace.id]! + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + [terrace.id]: { ...current, surfaceScript } as AnyNode, + }, + }) + expect(useScene.getState().dirtyNodes.has(chair.id)).toBe(true) + expect(useScene.getState().dirtyNodes.has(elsewhere.id)).toBe(false) + } + }) + test('slab reparent and undo mark covering dependents below both parent levels', async () => { const levels = [0, 1, 2, 3].map((ordinal) => makeLevel(`level_${ordinal}`, ordinal, 2.5, [ diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index e1f3dfa557..50db417f90 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -427,7 +427,8 @@ export function markSlabChangeDependents( next.elevation !== previous.elevation || next.holes !== previous.holes - if (supportChanged) { + // A slab's surface script is the surface what stands on the slab rests on. + if (supportChanged || next.surfaceScript !== previous.surfaceScript) { markNodesOverlappingSlab(previous, nodes, markDirty, previousNodes) markNodesOverlappingSlab(next, nodes, markDirty) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 024be0dd9a..678a19007a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -217,6 +217,7 @@ export { type FloorPlatePaintRefusal, floorPlatePaintRefusal, floorScriptPaintRefusal, + slabSurfaceScriptPaintRefusal, } from './lib/floor-plate-paint' export { FLOOR_SCRIPT_LIFT, @@ -225,6 +226,8 @@ export { floorScriptInputsKey, floorScriptRecord, floorScriptSurface, + slabScriptSurface, + slabSurfaceScriptInputs, } from './lib/floor-script' export { type FloorStepOverride, diff --git a/packages/core/src/lib/finish-script.ts b/packages/core/src/lib/finish-script.ts index 9ef7aea304..dd7879259a 100644 --- a/packages/core/src/lib/finish-script.ts +++ b/packages/core/src/lib/finish-script.ts @@ -12,6 +12,7 @@ import { floorScriptInputs, floorScriptInputsKey, floorScriptRecord, + slabSurfaceScriptInputs, } from './floor-script' import { type WallFaceScriptInputs, @@ -23,8 +24,8 @@ import { 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`). + * A finish a script builds for its host: a room's floor (`nodeId` a zone), a standalone slab's + * surface (`nodeId` a slab) or one face of a wall (`nodeId` a wall, with `face`). */ export type FinishScriptTarget = { nodeId: string; face?: WallFace } export type FinishScriptInputs = FloorScriptInputs | WallFaceScriptInputs @@ -33,6 +34,8 @@ 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 === 'slab') + return node.surfaceScript ? [{ nodeId: node.id, script: node.surfaceScript }] : [] if (node.type !== 'wall') return [] return (['a', 'b'] as const).flatMap((face) => { const script = node.finishScripts?.[face] @@ -50,7 +53,10 @@ function currentInputs( const inputs = wallFaceScriptInputs(nodes, nodeId, face, (script as WallFaceScript).toHeight) return inputs && { inputs, key: wallFaceScriptInputsKey(inputs) } } - const inputs = floorScriptInputs(nodes, nodeId) + const inputs = + nodes[nodeId]?.type === 'slab' + ? slabSurfaceScriptInputs(nodes, nodeId) + : floorScriptInputs(nodes, nodeId) return inputs && { inputs, key: floorScriptInputsKey(inputs) } } @@ -62,9 +68,9 @@ export type StaleFinishScript = FinishScriptTarget & { /** * 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. + * reconcile re-derived a room (a wall moved, an edge pushed, a floor raised), a slab was reshaped + * or holed, 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) => @@ -146,6 +152,8 @@ export function finishScriptWrites( 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 === 'slab') { + writes.set(node.id, { surfaceScript: script as ZoneFloorScript }) } else if (node.type === 'wall' && target.face) { const earlier = writes.get(node.id) as Pick | undefined writes.set(node.id, { diff --git a/packages/core/src/lib/floor-plate-paint.ts b/packages/core/src/lib/floor-plate-paint.ts index 706ae1bc8b..e0f0336374 100644 --- a/packages/core/src/lib/floor-plate-paint.ts +++ b/packages/core/src/lib/floor-plate-paint.ts @@ -19,12 +19,27 @@ export function floorScriptPaintRefusal( } } +/** Why a slab's top cannot be painted: its own surface script builds it, and owns its colours. */ +export function slabSurfaceScriptPaintRefusal( + slab: AnyNode | undefined, + role: string, +): FloorPlatePaintRefusal | undefined { + if (slab?.type === 'slab' && slab.surfaceScript && role === 'surface') + return { + status: 'refused', + code: 'floor-built-by-script', + message: `${slab.name?.trim() || 'This slab'}'s surface is built by a script, which owns its colours. Remove the script to paint the surface.`, + } +} + export function floorPlatePaintRefusal( nodes: Readonly>, node: AnyNode, role: string, ): FloorPlatePaintRefusal | undefined { if (node.type !== 'slab') return + const scripted = slabSurfaceScriptPaintRefusal(node, role) + if (scripted) return scripted const room = parseRoomFinishRole(role) if (room) return floorScriptPaintRefusal(nodes[room.zoneId]) if (node.plateRole !== 'base' || role !== 'surface') return diff --git a/packages/core/src/lib/floor-script.ts b/packages/core/src/lib/floor-script.ts index 5443f196f8..339ca4def1 100644 --- a/packages/core/src/lib/floor-script.ts +++ b/packages/core/src/lib/floor-script.ts @@ -1,7 +1,8 @@ import type { AnyNode, CompiledGeometryScript, SlabNode, ZoneFloorScript } from '../schema' import { DEFAULT_SLAB_ELEVATION } from '../schema/nodes/slab' +import { liftedManualSlab } from './floor-construction-lift' import { scriptSource } from './geometry-script-node' -import { levelWallCover, plateLevelContext } from './plate-surface' +import { isFloorPlate, levelWallCover, plateLevelContext } from './plate-surface' import { area, containsPoint, @@ -17,11 +18,12 @@ import { scriptInputsKey } from './script-inputs-key' type SceneNodes = Readonly> /** - * What a room's floor script is built for, in metres: the room's walking floor (its outline inside - * the wall faces, on its floor plate) as `polygon` with its `holes`, in the level's plan x/z. The - * build's y = 0 is the floor's surface: the module lays its boards from there up, Pascal stands the - * build on the plate's top (`floorScriptSurface`) and cuts it to the polygon less its holes. A plate - * raised or lowered moves the build without running the module again. + * What a floor script is built for, in metres: a room's walking floor (its outline inside the wall + * faces, on its floor plate) or a standalone slab's top, as `polygon` with its `holes`, in the + * level's plan x/z. The build's y = 0 is the floor's surface: the module lays its boards from there + * up, Pascal stands the build on the plate's or the slab's top (`floorScriptSurface`, + * `slabScriptSurface`) and cuts it to the polygon less its holes. A floor raised or lowered moves + * the build without running the module again. */ export type FloorScriptInputs = { polygon: [number, number][] @@ -58,10 +60,10 @@ export function floorScriptSurface(nodes: SceneNodes, zoneId: string): number | } /** - * Where a plan point standing on `slabId` finds a room's floor finish its script built: the top of - * that build, in level coordinates, so a rug or a sofa stands on the boards rather than in them. - * Null where no script builds on that plate there. Reads the level's children, and its slabs only - * once a scripted room holds the point. + * Where a plan point standing on `slabId` finds a floor finish a script built (a room's on its + * plate, or the slab's own surface script): the top of that build, in level coordinates, so a rug + * or a sofa stands on the boards rather than in them. Null where no script builds on that slab + * there. Reads the level's children, and its slabs only once a scripted room holds the point. */ export function floorFinishTopAt( nodes: SceneNodes, @@ -70,6 +72,13 @@ export function floorFinishTopAt( x: number, z: number, ): number | null { + const slab = nodes[slabId] + if (slab?.type === 'slab' && slab.surfaceScript) { + const top = slabScriptSurface(nodes, slab.id) + if (top === null || !containsPoint([{ outer: slab.polygon, holes: slab.holes ?? [] }], [x, z])) + return null + return top + FLOOR_SCRIPT_LIFT + Math.max(0, slab.surfaceScript.manifest.bounds.max[1]) + } const level = nodes[levelId] if (level?.type !== 'level') return null let slabs: SlabNode[] | undefined @@ -118,12 +127,42 @@ export function floorScriptInputs(nodes: SceneNodes, zoneId: string): FloorScrip return inputs } +/** + * A floor plate Pascal builds from rooms has no surface script of its own: each room's floor script + * builds there. Any other slab (a terrace, a platform, a plinth) is built for its own outline. + */ +export function slabSurfaceScriptInputs( + nodes: SceneNodes, + slabId: string, +): FloorScriptInputs | null { + const slab = nodes[slabId] + if (slab?.type !== 'slab' || isFloorPlate(slab) || slab.polygon.length < 3) return null + return { + polygon: roundRing(slab.polygon), + holes: (slab.holes ?? []).filter((hole) => hole.length >= 3).map(roundRing), + } +} + +/** + * The height, in level coordinates, of the top a slab's surface script stands on (the slab's top, + * lifted with the footprint it stands in as its geometry is), where the build's y = 0 is drawn; + * null when the slab has no surface a script builds on. + */ +export function slabScriptSurface(nodes: SceneNodes, slabId: string): number | null { + const slab = nodes[slabId] + if (slab?.type !== 'slab' || !slabSurfaceScriptInputs(nodes, slabId)) return null + return liftedManualSlab(nodes, slab).elevation +} + /** 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])) } -/** A compile as the zone keeps it: its script source and the key of the floor it was built for. */ +/** + * A compile as the zone (or the slab) keeps it: its script source and the key of the floor it was + * built for. + */ export function floorScriptRecord( compiled: CompiledGeometryScript, inputs: FloorScriptInputs, diff --git a/packages/core/src/lib/slab-surface-script.test.ts b/packages/core/src/lib/slab-surface-script.test.ts new file mode 100644 index 0000000000..14840cd54d --- /dev/null +++ b/packages/core/src/lib/slab-surface-script.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, test } from 'bun:test' +import { readableScript } from '../agent-operations/add-object' +import { applySceneChanges } from '../agent-operations/apply-changes' +import { paint } from '../agent-operations/paint' +import { setSlabSurfaceScript } from '../agent-operations/slab-surface-script' +import { isAgentRefusal } from '../agent-tools' +import { + type AnyNode, + type CompiledGeometryScript, + GeometryArtifactManifest, + LevelNode, + SlabNode, + WallNode, +} from '../schema' +import { + type FinishScriptHost, + finishScriptWrites, + refreshFinishScripts, + staleFinishScripts, +} from './finish-script' +import { floorPlatePaintRefusal } from './floor-plate-paint' +import { + FLOOR_SCRIPT_LIFT, + type FloorScriptInputs, + floorFinishTopAt, + slabScriptSurface, + slabSurfaceScriptInputs, +} from './floor-script' +import type { SceneNodes } from './structure-kernel' +import { reconcileSceneStructure } from './structure-reconcile' + +const LEVEL = 'level_slab_script' +const TERRACE = 'slab_terrace' +const corners: [number, number][] = [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], +] + +let minted = 0 +/** + * An 8 × 4 m room of 0.2 m walls reconciled into its zone and floor plate, and beside it a 4 × 3 m + * terrace slab, 0.3 m high, with a 1 m square hole (a tree pit). + */ +function scene(): SceneNodes { + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_ss_${index}`, + parentId: LEVEL, + start, + end: corners[(index + 1) % 4], + thickness: 0.2, + }), + ) + const terrace = SlabNode.parse({ + id: TERRACE, + parentId: LEVEL, + name: 'Terrace', + polygon: [ + [10, 0], + [14, 0], + [14, 3], + [10, 3], + ], + holes: [ + [ + [11, 1], + [12, 1], + [12, 2], + [11, 2], + ], + ], + elevation: 0.3, + thickness: 0.3, + }) + const level = LevelNode.parse({ + id: LEVEL, + children: [...walls.map((wall) => wall.id), terrace.id], + }) + const nodes = Object.fromEntries([level, ...walls, terrace].map((node) => [node.id, node])) + return reconcileSceneStructure({ nodes, mintId: (kind) => `${kind}_ss_${minted++}` }).nodes +} + +const terraceOf = (nodes: SceneNodes) => nodes[TERRACE] as SlabNode +const plateOf = (nodes: SceneNodes) => + Object.values(nodes).find( + (node): node is SlabNode => node.type === 'slab' && node.boundary === 'auto', + )! + +/** 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: { board: 0.14 }, + manifest: GeometryArtifactManifest.parse({ + bounds: { + min: [Math.min(...xs), 0, Math.min(...zs)], + max: [Math.max(...xs), 0.025, Math.max(...zs)], + }, + slots: [{ id: 'teak' }], + triangles: 12, + }), + } satisfies CompiledGeometryScript + }, + } + return { host, builds } +} + +/** The scene with decking on its terrace, built once by `host` for the terrace's outline. */ +async function scripted(host: FinishScriptHost) { + const nodes = scene() + const inputs = slabSurfaceScriptInputs(nodes, TERRACE)! + const compiled = await host.build({ nodeId: TERRACE, code: '', params: {}, inputs }) + const { changes } = setSlabSurfaceScript( + nodes, + { slabId: TERRACE, compiled, inputs }, + { 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 slab's surface script", () => { + test("is built for the slab's own outline less its holes, its y = 0 on the slab's top", () => { + const nodes = scene() + expect(slabSurfaceScriptInputs(nodes, TERRACE)).toEqual({ + polygon: terraceOf(nodes).polygon, + holes: terraceOf(nodes).holes, + }) + expect(slabScriptSurface(nodes, TERRACE)).toBeCloseTo(0.3, 9) + }) + + test("a room's floor plate has none: the room's floor script builds there", async () => { + const nodes = scene() + const plate = plateOf(nodes) + expect(slabSurfaceScriptInputs(nodes, plate.id)).toBeNull() + expect(slabScriptSurface(nodes, plate.id)).toBeNull() + const { host } = fakeHost() + const inputs = slabSurfaceScriptInputs(nodes, TERRACE)! + const compiled = await host.build({ nodeId: plate.id, code: '', params: {}, inputs }) + expect( + refusal(() => + setSlabSurfaceScript( + nodes, + { slabId: plate.id, compiled, inputs }, + { activeLevelId: LEVEL }, + ), + ), + ).toBe('room_floor_plate') + }) + + test('is written with the key of its outline, and taken off again', async () => { + let nodes = await scripted(fakeHost().host) + const script = terraceOf(nodes).surfaceScript! + expect(script).toMatchObject({ script: 'a'.repeat(64), params: { board: 0.14 } }) + expect(script.inputs).toEqual(expect.any(String)) + expect(staleFinishScripts(nodes)).toEqual([]) + expect(readableScript(nodes, TERRACE)).toMatchObject({ + id: TERRACE, + type: 'slab', + source: script, + }) + + nodes = applySceneChanges( + nodes, + setSlabSurfaceScript(nodes, { slabId: TERRACE, remove: true }, { activeLevelId: LEVEL }) + .changes, + ) as SceneNodes + expect(terraceOf(nodes).surfaceScript).toBeUndefined() + expect(refusal(() => readableScript(nodes, TERRACE))).toBe('not_authored') + }) + + test('refuses a Blender module and a build that cannot be measured', async () => { + const nodes = scene() + const inputs = slabSurfaceScriptInputs(nodes, TERRACE)! + const compiled = await fakeHost().host.build({ nodeId: TERRACE, code: '', params: {}, inputs }) + const set = (built: CompiledGeometryScript) => () => + setSlabSurfaceScript( + nodes, + { slabId: TERRACE, compiled: built, inputs }, + { activeLevelId: LEVEL }, + ) + expect(refusal(set({ ...compiled, language: 'bpy' }))).toBe('three_only') + const unmeasurable = { + ...compiled, + manifest: { ...compiled.manifest, bounds: { min: [0, 0, 0], max: [Number.NaN, 0, 0] } }, + } as CompiledGeometryScript + expect(refusal(set(unmeasurable))).toBe('unmeasurable_object') + }) + + test('a raised slab moves the build; a reshaped one runs the module again', async () => { + const { host, builds } = fakeHost() + let nodes = await scripted(host) + const raised = { ...nodes, [TERRACE]: { ...terraceOf(nodes), elevation: 0.45 } } + expect(slabScriptSurface(raised, TERRACE)).toBeCloseTo(0.45, 9) + expect(staleFinishScripts(raised)).toEqual([]) + + const wider: [number, number][] = [ + [10, 0], + [15, 0], + [15, 3], + [10, 3], + ] + nodes = { ...nodes, [TERRACE]: { ...terraceOf(nodes), polygon: wider } } + expect(staleFinishScripts(nodes).map((stale) => stale.nodeId)).toEqual([TERRACE]) + const refresh = await refreshFinishScripts(nodes, host) + expect(refresh.failed).toEqual([]) + expect(builds).toHaveLength(2) + expect(builds[1]!.polygon).toEqual(wider) + nodes = applySceneChanges(nodes, { + update: finishScriptWrites(nodes, refresh.rebuilt), + }) as SceneNodes + expect(terraceOf(nodes).surfaceScript?.artifact).toBe('2'.padStart(64, '0')) + expect(terraceOf(nodes).surfaceScript?.params).toEqual({ board: 0.14 }) + expect(terraceOf(nodes).polygon).toEqual(wider) + expect(staleFinishScripts(nodes)).toEqual([]) + }) + + test('refuses paint on its surface, through the Paint tool and the agent, until removed', async () => { + let nodes = await scripted(fakeHost().host) + expect(floorPlatePaintRefusal(nodes, terraceOf(nodes), 'surface')?.code).toBe( + 'floor-built-by-script', + ) + expect(floorPlatePaintRefusal(nodes, terraceOf(nodes), 'edge')).toBeUndefined() + const painting = (role?: string) => + paint(nodes, { targets: [TERRACE], role, color: '#336699' }, { activeLevelId: LEVEL }) + expect(refusal(() => painting())).toBe('surface_built_by_script') + expect(refusal(() => painting('surface'))).toBe('surface_built_by_script') + expect(painting('side').changes?.update).toHaveLength(1) + + nodes = applySceneChanges( + nodes, + setSlabSurfaceScript(nodes, { slabId: TERRACE, remove: true }, { activeLevelId: LEVEL }) + .changes, + ) as SceneNodes + expect(floorPlatePaintRefusal(nodes, terraceOf(nodes), 'surface')).toBeUndefined() + expect(painting('surface').changes?.update).toHaveLength(1) + }) + + test('what stands on it stands on the build; in its hole or off it, on nothing it built', async () => { + const nodes = await scripted(fakeHost().host) + expect(floorFinishTopAt(nodes, LEVEL, TERRACE, 13, 2)).toBeCloseTo( + 0.3 + FLOOR_SCRIPT_LIFT + 0.025, + 9, + ) + expect(floorFinishTopAt(nodes, LEVEL, TERRACE, 11.5, 1.5)).toBeNull() + expect(floorFinishTopAt(nodes, LEVEL, TERRACE, 16, 2)).toBeNull() + expect(floorFinishTopAt(scene(), LEVEL, TERRACE, 13, 2)).toBeNull() + }) +}) + +test('a slab saved before surface scripts parses as it did, and nothing in its scene is stale', () => { + const nodes = scene() + const saved = JSON.parse( + JSON.stringify({ ...terraceOf(nodes), slots: { surface: 'library:tile' } }), + ) + expect(SlabNode.parse(saved)).toEqual(saved) + expect(staleFinishScripts({ ...nodes, [TERRACE]: saved as AnyNode })).toEqual([]) +}) diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index f1a3cf20c3..ceaecc4782 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -35,6 +35,12 @@ export const MaterialSchema = z.object({ url: AssetUrl, repeat: z.tuple([z.number(), z.number()]).optional(), scale: z.number().optional(), + /** Shift of the pattern, in texture repeats, applied to every map below. */ + offset: z.tuple([z.number(), z.number()]).optional(), + normalMap: AssetUrl.optional(), + normalScale: z.number().optional(), + bumpMap: AssetUrl.optional(), + bumpScale: z.number().optional(), }) .optional(), }) diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index cf6629854f..309dcb0c93 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' import { SurfaceHoleMetadata } from './surface-hole-metadata' +import { ZoneFloorScript } from './zone' // Edit-time floor for `thickness` — a thinner slab z-fights the ceiling's // −0.01 underside offset. Applies to edits only; migration writes legacy @@ -64,6 +65,7 @@ export const SlabNode = BaseNode.extend({ zoneIds: z.array(z.string()).optional(), associatedZoneIds: z.array(z.string()).optional(), autoFromWalls: z.boolean().default(false), + surfaceScript: ZoneFloorScript.optional(), }).describe( dedent` Slab node - used to represent a slab/floor in the building @@ -80,6 +82,7 @@ export const SlabNode = BaseNode.extend({ - recessedRimElevation: optional rim anchor for a raised/lowered recess; absent means the level plane - fillToTerrain: manual slabs only; extends a solid slab's perimeter downward to terrain without changing its flat top or authored thickness - autoFromWalls: whether the slab is automatically generated from a closed wall loop + - surfaceScript: a three.js module that builds a standalone slab's surface finish (decking, tiles, paving) for its polygon, drawn over its top; it owns the surface's colours. A room's floor plate has none: its room's floor.script builds there `, ) diff --git a/packages/editor/src/components/ui/panels/room-panel.tsx b/packages/editor/src/components/ui/panels/room-panel.tsx index d4b9a335a8..4685c12086 100644 --- a/packages/editor/src/components/ui/panels/room-panel.tsx +++ b/packages/editor/src/components/ui/panels/room-panel.tsx @@ -10,7 +10,7 @@ import { type PascalI18n, useLocale, } from '@pascal-app/core' -import { setRoomFloorScript } from '@pascal-app/core/agent-operations' +import { type SceneChanges, 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' @@ -279,19 +279,41 @@ 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. - */ +/** The room's floor a script builds, with its code to read and a way to take it off. */ function RoomFloorScriptRow({ zoneId }: { zoneId: string }) { - const i18n = usePascalI18n() const sha = useScene((scene) => { const zone = scene.nodes[zoneId as AnyNodeId] return zone?.type === 'zone' ? zone.floor?.script?.script : undefined }) + if (!sha) return null + return ( + + setRoomFloorScript( + useScene.getState().nodes, + { zoneId, remove: true }, + { activeLevelId: null }, + ).changes + } + sha={sha} + /> + ) +} + +/** + * "Scripted finish": a floor a script builds (a room's floor, a slab's surface; 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: `remove` answers the removal's changes, applied as one undo step. + */ +export function ScriptedFinishRow({ + sha, + remove, + ...attributes +}: { sha: string; remove: () => SceneChanges | undefined } & Record<`data-${string}`, string>) { + const i18n = usePascalI18n() const [code, setCode] = useState<{ sha: string; text: string } | null>(null) const [open, setOpen] = useState(false) - if (!sha) return null const unreadable = i18n._({ id: 'editor.panels.scriptUnreadable', message: 'The script could not be read.', @@ -304,19 +326,15 @@ function RoomFloorScriptRow({ zoneId }: { zoneId: string }) { () => setCode({ sha, text: unreadable }), ) } - const remove = () => { - const { changes } = setRoomFloorScript( - useScene.getState().nodes, - { zoneId, remove: true }, - { activeLevelId: null }, - ) + const removeScript = () => { + const changes = remove() runAsSingleSceneHistoryStep(useScene, () => { for (const { id, data } of changes?.update ?? []) useScene.getState().updateNode(id as AnyNodeId, data) }) } return ( -
+
{i18n._({ id: 'editor.panels.roomPanel.scriptedFinish', message: 'Scripted finish' })}
@@ -329,7 +347,7 @@ function RoomFloorScriptRow({ zoneId }: { zoneId: string }) {
+ } @@ -587,4 +590,28 @@ export function SlabPanel() { ) } +/** The slab's surface a script builds, with its code to read and a way to take it off. */ +function SlabSurfaceScriptRow({ slabId }: { slabId: string }) { + const sha = useScene((scene) => { + const slab = scene.nodes[slabId as AnyNode['id']] + return slab?.type === 'slab' ? slab.surfaceScript?.script : undefined + }) + if (!sha) return null + return ( +
+ + setSlabSurfaceScript( + useScene.getState().nodes, + { slabId, remove: true }, + { activeLevelId: null }, + ).changes + } + sha={sha} + /> +
+ ) +} + export default SlabPanel diff --git a/packages/nodes/src/slab/renderer.tsx b/packages/nodes/src/slab/renderer.tsx new file mode 100644 index 0000000000..51cf0c68b3 --- /dev/null +++ b/packages/nodes/src/slab/renderer.tsx @@ -0,0 +1,36 @@ +'use client' + +import { + FLOOR_SCRIPT_LIFT, + type SlabNode, + slabScriptSurface, + useScene, + type ZoneFloorScript, +} from '@pascal-app/core' +import { ParametricNodeRenderer } from '@pascal-app/viewer' +import { FinishScriptModel } from '../shared/finish-script-model' + +/** + * The slab its geometry builds, and its surface script's build beside it rather than inside: the + * slab's group is rebuilt by its geometry and packed into the level's surface batch, which would + * freeze or drop a model loaded into it. + */ +const SlabRenderer = ({ node }: { node: SlabNode }) => ( + <> + + {node.surfaceScript ? : null} + +) + +/** The slab's surface finish its script built: in the level's plan, its y = 0 on the slab's top. */ +function SlabSurfaceScriptModel({ node, script }: { node: SlabNode; script: ZoneFloorScript }) { + const surface = useScene((state) => slabScriptSurface(state.nodes, node.id)) + if (surface === null) return null + return ( + + + + ) +} + +export default SlabRenderer diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index c8e4042fd7..3b79d2597e 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -12,6 +12,9 @@ export { ErrorBoundary } from './components/error-boundary' // `@pascal-app/nodes//renderer.tsx` and are loaded by the registry // — no per-kind re-exports needed. export { NodeRenderer } from './components/renderers/node-renderer' +// The generic renderer of a kind that ships `def.geometry`, for a kind's renderer that draws +// something beside its built group. +export { ParametricNodeRenderer } from './components/renderers/parametric-node-renderer' export { default as Viewer, pendingSceneBuildCount, diff --git a/packages/viewer/src/lib/materials.test.ts b/packages/viewer/src/lib/materials.test.ts index e206d78724..0ff900ee68 100644 --- a/packages/viewer/src/lib/materials.test.ts +++ b/packages/viewer/src/lib/materials.test.ts @@ -2,7 +2,7 @@ // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' import { MaterialProperties, type MaterialSchema } from '@pascal-app/core' -import { MeshPhysicalMaterial, MeshStandardMaterial } from 'three' +import { MeshPhysicalMaterial, MeshStandardMaterial, NoColorSpace, SRGBColorSpace } from 'three' import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { createMaterial as createSceneMaterial, @@ -44,6 +44,58 @@ describe('legacy texture repeat values', () => { }) }) +describe('native texture placement', () => { + const placed = (offset: [number, number]): MaterialSchema => ({ + texture: { + url: 'https://example.com/tile.png', + repeat: [2, 2], + offset, + normalMap: 'https://example.com/tile-normal.png', + normalScale: 0.5, + bumpMap: 'https://example.com/tile-height.png', + }, + }) + + // TextureLoader creates an ; no request is made in this runtime. + function withImageStub(run: () => T): T { + const previous = globalThis.document + globalThis.document = { + createElementNS: () => ({ addEventListener() {}, removeEventListener() {} }), + } as unknown as Document + try { + return run() + } finally { + globalThis.document = previous + } + } + + test('offset, normal and bump maps share the albedo placement; maps stay linear', () => { + const material = withImageStub(() => + createMaterial(placed([0.25, 0.5]), 'rendered'), + ) as MeshStandardNodeMaterial + for (const map of [material.map, material.normalMap, material.bumpMap]) { + expect(map?.offset.toArray()).toEqual([0.25, 0.5]) + expect(map?.repeat.toArray()).toEqual([2, 2]) + } + expect(material.map?.colorSpace).toBe(SRGBColorSpace) + expect(material.normalMap?.colorSpace).toBe(NoColorSpace) + expect(material.bumpMap?.colorSpace).toBe(NoColorSpace) + expect(material.normalScale.toArray()).toEqual([0.5, 0.5]) + expect(material.bumpScale).toBe(1) + }) + + test('a different offset is a different texture and material', () => { + expect(getTextureKey(placed([0, 0]))).not.toBe(getTextureKey(placed([0.5, 0]))) + const [first, second] = withImageStub(() => [ + createMaterial(placed([0, 0]), 'rendered') as MeshStandardNodeMaterial, + createMaterial(placed([0.5, 0]), 'rendered') as MeshStandardNodeMaterial, + ]) + expect(first).not.toBe(second) + expect(first.map).not.toBe(second.map) + expect(second.map?.offset.toArray()).toEqual([0.5, 0]) + }) +}) + describe('shared flat slot defaults', () => { test('interns colors by case, roughness, and shading with cache ownership', () => { const rendered = resolveSlotDefaultMaterial('#AbCdEf', 'rendered', 0.75) diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 5389f50298..4e67a379e6 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -236,36 +236,50 @@ export function resolveTextureRepeat(repeat: unknown, scale: unknown): [number, return [fallback, fallback] } +type MaterialTexture = NonNullable + +function textureOffset(texture: MaterialTexture): [number, number] { + return texture.offset ?? [0, 0] +} + +function getPlacementKey(texture: MaterialTexture): string { + const [repeatX, repeatY] = resolveTextureRepeat(texture.repeat, texture.scale) + const [offsetX, offsetY] = textureOffset(texture) + return `${repeatX}x${repeatY}+${offsetX},${offsetY}` +} + export function getTextureKey(material?: MaterialSchema): string { const texture = material?.texture if (!texture) return 'none' - const [repeatX, repeatY] = resolveTextureRepeat(texture.repeat, texture.scale) - return `${texture.url}-${repeatX}x${repeatY}` + const normal = texture.normalMap ? `-n:${texture.normalMap}*${texture.normalScale ?? 1}` : '' + const bump = texture.bumpMap ? `-b:${texture.bumpMap}*${texture.bumpScale ?? 1}` : '' + return `${texture.url}-${getPlacementKey(texture)}${normal}${bump}` } -function getTexture(material?: MaterialSchema): THREE.Texture | undefined { - const textureConfig = material?.texture - if (!textureConfig?.url) return undefined - - const cacheKey = getTextureKey(material) +/** One map of a native material's texture; every map shares the albedo's repeat and offset. */ +function getTexture( + textureConfig: MaterialTexture, + url: string, + slot: 'map' | 'normalMap' | 'bumpMap', +): THREE.Texture { + const cacheKey = `${url}-${getPlacementKey(textureConfig)}-${slot}` const cached = textureCache.get(cacheKey) if (cached) return cached - const resolvedUrl = /^(?:asset|blob|data):/.test(textureConfig.url) - ? textureConfig.url - : (resolveCdnUrl(textureConfig.url) ?? textureConfig.url) + const resolvedUrl = /^(?:asset|blob|data):/.test(url) ? url : (resolveCdnUrl(url) ?? url) const texture = loadTexture(resolvedUrl, cacheKey) texture.wrapS = THREE.RepeatWrapping texture.wrapT = THREE.RepeatWrapping const [repeatX, repeatY] = resolveTextureRepeat(textureConfig.repeat, textureConfig.scale) texture.repeat.set(repeatX, repeatY) + texture.offset.set(...textureOffset(textureConfig)) texture.updateMatrix() - texture.colorSpace = THREE.SRGBColorSpace + texture.colorSpace = slot === 'map' ? THREE.SRGBColorSpace : THREE.NoColorSpace stampPascalTextureRef(texture, { kind: 'project-asset', src: resolvedUrl, - slot: 'map', + slot, }) textureCache.set(cacheKey, texture) @@ -612,7 +626,7 @@ export function createMaterial( return materialCache.get(cacheKey)! } - const map = getTexture(material) + const texture = material?.texture?.url ? material.texture : undefined const materialParams: { color: string map?: THREE.Texture @@ -626,16 +640,28 @@ export function createMaterial( side: sideMap[props.side], } - if (map) materialParams.map = map + if (texture) materialParams.map = getTexture(texture, texture.url, 'map') - const threeMaterial = - shading === 'solid' - ? new MeshLambertNodeMaterial(materialParams) - : new MeshStandardNodeMaterial({ - ...materialParams, - roughness: props.roughness, - metalness: props.metalness, - }) + let threeMaterial: MeshLambertNodeMaterial | MeshStandardNodeMaterial + if (shading === 'solid') { + threeMaterial = new MeshLambertNodeMaterial(materialParams) + } else { + const standard = new MeshStandardNodeMaterial({ + ...materialParams, + roughness: props.roughness, + metalness: props.metalness, + }) + if (texture?.normalMap) { + standard.normalMap = getTexture(texture, texture.normalMap, 'normalMap') + const normalScale = texture.normalScale ?? 1 + standard.normalScale.set(normalScale, normalScale) + } + if (texture?.bumpMap) { + standard.bumpMap = getTexture(texture, texture.bumpMap, 'bumpMap') + standard.bumpScale = texture.bumpScale ?? 1 + } + threeMaterial = standard + } // Explicit source transparency keeps its opacity; the glass preset owns this styling. if (glassPreset) maybeApplyGlassFresnel(threeMaterial) diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 7c8d21be89..4e7df4c08c 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -61,7 +61,7 @@ A building of several dwellings or tenancies (a condo, an apartment building, a Furnishings follow a design brief. `design_brief` records the request once: what it fixes, what it leaves open, where the objects come from (`source`: `open`, `catalog`, `brand` with its `brand`, `existing`), a `vision` for what is open and, with an open source, the catalog pieces placed as they are (`asIs`, each with a reason). Before one, `search_assets` and `view_assets` read the catalog; `place_items`, `furnish_room`, `place_in_room`, `add_object` and `place_design` are refused (`design_brief_required`), inside `run_batch` too, and a batch holding such a call is refused whole. Source `open` opens the made pieces, places a catalog piece only when named in `asIs`, and keeps `furnish_room`, which places a whole stock set, closed (`brief_source_open`); `catalog`, `brand` and `existing` open the catalog's tools, and creation for what it lacks. Naming a piece (a double bed) states its function; its form, size within reason and finish are design unless the request fixes them. The brief is the session's: a scene load or clear starts another, and the next `design_brief` replaces it. -To give a surface a finish, call `paint` as the editor's Paint does: the targets, a `role` when it is not the default surface (a wall's `exterior` or `interior` face, a slot, a roof's `top`, `edge` or `wall`), and one finish: a library material whose `library:` you found with `search_materials`, a `color` (painted with the nearest library colour), or a native material for an exact finish, kept as a scene material and reused when an equal one exists. An unknown target, role or material refuses the whole call and nothing is painted. +To give a surface a finish, call `paint` as the editor's Paint does: the targets, a `role` when it is not the default surface (a wall's `exterior` or `interior` face, a slot, a roof's `top`, `edge` or `wall`), and one finish: a library material whose `library:` you found with `search_materials`, a `color` (painted with the nearest library colour, or exactly with `exact: true`), or a native material for an exact finish, kept as a scene material and reused when an equal one exists; a textured one takes its `repeat`, an `offset` that lines its joints up, and normal and bump maps. An unknown target, role or material refuses the whole call and nothing is painted. A roof lives on the level directly above the storey whose walls carry it; over the top storey that is the building's roof level, which `create_roof` makes (named Roof) or reuses, so exploded and solo level views lift the roof off the plan. Call `create_roof` once the storeys and their exterior walls stand; it refuses a second roof over a storey (`roof_exists`) and a storey with another above it (`storey_above`). `add_level` puts a new storey under the roof level. Never write a roof onto a storey's own level with `apply_patch`: `verify_scene` reports it (`roof_mixed_in_storey`), and a roof level below a storey (`roof_level_not_on_top`). @@ -75,9 +75,9 @@ For stairs, call `measure_stair` before changing proportions: it reports actual Stair `targets` are design preferences in meters (`maxRiserHeight`, `minimumGoing`, `targetGoing`, `minimumHeadroom`), not a building-code certification. Defaults are 0.18, 0.25, 0.28 and 2 respectively. Headroom checks cover modeled floors, ceilings and stair bodies; missing or unsupported obstacles can still require inspection. Inspect the connected server's contracts before calling these tools. -When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, 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 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; its slot colours show as the library finishes they match, and `exact: true` keeps them as written. 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 (`through`, a plan point its arc passes at its middle, bends one into an arc; a `curtain` makes it a glass wall) with their colour (`color`; `colorInside` / `colorOutside` for the faces that look into a room of the level and the face that does not, which Pascal finds once the rooms are derived and reports in `defaults`: say an outside wall's two finishes this way; `colorLeft` / `colorRight` per face win over both; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, `shape` for an arch or rounded top, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open; a `passage` is a doorway with no leaf), rooms with a seed point, a floor material and, in a building of several dwellings or tenancies (a condo, an apartment building, a hotel, shops under flats), the `unit` each belongs to by name (Apartment 2B, Suite 301, Shop 1; `units` on the payload gives a unit its `kind`, apartment, hotel-room, commercial or common, and its colour; a unit may span levels, a duplex; a room naming no unit is common area, a corridor, a lobby, the stairs; a single-family house has no units), slabs, `voids` (an opening in a level's floor that the room below rises through, a double-height room: Pascal cuts that floor and the ceiling under it and runs a railing along its open edges), columns (square or round), railings, `stairs` (per level: `start`, the middle of the bottom step's front edge, `rotation`, the climb's direction, `width`, `layout` straight, l or u, `turn`; each rises to the next level in `levels` as `create_stair` builds it and opens the floor above over its flights; never a stair as an item), 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; a module with mount `ceiling`, a pendant or a ceiling light, hangs from the ceiling of the room containing its plan position, its top on that ceiling; a rug, a mat or a runner lying on a room's floor is an item), 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` and `params.holes` (plan x/z in level coordinates) and builds the finish for exactly that floor, with y = 0 at the floor's surface: lay the finish from there up, and Pascal stands it on the floor plate and everything on that floor (a rug, a sofa) on its top, so give such an item y = 0; 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; cladding, brick, banded render or battens outside are the wall's `faceOutside`, the same module on the face no room looks into (which face that is, is Pascal's to find, as for `colorOutside`); 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, the ceiling a ceiling item hangs from, which faces `colorInside` took, each stair and its floor opening), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions, a stair given as an item, a stair no floor above covers), 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`, a ceiling item outside every room `no_ceiling`, a stair on the top level `no_level_above`), 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, and with no `payload` Pascal runs the adapter itself; a revision names only what changed (`edits` against the stored module and adapter). An item may name an `add_object` `piece` (furniture described in words, written by Pascal's own model in the background) instead of carrying `code`. The report's `checks` say what still falls short. 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. A level's `elevation` (its floor's surface) sets the storey below it: a storey is as tall as the climb to the next level's floor, the top one as its tallest wall. Walls keep the height you give them (the room's height as built) and Pascal draws the floor between storeys from the tallest wall within 0.5 m under it up to that floor: 2.7 m walls under a floor at 2.9 m give 2.7 m rooms and a 0.2 m floor, reported in `defaults`; a wall reaching into the floor above stops under it. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. +When you have written a whole design as free three.js (a room, a flat, a house: the richest results come from building it that way, with no Pascal tools in the loop), do not rebuild it tool by tool. Read your own module however you like (its source, running it and reading the objects, both) and call `author_scene` once with a conversion payload: levels, walls as centerlines (`through`, a plan point its arc passes at its middle, bends one into an arc; a `curtain` makes it a glass wall) with their colour (`color`; `colorInside` / `colorOutside` for the faces that look into a room of the level and the face that does not, which Pascal finds once the rooms are derived and reports in `defaults`: say an outside wall's two finishes this way; `colorLeft` / `colorRight` per face win over both; a hex is kept as an exact material) and their openings (each with `type`, how it opens, and `style`, its look, `shape` for an arch or rounded top, or its own `code` for a shape those cannot express; a window with no type is written fixed and cannot open; a `passage` is a doorway with no leaf), rooms with a seed point, a floor material and, in a building of several dwellings or tenancies (a condo, an apartment building, a hotel, shops under flats), the `unit` each belongs to by name (Apartment 2B, Suite 301, Shop 1; `units` on the payload gives a unit its `kind`, apartment, hotel-room, commercial or common, and its colour; a unit may span levels, a duplex; a room naming no unit is common area, a corridor, a lobby, the stairs; a single-family house has no units), slabs (a terrace's decking or paving its `surfaceCode`, a module laid on the slab's outline from its top, as a room's `floorCode` is; each `railing` run a fence standing on it, at its `height` and `color`), `voids` (an opening in a level's floor that the room below rises through, a double-height room: Pascal cuts that floor and the ceiling under it and runs a railing along its open edges), columns (square or round, `rotation`, `color`, or their own `code` for a shape those cannot express, mount `floor` as `add_column` takes it), railings, `stairs` (per level: `start`, the middle of the bottom step's front edge, `rotation`, the climb's direction, `width`, `layout` straight, l or u, `turn`, `railing` sides and `railingStyle`, `color` for the flight, `treads` over it, `railingColor`; each rises to the next level in `levels` as `create_stair` builds it and opens the floor above over its flights; never a stair as an item), 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; a module with mount `ceiling`, a pendant or a ceiling light, hangs from the ceiling of the room containing its plan position, its top on that ceiling; a rug, a mat or a runner lying on a room's floor is an item), 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` and `params.holes` (plan x/z in level coordinates) and builds the finish for exactly that floor, with y = 0 at the floor's surface: lay the finish from there up, and Pascal stands it on the floor plate and everything on that floor (a rug, a sofa) on its top, so give such an item y = 0; 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; cladding, brick, banded render or battens outside are the wall's `faceOutside`, the same module on the face no room looks into (which face that is, is Pascal's to find, as for `colorOutside`); 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, the ceiling a ceiling item hangs from, which faces `colorInside` took, each stair and its floor opening), what it wrote but doubts (`warnings`: thin or many slabs, many floor regions, a stair given as an item, a stair no floor above covers), 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`, a ceiling item outside every room `no_ceiling`, a stair on the top level `no_level_above`), 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, and with no `payload` Pascal runs the adapter itself; a revision names only what changed (`edits` against the stored module and adapter). An item may name an `add_object` `piece` (furniture described in words, written by Pascal's own model in the background) instead of carrying `code`. The report's `checks` say what still falls short. 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. A level's `elevation` (its floor's surface) sets the storey below it: a storey is as tall as the climb to the next level's floor, the top one as its tallest wall. Walls keep the height you give them (the room's height as built) and Pascal draws the floor between storeys from the tallest wall within 0.5 m under it up to that floor: 2.7 m walls under a floor at 2.9 m give 2.7 m rooms and a 0.2 m floor, reported in `defaults`; a wall reaching into the floor above stops under it. In the module, name a piece's group `item:` when you can, one helper per piece, and give everything that opens (drawers, wardrobe doors, windows) an `open` animation clip: named pieces convert with exact names and moving parts keep working after conversion. Pascal uses meters. X and Z are floor-plan axes; Y is vertical. Tool fields that accept measurements may also accept strings such as `"6 ft"` or `"180cm"`, but report final spatial values in meters and retain the user's original units when useful. diff --git a/skills/pascal-3d/references/tool-workflows.md b/skills/pascal-3d/references/tool-workflows.md index 0cb06226c2..63b173693b 100644 --- a/skills/pascal-3d/references/tool-workflows.md +++ b/skills/pascal-3d/references/tool-workflows.md @@ -60,7 +60,7 @@ If a live-sync version conflict occurs, call `load_scene`, inspect the newer gra `author_scene` turns a whole design you wrote as one three.js module into native, editable Pascal nodes in one call. The reading is yours; the writing is Pascal's. 1. Read your module: its source for intent (helper names, declared opening types), the executed graph for truth (world transforms, deleted objects gone). Most designs make walls with a helper such as `wall(x1, z1, x2, z2, height, thickness, openings)`: those arguments are the payload's walls. -2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `through` to bend it into an arc from start through that plan point to end (the arc's middle: put it outside the building for a bay that bulges out; at most half the span from the straight line): one curved wall for a bay, an apse or a drum, never a run of short straight ones, its openings' `along` measured along the arc, rooms derived from the arc, nothing hung on it, no face module; `curtain` for a glass wall between mullions, straight or curved (`spacing`, default 1.5 m, `frameColor`), whose faces take no paint or skirting, `color` for both faces, `colorInside` / `colorOutside` for the faces that look into a room of the level and the face that does not (Pascal finds which once the rooms are derived and says so in `defaults`; give an outside wall's two finishes this way), or `colorLeft` / `colorRight` for one face, the left being on your left looking from start to end, over the other two; 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` door, window or passage, a doorway with no leaf, `along` from the wall's start, `width`, `height`, `sill`, `shape` rectangle, rounded or arch with `archHeight`, `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`, `unit`, in a building of several dwellings or tenancies, the name of the dwelling, suite or lot the room belongs to (Apartment 2B, Suite 301, Shop 1; a room naming none is common area: a corridor, a lobby, the stairs; a single-family house has no units), `floor` as `library:` from `search_materials` or `#rrggbb`; `floorCode` for planks, tiles or boards, a module that receives `params.polygon` and `params.holes`, plan x/z in level coordinates and metres, and lays out the finish with y = 0 at the floor's surface (lay it from there up; Pascal stands the build on the floor plate and what stands on that floor, a rug or a sofa at y = 0, on the build's top), 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, or `faceOutside` for cladding, brick or battens on the face no room looks into (Pascal finds it, as for `colorOutside`), 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`), `voids` (a `polygon` in the level's floor that the room below rises through: a double-height living room under a gallery, an atrium; Pascal cuts that floor and the ceiling under it, stops the cut at the faces of the level's walls, and runs a `railing` (`height`, default 1.1 m, `color`; null for none) along its open edges, broken at walls and where a stair arrives; a ceiling item or a light under it hangs from the ceiling above; never on the first level), `columns` (`shape` square or round, a round one's `width` its diameter), `stairs` (`start`, the middle of the bottom step's front edge, `rotation` 0 climbing toward +z, 90 toward +x, `width` (default 1 m), `layout` straight, l or u, `turn` left or right, `length`: each rises to the next level in `levels`, as `create_stair` builds it, and opens the floor above over its flights; a stair is never an item), `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; a module with mount `ceiling`, a pendant or a ceiling light, hangs from the ceiling of the room containing its plan position, its top on that ceiling, y not read; a rug, a mat or a runner on a room's floor is an item), `lights` (`position`, `fixture`); and, on the payload, `units` (`name`, `kind` apartment, hotel-room, commercial or common, `color`) to type or colour the units the rooms name: Pascal makes one unit per distinct name under the building, holding the rooms that name it, on whatever levels they are (a duplex); a unit named only by rooms is an apartment. 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`. Give each level its `elevation`, its floor's surface: a storey is as tall as the climb to the next level's floor (the top one as its tallest wall); walls keep their height and Pascal draws the floor between storeys from the tallest wall within 0.5 m under it up to that floor (2.7 m walls under a floor at 2.9 m: 2.7 m rooms and a 0.2 m floor); a wall reaching into that floor stops under it. End a partition on the centerline of the wall it meets. One item per piece: a bed, each nightstand, each chair. +2. Build the payload: per level, `walls` (centerline `start`/`end`, `height`, `thickness`, `through` to bend it into an arc from start through that plan point to end (the arc's middle: put it outside the building for a bay that bulges out; at most half the span from the straight line): one curved wall for a bay, an apse or a drum, never a run of short straight ones, its openings' `along` measured along the arc, rooms derived from the arc, nothing hung on it, no face module; `curtain` for a glass wall between mullions, straight or curved (`spacing`, default 1.5 m, `frameColor`), whose faces take no paint or skirting, `color` for both faces, `colorInside` / `colorOutside` for the faces that look into a room of the level and the face that does not (Pascal finds which once the rooms are derived and says so in `defaults`; give an outside wall's two finishes this way), or `colorLeft` / `colorRight` for one face, the left being on your left looking from start to end, over the other two; 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` door, window or passage, a doorway with no leaf, `along` from the wall's start, `width`, `height`, `sill`, `shape` rectangle, rounded or arch with `archHeight`, `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`, `unit`, in a building of several dwellings or tenancies, the name of the dwelling, suite or lot the room belongs to (Apartment 2B, Suite 301, Shop 1; a room naming none is common area: a corridor, a lobby, the stairs; a single-family house has no units), `floor` as `library:` from `search_materials` or `#rrggbb`; `floorCode` for planks, tiles or boards, a module that receives `params.polygon` and `params.holes`, plan x/z in level coordinates and metres, and lays out the finish with y = 0 at the floor's surface (lay it from there up; Pascal stands the build on the floor plate and what stands on that floor, a rug or a sofa at y = 0, on the build's top), 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, or `faceOutside` for cladding, brick or battens on the face no room looks into (Pascal finds it, as for `colorOutside`), 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`; `surfaceCode` for a terrace's decking or paving, a module that receives the slab's `params.polygon` and `params.holes` and lays the finish from y = 0, the slab's top, as a room's `floorCode` does; each `railing` run (`start`, `end`, `height`, `color`) a fence standing on the slab), `voids` (a `polygon` in the level's floor that the room below rises through: a double-height living room under a gallery, an atrium; Pascal cuts that floor and the ceiling under it, stops the cut at the faces of the level's walls, and runs a `railing` (`height`, default 1.1 m, `color`; null for none) along its open edges, broken at walls and where a stair arrives; a ceiling item or a light under it hangs from the ceiling above; never on the first level), `columns` (`shape` square or round, a round one's `width` its diameter, `rotation`, `color` as an exact material, or `code`, a module with mount `floor` as `add_column` takes it, whose build sets the size and keeps its own colours), `stairs` (`start`, the middle of the bottom step's front edge, `rotation` 0 climbing toward +z, 90 toward +x, `width` (default 1 m), `layout` straight, l or u, `turn` left or right, `length`, `railing` none, left, right or both, `railingStyle`, `color` for the flight, `treads` over it, `railingColor`: each rises to the next level in `levels`, as `create_stair` builds it, and opens the floor above over its flights; a stair is never an item), `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; a module with mount `ceiling`, a pendant or a ceiling light, hangs from the ceiling of the room containing its plan position, its top on that ceiling, y not read; a rug, a mat or a runner on a room's floor is an item), `lights` (`position`, `fixture`); and, on the payload, `units` (`name`, `kind` apartment, hotel-room, commercial or common, `color`) to type or colour the units the rooms name: Pascal makes one unit per distinct name under the building, holding the rooms that name it, on whatever levels they are (a duplex); a unit named only by rooms is an apartment. 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`. Give each level its `elevation`, its floor's surface: a storey is as tall as the climb to the next level's floor (the top one as its tallest wall); walls keep their height and Pascal draws the floor between storeys from the tallest wall within 0.5 m under it up to that floor (2.7 m walls under a floor at 2.9 m: 2.7 m rooms and a 0.2 m floor); a wall reaching into that floor stops under it. 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), or with `module` and `adapter` alone: Pascal runs the adapter (`export default async function adapt({ THREE, addons, source, run, toModule })`, `run()` building your module and answering `{ scene }`, `toModule(object, options)` writing a part of it as an item's or an opening's module) and writes the payload it returns; an adapter that throws answers `adapter_failed` with its error, a payload its schema refuses `payload_invalid` with every issue's path, and nothing is written. Furniture you would rather describe than model: `add_object` with `piece` (`description`, `size`, `mount`, `id`) answers at once, `pending`, while Pascal's own model writes the module; an item with `piece: ""` instead of `code` places it, and one still being made is listed in `pending` and written when it lands, with no call from you. 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, `no_ceiling` for a ceiling item outside every room, `no_level_above` for a stair on the top level, `curved_host` for an item hung on a curved wall, `no_level_below` for a void on the first level, `no_floor` for a void over no floor of its level, `unit_split_room` for seeds of one enclosure naming different units, the room then in none…), `stairs` (stairs written), `units` (units made, `unit/` in `ids`), `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, `use_stairs` for a stair given as an item, `no_floor_opening` for a stair no floor above covers, `flat_on_arc` for a door or window too wide for its curved wall, with the widest that fits, `over_void` for an item standing over a void, `wall_in_void` for a wall standing inside one), `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, the ceiling a ceiling item hangs from, which faces `colorInside` took, each stair and its opening), `snapped` wall ends, `verify` issues, `itemsCompiled`, `pending`, `checks` (each `passed`, or with what failed: `openings` that a person operates but do not move, `glass` that is no glass, `floors` not covered, `pieces` not standing, `outside` debris or buried slabs, `faces`, `stairs` arriving outside a room or into a wall, `levels` holding no rooms or walls, since the ground, terraces and foundations belong to the ground level, `rooms_in_units` where units are named: every room on a level with units is in one or is common area by its name), `complete`, the editor link. 4. Fix the program, not the payload by hand, and call again with `replace: true`: the module and adapter you leave out are the stored ones, and `edits` (`{ in: 'module' | 'adapter', find, replace }`, each `find` occurring exactly once) change part of them without sending them again. Look at the scene with `view_scene` between rounds. Stop when `complete` is true and the scene looks like the design. Report what you could not convert in `notes`. diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 5c69813c80..201127d327 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -144,6 +144,8 @@ The cut removes what hangs outside the floor; it cannot add what the module neve The zone renderer draws the artifact 1 mm over the plate's top, following the plate when it moves, 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's outline (a wall moved, an edge pushed), 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. +A standalone slab (a terrace, a platform, a balcony, a plinth) takes the same record as `slab.surfaceScript`. `slabSurfaceScriptInputs(nodes, slabId)` gives the slab's own `polygon` and `holes` (rounded as a room's floor is, same key), and the build's y = 0 is the slab's top (`slabScriptSurface`: its elevation, lifted with the footprint it stands in as its geometry is). A floor plate Pascal builds from rooms (`isFloorPlate`, `boundary: 'auto'`) has no inputs: its rooms' floor scripts build there, and `setSlabSurfaceScript` refuses it with `room_floor_plate`. The slab's renderer composes the generic parametric renderer with the build beside the slab's group, not inside it, since that group is rebuilt by its geometry and packed into the level's surface batch; the slab's own top still draws underneath. The rest is the room's: `floorFinishTopAt` stands what is elected onto the slab on the build's top, the spatial-grid rules re-elevate it when the script changes, `floorPlatePaintRefusal` and `paint` refuse its `surface` (`surface_built_by_script`), `staleFinishScripts` rebuilds it when the slab is reshaped or holed, `get_source` reads it by the slab's id, and the slab panel shows the same "Scripted finish" row as the room panel. + ## 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.