Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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{}',
Expand Down
60 changes: 59 additions & 1 deletion packages/core/src/agent-operations/add-object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>) => ({
...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<string, AnyNode>
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<string, string> }
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', () => {
Expand Down
47 changes: 38 additions & 9 deletions packages/core/src/agent-operations/add-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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[]
Expand Down Expand Up @@ -308,6 +313,19 @@ export const addObject: AgentOperation<AddObjectInput> = (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<string, string>) => {
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')
Expand All @@ -331,10 +349,8 @@ export const addObject: AgentOperation<AddObjectInput> = (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 } } : {}),
Expand All @@ -358,6 +374,7 @@ export const addObject: AgentOperation<AddObjectInput> = (nodes, input, context)
{ id: next.id, data: next },
...resettled.map(({ id, position }) => ({ id, data: { position } })),
],
...(createdMaterials.length ? { materials: createdMaterials } : {}),
},
}
}
Expand Down Expand Up @@ -399,7 +416,7 @@ export const addObject: AgentOperation<AddObjectInput> = (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 } : {}) },
})
Expand All @@ -413,7 +430,10 @@ export const addObject: AgentOperation<AddObjectInput> = (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 } : {}),
},
}
}

Expand Down Expand Up @@ -653,17 +673,21 @@ export function scriptedFieldRefusal(node: AnyNode, data: Record<string, unknown
return `${node.id} is built from a script, so ${fields.join(', ')} ${fields.length === 1 ? 'comes' : 'come'} from it: rebuild it with ${tool} with nodeId and params (get_source shows them); nothing was changed.`
}

/** What `get_source` reads: a scripted node, a room's floor script, or a wall face's script. */
/**
* What `get_source` reads: a scripted node, a room's floor script, a slab's surface script, or a
* wall face's script.
*/
export type ScriptReadable = Pick<ScriptedNode, 'name' | 'source'> & {
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<string, AnyNode>,
Expand Down Expand Up @@ -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 })
Expand Down
163 changes: 163 additions & 0 deletions packages/core/src/agent-operations/author-scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
authorSceneChecks,
authorSceneFaceScripts,
authorSceneFloorScripts,
authorSceneSlabScripts,
finalizeAuthorScene,
setRoomFloorScript,
verifyScene,
Expand Down Expand Up @@ -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() }'
Expand Down Expand Up @@ -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<string, any>
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', () => {
Expand Down
Loading
Loading