diff --git a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts index 224ba09a49..3bb65495c3 100644 --- a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts @@ -327,6 +327,7 @@ export const NOTATION_CASES: NotationCase[] = [ * pixels, so neither is listed; a length or an angle added as a bare number fails the test. */ export const NOT_LENGTHS = new Set([ + 'author_scene.payload.levels[].lights[].intensity', // Coordinates, in level metres. 'add_column.x', 'add_column.y', diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 05fb74dfb6..371ea519f2 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from 'bun:test' import type { AuthorScenePayload } from '../agent-tools/author-scene' import { authorScenePayload } from '../agent-tools/author-scene' +import { cloneNodesInto } from '../registry/subtree' import type { AnyNode, CompiledGeometryScript, SceneMaterial } from '../schema' import { CEILING_DRAW_OFFSET, resolveCeilingHeight } from '../services' import { + applySceneChanges, authorScene, authorSceneCeilingPoints, authorSceneFaceScripts, @@ -177,6 +179,104 @@ const bedroom: AuthorScenePayload = authorScenePayload.parse({ }) describe('author_scene', () => { + test('a copied baseline cannot authorize replacement of another building', () => { + const first = run(bedroom, { bed, lamp: bed }) + const saved = applySceneChanges(scaffold(), first.changes) + const copied = cloneNodesInto( + Object.values(saved).filter((node) => node.type !== 'site'), + { + rootId: 'building_1', + parentId: 'site_1', + }, + ) + const nodes = { ...saved, ...Object.fromEntries(copied.nodes.map((node) => [node.id, node])) } + expect(() => + authorScene( + nodes, + { + payload: bedroom, + compiled: { bed, lamp: bed }, + buildingId: copied.root.id, + replace: true, + }, + { activeLevelId: null }, + ), + ).toThrow() + expect(nodes.building_1).toEqual(saved.building_1) + }) + test('an existing building and unrelated buildings are never erased on a first conversion', () => { + const scene = scaffold() + const first = run(bedroom, { bed, lamp: bed }) + const authored = applySceneChanges(scene, first.changes) + const foreign = Object.fromEntries( + Object.entries(authored).map(([id, node]) => [ + `other_${id}`, + { + ...node, + id: `other_${id}`, + parentId: node.parentId ? `other_${node.parentId}` : null, + ...('children' in node ? { children: node.children.map((id) => `other_${id}`) } : {}), + }, + ]), + ) as Record + const combined = { ...scene, ...foreign } + const outcome = authorScene( + combined, + { payload: bedroom, compiled: { bed, lamp: bed } }, + { activeLevelId: 'level_0' }, + ) + const saved = applySceneChanges(combined, outcome.changes) + for (const [id, node] of Object.entries(foreign)) expect(saved[id]).toEqual(node) + const manual = { ...authored, building_1: { ...authored.building_1!, metadata: {} } } + expect(() => + authorScene( + manual, + { payload: bedroom, compiled: { bed, lamp: bed }, replace: true }, + { activeLevelId: null }, + ), + ).toThrow() + }) + + test('regeneration keeps a manual move, an added object and a deleted object, and refuses an overlapping move', () => { + const first = run(bedroom, { bed, lamp: bed }) + const saved = applySceneChanges(scaffold(), first.changes) + const bedId = first.result.ids.bed! + const lampId = first.result.ids.lamp! + const moved = { ...saved[bedId]!, position: [0.4, 0, -1] } as AnyNode + const manual = applySceneChanges(saved, { + update: [{ id: bedId, data: moved }], + delete: [lampId], + create: [ + { + node: { + ...saved[bedId], + id: 'item_manual', + children: [], + parentId: first.result.ids['level/0'], + } as AnyNode, + }, + ], + }) + const repeat = authorScene( + manual, + { payload: bedroom, compiled: { bed, lamp: bed }, replace: true }, + { activeLevelId: null }, + ) + const after = applySceneChanges(manual, repeat.changes) + expect(after[bedId]).toEqual(manual[bedId]) + expect(after.item_manual).toEqual(manual.item_manual) + expect(after[lampId]).toBeUndefined() + const changed = structuredClone(bedroom) + changed.levels[0]!.items![0]!.position = [0.8, 0, -1] + expect(() => + authorScene( + manual, + { payload: changed, compiled: { bed, lamp: bed }, replace: true }, + { activeLevelId: null }, + ), + ).toThrow() + }) + test('writes the design as native nodes and reports what it did', () => { const outcome = authorScene( scaffold(), @@ -375,9 +475,7 @@ describe('author_scene', () => { { payload: bedroom, compiled: { bed, lamp: bed } }, { activeLevelId: 'level_0' }, ) - const scene = scaffold() - delete scene.level_0 - for (const { node } of first.changes!.create!) scene[node.id] = node + const scene = applySceneChanges(scaffold(), first.changes) expect(() => authorScene( scene, @@ -391,13 +489,10 @@ describe('author_scene', () => { { activeLevelId: null }, ) expect(again.result.walls).toBe(4) - // Written over itself, the same payload gets fresh ids, so nothing is created and then deleted. - const firstIds = new Set(first.changes!.create!.map((c) => c.node.id)) - expect(again.changes!.create!.length).toBe(first.changes!.create!.length) - expect(again.changes!.create!.some((c) => firstIds.has(c.node.id))).toBe(false) - expect(again.changes!.delete).toEqual([ - first.changes!.create!.find((c) => c.node.type === 'level')!.node.id, - ]) + expect(again.result.ids).toEqual(first.result.ids) + expect(again.changes!.create).toEqual([]) + expect(again.changes!.delete).toEqual([]) + expect(applySceneChanges(scene, again.changes)).toEqual(scene) }) test('an opening takes its type, style, leaves and a handle opposite the hinge', () => { diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 03fca45453..6680ad3789 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -45,10 +45,11 @@ import { getWallLocalFaceZ } from '../systems/wall/wall-frame' import { roomFaceCover } from '../systems/wall/wall-room-sides' import { migrateRoomZones } from '../utils/room-zone-migration' import { addObject } from './add-object' +import { authoredSceneChanges } from './authored-scene-merge' import { paint } from './paint' import { pointInPolygon, projectWorldPointToWallLocalX, type Vec2 } from './plan-geometry' import { resolveInlinePaintMaterial } from './scene-materials' -import type { AgentContext, AgentOperationOutcome, NodeWrite, SceneNodes } from './types' +import type { AgentContext, AgentOperationOutcome, SceneNodes } from './types' import { setWallSkirting } from './wall-skirting' /** @@ -72,6 +73,17 @@ export type AuthorSceneInput = { /** Why a module did not compile, keyed like `compiled`, for the report. */ compileErrors?: Record replace?: boolean + buildingId?: string + /** Hosts compile finishes against the candidate before making one scene commit. */ + prepareOnly?: boolean +} + +export type AuthorSceneState = { + version: 1 + buildingId: string + seed: string + ids: Record + nodes: Record } export type AuthorSceneDefect = { id: string; code: string; message: string } @@ -114,24 +126,29 @@ type Vec3 = [number, number, number] type AuthorSceneWall = NonNullable[number] /** - * Small, stable ids: the payload's own hash and a counter per kind, so a replay writes the same - * scene. A revision salt keeps a replacement's ids apart from the conversion it replaces: the - * same payload written over itself would otherwise create nodes that already exist and then - * delete them with the old levels. + * Explicit payload keys retain their identities across revisions; derived nodes use the + * conversion's retained seed. A changed payload never remints every existing wall or item. */ -function idFactory(payload: AuthorScenePayload, revision = 0) { - let hash = 0x811c9dc5 - for (const ch of `${revision}:${JSON.stringify(payload)}`) - hash = Math.imul(hash ^ ch.charCodeAt(0), 0x01000193) >>> 0 - const base = hash.toString(16).padStart(8, '0') +function idFactory(seed: string, previous: Record = {}) { const counts = new Map() - return (kind: string) => { + return (kind: string, key?: string) => { const n = (counts.get(kind) ?? 0) + 1 counts.set(kind, n) - return `${kind}_as${base}${n.toString(36).padStart(4, '0')}` + if (key && previous[key]?.startsWith(`${kind}_`)) return previous[key]! + let hash = 0x811c9dc5 + for (const ch of `${seed}:${kind}:${key ?? n}`) + hash = Math.imul(hash ^ ch.charCodeAt(0), 0x01000193) >>> 0 + return `${kind}_as${hash.toString(16).padStart(8, '0')}` } } +function payloadSeed(payload: AuthorScenePayload) { + let hash = 0x811c9dc5 + for (const ch of JSON.stringify(payload)) + hash = Math.imul(hash ^ ch.charCodeAt(0), 0x01000193) >>> 0 + return hash.toString(16) +} + const dist = (a: readonly number[], b: readonly number[]) => Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!) @@ -505,25 +522,54 @@ export function authorScene( scene: SceneNodes, input: AuthorSceneInput, context: AgentContext, -): AgentOperationOutcome & { result: AuthorSceneReport } { +): AgentOperationOutcome & { + result: AuthorSceneReport + candidate: AuthorSceneState + buildingId: string +} { const { payload } = input - const building = Object.values(scene).find((n) => n.type === 'building') + const buildings = Object.values(scene).filter((n) => n.type === 'building') + const selected = + input.buildingId ?? (context.activeLevelId ? scene[context.activeLevelId]?.parentId : undefined) + const building = selected + ? buildings.find((node) => node.id === selected) + : buildings.length === 1 + ? buildings[0] + : undefined if (!building) refuse('no_building', 'The scene has no building to put the levels in.') - const existingLevels = Object.values(scene).filter((n) => n.type === 'level') - const converted = existingLevels.some( - (l) => (l.metadata as { authorScene?: unknown })?.authorScene, + const existingLevels = Object.values(scene).filter( + (n) => n.type === 'level' && n.parentId === building.id, ) + const saved = building.metadata?.authorSceneState as AuthorSceneState | undefined + const previous = + saved?.version === 1 && + saved.buildingId === building.id && + saved.nodes && + saved.ids && + typeof saved.seed === 'string' + ? saved + : undefined + const converted = Boolean(previous) || existingLevels.some((l) => l.metadata?.authorScene) if (converted && !input.replace) refuse( 'conversion_exists', 'This scene already holds a conversion. Pass replace: true to write over it.', ) - // The first revision whose ids are free in this scene: every conversion writes a level, so its - // first level id tells whether this payload was written here before. - let revision = 0 - while (revision < 32 && scene[idFactory(payload, revision)('level')]) revision++ - const mint = idFactory(payload, revision) + if ( + !previous && + (converted || + existingLevels.some((level) => + Object.values(scene).some((node) => node.parentId === level.id), + )) + ) + refuse( + 'existing_design', + 'This building already contains work without a retained conversion baseline. Use its native edit tools or convert into a separate building; existing geometry will not be replaced.', + { mutationApplied: false }, + ) + const seed = previous?.seed ?? `${building.id}:${payloadSeed(payload)}` + const mint = idFactory(seed, previous?.ids) const report: AuthorSceneReport = { levels: 0, walls: 0, @@ -584,28 +630,15 @@ export function authorScene( ...(finishes.materials ? { materials: finishes.materials } : {}), }) - // The draft: the scene without its levels, then everything the payload describes. + // A separate candidate contains only its building and ancestors, never another building's work. const draft: Record = {} - for (const [id, node] of Object.entries(scene)) { - if (node.type === 'level') continue - const levelIds = new Set(existingLevels.map((l) => l.id)) - if (node.parentId && levelIds.has(node.parentId)) continue - draft[id] = node + let ancestor: AnyNode | undefined = building + while (ancestor && !draft[ancestor.id]) { + draft[ancestor.id] = { ...ancestor, children: [] } as AnyNode + ancestor = ancestor.parentId ? scene[ancestor.parentId] : undefined } - const underLevel = new Set() - for (const node of Object.values(scene)) { - let at = node.parentId - while (at) { - if (existingLevels.some((l) => l.id === at)) { - underLevel.add(node.id) - break - } - at = scene[at]?.parentId ?? null - } - } - for (const id of underLevel) delete draft[id] - const deleted = existingLevels.map((l) => l.id) + const scaffoldIds = new Set(Object.keys(draft)) const levelNodes: ReturnType[] = [] const itemNodeIds = new Map() const itemFrames = new Map() @@ -621,7 +654,7 @@ export function authorScene( const levelNode = LevelNode.parse({ object: 'node', type: 'level', - id: mint('level'), + id: mint('level', `level/${index}`), parentId: building!.id, visible: true, metadata: { authorScene: { elevation: level.elevation } }, @@ -630,6 +663,7 @@ export function authorScene( children: [], }) draft[levelNode.id] = levelNode + report.ids[`level/${index}`] = levelNode.id levelNodes.push(levelNode) const place = (node: AnyNode) => { draft[node.id] = node @@ -670,7 +704,7 @@ export function authorScene( const node = WallNode.parse({ object: 'node', type: 'wall', - id: mint('wall'), + id: mint('wall', w.spec.id), parentId: levelNode.id, visible: true, metadata: { authorScene: { id: w.spec.id } }, @@ -736,7 +770,7 @@ export function authorScene( ) const common = { object: 'node', - id: mint(o.kind), + id: mint(o.kind, id), parentId: node.id, visible: true, metadata: { authorScene: { id, color: o.color ?? null } }, @@ -858,7 +892,7 @@ export function authorScene( const node = SlabNode.parse({ object: 'node', type: 'slab', - id: mint('slab'), + id: mint('slab', id), parentId: levelNode.id, visible: true, metadata: { authorScene: { finish: s.finish ?? null } }, @@ -879,7 +913,7 @@ export function authorScene( const node = ColumnNode.parse({ object: 'node', type: 'column', - id: mint('column'), + id: mint('column', `column/${index}/${i}`), parentId: levelNode.id, visible: true, metadata: { authorScene: { color: c.color ?? null } }, @@ -991,7 +1025,7 @@ export function authorScene( const outcome = addObject( draft, { - compiled: { ...placed.compiled, nodeId: mint('item') }, + compiled: { ...placed.compiled, nodeId: mint('item', item.id) }, name: item.name, category: item.category, reason: `author_scene: ${item.name}`, @@ -1219,7 +1253,7 @@ export function authorScene( const outcome = addObject( written, { - compiled: { ...compiled, nodeId: mint('item') }, + compiled: { ...compiled, nodeId: mint('item', id) }, name: `Ceiling point in ${room!.name}`, category: 'light', reason: 'author_scene: ceiling point for a light without a fixture', @@ -1292,18 +1326,55 @@ export function authorScene( } }) - const create: NodeWrite[] = Object.values(written) - .filter((node) => !scene[node.id]) - .map((node) => ({ op: 'create', node, parentId: node.parentId ?? undefined })) - return { + const nodes = Object.fromEntries(Object.entries(written).filter(([id]) => !scaffoldIds.has(id))) + const candidate: AuthorSceneState = { + version: 1, + buildingId: building.id, + seed, + ids: report.ids, + nodes, + } + const outcome = { result: report, + candidate, + buildingId: building.id, + changes: finishes.created.length ? { materials: finishes.created } : {}, + } + return input.prepareOnly ? outcome : finalizeAuthorScene(scene, outcome) +} + +/** Commit the complete candidate after the host has compiled all of its finishes. */ +export function finalizeAuthorScene( + scene: SceneNodes, + outcome: AgentOperationOutcome & { + result: AuthorSceneReport + candidate: AuthorSceneState + buildingId: string + }, +) { + const building = scene[outcome.buildingId] + if (building?.type !== 'building') refuse('no_building', 'The target building no longer exists.') + const previous = building.metadata?.authorSceneState as AuthorSceneState | undefined + const base = + previous?.nodes ?? + Object.fromEntries( + Object.values(scene) + .filter((node) => node.type === 'level' && node.parentId === building.id) + .map((node) => [node.id, node]), + ) + const changes = authoredSceneChanges(scene, base, outcome.candidate.nodes) + return { + ...outcome, changes: { - ...(finishes.created.length ? { materials: finishes.created } : {}), - delete: deleted, - create: create.map((c) => ({ - node: (c as { node: AnyNode }).node, - parentId: (c as { parentId?: string }).parentId, - })), + ...changes, + ...(outcome.changes?.materials ? { materials: outcome.changes.materials } : {}), + update: [ + ...(changes.update ?? []), + { + id: building.id, + data: { metadata: { ...building.metadata, authorSceneState: outcome.candidate } }, + }, + ], }, } } diff --git a/packages/core/src/agent-operations/authored-scene-merge.ts b/packages/core/src/agent-operations/authored-scene-merge.ts new file mode 100644 index 0000000000..fa39f94a50 --- /dev/null +++ b/packages/core/src/agent-operations/authored-scene-merge.ts @@ -0,0 +1,114 @@ +import { refuse } from '../agent-tools/refusal' +import type { AnyNode } from '../schema' +import type { SceneChanges, SceneNodes } from './types' + +const equal = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) return true + if (Array.isArray(a) && Array.isArray(b)) + return a.length === b.length && a.every((value, index) => equal(value, b[index])) + if (record(a) && record(b)) { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + return [...keys].every((key) => equal(a[key], b[key])) + } + return false +} +const record = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value) + +function merged(base: unknown, current: unknown, next: unknown, path: string): unknown { + if (equal(base, next) || equal(current, next)) return current + if (equal(base, current)) return next + if (record(base) && record(current) && record(next)) { + const result: Record = {} + for (const key of new Set([ + ...Object.keys(base), + ...Object.keys(current), + ...Object.keys(next), + ])) { + const value = merged(base[key], current[key], next[key], `${path}.${key}`) + if (value !== undefined) result[key] = value + } + return result + } + if ( + path.endsWith('.children') && + Array.isArray(base) && + Array.isArray(current) && + Array.isArray(next) + ) { + return [ + ...new Set([ + ...current.filter((id) => !base.includes(id) || next.includes(id)), + ...next.filter((id) => !base.includes(id)), + ]), + ] + } + refuse( + 'authored_edit_conflict', + `Both the design and the current scene changed ${path}. Preserve the current edit and resolve this change explicitly.`, + { path, mutationApplied: false }, + ) +} + +/** Apply a design delta against its last candidate, preserving edits made since that candidate. */ +export function authoredSceneChanges( + current: SceneNodes, + base: SceneNodes, + next: SceneNodes, +): SceneChanges { + const create: NonNullable = [] + const update: NonNullable = [] + const remove: string[] = [] + const effective: Record = { ...current } + for (const id of new Set([...Object.keys(base), ...Object.keys(next)])) { + const old = base[id] + const now = current[id] + const desired = next[id] + if (!old) { + if (!desired) continue + if (now) { + if (!equal(now, desired)) + refuse('authored_id_conflict', `Node ${id} already belongs to existing work.`, { + mutationApplied: false, + }) + continue + } + create.push({ node: desired, parentId: desired.parentId ?? undefined }) + effective[id] = desired + } else if (!now) { + // A person's deletion wins over regeneration, including a same-payload replay. + delete effective[id] + } else if (!desired) { + if (!equal(now, old)) + refuse( + 'authored_edit_conflict', + `Node ${id} was edited after conversion and cannot be removed by regeneration.`, + { mutationApplied: false }, + ) + remove.push(id) + delete effective[id] + } else { + const value = merged(old, now, desired, id) as AnyNode + effective[id] = value + if (!equal(now, value)) { + const data: Record = {} + for (const key of new Set([...Object.keys(now), ...Object.keys(value)])) { + if ( + !equal((now as Record)[key], (value as Record)[key]) + ) + data[key] = (value as Record)[key] + } + update.push({ id, data: data as Partial }) + } + } + } + for (const node of Object.values(effective)) { + if (node.parentId && !effective[node.parentId]) + refuse( + 'authored_edit_conflict', + `Removing ${node.parentId} would discard or orphan ${node.id}. Preserve the current hierarchy.`, + { mutationApplied: false }, + ) + } + return { create, update, delete: remove } +} diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index 8bbb46d39a..a1911d9318 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -42,6 +42,7 @@ export * from './add-wall' export * from './apply-changes' export * from './apply-outcome' export * from './author-scene' +export * from './authored-scene-merge' export * from './collections' export * from './corner-window' export * from './create-roof' diff --git a/packages/core/src/agent-operations/retained-overhead.test.ts b/packages/core/src/agent-operations/retained-overhead.test.ts new file mode 100644 index 0000000000..a8f5416f8a --- /dev/null +++ b/packages/core/src/agent-operations/retained-overhead.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from 'bun:test' +import { sceneViewInterior, verifyScene } from '@pascal-app/core/agent-operations' +import { + type AnyNode, + BuildingNode, + CeilingNode, + ItemNode, + LevelNode, + WallNode, +} from '@pascal-app/core/schema' + +const asset = { + id: 'retained', + name: 'Authored surface', + category: 'custom', + thumbnail: '', + src: '/retained.glb', + dimensions: [6, 1, 5], +} + +function fixture() { + const building = BuildingNode.parse({ + id: 'building_house', + children: ['level_ground', 'level_roof'], + }) + const ground = LevelNode.parse({ + id: 'level_ground', + parentId: building.id, + level: 0, + height: 3, + children: ['wall_room'], + }) + const roofLevel = LevelNode.parse({ + id: 'level_roof', + parentId: building.id, + level: 1, + height: 2, + children: ['item_roof', 'item_vault'], + }) + const wall = WallNode.parse({ id: 'wall_room', parentId: ground.id, start: [0, 0], end: [6, 0] }) + const roof = ItemNode.parse({ + id: 'item_roof', + parentId: roofLevel.id, + metadata: { role: 'roof' }, + position: [3, 0, 2.5], + asset, + }) + const ceiling = ItemNode.parse({ + id: 'item_vault', + parentId: roofLevel.id, + metadata: { role: 'ceiling' }, + position: [3, -0.3, 2.5], + asset, + }) + return Object.fromEntries( + [building, ground, roofLevel, wall, roof, ceiling].map((n) => [n.id, n]), + ) as Record +} + +test('a retained ceiling shell counts as a ceiling and preserves roof-level ownership', () => { + const nodes = fixture() + const before = structuredClone(nodes) + const result = verifyScene(nodes, {} as never, { activeLevelId: 'level_ground' }).result + expect(result.levels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + levelId: 'level_roof', + role: 'roof', + content: expect.objectContaining({ roofs: 1, ceilings: 1, items: 0 }), + }), + ]), + ) + expect(result.issues).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'roof_level_occupied' })]), + ) + expect(nodes).toEqual(before) + + // Names and categories cannot let furniture bypass the roof-level placement rule. + nodes.level_roof!.metadata.role = 'roof' + nodes.item_vault!.metadata = {} + nodes.item_vault!.name = 'Vault ceiling cabinet' + expect(verifyScene(nodes, {} as never, { activeLevelId: null }).result.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'roof_level_occupied' })]), + ) + const native = CeilingNode.parse({ + id: 'ceiling_native', + parentId: 'level_roof', + polygon: [ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ], + }) + nodes.item_vault!.metadata.role = 'ceiling' + nodes[native.id] = native + ;(nodes.level_roof as LevelNode).children.push(native.id) + expect(verifyScene(nodes, {} as never, { activeLevelId: null }).result.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'roof_level_occupied' })]), + ) +}) + +test('interior capture omits an explicitly classified retained ceiling without hiding ordinary items', () => { + const nodes = fixture() + const ceiling = nodes.item_vault as ItemNode + ;(nodes.level_roof as LevelNode).children = ['item_roof'] + ceiling.parentId = 'level_ground' + ;(nodes.level_ground as LevelNode).children.push(ceiling.id) + const ordinary = ItemNode.parse({ + id: 'item_cabinet', + parentId: 'level_ground', + name: 'Ceiling-height cabinet', + asset, + }) + nodes[ordinary.id] = ordinary + ;(nodes.level_ground as LevelNode).children.push(ordinary.id) + const before = structuredClone(nodes) + const view = sceneViewInterior(nodes, 'level_ground') + expect(view.hiddenIds).toContain(ceiling.id) + expect(view.hiddenIds).not.toContain(ordinary.id) + expect(nodes).toEqual(before) +}) diff --git a/packages/core/src/agent-operations/roof-writes.test.ts b/packages/core/src/agent-operations/roof-writes.test.ts index 48d799e5a2..4814554f0c 100644 --- a/packages/core/src/agent-operations/roof-writes.test.ts +++ b/packages/core/src/agent-operations/roof-writes.test.ts @@ -102,6 +102,121 @@ const STOREY_CONTENT: [string, AnyNode][] = [ ] describe('seatWrittenRoofs', () => { + test('classifying and reparenting a retained roof keeps source lineage and its world height', () => { + const original = ItemNode.parse({ + ...STOREY_CONTENT.find(([kind]) => kind === 'item')![1], + parentId: 'level_g', + position: [2, 3.25, 1], + metadata: { + design: { originalSourceFileId: 'asset_original', mappingFileId: 'asset_map' }, + note: 'manual', + }, + }) + const before = scene( + ...Object.values(bungalow(level('level_r', 1, { role: 'roof' }))), + original, + ) + const ops: NodeWrite[] = [ + { + op: 'update', + id: original.id, + data: { + parentId: 'level_r', + position: [2, 0.25, 1], + metadata: { ...original.metadata, role: 'roof' }, + }, + }, + ] + seatWrittenRoofs(ops, before) + const after = landed(before, ops) + const saved = after[original.id] as typeof original + expect(saved.parentId).toBe('level_r') + expect(saved.position[1] + 3).toBe(original.position[1]) + expect(saved.metadata.design).toEqual(original.metadata.design) + expect(saved.metadata.note).toBe('manual') + expect(saved.asset).toEqual(original.asset) + const move: NodeWrite[] = [{ op: 'update', id: saved.id, data: { position: [3, 0.25, 1] } }] + expect(() => seatWrittenRoofs(move, after)).not.toThrow() + }) + + test('legacy misplaced content still accepts unrelated metadata, but a retained roof cannot host storey content on its roof level', () => { + const wall = { ...STOREY_CONTENT[0]![1], parentId: 'level_r' } + const legacy = scene(...Object.values(underRoofLevel()), wall) + expect(() => + seatWrittenRoofs( + [{ op: 'update', id: wall.id, data: { metadata: { note: 'existing condition' } } }], + legacy, + ), + ).not.toThrow() + const retained = ItemNode.parse({ + ...STOREY_CONTENT.find(([kind]) => kind === 'item')![1], + id: 'item_roof', + metadata: { role: 'roof' }, + }) + const child = ItemNode.parse({ + ...retained, + id: 'item_furniture', + parentId: retained.id, + metadata: {}, + }) + expect( + refusalOf(() => seatWrittenRoofs(creates(retained, child), underRoofLevel())), + ).toMatchObject({ code: 'roof_level', id: child.id }) + }) + + test('retained roof geometry keeps its placement, asset and roof role through writes and duplication', () => { + const retained = ItemNode.parse({ + ...STOREY_CONTENT.find(([kind]) => kind === 'item')![1], + id: 'item_retained-roof', + metadata: { role: 'roof' }, + position: [2, -0.25, 1], + provenance: { refs: [{ ns: 'asset_source', id: 'scene.mjs#roof', role: 'primary' }] }, + }) + const nodes = bungalow(level('level_r', 1, { role: 'roof' })) + const ops = creates(retained) + seatWrittenRoofs(ops, nodes) + const after = landed(nodes, ops) + expect(after[retained.id]).toEqual(retained) + expect(servesAsRoofLevel(after, after.level_r as LevelNode)).toBe(true) + const report = verifyScene(after, {}, { activeLevelId: null }).result + expect(report.levels).toContainEqual( + expect.objectContaining({ + levelId: 'level_r', + role: 'roof', + content: expect.objectContaining({ items: 0, roofs: 1 }), + }), + ) + expect(report.issues).not.toContainEqual( + expect.objectContaining({ type: 'roof_level_occupied' }), + ) + const duplicate = duplicateLevel(after, { levelId: 'level_r' }, { activeLevelId: null }) + const copied = applySceneChanges(after, duplicate.changes) + const roofLevels = Object.values(copied).filter( + (node) => node.type === 'level' && node.metadata.role === 'roof', + ) + expect(roofLevels).toHaveLength(2) + expect( + refusalOf(() => + seatWrittenRoofs([{ op: 'update', id: retained.id, data: { metadata: {} } }], after), + ), + ).toMatchObject({ code: 'roof_level' }) + }) + + test('a roof name or asset category does not admit furniture, and walls cannot claim the retained roof role', () => { + const item = ItemNode.parse({ + ...STOREY_CONTENT.find(([kind]) => kind === 'item')![1], + name: 'Roof', + }) + item.asset.category = 'roof' + expect(refusalOf(() => seatWrittenRoofs(creates(item), underRoofLevel()))).toMatchObject({ + code: 'roof_level', + }) + const wall = { ...STOREY_CONTENT[0]![1], metadata: { role: 'roof' } } + expect(refusalOf(() => seatWrittenRoofs(creates(wall), underRoofLevel()))).toMatchObject({ + code: 'roof_level', + }) + }) + for (const [kind, node] of STOREY_CONTENT) test(`a ${kind} created on the roof level is refused, naming the storey it covers`, () => { const refusal = refusalOf(() => seatWrittenRoofs(creates(node), underRoofLevel())) diff --git a/packages/core/src/agent-operations/roof-writes.ts b/packages/core/src/agent-operations/roof-writes.ts index 131e88ee2d..7cd28d1a81 100644 --- a/packages/core/src/agent-operations/roof-writes.ts +++ b/packages/core/src/agent-operations/roof-writes.ts @@ -2,6 +2,7 @@ import { AgentRefusal, refuse } from '../agent-tools/refusal' import { getLevelDisplayName } from '../lib/level-name' import { type AnyNode, parseNode } from '../schema' import { + isRoofGeometry, isRoofLevel, isStoreyContent, liftRoofLevels, @@ -91,17 +92,32 @@ export function seatWrittenRoofs(ops: NodeWrite[], scene: SceneNodes): string[] id: write.node.id, type: write.node.type, parentId: write.parentId ?? write.node.parentId, + metadata: after[write.node.id]?.metadata, } - : write.op === 'update' && typeof write.data.parentId === 'string' - ? { id: write.id, type: after[write.id]?.type, parentId: write.data.parentId } + : write.op === 'update' && + (typeof write.data.parentId === 'string' || + (isRoofGeometry(scene[write.id]) && !isRoofGeometry(after[write.id]))) + ? after[write.id] : null - if (!placed?.parentId || !isStoreyContent(placed.type)) return - const prior = scene[placed.parentId] + if (!placed?.parentId || !isStoreyContent(placed.type, placed.metadata)) return + // A retained assembly's separately addressed children still belong to its level. + let parentId = placed.parentId + let retainedRoofAncestor = false + const seen = new Set() + while (after[parentId]?.type === 'item' && !seen.has(parentId)) { + seen.add(parentId) + retainedRoofAncestor ||= isRoofGeometry(after[parentId]) + const next = after[parentId]?.parentId + if (!next) break + parentId = next + } + if (!retainedRoofAncestor) parentId = placed.parentId + const prior = scene[parentId] const what = `a ${placed.type}` - if (isRoofLevel(after[placed.parentId])) - throw onRoofLevel(after, placed.parentId, what, { index, id: placed.id }) + if (isRoofLevel(after[parentId])) + throw onRoofLevel(after, parentId, what, { index, id: placed.id }) if (prior?.type === 'level' && servesAsRoofLevel(scene, prior)) - throw onRoofLevel(scene, placed.parentId, what, { index, id: placed.id }) + throw onRoofLevel(scene, parentId, what, { index, id: placed.id }) }) writes.forEach((write, index) => { @@ -110,7 +126,10 @@ export function seatWrittenRoofs(ops: NodeWrite[], scene: SceneNodes): string[] if (!id || roleOf(write) !== 'roof' || isRoofLevel(scene[id]) || level?.type !== 'level') return const held = [ ...new Set( - level.children.map((child) => after[child]?.type).filter((type) => isStoreyContent(type)), + level.children + .map((child) => after[child]) + .filter((child) => isStoreyContent(child?.type, child?.metadata)) + .map((child) => child!.type), ), ] if (held.length) diff --git a/packages/core/src/agent-operations/scene-queries.ts b/packages/core/src/agent-operations/scene-queries.ts index ebdf97f12b..84c3f2d56e 100644 --- a/packages/core/src/agent-operations/scene-queries.ts +++ b/packages/core/src/agent-operations/scene-queries.ts @@ -1,5 +1,5 @@ import type { AnyNode } from '../schema' -import { servesAsRoofLevel } from '../services/roof-level' +import { isCeilingGeometry, isRoofGeometry, servesAsRoofLevel } from '../services/roof-level' import type { SceneNodes } from './types' export type LevelRole = 'occupied' | 'roof' | 'support' @@ -67,10 +67,12 @@ export function contentCounts(content: readonly AnyNode[]): ContentCounts { zones: count('zone'), doors: count('door'), windows: count('window'), - items: count('item'), + items: content.filter( + (node) => node.type === 'item' && !isRoofGeometry(node) && !isCeilingGeometry(node), + ).length, slabs: count('slab'), - ceilings: count('ceiling'), - roofs: count('roof'), + ceilings: content.filter(isCeilingGeometry).length, + roofs: content.filter(isRoofGeometry).length, stairs: count('stair'), } } diff --git a/packages/core/src/agent-operations/scene-view.test.ts b/packages/core/src/agent-operations/scene-view.test.ts index 1667c542c9..b8e3c07cd1 100644 --- a/packages/core/src/agent-operations/scene-view.test.ts +++ b/packages/core/src/agent-operations/scene-view.test.ts @@ -96,6 +96,80 @@ function building(): Record { const box: SceneViewBox = { min: [0, 0, 0], max: [20, 6, 10] } +test('level captures frame the settled Solo and Exploded geometry without changing the graph', () => { + const nodes = building() + const before = structuredClone(nodes) + const solo = sceneViewPlan(nodes, { presentation: { mode: 'solo', levelId: 'level_1' } }) + expect(solo.presentation).toEqual({ mode: 'solo', levelId: 'level_1' }) + expect(solo).not.toHaveProperty('interior') + expect(solo.pose.target[1]).toBe(4.5) + const exploded = sceneViewPlan(nodes, { presentation: { mode: 'exploded' } }) + expect(framesBox(exploded.pose, { min: [0, 0, 0], max: [20, 11, 10] })).toBe(true) + expect(exploded.pose.target[1]).toBe(5.5) + expect(sceneViewBounds(nodes, 'wall_level_1_0', { mode: 'exploded' }).min[1]).toBe(8) + expect(z.object(viewSceneTool.input).safeParse({ presentation: { mode: 'solo' } }).success).toBe( + false, + ) + expect(() => + sceneViewPlan(nodes, { + presentation: { mode: 'solo', levelId: 'level_1' }, + target: 'wall_level_0_0', + }), + ).toThrow('The target must belong') + expect(() => + sceneViewPlan(nodes, { + presentation: { mode: 'solo', levelId: 'level_1' }, + target: 'item_missing', + }), + ).toThrow('The target is not in the scene') + expect(() => + sceneViewPlan(nodes, { + presentation: { mode: 'exploded' }, + interior: { levelId: 'level_0' }, + }), + ).toThrow('Choose a level presentation') + expect(() => + sceneViewPlan(nodes, { + presentation: { mode: 'solo', levelId: 'level_missing' }, + }), + ).toThrow('Solo needs an existing level') + expect(() => + sceneViewPlan( + { ...nodes, level_1: { ...nodes.level_1!, visible: false } }, + { + presentation: { mode: 'solo', levelId: 'level_1' }, + }, + ), + ).toThrow('Show the level') + expect(nodes).toEqual(before) +}) + +test('capture framing includes retained roof extents beyond the occupied walls in every level presentation', () => { + const nodes = building() + const roof = ItemNode.parse({ + id: 'item_capture_roof', + parentId: 'level_1', + metadata: { role: 'roof' }, + position: [-10, 0, 0], + asset: { + id: 'retained', + name: 'Roof', + category: 'roof', + thumbnail: '', + src: '/roof.glb', + dimensions: [10, 8, 10], + }, + }) + nodes[roof.id] = roof + const bounds = sceneViewBounds(nodes, 'level_1') + expect(bounds.min[0]).toBe(-15) + expect(bounds.max[1]).toBe(11) + const exploded = sceneViewBounds(nodes, undefined, { mode: 'exploded' }) + expect(exploded.max[1]).toBe(16) + const plan = sceneViewPlan(nodes, { presentation: { mode: 'solo', levelId: 'level_1' } }) + expect(framesBox(plan.pose, bounds)).toBe(true) +}) + /** The two-storey house with a ceiling over the ground floor and a roof on the upper one. */ function finishedBuilding(): Record { const nodes = building() @@ -148,6 +222,41 @@ describe('a look inside one floor', () => { expect(inside.hiddenIds.sort()).toEqual(['level_0', 'roof_1']) }) + test('interior capture leaves out explicitly classified retained roofs, not similarly named objects', () => { + const nodes = finishedBuilding() + const asset = { + id: 'retained', + name: 'Retained geometry', + category: 'roof', + thumbnail: '', + src: '/retained.glb', + dimensions: [1, 1, 1], + } + const roof = ItemNode.parse({ + id: 'item_retained_roof', + parentId: 'level_1', + metadata: { role: 'roof' }, + asset, + }) + const item = ItemNode.parse({ + id: 'item_roof_furniture', + name: 'Roof terrace chair', + parentId: 'level_1', + asset, + }) + nodes[roof.id] = roof + nodes[item.id] = item + const level = nodes.level_1 as LevelNode + level.children.push(roof.id, item.id) + const before = structuredClone(nodes) + expect(sceneViewInterior(nodes, level.id).hiddenIds).toContain(roof.id) + expect(sceneViewInterior(nodes, level.id).hiddenIds).not.toContain(item.id) + expect( + refusalOf(() => sceneViewPlan(nodes, { interior: { levelId: level.id }, target: roof.id })), + ).toBe('interior_target_elsewhere') + expect(nodes).toEqual(before) + }) + test('a target in the floor frames it, and an eye given is kept', () => { const nodes = finishedBuilding() const wall = sceneViewPlan(nodes, { diff --git a/packages/core/src/agent-operations/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts index f958c0e9f5..cda7168d35 100644 --- a/packages/core/src/agent-operations/scene-view.ts +++ b/packages/core/src/agent-operations/scene-view.ts @@ -14,13 +14,15 @@ import type { ColumnNode, FenceNode, ItemNode, + LevelNode, RoofNode, SlabNode, StairNode, WallNode, } from '../schema' import { getActiveRoofHeight } from '../schema' -import { getLevelElevations } from '../services/storey' +import { isCeilingGeometry, isRoofGeometry } from '../services/roof-level' +import { EXPLODED_GAP, getLevelElevations } from '../services/storey' import { resolveWallExteriorSide } from '../systems/wall/wall-assembly' import { pointInPolygon } from './plan-geometry' @@ -39,6 +41,7 @@ export type SceneViewLighting = Exclude<(typeof VIEW_LIGHTING)[number], 'current export type SceneViewInput = { target?: string interior?: SceneViewInteriorInput + presentation?: SceneViewPresentation from?: SceneViewSide position?: number[] elevation?: number @@ -75,6 +78,46 @@ export type SceneViewInterior = { levelId: string; roomId?: string } /** What the agent asks for: a floor to look into, or a room (zone) to stand inside. */ export type SceneViewInteriorInput = { levelId?: string; zoneId?: string } +export type SceneViewPresentation = + | { mode: 'stacked' | 'exploded' } + | { mode: 'solo'; levelId: string } + +/** Validate against the scene being rendered as well as the scene used to request the view. */ +export function sceneViewPresentationLevel( + nodes: Readonly>, + presentation: SceneViewPresentation | undefined, +): LevelNode | undefined { + if (presentation?.mode !== 'solo') return undefined + const level = nodes[presentation.levelId] + if (level?.type !== 'level') + refuse('presentation_level_invalid', 'Solo needs an existing level id.', { + levelId: presentation.levelId, + }) + if (ancestry(nodes, level).some((node) => node.visible === false && node.type !== 'site')) + refuse('presentation_level_hidden', 'Show the level before inspecting it in Solo.', { + levelId: level.id, + }) + return level +} + +/** Older renderers may ignore new view options; their frames cannot verify those views. */ +export function confirmSceneViewPresentation( + requested: SceneViewPresentation | undefined, + applied: SceneViewPresentation | undefined, +): void { + if ( + requested && + (applied?.mode !== requested.mode || + (requested.mode === 'solo' && + (applied?.mode !== 'solo' || applied.levelId !== requested.levelId))) + ) + refuse( + 'view_presentation_unconfirmed', + 'The renderer did not confirm the requested level view. Refresh the renderer and retry; this picture cannot verify Solo or Exploded behavior.', + { requested, applied: applied ?? null }, + ) +} + /** How high up its storey an interior view cuts a floor: low enough to see every room. */ const INTERIOR_CUT = 0.65 const INTERIOR_CUT_WORDS = `${INTERIOR_CUT}` @@ -119,7 +162,7 @@ export function sceneViewInterior( .filter((node) => node.type === 'level' ? node.id !== levelId - : (node.type === 'roof' || node.type === 'ceiling') && + : (isRoofGeometry(node) || isCeilingGeometry(node)) && ancestry(nodes, node).some((at) => at.id === levelId), ) .map((node) => node.id) @@ -586,6 +629,7 @@ function placedBox(box: SceneViewBox, at: Standing): SceneViewBox { export function sceneViewBounds( nodes: Readonly>, targetId?: string, + presentation?: SceneViewPresentation, ): SceneViewBox { const target = targetId ? nodes[targetId] : undefined if (targetId && !target) @@ -593,8 +637,13 @@ export function sceneViewBounds( target: targetId, }) const elevations = getLevelElevations(nodes as Record) - const baseOf = (levelId: string | null | undefined) => - (levelId ? elevations.get(levelId)?.baseY : undefined) ?? 0 + const baseOf = (levelId: string | null | undefined) => { + const level = levelId ? elevations.get(levelId) : undefined + return ( + (level?.baseY ?? 0) + + (presentation?.mode === 'exploded' ? (level?.ordinal ?? 0) * EXPLODED_GAP : 0) + ) + } const placed = (box: SceneViewBox, levelId: string | null | undefined) => placedBox(box, standingOf(nodes, levelId)) const opening = openingOf(nodes, target) @@ -642,10 +691,11 @@ export function sceneViewBounds( points: (node.polygon as Pt[]).map((point) => onSite(point, at)), levelId: level.id, }) - else if (node.type === 'item') held.push(placedBox(itemBox(node, baseOf(level.id)), at)) + else if (node.type === 'item') + (isRoofGeometry(node) ? roofs : held).push(placedBox(itemBox(node, baseOf(level.id)), at)) else if (node.type.includes(':')) { const storey = elevations.get(level.id) - if (storey) pluginTop = Math.max(pluginTop, storey.baseY + storey.height + at.y) + if (storey) pluginTop = Math.max(pluginTop, baseOf(level.id) + storey.height + at.y) } else { const box = levelChildBox(nodes, node, baseOf(level.id)) if (box) (node.type === 'roof' ? roofs : held).push(placedBox(box, at)) @@ -667,8 +717,8 @@ export function sceneViewBounds( for (const { points, levelId } of outlines) { const storey = elevations.get(levelId) ?? { baseY: 0, height: 3 } const lift = standingOf(nodes, levelId).y - min[1] = Math.min(min[1], storey.baseY + lift) - max[1] = Math.max(max[1], storey.baseY + storey.height + lift) + min[1] = Math.min(min[1], baseOf(levelId) + lift) + max[1] = Math.max(max[1], baseOf(levelId) + storey.height + lift) for (const [x, z] of points) { min[0] = Math.min(min[0], x) max[0] = Math.max(max[0], x) @@ -1014,9 +1064,23 @@ export function sceneViewPlan( size: { w: number; h: number } crop?: SceneViewCrop interior?: SceneViewInterior + presentation?: SceneViewPresentation lighting?: SceneViewLighting } { const { camera } = input + const presentation = input.presentation + if (presentation && input.interior) + refuse('presentation_and_interior', 'Choose a level presentation or a clipped interior view.') + const solo = sceneViewPresentationLevel(nodes, presentation) + if (solo) { + if (input.target && !nodes[input.target]) + refuse('target_not_found', 'The target is not in the scene.', { target: input.target }) + if (input.target && !ancestry(nodes, nodes[input.target]!).some((node) => node.id === solo.id)) + refuse('presentation_target_elsewhere', 'The target must belong to the soloed level.', { + target: input.target, + levelId: solo.id, + }) + } const crop = cropOf(input.photo) const requested = input.interior ? interiorRequest(nodes, input.interior) : undefined const room = requested?.room @@ -1026,6 +1090,7 @@ export function sceneViewPlan( const extras = { ...(crop ? { crop } : {}), ...(interior ? { interior } : {}), + ...(presentation ? { presentation } : {}), ...(lighting ? { lighting } : {}), } const target = input.target ? nodes[input.target] : undefined @@ -1060,7 +1125,7 @@ export function sceneViewPlan( const side = input.from const from = side ? isStreetSide(side) - ? streetDirection(nodes, side, target ?? (interior && nodes[interior.levelId])) + ? streetDirection(nodes, side, target ?? solo ?? (interior && nodes[interior.levelId])) : side : opening && !input.position ? outsideSide(opening.opening, opening.wall, standingOf(nodes, opening.wall.parentId).yaw) @@ -1097,7 +1162,7 @@ export function sceneViewPlan( return { pose: sceneViewPose( input.target || !inside - ? sceneViewBounds(nodes, input.target) + ? sceneViewBounds(nodes, input.target ?? solo?.id, presentation) : placedRoom ? placedRoom.box : inside.bounds, diff --git a/packages/core/src/agent-operations/verify-scene.ts b/packages/core/src/agent-operations/verify-scene.ts index 53d6a0b02e..ff4d9a1142 100644 --- a/packages/core/src/agent-operations/verify-scene.ts +++ b/packages/core/src/agent-operations/verify-scene.ts @@ -7,6 +7,7 @@ import { siteBrief } from '../lib/site-placement' import { nodeRegistry } from '../registry' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema, type LevelNode } from '../schema' import { nodeSchemaForKind } from '../schema/compiled-node-parsers' +import { isCeilingGeometry } from '../services/roof-level' import { isRoofOnStoreyWalls } from '../services/roof-move' import { getStoredLevelHeight, getWallCoveringSlabs, getWallTop } from '../services/storey' import { computeSegmentTransforms, rotateXZ } from '../systems/stair/stair-footprint' @@ -394,10 +395,13 @@ export const verifyScene: AgentOperation = ( 'roof_level_no_roof', `${levelName} is a roof support level but has no roof geometry; add or move roof geometry there rather than deleting the support level to satisfy story count`, ) - if (occupiedContent(content) > 0) + const retainedCeilings = onLevel(level.levelId).filter( + (node) => node.type === 'item' && isCeilingGeometry(node), + ).length + if (occupiedContent(content) - retainedCeilings > 0) report( 'roof_level_occupied', - `${levelName} is a roof support level but contains occupied-story content; move rooms, walls, stairs, slabs, ceilings, and items to an occupied story and keep the roof level for roof geometry only`, + `${levelName} is a roof support level but contains occupied-story content; move rooms, walls, stairs, slabs, native room ceilings, and ordinary items to an occupied story; roof geometry and classified retained ceiling shells can stay`, ) // An empty storey counts: it is a floor still to be built, and the roof goes above it. const storeyAbove = levels.find( diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index d7edd9494b..5f08aac998 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -10,7 +10,7 @@ Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own fram The report says what was written, what Pascal defaulted (defaults: a window with no type is fixed and cannot open), what it warns about but wrote (warnings: thin slabs, too many slabs or regions) and what it refused (defects, by your ids). -Call again with the same scene and a corrected payload to replace the previous conversion (replace: true); the default refuses to overwrite a scene that already holds a conversion.` +Call again with the same scene and a corrected payload to update the previous conversion (replace: true). Stable element identities and manual changes are retained; overlapping changes are refused. An existing building without a conversion baseline is never cleared.` const plan = (what: string) => z.array(z.number()).length(2).describe(`[x, z] ${what} in plan metres.`) @@ -273,7 +273,7 @@ export const authorSceneLevel = z.object({ default: 0, }), walls: z.array(authorSceneWall).optional(), - rooms: z.array(authorSceneRoom).default([]), + rooms: z.array(authorSceneRoom).default([]).describe('Room seeds and finishes (default []).'), slabs: z.array(authorSceneSlab).optional(), columns: z.array(authorSceneColumn).optional(), items: z.array(authorSceneItem).optional(), @@ -314,7 +314,15 @@ export const authorSceneTool = { replace: z .boolean() .default(false) - .describe('Replace the conversion this scene already holds (default false).'), + .describe( + 'Apply a new design delta to the previous conversion, preserving manual edits (default false).', + ), + buildingId: z + .string() + .optional() + .describe( + 'The building to convert into. Required when there are several and no active level selects one.', + ), sceneId: NodeId.optional().describe('The scene to write; the active one when omitted.'), }, annotations: { destructiveHint: true }, diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 75d704a1e6..cb524f5c84 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,5 +1,6 @@ import { addObjectTool, getSourceTool } from './add-object' import { searchAssetsTool, viewAssetsTool } from './assets' +import { authorSceneTool } from './author-scene' import { runBatchTool } from './batch' import { setCheckpointTool } from './checkpoints' import { clearSceneTool } from './clear-scene' @@ -107,6 +108,7 @@ export const AGENT_TOOL_CONTRACTS = [ moveRoofsToRoofLevelTool, deleteNodeTool, addObjectTool, + authorSceneTool, getSourceTool, findByTypeTool, editCollectionTool, diff --git a/packages/core/src/agent-tools/view-scene.ts b/packages/core/src/agent-tools/view-scene.ts index aad87a430c..6583042752 100644 --- a/packages/core/src/agent-tools/view-scene.ts +++ b/packages/core/src/agent-tools/view-scene.ts @@ -43,7 +43,7 @@ export const viewSceneTool = { name: 'view_scene', title: 'Look at the scene', description: - "Look at the building in 3D from a viewpoint you pick and get the picture back: the finished house, every storey and the roof, whatever floor or view mode the person has on. Use it to compare what you built with a reference and say what differs before you fix it: the facade from the photo's own camera (camera: its position, aim, field of view and aspect), rendered at the photo's aspect to lay beside it; or from the photo's side at street height (from 'street', 'street-left', …, eyeHeight 1.7), in its light (lighting); one face square on (projection orthographic); the massing from above, or an aerial view (elevation 40). The view frames the target (a building, a level, a wall or a zone; the whole building, its roof included, by default) from outside, unless you place the eye yourself with position or camera. To stand inside a room and see its furniture and finishes at a person's height, give interior {zoneId} (a room: an id from get_zones or create_room): the eye stands in the room a step in from a corner, looking across it, with the cut below; from picks the side of the room it stands on, target something in the room to look at it. To see inside a floor, give interior {levelId}: the other floors and that floor's roofs and ceilings are left out of the picture, and everything on it, walls, openings and furniture alike, is cut at 0.65 of the storey's height (about two thirds), so a tall wardrobe or a door head is cut, not short; a roof level has no inside; with no eye given it looks down into the floor, or into the room or zone you target. The live view is not changed. A door, a window or an item on a floor frames at detail scale, an opening seen from its outside face: a close-up to lay beside the photo's crop of the same element, to see what differs. Over the MCP the user's editor tab open on the project renders the picture: with none open it is refused (editor_tab_required), a tab in the background must be brought to the front (editor_tab_hidden), and a tab that is open but busy or still loading answers editor_tab_busy: call view_scene again in a few seconds, the user need not be asked. A picture is not a measure: take sizes and counts from the tools.", + "Look at the building in 3D from a viewpoint you pick and get the picture back: the finished house, every storey and the roof by default, or an explicit Solo/Exploded level presentation, whatever view mode the person has on. Use it to compare what you built with a reference and say what differs before you fix it: the facade from the photo's own camera (camera: its position, aim, field of view and aspect), rendered at the photo's aspect to lay beside it; or from the photo's side at street height (from 'street', 'street-left', …, eyeHeight 1.7), in its light (lighting); one face square on (projection orthographic); the massing from above, or an aerial view (elevation 40). The view frames the target (a building, a level, a wall or a zone; the whole building, its roof included, by default) from outside, unless you place the eye yourself with position or camera. To stand inside a room and see its furniture and finishes at a person's height, give interior {zoneId} (a room: an id from get_zones or create_room): the eye stands in the room a step in from a corner, looking across it, with the cut below; from picks the side of the room it stands on, target something in the room to look at it. To see inside a floor, give interior {levelId}: the other floors and that floor's roofs and ceilings are left out of the picture, and everything on it, walls, openings and furniture alike, is cut at 0.65 of the storey's height (about two thirds), so a tall wardrobe or a door head is cut, not short; a roof level has no inside; with no eye given it looks down into the floor, or into the room or zone you target. The live view is not changed. A door, a window or an item on a floor frames at detail scale, an opening seen from its outside face: a close-up to lay beside the photo's crop of the same element, to see what differs. Over the MCP the user's editor tab open on the project renders the picture: with none open it is refused (editor_tab_required), a tab in the background must be brought to the front (editor_tab_hidden), and a tab that is open but busy or still loading answers editor_tab_busy: call view_scene again in a few seconds, the user need not be asked. A picture is not a measure: take sizes and counts from the tools.", input: { target: z .string() @@ -60,6 +60,16 @@ export const viewSceneTool = { .describe( "Look inside, with one of: levelId (a floor): the other floors and its roofs and ceilings are left out of the picture, everything on it (walls, openings, furniture) cut at 0.65 of the storey height. Default framing looks down into it; camera or position still wins. zoneId (a room: an id from get_zones or create_room): the same cut, and the eye stands inside the room at a person's height (1.6 m, or eyeHeight), a step in from its corner farthest from what it looks at (from picks the corner on that side), looking across the room with a 75 degree field of view (fov changes it); with a target on the floor it looks at the target. position, elevation, from above or projection orthographic put the eye outside again, over the cut room.", ), + presentation: z + .discriminatedUnion('mode', [ + z.object({ mode: z.literal('stacked') }), + z.object({ mode: z.literal('exploded') }), + z.object({ mode: z.literal('solo'), levelId: z.string().min(1) }), + ]) + .optional() + .describe( + 'Verify the actual level presentation: stacked (default), exploded, or solo with a levelId. Solo keeps full-height geometry, including ceilings and any roofs incorrectly owned by that floor; it is not the clipped interior view. Does not change the live view. Do not combine with interior.', + ), from: z .enum(VIEW_SIDES) .optional() diff --git a/packages/core/src/building/level-duplication.ts b/packages/core/src/building/level-duplication.ts index 0de507285e..5a524bdcfd 100644 --- a/packages/core/src/building/level-duplication.ts +++ b/packages/core/src/building/level-duplication.ts @@ -219,7 +219,9 @@ export function buildLevelDuplicateCreateOps({ // A walled level someone declared the roof level is a storey once copied: the copy is one. if ( isRoofLevel(copy) && - cleanedNodes.some((node) => node.parentId === newLevelId && isStoreyContent(node.type)) + cleanedNodes.some( + (node) => node.parentId === newLevelId && isStoreyContent(node.type, node.metadata), + ) ) { const { role: _role, ...metadata } = copy.metadata copy.metadata = metadata diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 1651e27ab4..19401ae2d0 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -772,6 +772,16 @@ export const NON_REFERENCES: readonly { kind: string; path: string; reason: stri }, ]), { kind: 'scan', path: 'layers.@key', reason: 'Layer visibility flag name.' }, + { + kind: 'site', + path: 'presentation.display.disciplines.@key', + reason: 'Discipline display filter names.', + }, + { + kind: 'site', + path: 'presentation.display.families.@key', + reason: 'Family display filter names.', + }, { kind: 'site', path: 'frontEdge', reason: "Index of the lot polygon's street-facing edge." }, ...[ 'boundaries', @@ -809,6 +819,18 @@ const meta = (path: string, r: Omit) => row({ kind: '*', path: `metadata.${path}`, ...r }) export const METADATA_REFERENCES: readonly ExistingReference[] = [ + meta('authorSceneState.buildingId', { + ...policy('node', 'internal', 'drop', 'strip'), + remaps: ['clone-scene-graph', 'clone-level-subtree'], + dependents: ['metadata.authorSceneState'], + note: 'The building that owns this conversion baseline; a partial copy cannot overwrite its source building.', + }), + meta('authorSceneState.ids.*', { + ...policy('node', 'internal', 'drop', 'strip'), + remaps: ['clone-scene-graph', 'clone-level-subtree'], + dependents: ['metadata.authorSceneState.nodes'], + note: 'Stable source-to-node identities and the corresponding authored baseline snapshot; whole graph clones remap the snapshot recursively.', + }), meta('managedByLeanTo', { ...policy('node', 'internal', 'cascade', 'strip'), targetKinds: ['lean-to-extension'], diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index def17eb0b9..2d1b5a7755 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -2,7 +2,7 @@ import type { ThreeEvent } from '@react-three/fiber' import mitt from 'mitt' import type { Object3D } from 'three' import type { PointAskPin, PointAskStatus } from '../agent-operations/point-context' -import type { SceneViewInterior } from '../agent-operations/scene-view' +import type { SceneViewInterior, SceneViewPresentation } from '../agent-operations/scene-view' import type { BlockNode, BoxVentNode, @@ -226,6 +226,7 @@ export interface SnapshotCapturedEvent { blob: Blob width: number height: number + presentation?: SceneViewPresentation } export interface ThumbnailGenerateEvent { @@ -282,6 +283,8 @@ export interface ThumbnailGenerateEvent { clip?: readonly { normal: [number, number, number]; constant: number }[] /** Look inside this floor for the frame: the rest of the house left out, its walls cut (view_scene). */ interior?: SceneViewInterior + /** The actual Solo/Exploded/Stacked presentation, without interior clipping. */ + presentation?: SceneViewPresentation /** Render the canvas at this multiple of its size for the frame (print-scale pictures). */ supersample?: number } diff --git a/packages/core/src/schema/nodes/site.test.ts b/packages/core/src/schema/nodes/site.test.ts index 7826d73073..0f062eac86 100644 --- a/packages/core/src/schema/nodes/site.test.ts +++ b/packages/core/src/schema/nodes/site.test.ts @@ -5,6 +5,76 @@ import { SiteNode } from './site' import { migrateSiteMetadata } from './site-facts' describe('SiteNode.terrain', () => { + test('project rendering defaults round-trip without changing geometry or legacy scenes', () => { + const legacy = SiteNode.parse({ id: 'site_look', children: ['building_house'] }) + expect(legacy.presentation).toBeUndefined() + const presentation = { + theme: 'studio', + exposure: 0.94, + shading: 'rendered', + edges: 'off', + lighting: { + background: '#dce4e5', + backgroundSky: null, + ambient: { color: '#ffffff', intensity: 0 }, + hemi: { sky: '#e5f1ff', ground: '#92866c', intensity: 0.95 }, + lights: [ + { + position: [-17, 29, 18], + target: [0, 0, 0], + color: '#fff2d6', + intensity: 2.8, + castShadow: true, + shadow: { + intensity: 1, + mapSize: 4096, + bias: -0.0002, + normalBias: 0.015, + radius: 1, + camera: { left: -26, right: 26, top: 26, bottom: -26, near: 0.5, far: 500 }, + }, + }, + ], + environment: { source: 'room', intensity: 0.28 }, + }, + postprocessing: { ambientOcclusion: false, colorGrading: false }, + } + const restored = SiteNode.parse(JSON.parse(JSON.stringify({ ...legacy, presentation }))) + expect(restored.presentation).toEqual(presentation) + const { presentation: _look, ...geometry } = restored + expect(geometry).toEqual(legacy) + expect(SiteNode.safeParse({ ...legacy, presentation: { exposure: -1 } }).success).toBe(false) + expect( + SiteNode.safeParse({ ...legacy, presentation: { lighting: { background: 'url(secret)' } } }) + .success, + ).toBe(false) + for (const shadow of [ + { mapSize: 8192 }, + { intensity: 1.1 }, + { camera: { left: 26, right: -26, top: 26, bottom: -26, near: 0.5, far: 500 } }, + { camera: { left: -26, right: 26, top: 26, bottom: -26, near: 500, far: 0.5 } }, + ]) { + expect( + SiteNode.safeParse({ + ...legacy, + presentation: { + lighting: { lights: [{ ...presentation.lighting.lights[0], shadow }] }, + }, + }).success, + ).toBe(false) + } + }) + test('ground presentation is optional and survives a scene round-trip without changing children', () => { + const original = SiteNode.parse({ id: 'site_authored-ground', children: ['building_house'] }) + expect(original.showGround).toBeUndefined() + const restored = SiteNode.parse(JSON.parse(JSON.stringify({ ...original, showGround: false }))) + expect(restored.showGround).toBe(false) + expect(restored.children).toEqual(original.children) + expect(restored.polygon).toEqual(original.polygon) + expect(restored.visible).toBe(original.visible) + expect(SiteNode.safeParse({ ...original, showGround: 'false' }).success).toBe(false) + }) + test('a scene saved before terrain existed still parses', () => { const parsed = SiteNode.parse({ id: 'site_1', type: 'site' }) expect(parsed.terrain).toBeUndefined() diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts index 6a7c427ab1..49658da981 100644 --- a/packages/core/src/schema/nodes/site.ts +++ b/packages/core/src/schema/nodes/site.ts @@ -3,6 +3,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { SitePresentation } from '../site-presentation' import { TerrainData } from '../terrain' import { SiteAddress, SiteParcel, SiteSetbacks } from './site-facts' @@ -87,6 +88,9 @@ export const SiteNode = BaseNode.extend({ * so ~11 KB of base64 zeroes does not land in every saved scene. */ terrain: TerrainData.optional(), + /** Hide site ground, including sculpted terrain, and the horizon when other geometry supplies them. */ + showGround: z.boolean().optional(), + presentation: SitePresentation.optional(), /** * Address + parcel provenance. All optional so every scene that predates * the parcel service keeps loading unchanged. @@ -145,6 +149,8 @@ export const SiteNode = BaseNode.extend({ Site node - used to represent a site - polygon: polygon data (metres, x east, z south, origin = geocoded point) - terrain: optional sculpted heightfield; absent means flat ground + - showGround: false hides site ground (including sculpted terrain) and horizon in 3D; buildings and parcel lines remain + - presentation: optional project rendering defaults (theme, lighting, exposure, shading, edges and postprocessing); local viewer choices override them - address / parcel: civic address + GIS parcel provenance (optional) - setbacks: required yards in metres; setbacksSource / zone cite them - frontEdge: index of the street-facing polygon edge diff --git a/packages/core/src/schema/site-presentation.ts b/packages/core/src/schema/site-presentation.ts new file mode 100644 index 0000000000..1e7503282f --- /dev/null +++ b/packages/core/src/schema/site-presentation.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' + +const color = z.string().regex(/^#[\da-f]{6}$/i) +const intensity = z.number().nonnegative() +const light = z.object({ color, intensity }) +const vector = z.tuple([z.number(), z.number(), z.number()]) +const shadowCamera = z + .object({ + left: z.number(), + right: z.number(), + top: z.number(), + bottom: z.number(), + near: z.number().positive(), + far: z.number().positive(), + }) + .refine( + (camera) => + camera.left < camera.right && camera.bottom < camera.top && camera.near < camera.far, + 'A shadow camera needs ordered horizontal, vertical and depth bounds.', + ) + +/** Saved rendering defaults. Geometry and canonical bake visibility are independent. */ +export const SitePresentation = z.object({ + theme: z.string().min(1).optional(), + exposure: intensity.optional(), + shading: z.enum(['solid', 'rendered']).optional(), + edges: z.enum(['off', 'soft', 'strong']).optional(), + shadows: z.boolean().optional(), + lighting: z + .object({ + background: color.optional(), + backgroundSky: color.nullable().optional(), + ground: color.optional(), + ambient: light.optional(), + hemi: z.object({ sky: color, ground: color, intensity }).optional(), + lights: z + .array( + light.extend({ + position: vector, + target: vector.optional(), + castShadow: z.boolean().optional(), + shadow: z + .object({ + intensity: z.number().min(0).max(1).optional(), + bias: z.number().optional(), + normalBias: intensity.optional(), + radius: intensity.optional(), + // One 4096 square map is the shared viewer's authored-shadow memory budget. + mapSize: z + .union([z.literal(512), z.literal(1024), z.literal(2048), z.literal(4096)]) + .optional(), + camera: shadowCamera.optional(), + }) + .optional(), + }), + ) + .optional(), + environment: z.object({ source: z.enum(['gradient', 'room', 'none']), intensity }).optional(), + }) + .optional(), + postprocessing: z + .object({ ambientOcclusion: z.boolean().optional(), colorGrading: z.boolean().optional() }) + .optional(), + display: z + .object({ + mode: z.enum(['finished', 'construction', 'systems']).optional(), + families: z + .partialRecord( + z.enum([ + 'finish', + 'exposed-structure', + 'framing', + 'masonry', + 'sheathing', + 'insulation', + 'membrane', + 'fill', + 'foundation', + 'service-space', + 'device', + 'run', + 'inspection', + ]), + z.boolean(), + ) + .optional(), + disciplines: z + .partialRecord( + z.enum(['mechanical', 'plumbing', 'electrical', 'low-voltage', 'fire']), + z.boolean(), + ) + .optional(), + xray: z.boolean().optional(), + colorBy: z.enum(['material', 'service']).optional(), + }) + .optional(), +}) + +export type SitePresentation = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 1d41061e5f..c57ae599f0 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -397,19 +397,4 @@ export type DisplayFamily = export type Discipline = 'mechanical' | 'plumbing' | 'electrical' | 'low-voltage' | 'fire' -/** - * The project's saved look on the `site` node (owner decision O3, frozen), never - * a scene-root key. `display` is the saved default for the personal display - * state; SI-R5 adds `theme`, `sun`, `exposure`, `edges` and `shading` beside - * it. Precedence: explicit local viewer toggles > `site.presentation` > - * theme defaults. The canonical bake ignores it. - */ -export type SitePresentation = { - display?: { - mode?: DisplayMode - families?: Partial> - disciplines?: Partial> - xray?: boolean - colorBy?: 'material' | 'service' - } -} +export type { SitePresentation } from './site-presentation' diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index f675bf17bc..a2d504bfb6 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -106,6 +106,7 @@ export { } from './roof-bearing' export { coveredStoreyId, + isRoofGeometry, isRoofLevel, isStoreyContent, liftRoofLevels, @@ -150,6 +151,7 @@ export { export { CEILING_CLAMP_MARGIN, CEILING_DRAW_OFFSET, + EXPLODED_GAP, findLevelAboveId, findLevelBelowId, getCeilingClampBound, diff --git a/packages/core/src/services/roof-level.ts b/packages/core/src/services/roof-level.ts index 73c3d09f87..96d7abf1ee 100644 --- a/packages/core/src/services/roof-level.ts +++ b/packages/core/src/services/roof-level.ts @@ -27,10 +27,22 @@ const STOREY_CONTENT: ReadonlySet = new Set([ * rooms, and every kind that stands on a floor (`capabilities.floorPlaced`: cabinets, shelves, * columns, …). */ -export const isStoreyContent = (type: string | undefined): boolean => +export const isStoreyContent = ( + type: string | undefined, + metadata: Record | undefined, +): boolean => type !== undefined && + !(type === 'item' && (metadata?.role === 'roof' || metadata?.role === 'ceiling')) && (STOREY_CONTENT.has(type) || nodeRegistry.get(type)?.capabilities?.floorPlaced !== undefined) +/** Retained roof geometry participates in level presentation without becoming parametric. */ +export const isRoofGeometry = (node: AnyNode | undefined): boolean => + node?.type === 'roof' || (node?.type === 'item' && node.metadata?.role === 'roof') + +/** Retained ceiling shells can lift with the roof while native room ceilings keep their hosts. */ +export const isCeilingGeometry = (node: AnyNode | undefined): boolean => + node?.type === 'ceiling' || (node?.type === 'item' && node.metadata?.role === 'ceiling') + /** A level declared for its building's roofs (`metadata.role: 'roof'`). */ export function isRoofLevel(node: AnyNode | undefined): boolean { return node?.type === 'level' && node.metadata?.role === 'roof' @@ -47,8 +59,8 @@ const holdsOnlyRoofs = (nodes: Nodes, level: LevelNode) => { if (isSupportLevel(level)) return false const children = childrenOf(nodes, level) return ( - children.some((child) => child.type === 'roof') && - !children.some((child) => isStoreyContent(child.type)) + children.some(isRoofGeometry) && + !children.some((child) => isStoreyContent(child.type, child.metadata)) ) } diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts index 298a53b85b..b211716d1a 100644 --- a/packages/core/src/services/storey.ts +++ b/packages/core/src/services/storey.ts @@ -18,6 +18,9 @@ import { DEFAULT_LEVEL_HEIGHT } from './level-height' */ export const CEILING_CLAMP_MARGIN = 0.01 +/** Metres added per level ordinal in Exploded view, also used to frame agent captures. */ +export const EXPLODED_GAP = 5 + /** * Stored storey height in meters (floor-to-floor). Falls back to * {@link DEFAULT_LEVEL_HEIGHT} for unmigrated legacy levels whose `height` diff --git a/packages/editor/src/components/editor/bake-thumbnail.tsx b/packages/editor/src/components/editor/bake-thumbnail.tsx index 5367321017..60fff901c7 100644 --- a/packages/editor/src/components/editor/bake-thumbnail.tsx +++ b/packages/editor/src/components/editor/bake-thumbnail.tsx @@ -333,7 +333,7 @@ export function BakeThumbnail({ } pipeline.applyEnvironment({ - theme: useViewer.getState().sceneTheme, + theme: useViewer.getState().sceneAppearance, transparent: false, grade: true, edges: useViewer.getState().edges, diff --git a/packages/editor/src/components/editor/snapshot-capture.test.ts b/packages/editor/src/components/editor/snapshot-capture.test.ts index 58bc2af618..9d349164d2 100644 --- a/packages/editor/src/components/editor/snapshot-capture.test.ts +++ b/packages/editor/src/components/editor/snapshot-capture.test.ts @@ -438,13 +438,25 @@ describe('an ephemeral capture', () => { await deliverSnapshot( { requestId: 'view-1', ephemeral: true }, blob, - { ...cameraData, resolution: { w: 1280, h: 800 } }, + { + ...cameraData, + resolution: { w: 1280, h: 800 }, + presentation: { mode: 'solo', levelId: 'level_upper' }, + }, (frame) => { stored.push(frame) }, ) expect(stored).toEqual([]) - expect(received).toEqual([{ requestId: 'view-1', blob, width: 1280, height: 800 }]) + expect(received).toEqual([ + { + requestId: 'view-1', + blob, + width: 1280, + height: 800, + presentation: { mode: 'solo', levelId: 'level_upper' }, + }, + ]) } finally { emitter.off('snapshot:captured', onCaptured) } diff --git a/packages/editor/src/components/editor/snapshot-capture.ts b/packages/editor/src/components/editor/snapshot-capture.ts index 3f56c758aa..16caebecab 100644 --- a/packages/editor/src/components/editor/snapshot-capture.ts +++ b/packages/editor/src/components/editor/snapshot-capture.ts @@ -149,7 +149,12 @@ export async function runSnapshotCapture( * Where a captured frame goes: to the host, which stores it (a snapshot, the project's * thumbnail), or for an ephemeral capture straight back to its caller, stored nowhere. */ -export function deliverSnapshot( +export function deliverSnapshot< + T extends object & { + resolution?: { w: number; h: number } + presentation?: ThumbnailGenerateEvent['presentation'] + }, +>( event: Pick, blob: Blob, cameraData: T, @@ -163,6 +168,7 @@ export function deliverSnapshot { + test('an explicit camera renders at the requested resolution and aspect without resizing the pane', () => { + const live: FakeCanvas = { width: 800, height: 1000, style: {} } + const renderer = fakeRenderer(live, 1) + const position: [number, number, number] = [2, 1, 3] + const target: [number, number, number] = [0, 0, 0] + for (const pose of [ + { perspective: { position, target, fov: 35 } }, + { ortho: { position, target, viewWidth: 4 } }, + ]) { + const size = captureDimensions( + [800, 1000], + { ...pose, standardSize: { w: 1920, h: 1280 } }, + 24_000_000, + ) + expect(size).toEqual([1920, 1280]) + const buffer = atCaptureSize(renderer, ...size, () => + renderer.getDrawingBufferSize(new Vector2()).toArray(), + ) + expect(buffer).toEqual([1920, 1280]) + expect([live.width, live.height]).toEqual([800, 1000]) + } + expect( + captureDimensions([800, 1000], { standardSize: { w: 1920, h: 1280 } }, 24_000_000), + ).toEqual([800, 1000]) + expect(captureDimensions([800, 1000], { supersample: 2 }, 24_000_000)).toEqual([1600, 2000]) + expect(captureDimensions([6000, 4000], { supersample: 2 }, 6_000_000)).toEqual([3000, 2000]) + }) + test('renders at its own size while the live canvas keeps its size', () => { // a 1.5 dpr screen: the old capture resized the live canvas with drawing // buffer pixels where CSS pixels were due — 3× during the capture, 1.5× diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index c37a750de8..d646c4efc4 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -16,6 +16,7 @@ import { heroCameraPose, holdLiveFrame, pendingSceneBuildCount, + presentLevelsForCapture, refreshIsolation, SNAPSHOT_MAX_EDGE, SNAPSHOT_MIME, @@ -55,6 +56,7 @@ export interface SnapshotCameraData { zoom?: number captureMode?: 'standard' | 'viewport' | 'area' resolution?: { w: number; h: number } + presentation?: ThumbnailGenerateEvent['presentation'] } interface ThumbnailGeneratorProps { @@ -76,6 +78,25 @@ const TILE_OVERLAP = 96 type CaptureLimits = { passEdge: number; maxPixels: number; software: boolean } +export function captureDimensions( + canvasSize: readonly [number, number], + event: Pick, + maxPixels: number, +): [number, number] { + const supersample = event.supersample ?? 1 + // A requested camera must not inherit the editor pane's aspect or be upscaled from its crop. + const size = + (event.ortho || event.perspective) && event.standardSize && supersample === 1 + ? [event.standardSize.w, event.standardSize.h] + : canvasSize + const scale = Math.min( + Math.max(1, supersample), + MAX_CAPTURE_EDGE / Math.max(size[0], size[1]), + Math.sqrt(maxPixels / (size[0] * size[1])), + ) + return [Math.round(size[0] * scale), Math.round(size[1] * scale)] +} + /** * What one capture may ask of this GPU: a pass no larger than its biggest * texture (and MAX_PASS_EDGE), and — on a software renderer, where a big @@ -700,21 +721,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // a supersampled frame renders at a multiple of the canvas' size into // the capture's own targets; the live canvas is never resized const canvasSize: [number, number] = [gl.domElement.width, gl.domElement.height] + const pose = ortho ?? perspective const limits = captureLimits(gl) - const scale = Math.max( - 1, - Math.min( - supersample, - MAX_CAPTURE_EDGE / Math.max(canvasSize[0], canvasSize[1]), - Math.sqrt(limits.maxPixels / (canvasSize[0] * canvasSize[1])), - ), - ) - const width = Math.round(canvasSize[0] * scale) - const height = Math.round(canvasSize[1] * scale) + const [width, height] = captureDimensions(canvasSize, event, limits.maxPixels) // a supersampled picture is encoded at the size it could render (the // caller asked for canvas × supersample, maybe past this GPU's limits) const encodeSize = supersample > 1 ? { w: width, h: height } : standardSize - const pose = ortho ?? perspective // a background tab draws no frames and (on the WebGL backend) never // finishes a readback: a sheet's capture says so at once if (pose && isTabHidden()) throw new Error(HIDDEN_TAB) @@ -813,7 +825,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro } pipeline?.applyEnvironment({ - theme: useViewer.getState().sceneTheme, + theme: useViewer.getState().sceneAppearance, transparent, grade: useViewer.getState().shading === 'rendered', edges, @@ -906,7 +918,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro thumbnailCamera.lookAt(hero.target[0], hero.target[1], hero.target[2]) thumbnailCamera.updateMatrixWorld() pipeline?.applyEnvironment({ - theme: useViewer.getState().sceneTheme, + theme: useViewer.getState().sceneAppearance, transparent, grade: useViewer.getState().shading === 'rendered', edges, @@ -934,6 +946,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // those meshes before either snapshot render path submits them, // and restore fog afterwards so the atmosphere never leaks in. restore(refreshIsolation(scene)) + if (event.presentation) { + restore(presentLevelsForCapture(event.presentation)) + cameraData.presentation = event.presentation + } if (interior) { interiorCutaway(interior.levelId, restore) restore(lightInterior(scene, thumbnailCamera)) @@ -973,7 +989,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro restore(() => thumbnailCamera.clearViewOffset()) thumbnailCamera.updateMatrixWorld() pipeline.applyEnvironment({ - theme: useViewer.getState().sceneTheme, + theme: useViewer.getState().sceneAppearance, transparent, grade: useViewer.getState().shading === 'rendered', edges, @@ -1038,7 +1054,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro } // a sheet's picture (or a supersampled one) is never drawn into // the live canvas the viewport shows - if (pose || scale > 1) throw new Error('Snapshot renderer is not ready. Try again.') + if (pose || width !== canvasSize[0] || height !== canvasSize[1]) + throw new Error('Snapshot renderer is not ready. Try again.') // Fallback: plain render directly to the canvas if (transparent) { const clearColor = ( @@ -1170,7 +1187,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro if (transparent || edgesOverride !== undefined || ortho !== undefined) { try { pipelineRef.current?.applyEnvironment({ - theme: useViewer.getState().sceneTheme, + theme: useViewer.getState().sceneAppearance, transparent: false, grade: useViewer.getState().shading === 'rendered', edges: useViewer.getState().edges, diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index e7128bc87f..5a21f1c966 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -183,7 +183,7 @@ export const ToolManager: React.FC = () => { // Placing a roof makes its level active (selectPlacedNode); a wall, room, slab, stair or item tool // armed there, then or after a level change, draws on the storey the roof level covers. useEffect(() => { - if (!(tool && activeLevelId && isStoreyContent(tool))) return + if (!(tool && activeLevelId && isStoreyContent(tool, undefined))) return const storeyId = storeyOf(useScene.getState().nodes, activeLevelId) if (storeyId !== activeLevelId) setSelection({ levelId: storeyId as LevelNode['id'] }) }, [tool, activeLevelId, setSelection]) diff --git a/packages/editor/src/lib/geometry-script/author.ts b/packages/editor/src/lib/geometry-script/author.ts index 1a786eeb91..8bdf0d078d 100644 --- a/packages/editor/src/lib/geometry-script/author.ts +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -54,6 +54,7 @@ export async function compileAndStoreGeometryScript(input: { nodeId?: string params?: Record kind?: 'item' | 'door' | 'window' | 'column' + inputs?: Record name?: string description?: string category?: string @@ -80,7 +81,11 @@ export async function compileAndStoreGeometryScript(input: { if (!blenderBuilder) throw new Error('This editor cannot run Blender (bpy) scripts') return { ...(await blenderBuilder({ code, params, nodeId, metadata })), nodeId } } - const { glb, ...compiled } = await compileGeometryScriptInWorker({ code, params }) + const { glb, ...compiled } = await compileGeometryScriptInWorker({ + code, + params, + inputs: input.inputs, + }) const context = { nodeId, metadata: { ...metadata, mount: compiled.mount } } const store = getArtifactStore() const [sha256] = await Promise.all([ diff --git a/packages/editor/src/lib/scene-clipboard.test.ts b/packages/editor/src/lib/scene-clipboard.test.ts index 9454fc4648..cfcc69160d 100644 --- a/packages/editor/src/lib/scene-clipboard.test.ts +++ b/packages/editor/src/lib/scene-clipboard.test.ts @@ -438,6 +438,37 @@ describe('scene clipboard', () => { expect(useViewer.getState().selection.levelId).toBe(storey.id) }) + test('a retained roof pasted onto a roof level keeps its roof ownership and can be undone', () => { + const storey = LevelNode.parse({ level: 0 }) + const roofLevel = LevelNode.parse({ level: 1, metadata: { role: 'roof' } }) + seedHouse(storey, roofLevel) + const retained = ItemNode.parse({ + parentId: sourceLevelId, + position: [1, 0.25, 2], + metadata: { role: 'roof' }, + asset: { + id: 'retained', + name: 'Authored roof', + category: 'custom', + thumbnail: '', + src: '/roof.glb', + }, + provenance: { refs: [{ ns: 'source', id: 'building/roof' }] }, + }) + useScene.getState().createNode(retained, sourceLevelId) + expect(copySelectedNodesToEditorClipboard([retained.id])).toBe(true) + const [pastedId] = pasteEditorClipboardToLevel(roofLevel.id)!.pastedIds + const pasted = useScene.getState().nodes[pastedId!] as typeof retained + expect(pasted.parentId).toBe(roofLevel.id) + expect(pasted.metadata.role).toBe('roof') + expect(pasted.asset).toEqual(retained.asset) + expect(pasted.provenance).toEqual(retained.provenance) + expect(useViewer.getState().selection.levelId).toBe(roofLevel.id) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[pastedId!]).toBeUndefined() + expect(useScene.getState().nodes[retained.id]).toBeDefined() + }) + // Measurements, cabinets and the like stayed on the roof level at its floor while the walls // copied with them went down: a paste lands on one level. test('a paste carrying walls lands whole on the storey a roof level covers', () => { diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index 520116124c..7ea7964420 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -609,7 +609,7 @@ function applyClipboardPayloadToLevel( // the plan is the storey's, and a roof in it that bears there is seated back up. const rootIdSet = new Set(payload.rootIds) const targetLevel = payload.nodes.some( - (node) => rootIdSet.has(node.id as AnyNodeId) && isStoreyContent(node.type), + (node) => rootIdSet.has(node.id as AnyNodeId) && isStoreyContent(node.type, node.metadata), ) ? ((scene.nodes[storeyOf(scene.nodes, aimedLevel.id) as AnyNodeId] as LevelNode | undefined) ?? aimedLevel) diff --git a/packages/geometry-script/package.json b/packages/geometry-script/package.json index 77daa80070..307eb7d7b9 100644 --- a/packages/geometry-script/package.json +++ b/packages/geometry-script/package.json @@ -6,6 +6,11 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { + "./author-scene": { + "types": "./dist/author-scene.d.ts", + "import": "./dist/author-scene.js", + "default": "./dist/author-scene.js" + }, ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", diff --git a/packages/geometry-script/src/author-scene.ts b/packages/geometry-script/src/author-scene.ts new file mode 100644 index 0000000000..842b749c5f --- /dev/null +++ b/packages/geometry-script/src/author-scene.ts @@ -0,0 +1,144 @@ +import { floorScriptInputs, wallFaceScriptInputs } from '@pascal-app/core' +import { + type AgentContext, + applySceneChanges, + authorScene, + authorSceneCeilingPoints, + authorSceneFaceScripts, + authorSceneFinishGaps, + authorSceneFloorScripts, + finalizeAuthorScene, + type SceneNodes, + setRoomFloorScript, + setWallFaceScript, +} from '@pascal-app/core/agent-operations' +import type { AuthorScenePayload } from '@pascal-app/core/agent-tools' +import type { CompiledGeometryScript } from '@pascal-app/core/schema' + +export type AuthorSceneCompile = (input: { + code: string + kind: 'object' | 'window' | 'door' | 'floor' | 'wall-face' + inputs?: Record + nodeId?: string +}) => Promise + +/** Build and validate a complete candidate before either host changes the live scene. */ +export async function compileAuthorScene( + nodes: SceneNodes, + input: { payload: AuthorScenePayload; replace?: boolean; buildingId?: string }, + context: AgentContext, + compile: AuthorSceneCompile, + signal?: AbortSignal, +) { + signal?.throwIfAborted() + authorScene(nodes, { ...input, compiled: {}, prepareOnly: true }, context) + const compiled: Record = {} + const compileErrors: Record = {} + const modules: { id: string; code: string; kind: 'object' | 'window' | 'door' }[] = [] + for (const level of input.payload.levels) { + for (const item of level.items ?? []) + modules.push({ id: item.id, code: item.code, kind: 'object' }) + for (const wall of level.walls ?? []) + wall.openings?.forEach((opening, index) => { + if (opening.code) + modules.push({ id: `${wall.id}/${index}`, code: opening.code, kind: opening.kind }) + }) + } + modules.push( + ...authorSceneCeilingPoints(input.payload).map((point) => ({ + ...point, + kind: 'object' as const, + })), + ) + let cursor = 0 + await Promise.all( + Array.from({ length: Math.min(4, modules.length) }, async () => { + while (cursor < modules.length) { + const { id, code, kind } = modules[cursor++]! + signal?.throwIfAborted() + try { + compiled[id] = await compile({ code, kind }) + } catch (error) { + signal?.throwIfAborted() + compileErrors[id] = error instanceof Error ? error.message : String(error) + } + } + }), + ) + const outcome = authorScene( + nodes, + { ...input, compiled, compileErrors, prepareOnly: true }, + context, + ) + let candidate = { ...nodes, ...outcome.candidate.nodes } + for (const { roomId, zoneId, code } of authorSceneFloorScripts(input.payload, outcome.result)) { + signal?.throwIfAborted() + try { + const inputs = floorScriptInputs(candidate, zoneId) + if (!inputs) throw new Error('The room has no floor to build on.') + const built = await compile({ code, kind: 'floor', nodeId: zoneId, inputs }) + candidate = applySceneChanges( + candidate, + setRoomFloorScript(candidate, { zoneId, compiled: built, inputs }, context).changes, + ) + outcome.result.floorScripts++ + const gaps = authorSceneFinishGaps(roomId, built.manifest, { + room: candidate[zoneId]?.name ?? roomId, + polygon: inputs.polygon, + }) + if (gaps) outcome.result.warnings.push(gaps) + } catch (error) { + signal?.throwIfAborted() + outcome.result.defects.push({ + id: roomId, + code: 'floor_script_failed', + message: `${error instanceof Error ? error.message : String(error)} (the room keeps its floor finish)`, + }) + } + } + for (const { wallId, face, nodeId, code, toHeight } of authorSceneFaceScripts( + input.payload, + outcome.result, + candidate, + )) { + signal?.throwIfAborted() + try { + const inputs = wallFaceScriptInputs(candidate, nodeId, face, toHeight) + if (!inputs) throw new Error('The face has nothing to build on.') + const built = await compile({ code, kind: 'wall-face', nodeId, inputs }) + candidate = applySceneChanges( + candidate, + setWallFaceScript( + candidate, + { wallId: nodeId, face, compiled: built, inputs, toHeight }, + context, + ).changes, + ) + outcome.result.faceScripts++ + const gaps = authorSceneFinishGaps(`${wallId}/${face}`, built.manifest, { + wall: wallId, + face, + }) + if (gaps) outcome.result.warnings.push(gaps) + } catch (error) { + signal?.throwIfAborted() + outcome.result.defects.push({ + id: `${wallId}/${face}`, + code: 'face_script_failed', + message: `${error instanceof Error ? error.message : String(error)} (the face keeps its paint)`, + }) + } + } + signal?.throwIfAborted() + outcome.candidate.nodes = Object.fromEntries( + Object.keys(outcome.candidate.nodes).map((id) => [id, candidate[id]!]), + ) + return { + ...finalizeAuthorScene(nodes, outcome), + itemTriangles: Object.values(compiled).reduce( + (count, item) => count + item.manifest.triangles, + 0, + ), + itemsCompiled: Object.keys(compiled).length, + } +} diff --git a/packages/mcp/src/operations/scene-operations.ts b/packages/mcp/src/operations/scene-operations.ts index 175f2fc197..1656d59741 100644 --- a/packages/mcp/src/operations/scene-operations.ts +++ b/packages/mcp/src/operations/scene-operations.ts @@ -38,6 +38,8 @@ export type CreateSceneOperationsOptions = { } export interface SceneOperations { + /** A host with an evidence-led design workflow owns its brief policy. */ + designBriefRequired?: boolean readonly hasBridge: boolean readonly hasStore: boolean readonly hasSceneEvents: boolean diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index 99861d5a56..f0dcce6269 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -43,6 +43,7 @@ export const AGENT_GUIDE = [ "- A call whose arguments a tool's schema rejects is refused with the code `invalid_input` before anything runs and its message names the arguments. A level you write without a `height` is given the height the editor would derive from what it holds (an empty level 2.5 m), and `apply_patch` says so in `notes`.", '- `apply_patch` takes create/update/delete ops in one call, applied in order so later ops can reference ids created earlier. A single call is atomic (all or nothing).', '- `create_room` returns the room’s `zoneId` and `wallIds`; `add_door` and `add_window` cut an opening in a wall; `furnish_room` places the catalog’s fixed set for a room type. Where walls already enclose a room, `create_room` names that room instead of adding a second one. A room with a name of its own (not a number the editor gave it) is refused (`room_named_by_person`): `rename: true` is for when the person asked for the rename, and the person is asked otherwise; it is never renamed silently. Drawing a room by hand behaves as before.', + '- When authored landscape geometry supplies the ground, `apply_patch` the site with `showGround: false` to hide its site ground (including sculpted terrain) and horizon in both editor and viewer without shifting the building or hiding parcel lines. The Site panel offers the same Show ground control; omitted or true retains existing presentation.', "- Where a reference's glass meets at a corner, with no wall between the panes, that is one corner window: `add_corner_window` builds both sides, the glass fused or at a post. Two separate windows near the corner leave it solid; `merge_windows` joins two that exist.", "- Finishes are given with `paint`, as the editor's Paint does: a library material found with `search_materials`, a colour, or a native material for an exact finish. `slots` and material presets are what `paint` writes, not what `apply_patch` is for.", '- Floors and ceilings are derived from rooms, not authored: walls, separators and a zone with `spaceRole: "room"` state the intent, and the structure reconciler builds the floor plate and the ceiling. Creating a slab or ceiling carrying the derived markers (`boundary` / `autoFromWalls`), or reshaping or relinking one, is refused; deleting one records that opt-out on the zone instead. A room without a floor or ceiling is expressed on its zone (`hasFloor: false`, `hasCeiling: false`), and its floor height with `floor.elevation`.', diff --git a/packages/mcp/src/tools/add-object.ts b/packages/mcp/src/tools/add-object.ts index a78bb9dca7..025eeb7a3e 100644 --- a/packages/mcp/src/tools/add-object.ts +++ b/packages/mcp/src/tools/add-object.ts @@ -43,6 +43,9 @@ import { briefInForce, toPatches } from './shared-tools' * artifacts live for the active scene. */ export type GeometryScriptHost = { + readonly signal?: AbortSignal + /** Recheck the host's active authorization after a long compile and before scene mutation. */ + assertActive?(): Promise compile(input: { code: string language: GeometryScriptLanguage diff --git a/packages/mcp/src/tools/apply-patch.test.ts b/packages/mcp/src/tools/apply-patch.test.ts index 746d3ef65b..3a238d79df 100644 --- a/packages/mcp/src/tools/apply-patch.test.ts +++ b/packages/mcp/src/tools/apply-patch.test.ts @@ -55,6 +55,52 @@ describe('apply_patch', () => { expect((stored as { thickness?: number }).thickness).toBe(0.2) }) + test('can replace site ground presentation without changing the site or its buildings', async () => { + const before = structuredClone(bridge.getNodes()) + const site = Object.values(before).find((node) => node.type === 'site')! + const result = await client.callTool({ + name: 'apply_patch', + arguments: { patches: [{ op: 'update', id: site.id, data: { showGround: false } }] }, + }) + expect(result.isError).toBeFalsy() + expect(bridge.getNodes()).toEqual({ + ...before, + [site.id]: { ...site, showGround: false }, + }) + const restored = await client.callTool({ + name: 'apply_patch', + arguments: { patches: [{ op: 'update', id: site.id, data: { showGround: null } }] }, + }) + expect(restored.isError).toBeFalsy() + expect(bridge.getNodes()).toEqual(before) + }) + + test('saves and clears project lighting through the existing patch contract', async () => { + const site = Object.values(bridge.getNodes()).find((node) => node.type === 'site')! + const before = structuredClone(bridge.getNodes()) + const presentation = { + exposure: 0.94, + edges: 'off', + shading: 'rendered', + lighting: { + background: '#dce4e5', + backgroundSky: null, + environment: { source: 'room', intensity: 0.28 }, + }, + postprocessing: { ambientOcclusion: false, colorGrading: false }, + } + for (const value of [presentation, null]) { + const result = await client.callTool({ + name: 'apply_patch', + arguments: { patches: [{ op: 'update', id: site.id, data: { presentation: value } }] }, + }) + expect(result.isError).toBeFalsy() + expect((bridge.getNode(site.id) as typeof site).presentation).toEqual(value ?? undefined) + for (const [id, node] of Object.entries(before)) + if (id !== site.id) expect(bridge.getNode(id)).toEqual(node) + } + }) + // A pier's `material: {color}` reported applied and stored {}, and // `materialPreset: null` was refused, so the agent could not clear the preset hiding it. test('an update the node would drop is refused, naming the path; null clears a field', async () => { diff --git a/packages/mcp/src/tools/author-scene.test.ts b/packages/mcp/src/tools/author-scene.test.ts index 2faee096f6..49651a0873 100644 --- a/packages/mcp/src/tools/author-scene.test.ts +++ b/packages/mcp/src/tools/author-scene.test.ts @@ -239,11 +239,15 @@ const PANEL = `export default function build({ THREE, params }) { describe('author_scene (MCP)', () => { let client: Client + let commitAllowed = true const stored: string[] = [] const artifacts = new Map() /** What each build was for, by the hash of its GLB. */ const builtFor = new Map | undefined>() const host: GeometryScriptHost = { + assertActive: async () => { + if (!commitAllowed) throw new Error('Invocation stopped before commit') + }, compile: async ({ code, inputs }) => { const { glb, ...compiled } = await compileGeometryScript({ code, inputs }) builtFor.set(compiled.sha256, inputs) @@ -258,6 +262,7 @@ describe('author_scene (MCP)', () => { } beforeEach(async () => { + commitAllowed = true stored.length = 0 const bridge = new SceneBridge() bridge.setScene({}, [], { materials: {} }) @@ -270,6 +275,17 @@ describe('author_scene (MCP)', () => { await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) }) + test('a stopped invocation never commits its compiled candidate', async () => { + const before = await client.callTool({ name: 'get_scene', arguments: {} }) + commitAllowed = false + const result = await client.callTool({ name: 'author_scene', arguments: { payload } }) + expect(result.isError).toBe(true) + const after = await client.callTool({ name: 'get_scene', arguments: {} }) + expect(after.structuredContent ?? after.content).toEqual( + before.structuredContent ?? before.content, + ) + }) + test('writes the design, keeps its provenance, and reports the gate', async () => { const result = await client.callTool({ name: 'author_scene', diff --git a/packages/mcp/src/tools/author-scene.ts b/packages/mcp/src/tools/author-scene.ts index 8b6adf8056..2534a730c1 100644 --- a/packages/mcp/src/tools/author-scene.ts +++ b/packages/mcp/src/tools/author-scene.ts @@ -1,17 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { floorScriptInputs, wallFaceScriptInputs } from '@pascal-app/core' import { type AgentContext, - type AuthorSceneReport, achievedChanges, - authorScene, - authorSceneCeilingPoints, - authorSceneFaceScripts, - authorSceneFinishGaps, - authorSceneFloorScripts, type SceneNodes, - setRoomFloorScript, - setWallFaceScript, verifyScene, } from '@pascal-app/core/agent-operations' import { @@ -19,13 +10,10 @@ import { authorSceneTool, isAgentRefusal, } from '@pascal-app/core/agent-tools' -import { - type AnyNode, - type CompiledGeometryScript, - GEOMETRY_SCRIPT_MIME_TYPES, -} from '@pascal-app/core/schema' +import { GEOMETRY_SCRIPT_MIME_TYPES } from '@pascal-app/core/schema' +import { compileAuthorScene } from '@pascal-app/geometry-script/author-scene' import type { SceneOperations } from '../operations' -import { compileAndStore, type GeometryScriptHost, type ScriptedKind } from './add-object' +import { compileAndStore, type GeometryScriptHost } from './add-object' import { DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' import { refusalResult, toolError } from './errors' import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' @@ -36,6 +24,7 @@ type Input = { module?: string adapter?: string replace?: boolean + buildingId?: string sceneId?: string } @@ -70,67 +59,35 @@ export function registerAuthorScene( code: 'no_active_scene', }) } - // Every module, compiled and stored one by one: the items', and the openings' by - // `/`. A failure is a defect, not a stop. - const compiled: Record = {} - const compileErrors: Record = {} - const modules: { id: string; code: string; kind: ScriptedKind }[] = [] - for (const level of args.payload.levels) { - for (const item of level.items ?? []) - modules.push({ id: item.id, code: item.code, kind: 'object' }) - for (const wall of level.walls ?? []) - wall.openings?.forEach((opening, index) => { - if (opening.code) - modules.push({ id: `${wall.id}/${index}`, code: opening.code, kind: opening.kind }) - }) - } - modules.push( - ...authorSceneCeilingPoints(args.payload).map((point) => ({ - ...point, - kind: 'object' as const, - })), - ) - for (const { id, code, kind } of modules) { - try { - compiled[id] = await compileAndStore( - host, - scene.id, - { code, language: 'three', params: undefined }, - kind, - ) - } catch (error) { - compileErrors[id] = error instanceof Error ? error.message : String(error) - } - } - // Provenance: the design's module and the program that read it, kept as scripts of the scene. - const provenance: Record = {} - for (const [kind, text] of [ - ['module', args.module], - ['adapter', args.adapter], - ] as const) { - if (!text) continue - provenance[kind] = await host.storeArtifact({ - sceneId: scene.id, - sha256: await sha256(text), - bytes: new TextEncoder().encode(text), - mimeType: GEOMETRY_SCRIPT_MIME_TYPES.three, - metadata: { kind: 'item' }, + if (args.sceneId && args.sceneId !== scene.id) { + return toolError('Open the requested scene before converting it.', { + code: 'scene_mismatch', + mutationApplied: false, }) } - - const before = { ...(bridge.getNodes() as Record) } + const before = bridge.getNodes() as SceneNodes + const beforeJson = JSON.stringify(before) const context: AgentContext = { activeLevelId: bridge.activeLevelId?.() ?? null, ...(bridge.supportsMaterialUpserts && bridge.getMaterials ? { materials: bridge.getMaterials() } : {}), } - let outcome: ReturnType + let outcome: Awaited> try { - outcome = authorScene( + outcome = await compileAuthorScene( before, - { payload: args.payload, compiled, compileErrors, replace: args.replace }, + args, context, + ({ code, kind, inputs, nodeId }) => + compileAndStore( + host, + scene.id, + { code, language: 'three', params: undefined, inputs }, + kind, + { nodeId }, + ), + host.signal, ) } catch (error) { if (isAgentRefusal(error)) return refusalResult(error) @@ -138,99 +95,44 @@ export function registerAuthorScene( code: 'conversion_failed', }) } - const patches = outcome.changes ? toPatches(outcome.changes) : [] - if (patches.length) bridge.applyPatch(patches) - // A room's floor module needs its floor, which exists only now: compiled for it and written - // on the zone; a failure leaves the room its `floor` finish. - for (const { roomId, zoneId, code } of authorSceneFloorScripts( - args.payload, - outcome.result, - )) { - try { - const inputs = floorScriptInputs(bridge.getNodes() as SceneNodes, zoneId) - if (!inputs) throw new Error('The room has no floor to build on.') - const built = await compileAndStore( - host, - scene.id, - { code, language: 'three', params: undefined, inputs }, - 'floor', - { nodeId: zoneId, metadata: { name: 'Floor finish' } }, - ) - const written = setRoomFloorScript( - bridge.getNodes() as SceneNodes, - { zoneId, compiled: built, inputs }, - context, - ) - if (written.changes) bridge.applyPatch(toPatches(written.changes)) - outcome.result.floorScripts++ - const room = (bridge.getNodes() as SceneNodes)[zoneId]?.name ?? roomId - const gaps = authorSceneFinishGaps(roomId, built.manifest, { - room, - polygon: inputs.polygon, - }) - if (gaps) outcome.result.warnings.push(gaps) - } catch (error) { - outcome.result.defects.push({ - id: roomId, - code: 'floor_script_failed', - message: `${error instanceof Error ? error.message : String(error)} (the room keeps its floor finish)`, - }) - } + // Provenance: the design's module and the program that read it, kept as scripts of the scene. + const provenance: Record = {} + for (const [kind, text] of [ + ['module', args.module], + ['adapter', args.adapter], + ] as const) { + if (!text) continue + provenance[kind] = await host.storeArtifact({ + sceneId: scene.id, + sha256: await sha256(text), + bytes: new TextEncoder().encode(text), + mimeType: GEOMETRY_SCRIPT_MIME_TYPES.three, + metadata: { kind: 'item' }, + }) } - // A face module needs its face, its openings cut and its rooms derived: compiled for it and - // written on the wall; a failure leaves the face its paint. - for (const { wallId, face, nodeId, code, toHeight } of authorSceneFaceScripts( - args.payload, - outcome.result, - bridge.getNodes() as SceneNodes, - )) { - try { - const inputs = wallFaceScriptInputs( - bridge.getNodes() as SceneNodes, - nodeId, - face, - toHeight, - ) - if (!inputs) throw new Error('The face has nothing to build on.') - const built = await compileAndStore( - host, - scene.id, - { code, language: 'three', params: undefined, inputs }, - 'wall-face', - { nodeId, metadata: { name: 'Wall finish' } }, - ) - const written = setWallFaceScript( - bridge.getNodes() as SceneNodes, - { wallId: nodeId, face, compiled: built, inputs, toHeight }, - context, - ) - if (written.changes) bridge.applyPatch(toPatches(written.changes)) - outcome.result.faceScripts++ - const id = `${wallId}/${face}` - const gaps = authorSceneFinishGaps(id, built.manifest, { wall: wallId, face }) - if (gaps) outcome.result.warnings.push(gaps) - } catch (error) { - outcome.result.defects.push({ - id: `${wallId}/${face}`, - code: 'face_script_failed', - message: `${error instanceof Error ? error.message : String(error)} (the face keeps its paint)`, - }) - } + + host.signal?.throwIfAborted() + await host.assertActive?.() + if (JSON.stringify(bridge.getNodes()) !== beforeJson) { + return toolError( + 'The scene changed while its candidate was compiling. Read the current scene and retry the conversion.', + { code: 'scene_changed', mutationApplied: false }, + ) } - const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building') - if (building) { - bridge.updateNode(building.id, { - metadata: { - ...building.metadata, - authorScene: { - ...provenance, - payload: withoutCode(args.payload), - report: outcome.result, - at: new Date().toISOString(), - }, - }, - } as Partial) + const buildingUpdate = outcome.changes.update.find( + (change) => change.id === outcome.buildingId, + )! + buildingUpdate.data.metadata = { + ...buildingUpdate.data.metadata, + authorScene: { + ...provenance, + payload: withoutCode(args.payload), + report: outcome.result, + at: new Date().toISOString(), + }, } + const patches = toPatches(outcome.changes) + if (patches.length) bridge.applyPatch(patches) const checks = verifyScene(bridge.getNodes() as SceneNodes, undefined, { activeLevelId: null, }).result as { valid: boolean; issues: { type?: string; message?: string }[] } @@ -238,7 +140,10 @@ export function registerAuthorScene( for (const issue of checks.issues) byType.set(issue.type ?? 'unknown', (byType.get(issue.type ?? 'unknown') ?? 0) + 1) const payload = { - ...gateReport(outcome.result, compiled), + ...outcome.result, + itemTriangles: outcome.itemTriangles, + itemsCompiled: outcome.itemsCompiled, + green: outcome.result.defects.length === 0, verify: { valid: checks.valid, issues: checks.issues.length, @@ -274,17 +179,6 @@ function withoutCode(payload: AuthorScenePayload) { } } -/** What the agent iterates against: the operation's report plus what the items hold. */ -function gateReport(report: AuthorSceneReport, compiled: Record) { - const items = Object.values(compiled) - return { - ...report, - itemTriangles: items.reduce((n, c) => n + c.manifest.triangles, 0), - itemsCompiled: items.length, - green: report.defects.length === 0, - } -} - async function sha256(text: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)) return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index c2ab822a52..468412b3d1 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -555,6 +555,7 @@ export function agentRuntime(bridge: SceneOperations): AgentHostRuntime { } export function briefInForce(bridge: SceneOperations): DesignBrief | null | undefined { + if (bridge.designBriefRequired === false) return undefined const session = bridge.agentSession return session ? (session.read().designBrief ?? null) : undefined } diff --git a/packages/mcp/src/tools/view-scene.test.ts b/packages/mcp/src/tools/view-scene.test.ts index 7afe840d70..37281055b4 100644 --- a/packages/mcp/src/tools/view-scene.test.ts +++ b/packages/mcp/src/tools/view-scene.test.ts @@ -123,6 +123,7 @@ describe('view_scene over the MCP', () => { height: request.size.h, tab: 'tab_1', capturedAt: '2026-10-12T09:00:00.000Z', + presentation: request.presentation, } }, }) @@ -136,11 +137,52 @@ describe('view_scene over the MCP', () => { expect(wrong.isError).toBe(true) expect(json(wrong.content)).toMatchObject({ code: 'interior_level_invalid' }) expect(asked).toHaveLength(2) + for (const presentation of [ + { mode: 'solo', levelId: view.levelId }, + { mode: 'exploded' }, + { mode: 'stacked' }, + ]) { + const result = await view.call({ presentation }) + expect(result.isError).toBe(false) + expect(json(result.content).presentation).toEqual(presentation) + expect(asked.at(-1)?.presentation).toEqual(presentation) + expect(asked.at(-1)).not.toHaveProperty('interior') + } } finally { await view.close() } }) + test('an unconfirmed or different level view is refused, never labelled as verified', async () => { + for (const presentation of [ + undefined, + { mode: 'stacked' as const }, + { mode: 'solo' as const, levelId: 'level_wrong' }, + ]) { + const view = await viewWith({ + capture: async () => ({ + image: new Uint8Array([1]), + mimeType: 'image/webp', + width: 640, + height: 400, + tab: 'tab_old', + capturedAt: '2026-10-18T18:00:00Z', + presentation, + }), + }) + try { + const result = await view.call({ presentation: { mode: 'solo', levelId: view.levelId } }) + expect(result.isError).toBe(true) + expect(json(result.content)).toMatchObject({ code: 'view_presentation_unconfirmed' }) + expect(result.content.some((part) => part.type === 'image')).toBe(false) + expect((await view.call({})).isError).toBe(false) + } finally { + await view.close() + } + } + }) + + // A building standing away from the site's origin was looked for at the origin. // The study of 15 Oct: no interior view in 4 of 4 turns. A room is stood in, at a person's // height, inside its outline, and the tab's picture comes back as an image. test('a room is looked at from inside, at eye height, and the picture comes back', async () => { diff --git a/packages/mcp/src/tools/view-scene.ts b/packages/mcp/src/tools/view-scene.ts index edbcfce863..7ab50b8765 100644 --- a/packages/mcp/src/tools/view-scene.ts +++ b/packages/mcp/src/tools/view-scene.ts @@ -1,9 +1,11 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { + confirmSceneViewPresentation, type SceneViewCrop, type SceneViewInterior, type SceneViewLighting, type SceneViewPose, + type SceneViewPresentation, sceneViewNote, sceneViewPlan, } from '@pascal-app/core/agent-operations' @@ -22,6 +24,7 @@ export type SceneViewCapture = { /** Which tab rendered it, and when: the scene as that tab showed it then. */ tab: string capturedAt: string + presentation?: SceneViewPresentation } /** @@ -35,6 +38,7 @@ export type SceneViewHost = { size: { w: number; h: number } /** A look inside this floor: the editor leaves the rest out and cuts everything on it. */ interior?: SceneViewInterior + presentation?: SceneViewPresentation /** The light to render it in; the scene's own when absent. */ lighting?: SceneViewLighting }): Promise @@ -68,7 +72,7 @@ export function registerViewScene( if (!projectId) refuse('no_project', 'This session has no project open to look at: load or create one.') // No view of the person's here: core refuses camera 'current' without one. - const { pose, size, crop, interior, lighting } = sceneViewPlan( + const { pose, size, crop, interior, presentation, lighting } = sceneViewPlan( operations.getNodes() as Record, input, ) @@ -83,16 +87,19 @@ export function registerViewScene( pose, size, ...(interior ? { interior } : {}), + ...(presentation ? { presentation } : {}), ...(lighting ? { lighting } : {}), }), crop ? views!.crop!(crop) : undefined, ]) + confirmSceneViewPresentation(presentation, shot.presentation) const payload = { status: 'viewed', camera: pose, size: { width: shot.width, height: shot.height }, tab: shot.tab, capturedAt: shot.capturedAt, + ...(shot.presentation ? { presentation: shot.presentation } : {}), note: sceneViewNote(operations.getNodes() as Record, interior, pose), ...(crop && cropped ? { diff --git a/packages/nodes/src/ceiling/materials.ts b/packages/nodes/src/ceiling/materials.ts index 44c48d9307..3a2195e295 100644 --- a/packages/nodes/src/ceiling/materials.ts +++ b/packages/nodes/src/ceiling/materials.ts @@ -1,22 +1,24 @@ import { getCatalogMaterialById, getMaterialPresetByRef, + type MaterialSchema, parseMaterialColor, parseMaterialRef, resolveMaterial, type SceneMaterial, type SceneMaterialId, } from '@pascal-app/core' +import { createMaterial, createMaterialFromPreset, type RenderShading } from '@pascal-app/viewer' +import type { Material } from 'three' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, MeshBasicNodeMaterial } from 'three/webgpu' /** * Ceiling material builders, shared by the renderer (mesh appearance) and the - * paint capability (hover preview). A ceiling is a flat tinted surface: the - * underside (`bottom`, seen from inside the room, `BackSide`) is opaque, while + * paint capability (hover preview). The underside faces the room, while * the `top` carries a transparent TSL grid overlay used while placing / - * selecting ceiling-hosted items. Both derive from a single colour, so slot - * painting resolves a colour and rebuilds these — it never applies a PBR map. + * selecting ceiling-hosted items. Only the overlay reduces a finish to colour; + * the underside keeps its lighting, textures and physical properties. */ const gridScale = 5 @@ -30,10 +32,10 @@ const gridOpacity = mix(float(0.2), float(0.6), gridPattern) export type CeilingMaterials = { topMaterial: MeshBasicNodeMaterial - bottomMaterial: MeshBasicNodeMaterial + bottomMaterial: Material } -function createCeilingMaterials(color = '#999999'): CeilingMaterials { +function createCeilingGrid(color: string): MeshBasicNodeMaterial { const topMaterial = new MeshBasicNodeMaterial({ color, transparent: true, @@ -42,22 +44,73 @@ function createCeilingMaterials(color = '#999999'): CeilingMaterials { }) topMaterial.opacityNode = gridOpacity - const bottomMaterial = new MeshBasicNodeMaterial({ - color, - side: BackSide, - }) - - return { topMaterial, bottomMaterial } + return topMaterial } -const ceilingMaterialCache = new Map() +const ceilingGridCache = new Map() + +export function getCeilingMaterials( + color = '#999999', + shading: RenderShading = 'rendered', +): CeilingMaterials { + let topMaterial = ceilingGridCache.get(color) + if (!topMaterial) { + topMaterial = createCeilingGrid(color) + ceilingGridCache.set(color, topMaterial) + } + return { + topMaterial, + bottomMaterial: createMaterial( + { + preset: 'custom', + properties: { + color, + roughness: 0.9, + metalness: 0, + opacity: 1, + transparent: false, + side: 'back', + }, + }, + shading, + ), + } +} -export function getCeilingMaterials(color = '#999999'): CeilingMaterials { - const cached = ceilingMaterialCache.get(color) - if (cached) return cached - const materials = createCeilingMaterials(color) - ceilingMaterialCache.set(color, materials) - return materials +/** Resolve through the shared caches, without mutating another surface's material. */ +export function resolveCeilingFinish( + finish: MaterialSchema | string | undefined, + sceneMaterials: Record | undefined, + shading: RenderShading, +): Material | null { + if (!finish) return null + if (typeof finish !== 'string') { + return createMaterial( + { ...finish, properties: { ...resolveMaterial(finish), side: 'back' } }, + shading, + ) + } + const color = parseMaterialColor(finish) + if (color) + return resolveCeilingFinish( + { properties: { ...resolveMaterial(), color } }, + sceneMaterials, + shading, + ) + const parsed = parseMaterialRef(finish) + if (parsed?.kind === 'scene') + return resolveCeilingFinish( + sceneMaterials?.[parsed.id as SceneMaterialId]?.material, + sceneMaterials, + shading, + ) + const preset = getMaterialPresetByRef(finish) + return preset + ? createMaterialFromPreset( + { ...preset, mapProperties: { ...preset.mapProperties, side: BackSide } }, + shading, + ) + : null } /** diff --git a/packages/nodes/src/ceiling/paint.test.ts b/packages/nodes/src/ceiling/paint.test.ts index 7e2e84e93f..bdeb5f6402 100644 --- a/packages/nodes/src/ceiling/paint.test.ts +++ b/packages/nodes/src/ceiling/paint.test.ts @@ -7,8 +7,9 @@ import { useScene, ZoneNode, } from '@pascal-app/core' -import { CEILING_REGION_MESH } from '@pascal-app/viewer' -import { Mesh, MeshBasicMaterial, Object3D } from 'three' +import { CEILING_REGION_MESH, createMaterial, useViewer } from '@pascal-app/viewer' +import { BackSide, FrontSide, Mesh, MeshBasicMaterial, Object3D } from 'three' +import type { MeshStandardNodeMaterial } from 'three/webgpu' import { ceilingPaint } from './paint' // Ceiling paint: a click lands on the region under it or the ceiling's own @@ -157,3 +158,64 @@ test('the hover preview swaps exactly the region mesh; erase shows the ceiling u recolor?.() expect(region.material).toBe(regionMaterial) }) + +test('ceiling paint retains physical finish properties without changing another surface', () => { + const viewer = useViewer.getState() + useViewer.setState({ shading: 'rendered' }) + try { + const finish = { + preset: 'custom' as const, + properties: { + color: '#c49756', + roughness: 0.23, + metalness: 0.65, + opacity: 1, + transparent: false, + side: 'front' as const, + }, + } + const wallMaterial = createMaterial(finish) + const original = new MeshBasicMaterial() + const root = new Mesh(undefined, original) + const restore = ceilingPaint.applyPreview({ + node: manual, + role: 'surface', + material: finish, + materialPreset: undefined, + root, + }) + const shown = root.material as unknown as MeshStandardNodeMaterial + expect(shown.isMeshStandardNodeMaterial).toBe(true) + expect(shown.roughness).toBe(0.23) + expect(shown.metalness).toBe(0.65) + expect(shown.side).toBe(BackSide) + expect(wallMaterial.side).toBe(FrontSide) + restore?.() + expect(root.material).toBe(original) + + ceilingPaint.commit!({ + node: manual, + role: 'surface', + material: finish, + materialPreset: undefined, + }) + const current = useScene.getState().nodes[manual.id]! + const effective = ceilingPaint.getEffectiveMaterial!({ + materials: useScene.getState().materials, + node: current, + role: 'surface', + nodes: useScene.getState().nodes, + })! + const restoreSaved = ceilingPaint.applyPreview({ + node: current, + role: 'surface', + ...effective, + root, + }) + expect((root.material as unknown as MeshStandardNodeMaterial).roughness).toBe(0.23) + expect((root.material as unknown as MeshStandardNodeMaterial).metalness).toBe(0.65) + restoreSaved?.() + } finally { + useViewer.setState(viewer) + } +}) diff --git a/packages/nodes/src/ceiling/paint.ts b/packages/nodes/src/ceiling/paint.ts index c05cfdf5db..a3c930fe73 100644 --- a/packages/nodes/src/ceiling/paint.ts +++ b/packages/nodes/src/ceiling/paint.ts @@ -12,15 +12,14 @@ import { type PaintPreviewArgs, parseCeilingRegionRole, parseMaterialRef, - resolveMaterial, type SceneMaterialId, useScene, } from '@pascal-app/core' -import { CEILING_REGION_MESH } from '@pascal-app/viewer' +import { CEILING_REGION_MESH, useViewer } from '@pascal-app/viewer' import type { Material, Mesh, Object3D } from 'three' import { createSlotPaintCapability, resolveSlotPaintMaterialRef } from '../shared/slot-paint' import { swapPreviewMaterial } from '../shared/swap-preview-material' -import { ceilingColorFromRef, getCeilingMaterials } from './materials' +import { getCeilingMaterials, resolveCeilingFinish } from './materials' /** * Ceiling paint on the unified slot model. A ceiling's own finish is its @@ -28,18 +27,10 @@ import { ceilingColorFromRef, getCeilingMaterials } from './materials' * the room's (`zone.ceiling.regions`) for an automatic ceiling, the ceiling's * own (`ceiling.regions`) for a manual one — drawn as its own mesh tagged * `region:`. A click lands on what shows there. Previews swap the ceiling's - * flat-tinted material (built `BackSide`, the way it renders) on exactly the + * room-facing material on exactly the * mesh the click would change, so the hover matches the committed result. */ -function previewColor(material: MaterialSchema | undefined, materialPreset: string | undefined) { - return materialPreset - ? ceilingColorFromRef(materialPreset, useScene.getState().materials) - : material - ? (resolveMaterial(material).color ?? null) - : null -} - function regionMesh(root: Object3D, role: string): Mesh | null { let found: Mesh | null = null root.traverse((object) => { @@ -52,22 +43,25 @@ function regionMesh(root: Object3D, role: string): Mesh | null { function applyCeilingPreview({ role, material, materialPreset, root }: PaintPreviewArgs) { const mesh = root as Mesh if (!mesh.isMesh) return null - const color = previewColor(material, materialPreset) + const shading = useViewer.getState().shading + const finish = resolveCeilingFinish( + materialPreset ?? material, + useScene.getState().materials, + shading, + ) const erasing = material === undefined && materialPreset === undefined if (role === CEILING_SURFACE_ROLE) { // Erasing shows the default the ceiling returns to. - const shown = color ?? (erasing ? CEILING_SLOT_DEFAULT_COLOR : null) + const shown = + finish ?? + (erasing ? getCeilingMaterials(CEILING_SLOT_DEFAULT_COLOR, shading).bottomMaterial : null) if (!shown) return () => {} - return swapPreviewMaterial(mesh, getCeilingMaterials(shown).bottomMaterial) + return swapPreviewMaterial(mesh, shown) } const region = regionMesh(mesh, role) if (!region) return null // An erased region shows the ceiling's own finish under it. - const preview: Material | null = color - ? getCeilingMaterials(color).bottomMaterial - : erasing - ? (mesh.material as Material) - : null + const preview: Material | null = finish ? finish : erasing ? (mesh.material as Material) : null return preview ? swapPreviewMaterial(region, preview) : () => {} } diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 5beee0c6ac..44db9bed66 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -6,8 +6,6 @@ import { CEILING_SLOT_DEFAULT_COLOR, type CeilingNode, ceilingPaintRegions, - getMaterialPresetByRef, - type MaterialSchema, resolveCeilingHeight, resolveMaterial, useLiveTransforms, @@ -20,13 +18,14 @@ import { createSurfaceRoleMaterial, NodeRenderer, resolveSurfaceColor, + useLibraryMaterialsVersion, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { BackSide, type Mesh } from 'three/webgpu' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' -import { ceilingColorFromRef, getCeilingMaterials } from './materials' +import { ceilingColorFromRef, getCeilingMaterials, resolveCeilingFinish } from './materials' function createEmptyGeometry() { return createPlaceholderGeometry() @@ -49,6 +48,8 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { useScene.getState().markDirty(node.id) }, [node.id]) const textures = useViewer((s) => s.textures) + const shading = useViewer((s) => s.shading) + const libraryMaterialsVersion = useLibraryMaterialsVersion() const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) // Subscribe to the scene-material library so editing a `scene:` material the @@ -74,6 +75,7 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { [gridPlaceholderGeometry, placeholderGeometry], ) + // biome-ignore lint/correctness/useExhaustiveDependencies: dynamic library registration changes finish resolution const materials = useMemo(() => { // Textures-off mode takes the themed 'ceiling' role colour — the guaranteed // escape hatch, independent of any slot override. The bottom (seen from @@ -91,22 +93,25 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { } } - // Unified slot override — shared scene material or catalog `library:` finish - // (resolved to its base colour; a ceiling renders flat-tinted, not mapped). - const slotColor = ceilingColorFromRef(node.slots?.surface, sceneMaterials) - if (slotColor) return getCeilingMaterials(slotColor) - - // Legacy inline material / preset (scenes painted before the slot model). - if (node.materialPreset || node.material) { - const preset = getMaterialPresetByRef(node.materialPreset) - const props = preset?.mapProperties ?? resolveMaterial(node.material) - return getCeilingMaterials(props.color || '#999999') + // The grid remains transparent from above; only the room-facing material + // uses the authored finish and the viewer's shading mode. + const gridColor = + ceilingColorFromRef(node.slots?.surface, sceneMaterials) ?? + ceilingColorFromRef(node.materialPreset, sceneMaterials) ?? + (node.material ? resolveMaterial(node.material).color : CEILING_SLOT_DEFAULT_COLOR) + const fallback = getCeilingMaterials(gridColor, shading) + return { + topMaterial: fallback.topMaterial, + bottomMaterial: + resolveCeilingFinish(node.slots?.surface, sceneMaterials, shading) ?? + resolveCeilingFinish(node.materialPreset, sceneMaterials, shading) ?? + resolveCeilingFinish(node.material, sceneMaterials, shading) ?? + fallback.bottomMaterial, } - - // Declared slot default. - return getCeilingMaterials(CEILING_SLOT_DEFAULT_COLOR) }, [ textures, + shading, + libraryMaterialsVersion, colorPreset, sceneTheme, sceneMaterials, @@ -118,20 +123,13 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { node.material?.texture, ]) - // Painted parts draw with the same flat tint as the ceiling; the system builds - // their meshes and asks this for the material. + // Regions use the same finish resolver as the whole surface and paint preview. const regionMaterial = useCallback( (finish) => { if (!textures) return materials.bottomMaterial - const color = - typeof finish === 'string' - ? ceilingColorFromRef(finish, sceneMaterials) - : finish - ? resolveMaterial(finish as MaterialSchema).color - : null - return getCeilingMaterials(color || CEILING_SLOT_DEFAULT_COLOR).bottomMaterial + return resolveCeilingFinish(finish, sceneMaterials, shading) ?? materials.bottomMaterial }, - [textures, sceneMaterials, materials], + [textures, sceneMaterials, shading, materials], ) useLayoutEffect(() => { const mesh = ref.current @@ -157,6 +155,8 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { return ( { }) describe('ItemGLTFLoader', () => { + test('preserves imported unlit lighting without changing native basic materials', async () => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const gltf = await new ItemGLTFLoader().parseAsync( + JSON.stringify({ + asset: { version: '2.0' }, + extensionsUsed: ['KHR_materials_unlit'], + buffers: [ + { + byteLength: positions.byteLength, + uri: `data:application/octet-stream;base64,${Buffer.from(positions.buffer).toString('base64')}`, + }, + ], + bufferViews: [{ buffer: 0, byteLength: positions.byteLength }], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: 'VEC3', + min: [0, 0, 0], + max: [1, 1, 0], + }, + ], + materials: [{ extensions: { KHR_materials_unlit: {} } }, {}], + meshes: [0, 1].map((material) => ({ + primitives: [{ attributes: { POSITION: 0 }, material }], + })), + nodes: [{ mesh: 0 }, { mesh: 1 }], + scene: 0, + scenes: [{ nodes: [0, 1] }], + }), + '', + ) + const imported = (gltf.scene.children[0] as Mesh).material as MeshBasicMaterial + const lit = (gltf.scene.children[1] as Mesh).material as MeshBasicMaterial + expect(imported.userData.pascalImportedUnlit).toBe(true) + expect(imported.clone().userData.pascalImportedUnlit).toBe(true) + expect(lit.userData.pascalImportedUnlit).toBeUndefined() + expect(new MeshBasicMaterial().userData.pascalImportedUnlit).toBeUndefined() + }) + test('resolves missing responses as an unavailable item instead of rejecting', async () => { const consoleError = spyOn(console, 'error').mockImplementation(() => {}) try { diff --git a/packages/nodes/src/item/model-loader.ts b/packages/nodes/src/item/model-loader.ts index 33b146193a..a4aafae055 100644 --- a/packages/nodes/src/item/model-loader.ts +++ b/packages/nodes/src/item/model-loader.ts @@ -1,3 +1,4 @@ +import { preserveGltfUnlit } from '@pascal-app/viewer' import { DefaultLoadingManager, Group, LoadingManager } from 'three' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' @@ -92,6 +93,7 @@ export class ItemGLTFLoader extends GLTFLoader { constructor(manager?: LoadingManager, retryDelaysMs = DEFAULT_RETRY_DELAYS_MS) { super(new LoadingManager()) + this.register(preserveGltfUnlit) this.manager.onError = (url) => { this.resourceFailures.set(url, ++this.resourceFailureVersion) } diff --git a/packages/nodes/src/item/renderer.test.tsx b/packages/nodes/src/item/renderer.test.tsx index 77d2227323..4b61934a24 100644 --- a/packages/nodes/src/item/renderer.test.tsx +++ b/packages/nodes/src/item/renderer.test.tsx @@ -11,7 +11,7 @@ import { } from '@pascal-app/core' import { resolveCdnUrl, useViewer } from '@pascal-app/viewer' import { useLoader } from '@react-three/fiber' -import { BoxGeometry, Group, Mesh, MeshStandardMaterial } from 'three' +import { BoxGeometry, DataTexture, Group, Mesh, MeshStandardMaterial } from 'three' import { installMountedScene, mount } from '../__tests__/harness' import { ScriptedOpeningModel } from '../shared/scripted-opening' import { itemDefinition } from './definition' @@ -20,6 +20,76 @@ import { ItemRenderer } from './renderer' installMountedScene({ nodes: [itemDefinition] }) +test.each([false, true])( + 'authored Glass appearance survives item rendering (material array: %s)', + async (array) => { + useViewer.setState({ textures: true, shading: 'rendered' }) + const texture = new DataTexture(new Uint8Array([33, 80, 120, 90]), 1, 1) + const authored = new MeshStandardMaterial({ + color: '#227799', + map: texture, + transparent: true, + opacity: 0.42, + roughness: 0.17, + metalness: 0.23, + }) + authored.name = 'Glass' + const node = ItemNode.parse({ + id: 'item_authored-glass', + asset: { + id: 'authored-glass', + name: 'Authored glazing', + category: 'object', + src: `/authored-glass-${array}.glb`, + thumbnail: '', + dimensions: [1, 2, 0.02], + }, + }) + useScene.setState({ nodes: { [node.id]: node } }) + const loader = spyOn(ItemGLTFLoader.prototype, 'load').mockImplementation((_url, onLoad) => { + const scene = new Group() + const pane = new Mesh( + new BoxGeometry(1, 2, 0.02), + array ? [authored, new MeshStandardMaterial()] : authored, + ) + pane.name = 'authored-pane' + scene.add(pane) + onLoad({ + scene, + scenes: [scene], + animations: [], + cameras: [], + asset: { version: '2.0' }, + parser: {}, + } as never) + }) + const url = resolveCdnUrl(node.asset.src)! + try { + useLoader.preload(ItemGLTFLoader, url) + await Promise.resolve() + const renderer = await mount() + const pane = renderer.scene.instance.getObjectByName('authored-pane') as Mesh + const material = ( + Array.isArray(pane.material) ? pane.material[0] : pane.material + ) as MeshStandardMaterial + expect(material.color.getHexString()).toBe('227799') + expect(material).toMatchObject({ + transparent: true, + opacity: 0.42, + roughness: 0.17, + metalness: 0.23, + }) + expect(material.map?.image.data).toEqual(texture.image.data) + await renderer.unmount() + } finally { + useLoader.clear(ItemGLTFLoader, url) + loader.mockRestore() + texture.dispose() + authored.dispose() + } + }, +) + const source = { kind: 'script' as const, script: 'b'.repeat(64), diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 746e9d4aa3..dcab151674 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -190,7 +190,7 @@ const resolveItemMaterial = ( ): Material => { // Monochrome (textures off): collapse to the themed furnishing clay colour. if (!textures) return createSurfaceRoleMaterial('furnishing', colorPreset) - if (authoredMaterial.name.toLowerCase() === 'glass') return glassMaterial + // Authored material names do not opt an asset into a Pascal preset. if (slotId != null) { const override = resolveMaterialRef(nodeSlots?.[slotId], sceneMaterials, shading) if (override) return override diff --git a/packages/nodes/src/site/panel.tsx b/packages/nodes/src/site/panel.tsx index 80ffea92a7..2f27e9197f 100644 --- a/packages/nodes/src/site/panel.tsx +++ b/packages/nodes/src/site/panel.tsx @@ -275,7 +275,56 @@ export function SiteNodePanel() { + +

+ Save the current lighting and display as this project's default. Personal display changes + stay in this browser. +

+ + { + const viewer = useViewer.getState() + const look = viewer.sceneAppearance + update({ + presentation: { + ...node.presentation, + theme: viewer.sceneTheme, + exposure: look.toneMappingExposure, + shading: viewer.shading, + edges: viewer.edges, + shadows: viewer.shadows, + lighting: { + background: look.background, + backgroundSky: look.backgroundSky, + ground: look.ground, + ambient: look.ambient, + hemi: look.hemi, + lights: look.lights, + environment: look.environment, + }, + postprocessing: look.postprocessing, + }, + }) + viewer.resetProjectLook() + }} + /> + useViewer.getState().resetProjectLook()} + /> + +
+ +