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 @@ -328,6 +328,8 @@ export const NOTATION_CASES: NotationCase[] = [
*/
export const NOT_LENGTHS = new Set([
'author_scene.payload.levels[].lights[].intensity',
'author_scene.payload.levels[].lights[].softness',
'author_scene.payload.levels[].lights[].glow.strength',
// Coordinates, in level metres.
'add_column.x',
'add_column.y',
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/agent-operations/add-object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,45 @@ describe('add_object', () => {
}
})

// author_scene hangs lights on an authored fixture after its module's own; a rebuild keeps them.
test("a rebuilt object keeps the lights hung on it after its new module's own", () => {
const level = LevelNode.parse({ id: 'level_0', parentId: 'building_main', level: 0 })
const solid = compiledSolid([0.3, 0.6, 0.3])
const bulb = (intensity: number) => ({
id: 'bulb',
position: [0, 0.5, 0],
color: '#ffcf95',
intensity,
})
const compiled = (intensity: number) =>
({ ...solid, manifest: { ...solid.manifest, lights: [bulb(intensity)] } }) as typeof solid
const input = (extra: Record<string, unknown>, intensity: number) => ({
...z.object(addObjectTool.input).parse({ code: 'x', name: 'Lamp', reason: 'r', ...extra }),
compiled: compiled(intensity),
})
let nodes = { [level.id]: level } as Record<string, AnyNode>
const created = addObject(nodes, input({}, 2), { activeLevelId: level.id })
nodes = applySceneChanges(nodes, created.changes)
const lamp = nodes[created.result.nodeId] as AnyNode & { asset: any }
const hung = {
kind: 'light',
color: '#ffffff',
intensityRange: [0, 4],
offset: [0, 0.6, 0.1],
spot: { direction: [0, 1, 0], angle: 60, softness: 0.4 },
}
lamp.asset.interactive.effects.push(hung)
const rebuilt = addObject(nodes, input({ nodeId: lamp.id }, 3), { activeLevelId: level.id })
const after = applySceneChanges(nodes, rebuilt.changes)[lamp.id] as AnyNode & { asset: any }
expect(after.asset.interactive.effects).toMatchObject([
{ kind: 'light', intensityRange: [0, 3] },
hung,
])
expect(after.asset.interactive.controls).toEqual([
{ kind: 'toggle', label: 'Lights', default: true },
])
})

// 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 })
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/agent-operations/add-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { artifactUrl } from '../lib/artifact-store'
import {
isScriptedNode,
matchScriptSlotsToLibrary,
rebuiltInteractive,
requireMeasured,
type ScriptedNode,
scriptedSize,
Expand Down Expand Up @@ -80,12 +81,15 @@ const HOSTS: Record<GeometryScriptMount, readonly AnyNode['type'][]> = {
function scriptAsset(
compiled: CompiledGeometryScript,
input: AddObjectInput,
previous: ItemNode['asset'] | undefined,
previousNode: ItemNode | undefined,
): ItemNode['asset'] {
const previous = previousNode?.asset
const { min, max } = compiled.manifest.bounds
const restingHeight = geometryRestingHeight(compiled.manifest)
const attachTo = ATTACH[compiled.mount]
const interactive = scriptInteractive(compiled.manifest)
const interactive = previousNode
? rebuiltInteractive(compiled.manifest, previousNode)
: scriptInteractive(compiled.manifest)
return {
id: `script_${compiled.sha256.slice(0, 16)}`,
category: input.category ?? previous?.category ?? 'object',
Expand Down Expand Up @@ -352,7 +356,7 @@ export const addObject: AgentOperation<AddObjectInput> = (nodes, input, context)
slots: slotsOf(
matchScriptSlotsToLibrary(compiled.manifest, previous.slots, previous.source?.manifest),
),
asset: scriptAsset(compiled, input, previous.asset),
asset: scriptAsset(compiled, input, previous),
...(input.reason ? { metadata: { ...previous.metadata, reason: input.reason } } : {}),
})
// Children resting on or hanging from the object follow its new geometry.
Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/agent-operations/author-scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,51 @@ describe('author_scene', () => {
)
})

test("a spotlight keeps its aim in its fixture's frame, with its shadows and glow", () => {
const payload = authorScenePayload.parse({
levels: [
{
name: 'Ground floor',
items: [
{
id: 'sconce',
name: 'Sconce',
category: 'light',
code: '// sconce',
position: [0, 0, 0],
rotation: 90,
},
],
lights: [
{
position: [1, 2, 0],
target: [2, 2, 0],
fixture: 'sconce',
shadows: true,
glow: { size: 0.2 },
},
{ position: [0, 2, 0], fixture: 'sconce', angle: 30 },
],
},
],
})
const outcome = run(payload, { sconce: bed }, {})
expect(outcome.result.defects).toEqual([])
const [aimed, down] = byAuthorId(outcome, 'sconce').asset.interactive.effects
// The target is 1 m along +x from the bulb, as the bulb is from the fixture: both turn alike.
expect(aimed.spot.direction[0]).toBeCloseTo(aimed.offset[0], 4)
expect(aimed.spot.direction[1]).toBeCloseTo(0, 4)
expect(aimed.spot.direction[2]).toBeCloseTo(aimed.offset[2], 4)
expect(Math.abs(aimed.offset[2])).toBeCloseTo(1, 4)
expect(aimed).toMatchObject({
spot: { angle: 60, softness: 0.4 },
shadows: true,
glow: { size: 0.2, strength: 1 },
})
expect(down.spot).toEqual({ direction: [0, -1, 0], angle: 30, softness: 0.4 })
expect(down.shadows).toBeUndefined()
})

test('a ceiling point whose module failed is reported by the light id', () => {
const payload = authorScenePayload.parse({
...bedroom,
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/agent-operations/author-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,32 @@ function toLocal(frame: Frame, world: Vec3): Vec3 {
return [c * dx - s * dz, dy, s * dx + c * dz]
}

type PayloadLight = AuthorScenePayload['levels'][number]['lights'][number]

/**
* A payload light's beam in its host's frame (so it turns with the host), or none for a point
* light. A target on the light itself points it down.
*/
function lightSpot(light: PayloadLight, at: Vec3, host: Frame) {
if (!light.direction && !light.target && light.angle === undefined) return undefined
let [x, y, z] = light.target
? [light.target[0]! - at[0], light.target[1]! - at[1], light.target[2]! - at[2]]
: (light.direction ?? [0, -1, 0])
let length = Math.hypot(x!, y!, z!)
if (length < 1e-6) [x, y, z, length] = [0, -1, 0, 1]
const [c, s] = [Math.cos(host.yaw), Math.sin(host.yaw)]
const round = (v: number) => Math.round(v * 1e4) / 1e4
return {
direction: [
round((c * x! - s * z!) / length),
round(y! / length),
round((s * x! + c * z!) / length),
] as Vec3,
angle: light.angle ?? 60,
softness: light.softness ?? 0.4,
}
}

/**
* 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
Expand Down Expand Up @@ -2226,6 +2252,9 @@ export function authorScene(
color: light.color,
intensityRange: [0, light.intensity],
offset: toLocal(hostFrame!, at),
spot: lightSpot(light, at, hostFrame!),
shadows: light.shadows,
glow: light.glow,
},
]
written[hostNode.id] = {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/agent-tools/add-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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_<finish> (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:<id> (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:<id> where the bulb is; it becomes a switchable light.
- Lights: add a THREE.PointLight or SpotLight named light:<id> where the bulb is; it becomes a switchable light. A SpotLight keeps its aim (its target), angle and penumbra (a sconce's cone, a recessed downlight). castShadow = true makes it cast shadows (only the couple nearest the view do at once, so keep it for the lights that shape a room). userData.glow = { size, strength } draws a soft halo around the lamp in its colour (size across in metres, about 0.15 for a sconce to 0.4 for a lantern; strength default 1), strongest at dusk and night.
- Motion: put THREE.AnimationClips on the returned group's .animations, as in any three.js project; tracks target <objectName>.<property> or <object.uuid>.<property> 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.
- Cuts: a mesh named cutout cuts only the mounted host; cut:wall, cut:ceiling and cut:slab require that host kind (floor items cut their support slab). The hidden cutter must reach the host; walls use its real shape, ceilings and slabs its through-hole footprint.
- Sockets: an empty Object3D named anchor:<id> marks where other things attach.
Expand All @@ -29,7 +29,7 @@ Blender instead (language 'bpy'): when the shape is easier with Blender's modell
PARAMS = {"width": {"default": 1.8, "min": 1.2, "max": 2.6, "step": 0.05, "unit": "m", "label": "Width"}}
MOUNT = "floor"
def build(params): ... # add objects to the empty scene; everything in it is exported, modifiers applied
The same conventions in Blender terms: metres, Z up, the front faces -Y (Blender's Front view); part:<id>, light:<id> (a light object; its custom property intensity sets the brightness, default 2), anchor:<id> (an empty), cutout, cut:wall|ceiling|slab and collider are object names; userData is the object's custom properties (obj["type"] = "panel"); slot_<finish> are material names. A clip is one action named open, close, loop or anything else, assigned to every object that moves in it. Use the data API and bmesh, not operators that need a 3D viewport. A Blender build takes a few seconds.
The same conventions in Blender terms: metres, Z up, the front faces -Y (Blender's Front view); part:<id>, light:<id> (a point or spot light object; its custom properties intensity sets the brightness, default 2, shadows = True casts shadows, glow = {"size": 0.2, "strength": 1} draws the halo), anchor:<id> (an empty), cutout, cut:wall|ceiling|slab and collider are object names; userData is the object's custom properties (obj["type"] = "panel"); slot_<finish> are material names. A clip is one action named open, close, loop or anything else, assigned to every object that moves in it. Use the data API and bmesh, not operators that need a 3D viewport. A Blender build takes a few seconds.

Several objects: they do not depend on each other, so several add_object calls can go in one step, or in one run_batch call (a call per object; each is reported on its own with its receipt, and one that fails does not stop the others).

Expand Down
Loading
Loading