diff --git a/.agents/skills/review-architecture/SKILL.md b/.agents/skills/review-architecture/SKILL.md index a1db5761ab..b59fa83620 100644 --- a/.agents/skills/review-architecture/SKILL.md +++ b/.agents/skills/review-architecture/SKILL.md @@ -20,6 +20,7 @@ Read these before reviewing any diff. They are the source of truth, not your tra - `wiki/architecture/node-definitions.md` — the three-checkbox composition model (`geometry` / `renderer` / `system`) - `wiki/architecture/plugin-authoring.md` — public contract for external node packs - `wiki/architecture/agent-surfaces.md` — MCP ↔ hosted chat ↔ skill parity. Read whenever the diff touches `packages/mcp/**` or `skills/**`. +- `wiki/architecture/scene-runtime.md` — detached scenes, tracked reads, `reconcileDocument`. Read whenever the diff touches `packages/core/src/agent-operations/**`, `packages/core/src/store/**`, a system that writes saved fields, or `packages/core/src/runtime/**`. Required on every review. Read the remaining pages on demand when the diff touches their subject area: diff --git a/DECISIONS.md b/DECISIONS.md index 51508f2cc4..2e956d7c56 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -69,6 +69,8 @@ Each entry: decision · why · enforced by · revisit when. Detail and examples **E-017 Additive node editing and surface contracts (owner, 2026-10-16).** Surface boundaries, connected movement, primary inspector predicates, independent child movement and preserved level/elevation are optional contributions on `NodeDefinition` and its existing capability types. They do not add fields to the v1 `Plugin` manifest (E-014). Boundary providers own their geometry and coordinates; framework consumers dispatch through the registry. Connected-move providers return pure patches, previewed with live overrides and committed once per gesture. Inspector predicates select an ID without subscribing the card to whole nodes. `metadata.roomBoundary === false` is the explicit opt-out from room topology. Authoring tools that node packs share come from a host package, never from importing the built-in plugin (E-001). · Why: external node packs need the same authoring behavior without depending on built-in kinds or private plugin shapes. · Enforced: `core/src/services/port-connectivity.test.ts` (connected move), `editor/src/lib/direct-manipulation.test.ts` (independent children), `core/src/lib/space-detection.test.ts` (room-boundary opt-out), `nodes/src/stair/surface-transition.test.ts` (surface boundaries). · See `plugin-authoring.md`. · Revisit: when a contribution needs a field on the `Plugin` manifest itself. +**E-018 Agent computation runs on a scene runtime (2026-10-11).** Agent operations can compute on a detached copy of the scene (`@pascal-app/core/runtime`) as well as on the editor's store, with the same result: a scene store owns its history environment, so a second store never reaches the editor's commits, history or subscribers; ids come from a synchronous allocator scope (`effectIds` makes a recomputation mint the same ids); every derived write a saved document owes settles synchronously in `reconcileDocument`, which the editor's systems plan with too; shared operations select what they read (`queryNodes`), so the recorded dependencies are precise; and a patch reaches the live scene only through `applyPatchWithDependencies`'s check. Core exports the runtime; the MCP or the host constructs `SceneBridge({ runtime })`. · Why: a long agent tool must neither hold the person's editor while it computes nor write the live scene before it is known to fit; one implementation must give the same patch on both paths. · Enforced: `core/src/runtime/fixture-parity.test.ts` (every shared fixture, detached = live, no deferred write), `core/src/runtime/detached-scene.test.ts` (isolation, disposal, ids, conflict table), the `raw-scene-enumeration` Biome rule on `agent-operations/`. · See `scene-runtime.md`. · Revisit: when a derived writer cannot run synchronously. + ## Testing **T-001 (2026-10-01, owner).** A test protects an observable result, a persisted or wire contract, or an explicit architecture/performance invariant — and tolerates an equivalent implementation. Therefore: test through package exports; no source-grep, prose-pinning or import-spelling tests (use Biome rules); no cross-package `src/` imports in tests; no `mock.module` that forces sibling files to re-spawn `bun test`; no real sleeps (inject a clock); no tests named after review rounds or bots; refactor scaffolding (parity tables, "matches main" goldens, frozen copies of old code) is deleted in the PR that lands the refactor; performance claims live in `__bench__` / bench lanes, not unit gates; a bug fix is one repro in the feature's existing file, not a new `*-audit*` file; a test that needs an opt-in flag must have a lane that sets it, or go. "Internal" is not the deletion criterion: core's import boundary, the writers allowlist and the migration thresholds pin internals with external consequences and stay. · Why: 231k test lines (82k of them fixtures) had become the main cost of every change for people and agents alike. · Enforced: review; Biome rules as they land. diff --git a/biome.jsonc b/biome.jsonc index aa4381846b..edd2c4afee 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -274,6 +274,11 @@ } } }, + // E-018: shared operations select what they read, so a detached scene records a selection. + { + "includes": ["packages/core/src/agent-operations/**/*.ts", "!**/*.test.ts"], + "plugins": ["./scripts/biome/raw-scene-enumeration.grit"] + }, // T-001: tests go through package exports. `warn` while ~50 existing tests still reach into // another package's src/ (mostly nodes tests driving editor internals, and shared core // fixtures); becomes an error once that count reaches zero. The two mounted-scene fixtures are diff --git a/packages/core/package.json b/packages/core/package.json index ce13765028..697135ac7a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -115,6 +115,11 @@ "types": "./dist/agent-operations/index.d.ts", "import": "./dist/agent-operations/index.js", "default": "./dist/agent-operations/index.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "import": "./dist/runtime/index.js", + "default": "./dist/runtime/index.js" } }, "files": [ diff --git a/packages/core/src/agent-operations/add-column.ts b/packages/core/src/agent-operations/add-column.ts index d634009ac6..33672b5288 100644 --- a/packages/core/src/agent-operations/add-column.ts +++ b/packages/core/src/agent-operations/add-column.ts @@ -12,6 +12,7 @@ import { resettledPosition } from '../lib/geometry-surfaces' import { ColumnNode, type CompiledGeometryScript, type GeometryScriptParamValue } from '../schema' import { editedScriptParams, refuseParamsWithoutScript } from './add-object' import { targetLevel } from './level-target' +import { queryNodes } from './scene-queries' import type { AgentOperation } from './types' export type AddColumnInput = z.infer> & { @@ -83,9 +84,7 @@ export const addColumn: AgentOperation = (nodes, input, context) ...fields, ...(compiled?.nodeId ? { id: compiled.nodeId } : {}), name: - input.name ?? - previous?.name ?? - `Column ${Object.values(nodes).filter((node) => node.type === 'column').length + 1}`, + input.name ?? previous?.name ?? `Column ${queryNodes(nodes, { type: 'column' }).length + 1}`, parentId: parent.id, position: [ x ?? previous?.position[0] ?? 0, diff --git a/packages/core/src/agent-operations/add-fence.ts b/packages/core/src/agent-operations/add-fence.ts index bb9f5e54e1..a8c4bf113c 100644 --- a/packages/core/src/agent-operations/add-fence.ts +++ b/packages/core/src/agent-operations/add-fence.ts @@ -3,6 +3,7 @@ import { FenceNode } from '../schema' import { type LevelTargetInput, targetLevel } from './level-target' import { finishSurface, requireMaterialRef } from './material-refs' import { refuseRoofLevel } from './roof-writes' +import { queryNodes } from './scene-queries' import type { AgentOperation } from './types' type Pt = [number, number] @@ -42,7 +43,7 @@ export const addFence: AgentOperation = (nodes, input, context) = `The side from [${short[0]}] to [${short[1]}] is shorter than ${FENCE_MIN_SIDE * 100} cm: drop the repeated point.`, { start: short[0], end: short[1] }, ) - const first = Object.values(nodes).filter((node) => node.type === 'fence').length + const first = queryNodes(nodes, { type: 'fence' }).length const fences = sides.map(([start, end], i) => FenceNode.parse({ name: `Fence ${first + i + 1}`, diff --git a/packages/core/src/agent-operations/add-level.ts b/packages/core/src/agent-operations/add-level.ts index 3a795a1e28..2b33feaed9 100644 --- a/packages/core/src/agent-operations/add-level.ts +++ b/packages/core/src/agent-operations/add-level.ts @@ -2,7 +2,7 @@ import { refuse } from '../agent-tools/refusal' import { BuildingNode, LevelNode, SiteNode } from '../schema' import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' import { storeyInsertIndex } from '../services/roof-level' -import { levelsOf } from './scene-queries' +import { levelsOf, queryNodes } from './scene-queries' import type { AgentContext, AgentOperation, SceneNodes } from './types' type AddLevelInput = { @@ -31,9 +31,7 @@ export function targetBuilding( const viewed = context.activeLevelId ? nodes[context.activeLevelId] : undefined const holder = viewed?.parentId ? nodes[viewed.parentId] : undefined if (holder?.type === 'building') return holder - const buildings = Object.values(nodes).filter( - (node): node is BuildingNode => node.type === 'building', - ) + const buildings = queryNodes(nodes, { type: 'building' }) if (buildings.length === 1) return buildings[0]! if (!buildings.length) refuse('no_building', 'The scene has no building.') return refuse( @@ -48,7 +46,7 @@ export function targetBuilding( * building, the ground level. An agent that cleared the scene to restart begins again here. */ function freshStart(nodes: SceneNodes, input: AddLevelInput) { - const standing = Object.values(nodes).find((node) => node.type === 'site') + const standing = queryNodes(nodes, { type: 'site' })[0] const site = standing ? null : SiteNode.parse({ children: [] }) const siteId = standing?.id ?? site!.id const building = BuildingNode.parse({ parentId: siteId, children: [] }) @@ -85,7 +83,7 @@ function freshStart(nodes: SceneNodes, input: AddLevelInput) { * one), or under its lowest level — the editor's +. */ export const addLevel: AgentOperation = (nodes, input, context) => { - if (!(input.buildingId || Object.values(nodes).some((node) => node.type === 'building'))) + if (!(input.buildingId || queryNodes(nodes, { type: 'building' }).length)) return freshStart(nodes, input) const building = targetBuilding(nodes, input.buildingId, context) const floors = levelsOf(nodes) diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index 412e07851a..44ebd06cf6 100644 --- a/packages/core/src/agent-operations/add-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -28,6 +28,7 @@ import { import { achievedChanges } from './achieved' import { targetLevel } from './level-target' import { roofLevelRefusal } from './roof-writes' +import { queryNodes } from './scene-queries' import type { AgentOperation, BatchedObject, SceneNodes } from './types' type Vec3 = [number, number, number] @@ -156,9 +157,7 @@ function shareOverFloor( compiled: CompiledGeometryScript, input: AddObjectInput, ) { - const slabs = Object.values(nodes).filter( - (node): node is SlabNode => node.type === 'slab' && node.parentId === levelId, - ) + const slabs = queryNodes(nodes, { type: 'slab', parentId: levelId }) const { min, max } = compiled.manifest.bounds const footprintArea = (max[0] - min[0]) * (max[2] - min[2]) if (slabs.length === 0 || !(footprintArea > 0)) return 0 diff --git a/packages/core/src/agent-operations/add-wall.ts b/packages/core/src/agent-operations/add-wall.ts index e8a310fe3a..e4e650eeee 100644 --- a/packages/core/src/agent-operations/add-wall.ts +++ b/packages/core/src/agent-operations/add-wall.ts @@ -7,6 +7,7 @@ import { WALL_MIN_LENGTH } from '../systems/wall/wall-topology' import { applySceneChanges } from './apply-changes' import { type LevelTargetInput, targetLevel } from './level-target' import { refuseRoofLevel } from './roof-writes' +import { queryNodes } from './scene-queries' import type { AgentOperation } from './types' type Pt = [number, number] @@ -34,7 +35,7 @@ export const addWall: AgentOperation = (nodes, input, context) => const curveOffset = input.curveOffset ? normalizeWallCurveOffset({ start, end }, input.curveOffset) : 0 - const walls = Object.values(nodes).filter((node) => node.type === 'wall').length + const walls = queryNodes(nodes, { type: 'wall' }).length const wall = WallNode.parse({ name: `Wall ${walls + 1}`, parentId: level.id, diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 475f656973..b2489985dd 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -49,6 +49,7 @@ import { authoredSceneChanges } from './authored-scene-merge' import { paint } from './paint' import { pointInPolygon, projectWorldPointToWallLocalX, type Vec2 } from './plan-geometry' import { resolveInlinePaintMaterial } from './scene-materials' +import { queryNodes } from './scene-queries' import type { AgentContext, AgentOperationOutcome, SceneNodes } from './types' import { setWallSkirting } from './wall-skirting' @@ -386,10 +387,10 @@ function resolveFaceScripts( const roomId = `room/${index}/${i}` const zone = nodes[ids[roomId] ?? ''] if (!room.wallCode || zone?.type !== 'zone') continue - const boundaries = Object.values(nodes).filter( - (n): n is BoundaryNode => - n.parentId === zone.parentId && (n.type === 'wall' || n.type === 'separator'), - ) + const boundaries = queryNodes(nodes, { + type: ['wall', 'separator'], + parentId: zone.parentId ?? null, + }) const zoneWalls = [...zone.boundaryWallIds].sort().join() const derived = floorRoomFaces(boundaries).find( (r) => diff --git a/packages/core/src/agent-operations/ceiling-object-observations.ts b/packages/core/src/agent-operations/ceiling-object-observations.ts index 9a4e91c8c8..48faff2973 100644 --- a/packages/core/src/agent-operations/ceiling-object-observations.ts +++ b/packages/core/src/agent-operations/ceiling-object-observations.ts @@ -2,10 +2,11 @@ import { liftedManualSlab } from '../lib/floor-construction-lift' import { withHostedCutterHoles } from '../lib/object-cuts' import { area, difference, intersection, type Ring } from '../lib/polygon-boolean' import { getRenderableSlabPolygon, slabPolygonContextForLevel } from '../lib/slab-polygon' -import type { AnyNode } from '../schema' +import type { AnyNode, ItemNode, SlabNode } from '../schema' import { resolveCeilingHeight } from '../services/level-height' import { CEILING_DRAW_OFFSET, getLevelElevations } from '../services/storey' import { scriptItemBoundsInLevel } from './layout-clearance' +import { queryNodes } from './scene-queries' type BoundsEvidence = { severity?: 'info' @@ -58,8 +59,8 @@ export function ceilingObjectObservations( ): CeilingObjectObservation[] { const observations: CeilingObjectObservation[] = [] const elevations = getLevelElevations(nodes) - for (const item of Object.values(nodes)) { - if (item.type !== 'item' || item.asset.attachTo !== 'ceiling') continue + for (const item of queryNodes(nodes, { type: 'item' })) { + if (item.asset.attachTo !== 'ceiling') continue const ceiling = item.parentId ? nodes[item.parentId] : undefined if (ceiling?.type !== 'ceiling') continue const resolved = scriptItemBoundsInLevel(item, nodes) @@ -90,8 +91,8 @@ export function ceilingObjectObservations( attachmentElevation: attachment, }) const footprint = projectedHull(resolved.corners) - for (const original of Object.values(nodes)) { - if (original.type !== 'slab' || original.recessed || !original.parentId) continue + for (const original of queryNodes(nodes, { type: 'slab' })) { + if (original.recessed || !original.parentId) continue const slabLevel = elevations.get(original.parentId) // Unlinked legacy levels are not evidence that two different storeys share a building. if ( diff --git a/packages/core/src/agent-operations/collections.ts b/packages/core/src/agent-operations/collections.ts index 512fbfdcf9..dde2d6b23b 100644 --- a/packages/core/src/agent-operations/collections.ts +++ b/packages/core/src/agent-operations/collections.ts @@ -1,4 +1,6 @@ import { refuse } from '../agent-tools/refusal' +import { queryCollections } from '../runtime/node-queries' +import { untracked } from '../runtime/reader' import { type AnyNodeId, COLLECTION_TEMPLATES, @@ -103,14 +105,13 @@ export function editCollection( scene: CollectionScene, input: EditCollectionInput, ): AgentOperationOutcome { - const all = Object.values(scene.collections) - const nameKey = input.name?.toLowerCase() + // A lookup by template or name reads which collections match, absent ones included. const existing = input.collectionId ? collectionById(scene, input.collectionId) : input.template - ? all.find((collection) => collection.template === input.template) - : nameKey - ? all.find((collection) => collection.name.toLowerCase() === nameKey) + ? queryCollections(scene.collections, { template: input.template })[0] + : input.name + ? queryCollections(scene.collections, { name: input.name })[0] : refuse('collection_required', 'Pass a collectionId, a template or a name.') if (input.delete) { @@ -140,8 +141,9 @@ export function editCollection( const nodeIds = [...new Set([...base.nodeIds, ...((input.add ?? []) as AnyNodeId[])])].filter( (id) => !removed.has(id), ) + // The fields this call does not set are copied through, not read. const next: Collection = { - ...base, + ...untracked(base), ...(input.name ? { name: input.name } : {}), ...(input.color ? { color: input.color } : {}), ...(input.template ? { template: input.template } : {}), @@ -163,7 +165,7 @@ export function listCollections( ): AgentOperationOutcome { const collections = collectionId ? [collectionById(scene, collectionId)] - : Object.values(scene.collections) + : queryCollections(scene.collections, {}) return { result: { count: collections.length, diff --git a/packages/core/src/agent-operations/corner-window.ts b/packages/core/src/agent-operations/corner-window.ts index 28f42158c2..f7dd6dbc11 100644 --- a/packages/core/src/agent-operations/corner-window.ts +++ b/packages/core/src/agent-operations/corner-window.ts @@ -3,6 +3,7 @@ import { cornerPair, wallEndPoint, windowCornerEnd } from '../lib/corner-pair' import type { WallNode, WindowNode } from '../schema' import { achievedChanges } from './achieved' import { type LevelTargetInput, targetLevel } from './level-target' +import { queryNodes } from './scene-queries' import type { AgentOperation, SceneChanges } from './types' import { registerSceneCheck, type SceneIssue } from './verify-scene' @@ -42,9 +43,7 @@ const round = (value: number) => Math.round(value * 100) / 100 export function cornerWindowIssues(nodes: Parameters[0]): SceneIssue[] { const issues: SceneIssue[] = [] const atCorner = new Map() - for (const node of Object.values(nodes)) { - if (node.type !== 'window') continue - const window = node as WindowNode + for (const window of queryNodes(nodes, { type: 'window' })) { const pair = cornerPair(nodes, window) // Informational: what broke the pair (a side deleted or moved, a wall moved) was a choice. if (window.corner && !pair) { diff --git a/packages/core/src/agent-operations/create-roof.ts b/packages/core/src/agent-operations/create-roof.ts index 7854836d02..d1d0707595 100644 --- a/packages/core/src/agent-operations/create-roof.ts +++ b/packages/core/src/agent-operations/create-roof.ts @@ -19,7 +19,7 @@ import { applySceneChanges } from './apply-changes' import { requireLevel } from './level-target' import { finishSurface, requireMaterialRef } from './material-refs' import { buildingFrame, type RoofRectangle, roofRectangles } from './roof-footprint' -import { levelRole, levelsOf } from './scene-queries' +import { levelRole, levelsOf, queryNodes } from './scene-queries' import type { AgentContext, AgentOperation, SceneChanges, SceneNodes } from './types' type Pt = [number, number] @@ -141,9 +141,7 @@ function coveredStorey( * still gaps, got a roof over a few rooms only). */ function wallOutline(nodes: SceneNodes, storeyId: string) { - const walls = Object.values(nodes).filter( - (node): node is WallNode => node.type === 'wall' && node.parentId === storeyId, - ) + const walls = queryNodes(nodes, { type: 'wall', parentId: storeyId }) if (walls.length < 3) return [] const bridges = gapBridges(walls).map( (gap, index) => @@ -217,10 +215,8 @@ function footprintPolygons(nodes: SceneNodes, storeyId: string): Pt[][] { const outline = wallOutline(nodes, storeyId) if (outline.length) return outline const on = (type: 'slab' | 'zone') => - Object.values(nodes).filter( + queryNodes(nodes, { type, parentId: storeyId }).filter( (node) => - node.type === type && - node.parentId === storeyId && !( node.type === 'slab' && node.metadata && diff --git a/packages/core/src/agent-operations/create-room.ts b/packages/core/src/agent-operations/create-room.ts index 140bdf2ff8..de11dad00c 100644 --- a/packages/core/src/agent-operations/create-room.ts +++ b/packages/core/src/agent-operations/create-room.ts @@ -16,7 +16,7 @@ import { import { applySceneChanges } from './apply-changes' import { type LevelTargetInput, targetLevel } from './level-target' import { polygonArea, type Vec2 } from './plan-geometry' -import { levelRole } from './scene-queries' +import { levelRole, queryNodes } from './scene-queries' import type { AgentOperation, SceneNodes } from './types' type OpeningSpec = { @@ -68,9 +68,7 @@ export function edgeProjection(a: Vec2, b: Vec2, point: readonly number[]) { } const wallsOn = (nodes: SceneNodes, levelId: string) => - Object.values(nodes).filter( - (node): node is WallNode => node.type === 'wall' && node.parentId === levelId, - ) + queryNodes(nodes, { type: 'wall', parentId: levelId }) /** The walls of the level that run along the edge a → b, both ends on its line. */ function wallsAlong(nodes: SceneNodes, levelId: string, a: Vec2, b: Vec2) { @@ -100,7 +98,7 @@ function wallAt(nodes: SceneNodes, levelId: string, a: Vec2, b: Vec2, point: Vec /** The construction the host derived for the room: its floor plate and its ceiling. */ function derivedSurfaces(nodes: SceneNodes, levelId: string, zoneId: string) { - const children = Object.values(nodes).filter((node) => node.parentId === levelId) + const children = queryNodes(nodes, { parentId: levelId }) const slab = children.find( (node) => node.type === 'slab' && node.boundary === 'auto' && node.zoneIds?.includes(zoneId), ) diff --git a/packages/core/src/agent-operations/create-stair.ts b/packages/core/src/agent-operations/create-stair.ts index 43c1a3a130..a45aee9153 100644 --- a/packages/core/src/agent-operations/create-stair.ts +++ b/packages/core/src/agent-operations/create-stair.ts @@ -20,7 +20,7 @@ import { applySceneChanges } from './apply-changes' import { requireLevel, targetLevel } from './level-target' import { finishSurface, requireMaterialRef } from './material-refs' import { refuseRoofLevel } from './roof-writes' -import { levelsOf } from './scene-queries' +import { levelsOf, queryNodes } from './scene-queries' import type { AgentOperation, SceneChanges, SceneNodes } from './types' /** The margin round a stair's opening when none is given, as the editor's stair tool cuts it. */ @@ -157,7 +157,7 @@ export const createStair: AgentOperation = (nodes, input, cont const withUpper = applySceneChanges(nodes, changes) const rotation = input.rotation ?? 0 const width = input.width ?? 1 - const stairs = Object.values(nodes).filter((node) => node.type === 'stair').length + const stairs = queryNodes(nodes, { type: 'stair' }).length const railingMode = input.railingMode ?? 'both' // A flight to no level rises as asked, else its storey's height, and keeps that rise; one to a // level follows the storey (no totalRise) and keeps tracking it. diff --git a/packages/core/src/agent-operations/find-by-type.ts b/packages/core/src/agent-operations/find-by-type.ts index 27ec2cb3a0..9103dbb28f 100644 --- a/packages/core/src/agent-operations/find-by-type.ts +++ b/packages/core/src/agent-operations/find-by-type.ts @@ -264,6 +264,7 @@ function namesAnything( const known = (name: string) => CORE_KINDS.has(name) || registered.includes(name) || Boolean(nodeSchema?.(name)) if ([...names].some(known)) return true + // biome-ignore lint/plugin: a type word may name any node's kind, category or part return Object.values(nodes).some( (node) => names.has(kindOf(node)) || @@ -297,6 +298,7 @@ export const findByType: AgentOperation = (nodes, input, contex ? undefined : new Set([kind, kind.includes(':') ? kind : kind.replace(/s$/, '')]) const results: Found[] = [] + // biome-ignore lint/plugin: any node, or any typed part of an authored object, may match for (const node of Object.values(nodes)) { if (parentId && node.parentId !== parentId) continue if (fromSource && !fromSource(node)) continue @@ -353,6 +355,7 @@ export const findByType: AgentOperation = (nodes, input, contex results.push(...(inZone ? inZone(node, level, matched) : matched).map(({ found }) => found)) } if (names && results.length === 0 && !namesAnything(nodes, names, context)) { + // biome-ignore lint/plugin: the refusal lists every kind the scene holds const kinds = [...new Set(Object.values(nodes).map(kindOf))].sort() refuse( 'unknown_type', diff --git a/packages/core/src/agent-operations/furnish-room.ts b/packages/core/src/agent-operations/furnish-room.ts index dc5cd86b86..1683eadbd8 100644 --- a/packages/core/src/agent-operations/furnish-room.ts +++ b/packages/core/src/agent-operations/furnish-room.ts @@ -1,7 +1,7 @@ import type { FURNISHED_ROOM_TYPES } from '../agent-tools/furnish-room' import { refuse } from '../agent-tools/refusal' import { OTHER_SOURCES } from '../agent-tools/sources' -import { type AssetInput, ItemNode } from '../schema' +import { type AssetInput, ItemNode, type WallNode } from '../schema' import { edgeProjection } from './create-room' import { collectDoorKeepouts, @@ -22,7 +22,7 @@ import { } from './layout-clearance' import { type LevelTargetInput, targetLevel } from './level-target' import { polygonArea, polygonBounds, type Vec2 } from './plan-geometry' -import { levelIdOf, levelRole } from './scene-queries' +import { levelIdOf, levelRole, queryNodes } from './scene-queries' import type { AgentContext, AgentOperation, SceneNodes } from './types' type RoomType = (typeof FURNISHED_ROOM_TYPES)[number] @@ -77,8 +77,7 @@ export function roomToFurnish( /** The doors of the level that stand on an edge of the room, by where they stand. */ function doorsOfRoom(nodes: SceneNodes, levelId: string, polygon: Vec2[]) { const found: { doorId: string; edge: number }[] = [] - for (const wall of Object.values(nodes)) { - if (wall.type !== 'wall' || wall.parentId !== levelId) continue + for (const wall of queryNodes(nodes, { type: 'wall', parentId: levelId })) { const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) if (length < 1e-9) continue for (const childId of wall.children) { @@ -301,6 +300,7 @@ export const furnishRoom: AgentOperation = (nodes, input, cont const doors = doorsOfRoom(nodes, levelId, polygon) const doorWall = input.doorWallIndex ?? doors[0]?.edge ?? 0 + // biome-ignore lint/plugin: door keep-outs and occupied footprints resolve each item through its parents const all = Object.values(nodes) const doorKeepouts = collectDoorKeepouts(all, { levelId }) const keepouts: PlanAabb[] = doorKeepouts.map((door) => door.aabb) diff --git a/packages/core/src/agent-operations/layout-clearance.ts b/packages/core/src/agent-operations/layout-clearance.ts index b3d7905070..ab6488be12 100644 --- a/packages/core/src/agent-operations/layout-clearance.ts +++ b/packages/core/src/agent-operations/layout-clearance.ts @@ -45,6 +45,8 @@ export { resolveNodeLevelId, } from './door-clearance' +import { queryNodes } from './scene-queries' + /** Minimum free space (m) required between item footprints. */ export const DEFAULT_ITEM_GAP = 0.08 @@ -140,7 +142,7 @@ function itemFloorLift(node: ItemNode, nodes: Record): number { levelBaseElevationAt(nodes, levelId, node.position[0], node.position[2]) + floorConstructionLift(nodes, node) if (node.supportSlabId === 'ground') return ground() - const list = Object.values(nodes) + const list = queryNodes(nodes, { type: ['slab', 'wall'] }) const slabs = list .filter( (other): other is Extract => diff --git a/packages/core/src/agent-operations/photo-elevation.ts b/packages/core/src/agent-operations/photo-elevation.ts index fc2d0de4a3..8b1b71791e 100644 --- a/packages/core/src/agent-operations/photo-elevation.ts +++ b/packages/core/src/agent-operations/photo-elevation.ts @@ -22,6 +22,7 @@ import { } from '../building/photo-elevation' import type { AnyNode, AnyNodeId, WallNode } from '../schema' import { getLevelElevations, type LevelElevation } from '../services/storey' +import { queryNodes } from './scene-queries' import type { SceneNodes } from './types' type Pt = [number, number] @@ -1028,9 +1029,7 @@ function photoPose( let best: { spread: number; places: ReturnType[] } | null = null if (faces.length === 1) { // One face: the camera stands outside the building, away from its walls' middle. - const walls = Object.values(nodes).filter( - (node): node is WallNode => node.type === 'wall' && node.parentId === faces[0]!.levelId, - ) + const walls = queryNodes(nodes, { type: 'wall', parentId: faces[0]!.levelId }) const middle = walls.flatMap((wall) => [wall.start, wall.end]) const cx = mean(middle.map((p) => p[0])) const cz = mean(middle.map((p) => p[1])) diff --git a/packages/core/src/agent-operations/place-in-room.ts b/packages/core/src/agent-operations/place-in-room.ts index a86793d988..55ca98b132 100644 --- a/packages/core/src/agent-operations/place-in-room.ts +++ b/packages/core/src/agent-operations/place-in-room.ts @@ -345,6 +345,7 @@ export const placeInRoom: AgentOperation = (nodes, input, cont `The room has ${polygon.length} edges (0–${polygon.length - 1}); there is no edge ${input.edge}.`, ) + // biome-ignore lint/plugin: door keep-outs and occupied footprints resolve each item through its parents const all = Object.values(nodes) const bounds = polygonBounds(polygon) const room: Room = { diff --git a/packages/core/src/agent-operations/place-items.ts b/packages/core/src/agent-operations/place-items.ts index 74f1e25af4..3f5f2ef370 100644 --- a/packages/core/src/agent-operations/place-items.ts +++ b/packages/core/src/agent-operations/place-items.ts @@ -7,7 +7,7 @@ import { geometryUndersideAt, mountsFlush, } from '../lib/geometry-surfaces' -import { type AnyNode, type AssetInput, ItemNode, type WallNode } from '../schema' +import { type AnyNode, type AssetInput, ItemNode, type WallNode, type ZoneNode } from '../schema' import { getWallLocalFaceZ } from '../systems/wall/wall-frame' import { collectDoorKeepouts, type DoorKeepout } from './door-clearance' import { type LevelTargetInput, targetLevel } from './level-target' @@ -18,6 +18,7 @@ import { wallLength, } from './plan-geometry' import { roofLevelRefusal } from './roof-writes' +import { queryNodes } from './scene-queries' import type { AgentOperation, SceneChanges, SceneNodes } from './types' type Entry = { @@ -58,10 +59,8 @@ const isOutdoor = (asset: AssetInput) => OUTDOOR_ASSET.test(`${asset.name} ${asset.category ?? ''} ${(asset.tags ?? []).join(' ')}`) function roomsOn(nodes: SceneNodes, levelId: string): Vec2[][] { - return Object.values(nodes).flatMap((node) => - node.type === 'zone' && node.parentId === levelId && node.polygon.length >= 3 - ? [node.polygon as Vec2[]] - : [], + return queryNodes(nodes, { type: 'zone', parentId: levelId }).flatMap((node) => + node.polygon.length >= 3 ? [node.polygon as Vec2[]] : [], ) } @@ -306,7 +305,10 @@ export const placeItems: AgentOperation = (nodes, input, contex 'outside_rooms', `"${asset.name}" at (${x}, ${z}) is outside every room on this level: indoor items go inside a room (read the rooms with get_zones); trees and garden items may stand outside. Outside a room, ${OUTSIDE_SOURCES}`, ) - doorsOn[floorId] ??= collectDoorKeepouts(Object.values(nodes), { levelId: floorId }) + doorsOn[floorId] ??= collectDoorKeepouts( + queryNodes(nodes, { type: ['door', 'wall', 'level'] }), + { levelId: floorId }, + ) const misfit = floorFit(nodes, floorId, entry, asset, doorsOn[floorId]) if (misfit) return misfit const node = make(asset, { diff --git a/packages/core/src/agent-operations/scene-checkpoint.ts b/packages/core/src/agent-operations/scene-checkpoint.ts index 2e4cc5b83b..fa075d47dd 100644 --- a/packages/core/src/agent-operations/scene-checkpoint.ts +++ b/packages/core/src/agent-operations/scene-checkpoint.ts @@ -138,6 +138,7 @@ const nameOf = (node: AnyNode) => /** The scene as a checkpoint remembers it: every node's fingerprint and the registered families' places. */ export function takeCheckpoint(nodes: SceneNodes, name: string, madeAt: number): SessionCheckpoint { const remembered: Record = {} + // biome-ignore lint/plugin: a checkpoint remembers every node for (const node of Object.values(nodes)) remembered[node.id] = { type: node.type, @@ -191,6 +192,7 @@ export function createdBetween(before: SceneNodes, after: SceneNodes): MadeEntry * the session has one, or the scene is too large to remember. */ export function startOfSession(session: SessionRecord, nodes: SceneNodes): SessionKeep | undefined { + // biome-ignore lint/plugin: a checkpoint remembers every node, so its size is the scene's if (session.checkpoints.start || Object.keys(nodes).length > MAX_CHECKPOINT_NODES) return undefined return { checkpoint: takeCheckpoint(nodes, 'start', session.made.length) } @@ -214,6 +216,7 @@ const Checkpoint = z.object({ measure: Measure, nodes: z .record(z.string(), Remembered) + // biome-ignore lint/plugin: a stored checkpoint's own record, not the scene .refine((nodes) => Object.keys(nodes).length <= MAX_CHECKPOINT_NODES), issues: z.array(z.string()), madeAt: z.number().int().min(0), diff --git a/packages/core/src/agent-operations/scene-queries.ts b/packages/core/src/agent-operations/scene-queries.ts index 84c3f2d56e..d27e08fdd9 100644 --- a/packages/core/src/agent-operations/scene-queries.ts +++ b/packages/core/src/agent-operations/scene-queries.ts @@ -1,7 +1,10 @@ +import { queryNodes } from '../runtime/node-queries' import type { AnyNode } from '../schema' import { isCeilingGeometry, isRoofGeometry, servesAsRoofLevel } from '../services/roof-level' import type { SceneNodes } from './types' +export { orderedChildIds, queryNodes } from '../runtime/node-queries' + export type LevelRole = 'occupied' | 'roof' | 'support' export type ContentCounts = { walls: number @@ -22,9 +25,9 @@ const metadataString = (node: AnyNode, key: string) => { /** Every level, in floor order (ties by id). */ export function levelsOf(nodes: SceneNodes): (AnyNode & { type: 'level' })[] { - return Object.values(nodes) - .filter((node): node is AnyNode & { type: 'level' } => node.type === 'level') - .sort((a, b) => a.level - b.level || a.id.localeCompare(b.id)) + return queryNodes(nodes, { type: 'level' }).sort( + (a, b) => a.level - b.level || a.id.localeCompare(b.id), + ) } /** The level a node sits on, walking up its parents (a level resolves to itself). */ diff --git a/packages/core/src/agent-operations/scene-review.ts b/packages/core/src/agent-operations/scene-review.ts index e7d34917a3..3bbdb83ed2 100644 --- a/packages/core/src/agent-operations/scene-review.ts +++ b/packages/core/src/agent-operations/scene-review.ts @@ -1,9 +1,8 @@ import { refuse } from '../agent-tools/refusal' import { getLevelDisplayName } from '../lib/level-name' -import type { AnyNode } from '../schema' import { hashNode as hashOf, type SessionRecord } from './scene-checkpoint' import { changesSince, type LostSinceCheckpointIssue, type MeasureChange } from './scene-measure' -import { levelIdOf, levelsOf } from './scene-queries' +import { levelIdOf, levelsOf, queryNodes } from './scene-queries' import type { SceneNodes } from './types' /** A node of these kinds can be the target of a view_scene; the others are seen through their level. */ @@ -84,6 +83,7 @@ export function reviewSince(session: SessionRecord, name: string, nodes: SceneNo return checkpoint.nodes[levelId]?.name ?? levelId } + // biome-ignore lint/plugin: a review compares every node with the checkpoint for (const node of Object.values(nodes)) { const was = checkpoint.nodes[node.id] const levelId = levelIdOf(nodes, node.id) @@ -154,4 +154,4 @@ export function reviewSince(session: SessionRecord, name: string, nodes: SceneNo } const firstBuilding = (nodes: SceneNodes): string | undefined => - Object.values(nodes).find((node: AnyNode) => node.type === 'building')?.id + queryNodes(nodes, { type: 'building' })[0]?.id diff --git a/packages/core/src/agent-operations/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts index cda7168d35..891086d44b 100644 --- a/packages/core/src/agent-operations/scene-view.ts +++ b/packages/core/src/agent-operations/scene-view.ts @@ -25,6 +25,7 @@ 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' +import { queryNodes } from './scene-queries' /** * Where `view_scene` looks from, the same on every surface; the picture is the host's: the chat's @@ -158,6 +159,7 @@ export function sceneViewInterior( target: node.id, }) } + // biome-ignore lint/plugin: a view hides every level but its own, and what covers it const hiddenIds = Object.values(nodes) .filter((node) => node.type === 'level' @@ -296,7 +298,7 @@ function streetDirection( */ function framedBuilding(nodes: Readonly>, lookedAt: AnyNode | undefined) { if (lookedAt) return ancestry(nodes, lookedAt).find((node) => node.type === 'building') ?? null - const buildings = Object.values(nodes).filter((node) => node.type === 'building') + const buildings = queryNodes(nodes, { type: 'building' }) const sites = new Set(buildings.map((building) => building.parentId ?? null)) if (sites.size > 1) refuse( @@ -671,6 +673,7 @@ export function sceneViewBounds( const held: SceneViewBox[] = [] const roofs: SceneViewBox[] = [] let pluginTop = Number.NEGATIVE_INFINITY + // biome-ignore lint/plugin: a view frames whatever the level holds for (const node of Object.values(nodes)) { const level = levelOf(node) if (level?.type !== 'level') continue diff --git a/packages/core/src/agent-operations/set-checkpoint.ts b/packages/core/src/agent-operations/set-checkpoint.ts index 0200997cd8..36d3273554 100644 --- a/packages/core/src/agent-operations/set-checkpoint.ts +++ b/packages/core/src/agent-operations/set-checkpoint.ts @@ -23,6 +23,7 @@ export const setCheckpoint: AgentOperation<{ name: string }> = (nodes, input, co `A session keeps at most ${MAX_CHECKPOINTS} checkpoints and has ${names.join(', ')}. Use one of those names to replace it.`, { checkpoints: names }, ) + // biome-ignore lint/plugin: a checkpoint remembers every node, so its size is the scene's const size = Object.keys(nodes).length if (size > MAX_CHECKPOINT_NODES) refuse( diff --git a/packages/core/src/agent-operations/site.ts b/packages/core/src/agent-operations/site.ts index 2127875131..e5b64407fe 100644 --- a/packages/core/src/agent-operations/site.ts +++ b/packages/core/src/agent-operations/site.ts @@ -7,9 +7,9 @@ import { siteBrief, type YardKey, } from '../lib/site-placement' -import type { AnyNode } from '../schema' import { targetBuilding } from './add-level' import { pointInPolygon, type Vec2 } from './plan-geometry' +import { queryNodes } from './scene-queries' import type { AgentOperation, SceneNodes } from './types' type SiteInput = { building?: string; site?: string } @@ -36,7 +36,7 @@ export const getSite: AgentOperation = (nodes, input, context) => { type: site.type, }) } - const anyBuilding = Object.values(nodes).some((node) => node.type === 'building') + const anyBuilding = queryNodes(nodes, { type: 'building' }).length > 0 const building = input.building || (anyBuilding && !input.site) ? targetBuilding(nodes, input.building, context) @@ -86,7 +86,7 @@ function outdoorOnLevels(nodes: SceneNodes, buildingId: string, rings: readonly const outside = (x: number, z: number) => !rings.some((ring) => pointInPolygon([x, z], ring as Vec2[])) const moved: { id: string; type: string; name?: string }[] = [] - for (const node of Object.values(nodes) as AnyNode[]) { + for (const node of queryNodes(nodes, { type: ['fence', 'slab', 'item'] })) { if (!(node.parentId && levels.has(node.parentId))) continue const flatwork = node.type === 'slab' && diff --git a/packages/core/src/agent-operations/verify-scene.ts b/packages/core/src/agent-operations/verify-scene.ts index ff4d9a1142..ec957b4832 100644 --- a/packages/core/src/agent-operations/verify-scene.ts +++ b/packages/core/src/agent-operations/verify-scene.ts @@ -5,7 +5,13 @@ import { wallSupportForNodes } from '../lib/opening-floor-datum' import { detectOpenWallEnds, type OpenWallEnd } from '../lib/room-graph' import { siteBrief } from '../lib/site-placement' import { nodeRegistry } from '../registry' -import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema, type LevelNode } from '../schema' +import { + type AnyNode, + type AnyNodeId, + AnyNode as AnyNodeSchema, + type BuildingNode, + type LevelNode, +} from '../schema' import { nodeSchemaForKind } from '../schema/compiled-node-parsers' import { isCeilingGeometry } from '../services/roof-level' import { isRoofOnStoreyWalls } from '../services/roof-move' @@ -35,6 +41,7 @@ import { levelRole, levelsOf, nodesOnLevel, + queryNodes, } from './scene-queries' import { reviewSince } from './scene-review' import type { AgentContext, AgentOperation, SceneNodes } from './types' @@ -324,6 +331,7 @@ export const verifyScene: AgentOperation = ( unreadOn.size === 0 ? nodes : Object.fromEntries( + // biome-ignore lint/plugin: verify_scene reads the whole scene Object.entries(nodes).filter(([id]) => !unreadOn.has(levelIdOf(scene, id) ?? '')), ) @@ -631,8 +639,7 @@ export const verifyScene: AgentOperation = ( // Where each building stands on its lot: past the lot line or into the required yards is a // problem, off-centre is not. - for (const building of Object.values(nodes)) { - if (building.type !== 'building') continue + for (const building of queryNodes(nodes, { type: 'building' })) { const brief = siteBrief(nodes, { buildingId: building.id }) if (!brief) continue const name = building.name ?? 'The building' @@ -730,6 +737,7 @@ export const verifyScene: AgentOperation = ( /** Objects built by add_object, with what each stands in for: each is a gap in what Pascal builds. */ function authoredObjects(nodes: SceneNodes) { + // biome-ignore lint/plugin: verify_scene reads the whole scene const objects = Object.values(nodes) .flatMap((node) => node.type === 'item' && node.source diff --git a/packages/core/src/agent-operations/wall-object-observations.ts b/packages/core/src/agent-operations/wall-object-observations.ts index 00eb2df0e7..1e9e0f2e44 100644 --- a/packages/core/src/agent-operations/wall-object-observations.ts +++ b/packages/core/src/agent-operations/wall-object-observations.ts @@ -17,6 +17,7 @@ import { import type { AnyNode, ItemNode, WallNode } from '../schema' import { getLevelElevations, getWallTop } from '../services/storey' import { getWallBodyCenterOffset, getWallLocalFaceZ } from '../systems/wall/wall-frame' +import { queryNodes } from './scene-queries' type Vec3 = [number, number, number] export type WallObjectFace = { normal: Vec3; depth: number; polygon: Ring } @@ -130,8 +131,7 @@ function wallSolid( ) return const holes: Ring[] = [] - for (const child of Object.values(nodes)) { - if (child.parentId !== wall.id) continue + for (const child of queryNodes(nodes, { parentId: wall.id })) { // Wall cutters have no persisted XY outline: their bounds cannot prove solid contact. if ( (child.type === 'item' || child.type === 'door' || child.type === 'window') && @@ -185,9 +185,8 @@ export function wallObjectObservations( const elevations = getLevelElevations(nodes) const solids = new Map() const peers: { evidence: Evidence; patches: ReturnType }[] = [] - for (const item of Object.values(nodes)) { + for (const item of queryNodes(nodes, { type: 'item' })) { if ( - item.type !== 'item' || !item.visible || item.roofSegmentId || item.blockFaceId || diff --git a/packages/core/src/building/floor-item-fit.ts b/packages/core/src/building/floor-item-fit.ts index 0b2f3dace7..0b9a526e4a 100644 --- a/packages/core/src/building/floor-item-fit.ts +++ b/packages/core/src/building/floor-item-fit.ts @@ -7,6 +7,7 @@ import { import { findValidPlacement } from '../agent-operations/layout-clearance' import { pointInPolygon, polygonBounds, type Vec2 } from '../agent-operations/plan-geometry' import type { SceneNodes } from '../agent-operations/types' +import { queryNodes } from '../runtime/node-queries' import type { ZoneNode } from '../schema' export type FloorItemRoom = { id: string; name: string; width: number; depth: number } @@ -39,12 +40,8 @@ export function floorItemFit( doors?: DoorKeepout[] }, ): FloorItemMisfit | null { - const zone = Object.values(nodes).find( - (node): node is ZoneNode => - node.type === 'zone' && - node.parentId === item.levelId && - node.polygon.length >= 3 && - pointInPolygon([item.x, item.z], node.polygon as Vec2[]), + const zone = queryNodes(nodes, { type: 'zone', parentId: item.levelId }).find( + (node) => node.polygon.length >= 3 && pointInPolygon([item.x, item.z], node.polygon as Vec2[]), ) const bounds = zone ? polygonBounds(zone.polygon as Vec2[]) : undefined const room = zone && diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts index f1c68c2bb3..4b9768644b 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts @@ -11,7 +11,7 @@ import { getFloorPlacedFootprints } from './floor-placed-footprints' export { getFloorPlacedFootprints } from './floor-placed-footprints' -import { spatialGridManager } from './spatial-grid-manager' +import { activeSpatialGrid } from './spatial-grid-manager' export { GROUND_SUPPORT_ID } from './support-host-id' @@ -118,7 +118,7 @@ export function getFloorPlacedElevation({ const host = nodes[supportSlabId] for (const footprint of footprints) { if (host?.type === 'slab' && host.plateRole === 'base') { - const coverings = spatialGridManager + const coverings = activeSpatialGrid() .getSupportCandidatesForFootprint( resolvedLevelId, footprint.position ?? position, @@ -134,7 +134,7 @@ export function getFloorPlacedElevation({ const covering = coverings[0] if (covering) return finiteSlabElevation(covering.elevation) } - const hosted = spatialGridManager.getHostSlabElevationForFootprint( + const hosted = activeSpatialGrid().getHostSlabElevationForFootprint( resolvedLevelId, supportSlabId, footprint.position ?? position, @@ -157,7 +157,7 @@ export function getFloorPlacedElevation({ // sinking into the recess. let elected = Number.NEGATIVE_INFINITY for (const footprint of footprints) { - const support = spatialGridManager.getSlabSupportForItem( + const support = activeSpatialGrid().getSlabSupportForItem( resolvedLevelId, footprint.position ?? position, footprint.dimensions, diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 50c3a2a02f..fdc92fc539 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -9,7 +9,7 @@ import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import { getWallTop } from '../../services/storey' import useLiveNodeOverrides, { getEffectiveNode } from '../../store/use-live-node-overrides' import useLiveTerrain from '../../store/use-live-terrain' -import useLiveTransforms from '../../store/use-live-transforms' +import useLiveTransforms, { type LiveTransform } from '../../store/use-live-transforms' import useScene from '../../store/use-scene' import { computeWallSlabSupport, @@ -199,6 +199,40 @@ export type PointedSupportSurface = ItemSlabSupport & { point: [number, number] | null } +/** + * What a manager reads besides its own index: the scene's nodes and the live previews (field + * overrides, translation deltas, terrain sculpting) that support queries honour mid-drag. + */ +export type SpatialGridSource = { + nodes(): Record + overrides(): ReadonlyMap + transforms(): ReadonlyMap + effective(node: T): T + terrain(): ReturnType | undefined +} + +/** The editor's store and its live previews: the singleton manager's source. */ +export const liveSpatialGridSource: SpatialGridSource = { + nodes: () => useScene.getState().nodes, + overrides: () => useLiveNodeOverrides.getState().overrides, + transforms: () => useLiveTransforms.getState().transforms, + effective: getEffectiveNode, + terrain: () => useLiveTerrain.getState(), +} + +const NO_PREVIEWS = new Map() + +/** A fixed set of nodes, without previews: what a detached scene's support queries read. */ +export function snapshotSpatialGridSource(nodes: () => Record): SpatialGridSource { + return { + nodes, + overrides: () => NO_PREVIEWS, + transforms: () => NO_PREVIEWS, + effective: (node) => node, + terrain: () => undefined, + } +} + export class SpatialGridManager { private readonly floorGrids = new Map() // levelId -> grid private readonly wallGrids = new Map() // levelId -> wall grid @@ -209,9 +243,89 @@ export class SpatialGridManager { private readonly itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) private readonly cellSize: number + private readonly source: SpatialGridSource + /** + * Item collision cells (floor, wall, ceiling) are only built when a placement query needs + * them: support queries read slabs, walls and ceilings. The editor's manager keeps them live. + */ + private itemsIndexed: boolean + private readonly levelIdOf: ((node: AnyNode, nodes: Record) => string) | null - constructor(cellSize = 0.5) { + constructor( + cellSize = 0.5, + source: SpatialGridSource = liveSpatialGridSource, + options: { lazyItems?: (node: AnyNode, nodes: Record) => string } = {}, + ) { this.cellSize = cellSize + this.source = source + this.levelIdOf = options.lazyItems ?? null + this.itemsIndexed = !options.lazyItems + } + + private ensureItemIndex() { + if (this.itemsIndexed || !this.levelIdOf) return + this.itemsIndexed = true + const nodes = this.source.nodes() + for (const node of Object.values(nodes)) + if (node.type === 'item') this.handleNodeCreated(node, this.levelIdOf(node, nodes)) + } + + private dropItemIndex() { + if (!this.levelIdOf || !this.itemsIndexed) return + this.itemsIndexed = false + this.floorGrids.clear() + this.wallGrids.clear() + this.ceilingGrids.clear() + this.itemCeilingMap.clear() + } + + /** Indexes `nodes` at once: support structures only, caches dropped once rather than per node. */ + bulkLoad(nodes: Record) { + const levelIdOf = this.levelIdOf + if (!levelIdOf) throw new Error('bulkLoad is for snapshot managers') + for (const node of Object.values(nodes)) { + if (node.type === 'slab') + this.getSlabMap(levelIdOf(node, nodes)).set(node.id, node as SlabNode) + else if (node.type === 'wall') this.walls.set(node.id, node as WallNode) + else if (node.type === 'ceiling') this.ceilings.set(node.id, node as CeilingNode) + } + this.renderedSlabPolygons.clear() + this.supportInputsRevision += 1 + } + + /** + * Brings a snapshot manager from `before` to `after` in place. Returns false, changing nothing, + * when a rebuild is simpler and exact: many changes, or a node changing kind or level. + */ + syncSnapshot(before: Record, after: Record): boolean { + const levelIdOf = this.levelIdOf + if (!levelIdOf) return false + const changed: string[] = [] + for (const id in after) if (after[id] !== before[id]) changed.push(id) + for (const id in before) if (!(id in after)) changed.push(id) + if (changed.length > 256) return false + for (const id of changed) { + const previous = before[id] + const next = after[id] + if ( + previous && + next && + (previous.type !== next.type || levelIdOf(previous, before) !== levelIdOf(next, after)) + ) + return false + } + for (const id of changed) { + const previous = before[id] + const next = after[id] + if (previous?.type === 'wall' || previous?.type === 'item' || previous?.type === 'ceiling') + this.dropItemIndex() + if (next?.type === 'wall' || next?.type === 'item' || next?.type === 'ceiling') + this.dropItemIndex() + if (previous && !next) this.handleNodeDeleted(id, previous.type, levelIdOf(previous, before)) + else if (!previous && next) this.handleNodeCreated(next, levelIdOf(next, after)) + else if (next) this.handleNodeUpdated(next, levelIdOf(next, after)) + } + return true } private getFloorGrid(levelId: string): SpatialGrid { @@ -240,7 +354,7 @@ export class SpatialGridManager { const wall = this.walls.get(wallId) if (!wall) return 0 - const nodes = useScene.getState().nodes + const nodes = this.source.nodes() const levelId = resolveNodeLevelId(wall, nodes) const support = this.getSlabSupportForWall( levelId, @@ -297,17 +411,17 @@ export class SpatialGridManager { * live-effective records and skip the rendered-polygon cache. */ private levelHasLivePreview(levelId: string): boolean { - const nodes = useScene.getState().nodes + const nodes = this.source.nodes() const structuralOnLevel = (id: string) => { const node = nodes[id as AnyNodeId] if (!node || (node.type !== 'slab' && node.type !== 'wall')) return false return resolveNodeLevelId(node, nodes) === levelId } - const overrides = useLiveNodeOverrides.getState().overrides + const overrides = this.source.overrides() for (const id of overrides.keys()) { if (structuralOnLevel(id)) return true } - const transforms = useLiveTransforms.getState().transforms + const transforms = this.source.transforms() for (const id of transforms.keys()) { if (structuralOnLevel(id)) return true } @@ -324,8 +438,8 @@ export class SpatialGridManager { * never re-map, or the delta would apply twice. */ private effectiveSlabRecord(slab: SlabNode): SlabNode { - let effective = getEffectiveNode(slab) - const live = useLiveTransforms.getState().get(slab.id) + let effective = this.source.effective(slab) + const live = this.source.transforms().get(slab.id) if (live) { const [dx, dy, dz] = live.position if (dx !== 0 || dy !== 0 || dz !== 0) { @@ -339,7 +453,7 @@ export class SpatialGridManager { } } } - return liftedManualSlab(useScene.getState().nodes, effective) + return liftedManualSlab(this.source.nodes(), effective) } private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { @@ -355,7 +469,7 @@ export class SpatialGridManager { } const walls = this.getLevelWallNodes(levelId) const polygon = getRenderableSlabPolygon(slab, { - walls: live ? walls.map((wall) => getEffectiveNode(wall)) : walls, + walls: live ? walls.map((wall) => this.source.effective(wall)) : walls, siblingSlabs, }) if (!live) this.renderedSlabPolygons.set(slab.id, polygon) @@ -395,7 +509,7 @@ export class SpatialGridManager { this.walls.set(wall.id, wall) // Rendered slab polygons adopt wall bands — a new wall can extend them. this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'item') { + } else if (node.type === 'item' && this.itemsIndexed) { const item = node as ItemNode if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { // Wall-attached item - use parentId as the wall ID @@ -454,7 +568,7 @@ export class SpatialGridManager { const wall = node as WallNode this.walls.set(wall.id, wall) this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'item') { + } else if (node.type === 'item' && this.itemsIndexed) { const item = node as ItemNode if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { // Remove old placement and re-insert @@ -524,7 +638,7 @@ export class SpatialGridManager { // Remove all items attached to this wall from the spatial grid const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) return removedItemIds // Caller can use this to delete the items from scene - } else if (nodeType === 'item') { + } else if (nodeType === 'item' && this.itemsIndexed) { this.getFloorGrid(levelId).remove(nodeId) this.getWallGrid(levelId).removeByItemId(nodeId) // Also clean up ceiling grid @@ -557,7 +671,7 @@ export class SpatialGridManager { }[], ignoreIds?: string[], ) { - const nodes = useScene.getState().nodes + const nodes = this.source.nodes() const ignoreSet = expandIgnoredNodeIds(ignoreIds, nodes) const draftBounds = footprints.map((footprint) => footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), @@ -636,6 +750,7 @@ export class SpatialGridManager { side?: 'front' | 'back', ignoreIds?: string[], ) { + this.ensureItemIndex() const wallLength = this.getWallLength(wallId) if (wallLength === 0) { return { valid: false, conflictIds: [] } @@ -659,7 +774,7 @@ export class SpatialGridManager { if (!baseResult.valid) return baseResult - const nodes = useScene.getState().nodes + const nodes = this.source.nodes() const ignoreSet = new Set(ignoreIds ?? []) const draftBounds = { minX: localX - itemWidth / 2, @@ -697,6 +812,7 @@ export class SpatialGridManager { } getWallForItem(levelId: string, itemId: string): string | undefined { + this.ensureItemIndex() return this.getWallGrid(levelId).getWallForItem(itemId) } @@ -934,7 +1050,7 @@ export class SpatialGridManager { // Sampled at the wall's own start point — the same anchor the mesh is // positioned at, so the resolver and the renderer cannot disagree about // where the ground is under this wall. - const levelBase = levelBaseElevationAt(useScene.getState().nodes, levelId, start[0], start[1]) + const levelBase = levelBaseElevationAt(this.source.nodes(), levelId, start[0], start[1]) const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) { @@ -963,7 +1079,7 @@ export class SpatialGridManager { inputs.terrainPlates.some((slab) => wallOverlapsPolygon({ start, end, curveOffset, thickness, justification }, slab.polygon), ) - ? useLiveTerrain.getState() + ? this.source.terrain() : undefined, ) return support @@ -995,9 +1111,9 @@ export class SpatialGridManager { >() private getSupportInputs(levelId: string, slabMap: Map) { - const nodes = useScene.getState().nodes - const overrides = useLiveNodeOverrides.getState().overrides - const transforms = useLiveTransforms.getState().transforms + const nodes = this.source.nodes() + const overrides = this.source.overrides() + const transforms = this.source.transforms() const cached = this.supportInputs.get(levelId) if ( cached && @@ -1024,11 +1140,11 @@ export class SpatialGridManager { for (const slab of slabs) for (const id of slab.zoneIds ?? []) { const zone = nodes[id as AnyNodeId] - if (zone?.type === 'zone') supportNodes[id] = getEffectiveNode(zone) + if (zone?.type === 'zone') supportNodes[id] = this.source.effective(zone) } for (const node of Object.values(nodes)) if (node.parentId === levelId && (node.type === 'separator' || node.type === 'zone')) - supportNodes[node.id] = getEffectiveNode(node) + supportNodes[node.id] = this.source.effective(node) for (const node of Object.values(nodes)) if (node.type === 'site' || node.type === 'building' || node.type === 'level') supportNodes[node.id] = node @@ -1046,7 +1162,7 @@ export class SpatialGridManager { baseAt: (x: number, z: number) => levelBaseElevationAt(nodes, levelId, x, z), slabs, supportNodes, - walls: this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), + walls: this.getLevelWallNodes(levelId).map((wall) => this.source.effective(wall)), } this.supportInputs.set(levelId, next) return next @@ -1061,7 +1177,7 @@ export class SpatialGridManager { private readonly levelWallsCache = new WeakMap>() private getLevelWallNodes(levelId: string): WallNode[] { - const nodes = useScene.getState().nodes + const nodes = this.source.nodes() let byLevel = this.levelWallsCache.get(nodes) if (!byLevel) { byLevel = new Map() @@ -1099,6 +1215,7 @@ export class SpatialGridManager { rotation: [number, number, number], ignoreIds?: string[], ): { valid: boolean; conflictIds: string[] } { + this.ensureItemIndex() const ceiling = this.ceilings.get(ceilingId) if (!ceiling || ceiling.polygon.length < 3) { return { valid: false, conflictIds: [] } @@ -1121,7 +1238,7 @@ export class SpatialGridManager { } } - const nodes = useScene.getState().nodes + const nodes = this.source.nodes() const ignoreSet = new Set(ignoreIds ?? []) const [width, , depth] = dimensions const yRot = rotation[1] @@ -1180,13 +1297,70 @@ export class SpatialGridManager { // Singleton instance export const spatialGridManager = new SpatialGridManager() +const activeGrids: Array<() => SpatialGridManager> = [] + +/** + * The manager support queries read: the editor's singleton, or, inside `withSpatialGrid`, the one + * a detached scene builds from its own nodes (scene runtime). + */ +export function activeSpatialGrid(): SpatialGridManager { + return activeGrids.at(-1)?.() ?? spatialGridManager +} + +export function withSpatialGrid(grid: () => SpatialGridManager, run: () => T): T { + activeGrids.push(grid) + try { + return run() + } finally { + activeGrids.pop() + } +} + +/** A manager indexed over `nodes()` as they are now, without previews. */ +export function createSnapshotSpatialGrid( + nodes: () => Record, + levelIdOf: (node: AnyNode, nodes: Record) => string, +): SpatialGridManager { + const grid = new SpatialGridManager(0.5, snapshotSpatialGridSource(nodes), { + lazyItems: levelIdOf, + }) + grid.bulkLoad(nodes()) + return grid +} + +type NodesStore = { getState(): { nodes: Record } } +const supportIndexes = new WeakMap< + object, + { nodes: Record; grid: SpatialGridManager } +>() + +/** + * The support index of a store's saved nodes, kept per store and brought up to date in place on + * each use (`syncSnapshot`), rebuilt only when that is simpler: what reconciliation queries. + */ +export function supportIndexOf( + store: NodesStore, + levelIdOf: (node: AnyNode, nodes: Record) => string, +): SpatialGridManager { + const nodes = store.getState().nodes + const cached = supportIndexes.get(store) + if (cached?.nodes === nodes) return cached.grid + if (cached?.grid.syncSnapshot(cached.nodes, nodes)) { + cached.nodes = nodes + return cached.grid + } + const grid = createSnapshotSpatialGrid(() => store.getState().nodes, levelIdOf) + supportIndexes.set(store, { nodes, grid }) + return grid +} + /** Level-local Y where the rendered wall mesh begins. */ export function getWallBaseElevationForNodes( wall: WallNode, nodes: Record, ): number { const levelId = resolveNodeLevelId(wall, nodes) - return spatialGridManager.getSlabSupportForWall( + return activeSpatialGrid().getSlabSupportForWall( levelId, wall.start, wall.end, @@ -1211,7 +1385,7 @@ export function getWallEffectiveHeightForNodes( nodes: Record, ): number { const levelId = resolveNodeLevelId(wall, nodes) - const support = spatialGridManager.getSlabSupportForWall( + const support = activeSpatialGrid().getSlabSupportForWall( levelId, wall.start, wall.end, diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts index 883ebcc53b..ea0f7ec347 100644 --- a/packages/core/src/hooks/spatial-grid/support-host-patch.ts +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -8,7 +8,7 @@ import { getFloorPlacedElevation, getFloorPlacedFootprints, } from './floor-placed-elevation' -import { SUPPORT_ELEVATION_EPSILON, spatialGridManager } from './spatial-grid-manager' +import { activeSpatialGrid, SUPPORT_ELEVATION_EPSILON } from './spatial-grid-manager' export type SupportSlabPatch = { supportSlabId: string | undefined } @@ -61,7 +61,7 @@ export function resolveSupportSlabPatch( for (const footprint of footprints) { const position = footprint.position ?? (node as { position?: unknown }).position if (!Array.isArray(position) || position.length !== 3) continue - const elevation = spatialGridManager.getHostSlabElevationForFootprint( + const elevation = activeSpatialGrid().getHostSlabElevationForFootprint( parent.id, options.preferredSlabId, position as [number, number, number], @@ -84,7 +84,7 @@ export function resolveSupportSlabPatch( for (const footprint of footprints) { const position = footprint.position ?? (node as { position?: unknown }).position if (!Array.isArray(position) || position.length !== 3) continue - const candidates = spatialGridManager.getSupportCandidatesForFootprint( + const candidates = activeSpatialGrid().getSupportCandidatesForFootprint( parent.id, position as [number, number, number], footprint.dimensions, @@ -92,7 +92,7 @@ export function resolveSupportSlabPatch( ) for (const candidate of candidates) candidateElevations.add(candidate.elevation) - const support = spatialGridManager.getSlabSupportForItem( + const support = activeSpatialGrid().getSlabSupportForItem( parent.id, position as [number, number, number], footprint.dimensions, @@ -179,7 +179,7 @@ export function resolveWallSupportSlabPatch( // Winner under the pointer cap (when given): a deck hanging above the // aimed-at surface can't capture the elected base, so a wall drawn at the // floor underneath it persists the floor slab the user actually targeted. - const support = spatialGridManager.getSlabSupportForWall( + const support = activeSpatialGrid().getSlabSupportForWall( parent.id, wall.start, wall.end, @@ -194,7 +194,7 @@ export function resolveWallSupportSlabPatch( for (const node of Object.values(nodes)) { if (node.type !== 'slab' || node.parentId !== parent.id) continue const candidate = node as SlabNode - const preferred = spatialGridManager.getSlabSupportForWall( + const preferred = activeSpatialGrid().getSlabSupportForWall( parent.id, wall.start, wall.end, @@ -324,7 +324,7 @@ export function resolveFenceSupportSlabPatch( const rotation: [number, number, number] = [0, Math.atan2(bz - az, bx - ax), 0] if (options?.preferredSlabId) { - const preferredElevation = spatialGridManager.getHostSlabElevationForFootprint( + const preferredElevation = activeSpatialGrid().getHostSlabElevationForFootprint( parent.id, options.preferredSlabId, position, @@ -339,7 +339,7 @@ export function resolveFenceSupportSlabPatch( } } - const candidates = spatialGridManager.getSupportCandidatesForFootprint( + const candidates = activeSpatialGrid().getSupportCandidatesForFootprint( parent.id, position, dimensions, @@ -347,7 +347,7 @@ export function resolveFenceSupportSlabPatch( ) for (const candidate of candidates) candidateElevations.add(candidate.elevation) - const support = spatialGridManager.getSlabSupportForItem( + const support = activeSpatialGrid().getSlabSupportForItem( parent.id, position, dimensions, @@ -516,7 +516,7 @@ export function resolveWallConstruction( maxElevation: wallOptions?.supportCap ?? null, preferredSlabId: preferredSupportSlabId, }) - const sourceSupport = spatialGridManager.getSlabSupportForWall( + const sourceSupport = activeSpatialGrid().getSlabSupportForWall( levelId, createdWall.start, createdWall.end, diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 7902be6723..fc6710f76f 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -7,6 +7,7 @@ import { } from '../schema' import { generateId } from '../schema/base' import { findLevelAboveId, getLevelBelow, getLevelElevations } from '../services/storey' +import { isSettlingDocument } from '../store/document-settling' import { activeSceneCommitNodeIds, getSceneHistoryPauseDepth, @@ -16,6 +17,11 @@ import { resumeSceneHistory, subscribeSceneCommits, } from '../store/history-control' +import { + currentSceneEnvironment, + type SceneEnvironment, + sceneEnvironmentOf, +} from '../store/scene-environment' import { getClampedWallCurveOffset } from '../systems/wall/wall-curve' import { changedLevelConstructionDisplacements } from './floor-foundation-stack' import { createFloorOpeningIndex, floorOpeningTargets } from './floor-opening-intent' @@ -89,7 +95,12 @@ function wallGeometrySignature(wall: WallNode, nodes: Record, level // Plane-bound (no stored height) is a distinct state, not a default // value: it resolves to the storey plane, so it must not alias an // explicit height of the same magnitude in the trigger signature. - wall.height == null ? 'plane' : wall.height.toFixed(4), + // A legacy height that is not a number still signs, rather than throwing. + wall.height == null + ? 'plane' + : typeof wall.height === 'number' + ? wall.height.toFixed(4) + : String(wall.height), wall.supportSlabId ?? 'elected', (wall.supportOffset ?? 0).toFixed(4), getClampedWallCurveOffset(wall).toFixed(4), @@ -413,7 +424,8 @@ function fallbackLevelIdsForCandidates( const changed = !old || !next || - ['thickness', 'floorHeight', 'polygon', 'holes', 'elevation'].some( + // Everything the covering underside below reads, as well as this level's own floor. + ['thickness', 'floorHeight', 'polygon', 'holes', 'elevation', 'recessed'].some( (field) => JSON.stringify((old as unknown as Record)[field]) !== JSON.stringify((next as unknown as Record)[field]), @@ -467,6 +479,48 @@ export function detectSpacesForLevel(levelId: string, walls: BoundaryNode[]) { return detectSpacesFromWalls(levelId, walls) } +/** + * Levels whose structure intent differs between two versions of a scene: what a paused batch's + * resume (and a detached scene's reconciliation) re-derives. + */ +export function changedStructureLevelIds(before: SceneNodes, after: SceneNodes): string[] { + const previous = levelStructureSnapshots(before) + const current = levelStructureSnapshots(after) + return [...new Set([...previous.keys(), ...current.keys()])].filter( + (id) => previous.get(id) !== current.get(id), + ) +} + +/** + * Levels whose structure an edit from `before` to `after` asks to re-derive, chosen as space + * detection chooses them for a store commit: from the nodes the edit changed (walls by level, and + * the fallbacks for levels, stairs, openings, slabs, ceilings, zones and separators), without + * signing every level of the scene. + */ +export function structureLevelsForChange(before: SceneNodes, after: SceneNodes): string[] { + const candidateIds = new Set() + for (const id in after) + if (after[id] !== before[id] && !onlyChildrenChanged(before[id], after[id])) + candidateIds.add(id as AnyNodeId) + for (const id in before) if (!(id in after)) candidateIds.add(id as AnyNodeId) + if (!candidateIds.size) return [] + const levels = fallbackLevelIdsForCandidates(before, after, candidateIds) + for (const levelId of changedWallIdsByLevel(before, after, candidateIds).keys()) + levels.add(levelId) + return [...levels] +} + +/** + * A parent whose child list alone changed: the children created or deleted are candidates of + * their own, so the parent adds nothing (an item placed on a level re-derives no structure). + */ +function onlyChildrenChanged(before: SceneNodes[string], after: SceneNodes[string]): boolean { + if (!(before && after)) return false + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) + if (key !== 'children' && before[key] !== after[key]) return false + return true +} + function syncSceneStructure( levelIds: string[], sceneStore: any, @@ -492,24 +546,31 @@ function publishSpaces(levelIds: string[], nodes: SceneNodes, editorStore: any) // Batch writers pause intermediate topology; the final resume reconciles every // level whose intent changed, including batches completed outside a store commit. -let spaceDetectionPauseDepth = 0 -const resumeListeners = new Set<() => void>() +// Per scene environment: a detached scene's batch never pauses (or resumes) the editor's. +type SpaceDetectionPause = { depth: number; resumeListeners: Set<() => void> } +const SPACE_DETECTION_PAUSE = Symbol('space-detection-pause') +const pauseOf = (environment: SceneEnvironment = currentSceneEnvironment()) => + environment.slot(SPACE_DETECTION_PAUSE, () => ({ + depth: 0, + resumeListeners: new Set(), + })) /** Pause structure reconciliation. Refcounted — pair with `resumeSpaceDetection`. */ export function pauseSpaceDetection(): void { - spaceDetectionPauseDepth += 1 + pauseOf().depth += 1 } /** Resume structure reconciliation. No-op if not currently paused. */ export function resumeSpaceDetection(): void { - if (spaceDetectionPauseDepth === 0) return - spaceDetectionPauseDepth -= 1 - if (spaceDetectionPauseDepth === 0) for (const reconcile of resumeListeners) reconcile() + const pause = pauseOf() + if (pause.depth === 0) return + pause.depth -= 1 + if (pause.depth === 0) for (const reconcile of pause.resumeListeners) reconcile() } /** True iff structure reconciliation is currently paused. */ export function isSpaceDetectionPaused(): boolean { - return spaceDetectionPauseDepth > 0 + return pauseOf().depth > 0 } export function createRoomTopologyIndex() { @@ -530,6 +591,7 @@ export function initSpaceDetectionSync( // scene that merely loaded — rerunning on hydration resurrected auto slabs // the user had deleted in an earlier session. const initialNodes = sceneStore.getState().nodes + const pause = pauseOf(sceneEnvironmentOf(sceneStore)) const topologyIndex = new RoomTopologyIndex({ detectRooms: extractRooms, sampleWall: (wall) => sampleWallPointsForRoomDetection(wall).map(pointToTuple), @@ -556,10 +618,12 @@ export function initSpaceDetectionSync( adoptSceneBaseline(initialNodes) - const unsubscribeCommits = subscribeSceneCommits((commit) => { - if (commit.origin === 'local') return - adoptSceneBaseline(commit.current.nodes) - }) + const unsubscribeCommits = sceneEnvironmentOf(sceneStore).run(() => + subscribeSceneCommits((commit) => { + if (commit.origin === 'local') return + adoptSceneBaseline(commit.current.nodes) + }), + ) // Keep reconciliation in this synchronous store subscription. Zundo emits // the originating local SceneCommit only after subscribers return, so the @@ -572,13 +636,29 @@ export function initSpaceDetectionSync( adoptSceneBaseline(state.nodes) return } - if (getSceneHistoryPauseDepth() > 0 || sceneStore.temporal.getState().isTracking === false) + // A settling host derives this edit's structure itself (reconcileDocument): only the topology + // and spaces of the levels whose walls it changed are brought up to date here. Its derived + // writes are history-paused, so this runs before the pause check: the baseline must take them + // in, or the next edit would derive the host's rooms again from before it. + if (isSettlingDocument(sceneStore)) { + const levels = [ + ...changedWallIdsByLevel(previousNodes, state.nodes, activeSceneCommitNodeIds()).keys(), + ] + for (const levelId of levels) topologyIndex.rebuildLevel(levelId, state.nodes) + if (levels.length) publishSpaces(levels, state.nodes, editorStore) + previousNodes = state.nodes + return + } + if ( + getSceneHistoryPauseDepth(sceneStore) > 0 || + sceneStore.temporal.getState().isTracking === false + ) return const nodes = state.nodes const candidateIds = activeSceneCommitNodeIds() - if (spaceDetectionPauseDepth > 0) return + if (pause.depth > 0) return const changedWalls = changedWallIdsByLevel(previousNodes, nodes, candidateIds) if (candidateIds && changedWalls.size > 0) { @@ -664,12 +744,7 @@ export function initSpaceDetectionSync( return { nodes, rootNodeIds, collections, materials, installedPlugins } } const beforeCommit = capture() - const nodes = beforeCommit.nodes - const before = levelStructureSnapshots(previousNodes) - const after = levelStructureSnapshots(nodes) - const levels = [...new Set([...before.keys(), ...after.keys()])].filter( - (id) => before.get(id) !== after.get(id), - ) + const levels = changedStructureLevelIds(previousNodes, beforeCommit.nodes) if (!levels.length) return isProcessing = true pauseSceneHistory(sceneStore) @@ -686,12 +761,12 @@ export function initSpaceDetectionSync( // so collaboration receives the originator's ids even for AI/template batches. notifySceneCommit({ origin: 'local', before: beforeCommit, current: capture() }) } - resumeListeners.add(reconcilePaused) + pause.resumeListeners.add(reconcilePaused) return () => { unsubscribe() unsubscribeCommits() - resumeListeners.delete(reconcilePaused) + pause.resumeListeners.delete(reconcilePaused) } } diff --git a/packages/core/src/lib/terrain-source.ts b/packages/core/src/lib/terrain-source.ts index 12845e5725..8e3327922f 100644 --- a/packages/core/src/lib/terrain-source.ts +++ b/packages/core/src/lib/terrain-source.ts @@ -1,4 +1,5 @@ import type { SiteNode } from '../schema/nodes/site' +import { livePreviewsVisible } from '../store/live-previews' import useLiveTerrain from '../store/use-live-terrain' import type { TerrainField } from './terrain-field' import { persistedTerrainFieldOf } from './terrain-source-persisted' @@ -28,7 +29,7 @@ export { export function terrainFieldOf( site: (Pick & { id?: string }) | null | undefined, ): TerrainField | null { - if (site?.id) { + if (site?.id && livePreviewsVisible()) { const live = useLiveTerrain.getState().fieldOf(site.id) if (live) return live } diff --git a/packages/core/src/runtime/__fixtures__/live-scene.ts b/packages/core/src/runtime/__fixtures__/live-scene.ts new file mode 100644 index 0000000000..28161ba67c --- /dev/null +++ b/packages/core/src/runtime/__fixtures__/live-scene.ts @@ -0,0 +1,69 @@ +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { initSpatialGridSync } from '../../hooks/spatial-grid/spatial-grid-sync' +import { initSpaceDetectionSync } from '../../lib/space-detection' +import type { SceneSnapshot } from '../../store/history-control' +import useScene, { clearSceneHistory, getSceneDocument } from '../../store/use-scene' +import { initializeElevatorOpeningSync } from '../../systems/elevator/elevator-opening-system' +import { initializeRoofElevationSync } from '../../systems/roof/roof-elevation-system' +import { initializeStairOpeningSync } from '../../systems/stair/stair-opening-system' +import { loadCanonicalSnapshot } from '../scene-runtime' + +/** The editor's document systems, as a mounted editor runs them (without a renderer). */ +function mountDocumentSystems(): () => void { + let spaces = {} + const editor = { + getState: () => ({ + spaces, + setSpaces: (next: object) => { + spaces = next + }, + }), + } + const stops = [ + initSpatialGridSync(), + initSpaceDetectionSync(useScene, editor), + initializeStairOpeningSync(), + initializeElevatorOpeningSync(), + initializeRoofElevationSync(), + ] + return () => { + for (const stop of stops.reverse()) stop() + } +} + +export async function settle(): Promise { + for (let i = 0; i < 6; i++) await Promise.resolve() +} + +let unmount: (() => void) | null = null + +/** + * Opens `snapshot` in the editor's store with its document systems mounted, lets their load-time + * normalisation settle, and returns the document they leave: the canonical snapshot of a scene as + * the editor holds it. History starts empty there. + */ +export async function openLiveScene(snapshot: SceneSnapshot): Promise { + closeLiveScene() + loadCanonicalSnapshot(useScene, snapshot) + clearSceneHistory() + unmount = mountDocumentSystems() + await settle() + clearSceneHistory() + return getSceneDocument() +} + +export function closeLiveScene(): void { + unmount?.() + unmount = null + spatialGridManager.clear() +} + +export function snapshotOf(graph: { nodes: Record; rootNodeIds: string[] }) { + return { + nodes: graph.nodes, + rootNodeIds: graph.rootNodeIds, + collections: {}, + materials: {}, + installedPlugins: [], + } as unknown as SceneSnapshot +} diff --git a/packages/core/src/runtime/apply.ts b/packages/core/src/runtime/apply.ts new file mode 100644 index 0000000000..8d909537d8 --- /dev/null +++ b/packages/core/src/runtime/apply.ts @@ -0,0 +1,364 @@ +import type { AnyNode, AnyNodeId, Collection } from '../schema' +import type { SceneSnapshot } from '../store/history-control' +import { + nextSceneOperationState, + type SceneCollectionPatch, + type SceneNodeStructuralPatch, + type SceneOperationPatch, +} from '../store/use-scene' +import { type Dependency, levelOf, queryIds } from './dependencies' +import { childIdsOf, fieldChanges, sameValue } from './patch' + +/** Something that changed between a computation's base and the scene it is applied to. */ +export type ChangedEntity = { + kind: + | 'node' + | 'collection' + | 'material' + | 'plugins' + | 'query' + | 'order' + | 'scope' + | 'file' + | 'artifact' + | 'patch' + /** The entity: a node, collection or material id, a query's entity, a scope's level. */ + id?: string + field?: string + /** + * `dependency`: something the computation read is different now. `write`: something it writes + * was also changed by someone else. `invalid`: the patch no longer applies at all. + */ + reason: 'dependency' | 'write' | 'invalid' +} + +/** Revisions the host looks up for `file` and `artifact` dependencies. */ +export type ExternalRevisions = { + fileUpdatedAt?(assetId: string): string | null + artifactExists?(sha256: string): boolean +} + +export type PatchApplication = + | { ok: true; next: SceneSnapshot; patch: SceneOperationPatch } + | { ok: false; changed: ChangedEntity[] } + +type Doc = SceneSnapshot + +const nodeOf = (doc: Doc, id: string) => doc.nodes[id as AnyNodeId] as AnyNode | undefined +const fieldOf = (node: AnyNode | undefined, field: string) => + node ? (node as Record)[field] : undefined + +/** Ids of nodes that differ between two documents (created, deleted or changed). */ +function changedNodeIds(base: Doc, current: Doc): string[] { + const ids: string[] = [] + for (const id of new Set([...Object.keys(base.nodes), ...Object.keys(current.nodes)])) { + const before = nodeOf(base, id) + const after = nodeOf(current, id) + if (before !== after && !sameValue(before, after)) ids.push(id) + } + return ids +} + +function ancestorIds(doc: Doc, id: string | undefined): string[] { + const ids: string[] = [] + let parentId = id ? (nodeOf(doc, id)?.parentId as string | null | undefined) : null + while (parentId && !ids.includes(parentId)) { + ids.push(parentId) + parentId = nodeOf(doc, parentId)?.parentId as string | null | undefined + } + return ids +} + +function documentChanged(base: Doc, current: Doc): boolean { + return ( + changedNodeIds(base, current).length > 0 || + !sameValue(base.rootNodeIds, current.rootNodeIds) || + !sameValue(base.collections, current.collections) || + !sameValue(base.materials, current.materials) || + !sameValue(base.installedPlugins, current.installedPlugins) + ) +} + +function dependencyChanges( + dependency: Dependency, + base: Doc, + current: Doc, + external: ExternalRevisions, + changedIds: () => string[], +): ChangedEntity[] { + const changed = (entity: Omit): ChangedEntity[] => [ + { ...entity, reason: 'dependency' }, + ] + switch (dependency.kind) { + case 'node': { + const before = nodeOf(base, dependency.id) + const after = nodeOf(current, dependency.id) + if (!before !== !after) return changed({ kind: 'node', id: dependency.id }) + return dependency.fields.flatMap((field) => + sameValue(fieldOf(before, field), fieldOf(after, field)) + ? [] + : changed({ kind: 'node', id: dependency.id, field }), + ) + } + case 'query': + return sameValue(queryIds(base, dependency), queryIds(current, dependency)) + ? [] + : changed({ kind: 'query', id: dependency.entity }) + case 'order': + return sameValue( + childIdsOf(nodeOf(base, dependency.parentId)), + childIdsOf(nodeOf(current, dependency.parentId)), + ) + ? [] + : changed({ kind: 'order', id: dependency.parentId }) + case 'collection': { + const before = base.collections[dependency.id as Collection['id']] + const after = current.collections[dependency.id as Collection['id']] + if (!before !== !after) return changed({ kind: 'collection', id: dependency.id }) + return dependency.fields.flatMap((field) => + sameValue( + (before as Record | undefined)?.[field], + (after as Record | undefined)?.[field], + ) + ? [] + : changed({ kind: 'collection', id: dependency.id, field }), + ) + } + case 'material': + return sameValue( + base.materials[dependency.id as keyof Doc['materials']], + current.materials[dependency.id as keyof Doc['materials']], + ) + ? [] + : changed({ kind: 'material', id: dependency.id }) + case 'plugins': + return sameValue(base.installedPlugins, current.installedPlugins) && + (base.hasExplicitPluginInstallState ?? false) === + (current.hasExplicitPluginInstallState ?? false) + ? [] + : changed({ kind: 'plugins' }) + case 'file': + return external.fileUpdatedAt?.(dependency.assetId) === dependency.updatedAt + ? [] + : changed({ kind: 'file', id: dependency.assetId }) + case 'artifact': + return external.artifactExists?.(dependency.sha256) + ? [] + : changed({ kind: 'artifact', id: dependency.sha256 }) + case 'scope': { + if (dependency.whole) return documentChanged(base, current) ? changed({ kind: 'scope' }) : [] + const level = dependency.level + // The level's own content, and what it stands on: its building and site (transforms, + // sculpted terrain) reach every support answer on it. + const ancestors = new Set([...ancestorIds(base, level), ...ancestorIds(current, level)]) + const touched = changedIds().some( + (id) => + id === level || + ancestors.has(id) || + levelOf(base.nodes, id) === level || + levelOf(current.nodes, id) === level, + ) + return touched ? changed({ kind: 'scope', id: level }) : [] + } + } +} + +function writeChanges(patch: SceneOperationPatch, base: Doc, current: Doc): ChangedEntity[] { + const changed: ChangedEntity[] = [] + const write = (entity: Omit) => + changed.push({ ...entity, reason: 'write' }) + const createdIds = new Set(patch.nodeCreates.map(({ node }) => node.id as string)) + + for (const { id, data, removeFields } of patch.nodeUpdates) { + const before = nodeOf(base, id) + const after = nodeOf(current, id) + if (!after) { + write({ kind: 'node', id }) + continue + } + const written: Record = { ...data } + for (const field of removeFields) written[field] = undefined + for (const [field, value] of Object.entries(written)) { + const theirs = fieldOf(after, field) + if (!sameValue(fieldOf(before, field), theirs) && !sameValue(theirs, value)) + write({ kind: 'node', id, field }) + } + } + for (const { node } of patch.nodeDeletes) { + const after = nodeOf(current, node.id) + if (!after || !sameValue(nodeOf(base, node.id), after)) write({ kind: 'node', id: node.id }) + } + for (const { node } of patch.nodeCreates) { + if (nodeOf(current, node.id)) write({ kind: 'node', id: node.id }) + const parentId = node.parentId as string | null | undefined + if (parentId && !createdIds.has(parentId) && !nodeOf(current, parentId)) + write({ kind: 'node', id: parentId }) + } + for (const { id, collection } of patch.collectionChanges ?? []) { + const before = base.collections[id] + const after = current.collections[id] + if (collection === null || !before) { + if (!sameValue(before, after) && !sameValue(after, collection)) + write({ kind: 'collection', id }) + continue + } + if (!after) { + write({ kind: 'collection', id }) + continue + } + const { data, removeFields } = fieldChanges(before, collection) + const written: Record = { ...data } + for (const field of removeFields) written[field] = undefined + for (const [field, value] of Object.entries(written)) { + const theirs = (after as Record)[field] + if ( + !sameValue((before as Record)[field], theirs) && + !sameValue(theirs, value) + ) + write({ kind: 'collection', id, field }) + } + } + for (const { id, material } of patch.materialChanges) { + const after = current.materials[id] + if (!sameValue(base.materials[id], after) && !sameValue(after, material ?? undefined)) + write({ kind: 'material', id }) + } + if ( + (patch.pluginInstallState || patch.pluginChanges?.length) && + !sameValue(base.installedPlugins, current.installedPlugins) + ) + write({ kind: 'plugins' }) + return changed +} + +/** + * Where a patch's created siblings go among `currentSiblings`: each after the sibling it followed + * in the patch's own result (the nearest earlier one still there), so creates made independently + * on the same parent all keep their place. + */ +function rebasedCreates( + intended: readonly string[], + currentSiblings: readonly string[], + creates: readonly SceneNodeStructuralPatch[], +): SceneNodeStructuralPatch[] { + const created = new Set(creates.map(({ node }) => node.id as string)) + const present = new Set(currentSiblings) + const after = new Map() + let anchor: string | null = null + for (const id of intended) { + if (created.has(id)) { + const list = after.get(anchor) ?? [] + list.push(id) + after.set(anchor, list) + } else if (present.has(id)) anchor = id + } + const final = [...(after.get(null) ?? [])] + for (const id of currentSiblings) final.push(id, ...(after.get(id) ?? [])) + return creates.map((create) => ({ node: create.node, position: final.indexOf(create.node.id) })) +} + +/** The patch with its structural positions and collection writes moved onto `current`. */ +function rebase(patch: SceneOperationPatch, base: Doc, current: Doc): SceneOperationPatch { + const deleted = new Set(patch.nodeDeletes.map(({ node }) => node.id as string)) + const created = new Set(patch.nodeCreates.map(({ node }) => node.id as string)) + const childUpdates = new Map( + patch.nodeUpdates.flatMap(({ id, data }) => + 'children' in data ? [[id as string, data.children as unknown as string[]] as const] : [], + ), + ) + const siblingsOf = (doc: Doc, parentId: string | null) => + (parentId ? childIdsOf(nodeOf(doc, parentId)) : doc.rootNodeIds) as string[] + + const nodeDeletes = patch.nodeDeletes.map(({ node }) => { + const theirs = nodeOf(current, node.id)! + const parentId = (theirs.parentId as string | null | undefined) ?? null + return { node: theirs, position: siblingsOf(current, parentId).indexOf(node.id) } + }) + + const createsByParent = new Map() + const nodeCreates: SceneNodeStructuralPatch[] = [] + for (const create of patch.nodeCreates) { + const parentId = (create.node.parentId as string | null | undefined) ?? null + if (parentId && created.has(parentId)) { + nodeCreates.push(create) + continue + } + const list = createsByParent.get(parentId) ?? [] + list.push(create) + createsByParent.set(parentId, list) + } + for (const [parentId, creates] of createsByParent) { + const reordered = parentId ? childUpdates.get(parentId) : undefined + const kept = (siblings: string[]) => reordered ?? siblings.filter((id) => !deleted.has(id)) + const intended = [...kept(siblingsOf(base, parentId))] + for (const create of [...creates].sort((a, b) => a.position - b.position)) + intended.splice(create.position, 0, create.node.id) + nodeCreates.push(...rebasedCreates(intended, kept(siblingsOf(current, parentId)), creates)) + } + + const collectionChanges: SceneCollectionPatch[] = (patch.collectionChanges ?? []).map( + ({ id, collection }) => { + const before = base.collections[id] + const after = current.collections[id] + if (!(collection && before && after)) return { id, collection } + const { data, removeFields } = fieldChanges(before, collection) + const merged: Record = { ...after, ...data } + for (const field of removeFields) delete merged[field] + return { id, collection: merged as Collection } + }, + ) + + return { + ...patch, + nodeCreates, + nodeDeletes, + ...(patch.collectionChanges ? { collectionChanges } : {}), + } +} + +/** + * Applies a patch computed from `baseDoc` to `current`, the scene as it is now, or says what + * changed meanwhile so it cannot be: + * + * - every dependency (field, query membership with absent matches, order, collection, material, + * plugins, file, artifact, scope) is compared between `baseDoc` and `current`; + * - every field the patch writes must not have been changed meanwhile to another value; + * - otherwise the patch lands field by field: other fields others changed on the same node or + * collection are kept, and a parent's children created independently keep their place. + * + * Returns the next document and the patch rebased onto `current` (what an editor applies). + */ +export function applyPatchWithDependencies( + current: SceneSnapshot, + patch: SceneOperationPatch, + dependencies: readonly Dependency[], + baseDoc: SceneSnapshot, + external: ExternalRevisions = {}, +): PatchApplication { + let changedIds: string[] | null = null + const lazyChangedIds = () => { + changedIds ??= changedNodeIds(baseDoc, current) + return changedIds + } + const changed = [ + ...dependencies.flatMap((dependency) => + dependencyChanges(dependency, baseDoc, current, external, lazyChangedIds), + ), + ...writeChanges(patch, baseDoc, current), + ] + if (changed.length) return { ok: false, changed: dedupe(changed) } + const rebased = rebase(patch, baseDoc, current) + const next = nextSceneOperationState(current, rebased) + if (!next) return { ok: false, changed: [{ kind: 'patch', reason: 'invalid' }] } + return { ok: true, next: { ...current, ...next }, patch: rebased } +} + +function dedupe(changed: ChangedEntity[]): ChangedEntity[] { + const seen = new Set() + return changed.filter((entity) => { + const key = JSON.stringify(entity) + if (seen.has(key)) return false + seen.add(key) + return true + }) +} diff --git a/packages/core/src/runtime/dependencies.ts b/packages/core/src/runtime/dependencies.ts new file mode 100644 index 0000000000..c5ec880526 --- /dev/null +++ b/packages/core/src/runtime/dependencies.ts @@ -0,0 +1,109 @@ +import { findLevelAncestorId } from '../lib/node-ancestry' +import type { AnyNode, AnyNodeId, Collection } from '../schema' + +/** Nodes a query selects: every node, or those of a kind and/or under a parent (`null`: roots). */ +export type NodePredicate = { type?: string | readonly string[]; parentId?: string | null } +/** Collections a query selects: by name (case-insensitive) and/or template. */ +export type CollectionPredicate = { name?: string; template?: string } + +export type EntityQuery = + | { entity: 'node'; where?: NodePredicate } + | { entity: 'collection'; where?: CollectionPredicate } + | { entity: 'material' } + | { entity: 'root' } + +/** + * What a computation read, recorded so a later apply can tell whether the scene it read still + * holds. Every dependency is compared between the scene the computation started from and the + * scene at apply time; any difference is a conflict. + * + * - `node`: the node's presence, and the fields read (top level; `[]` when only its presence was). + * - `query`: the ids an entity query matched, absent matches included (`[]` when none did). + * - `order`: a parent's children, in order, where the order was consumed. + * - `collection`: a collection's presence and the fields read. `material`: a material's record. + * - `plugins`: the installed plugin list. `file` and `artifact`: a project file at a revision, + * a stored artifact, declared by the host that read them. + * - `scope`: a conservative fallback for reads the recorder cannot see: everything on a level, + * or the whole document. + */ +export type Dependency = + | { kind: 'node'; id: string; fields: readonly string[] } + | { kind: 'query'; entity: 'node'; predicate: NodePredicate | null; resultIds: readonly string[] } + | { + kind: 'query' + entity: 'collection' + predicate: CollectionPredicate | null + resultIds: readonly string[] + } + | { kind: 'query'; entity: 'material' | 'root'; predicate: null; resultIds: readonly string[] } + | { kind: 'order'; parentId: string; childIds: readonly string[] } + | { kind: 'collection'; id: string; fields: readonly string[] } + | { kind: 'material'; id: string } + | { kind: 'plugins' } + | { kind: 'file'; assetId: string; updatedAt: string } + | { kind: 'artifact'; sha256: string } + | { kind: 'scope'; level?: string; whole?: true } + +export type QueryDependency = Extract + +export function matchesNodePredicate(node: AnyNode, predicate: NodePredicate | null): boolean { + if (!predicate) return true + const { type, parentId } = predicate + if ( + type !== undefined && + !(typeof type === 'string' ? node.type === type : type.includes(node.type)) + ) + return false + if (parentId !== undefined && (node.parentId ?? null) !== parentId) return false + return true +} + +export function matchesCollectionPredicate( + collection: Collection, + predicate: CollectionPredicate | null, +): boolean { + if (!predicate) return true + if ( + predicate.name !== undefined && + collection.name.toLowerCase() !== predicate.name.toLowerCase() + ) + return false + if (predicate.template !== undefined && collection.template !== predicate.template) return false + return true +} + +/** The ids `query` selects in `doc`, in the document's own order. */ +/** The parts of a document queries read. */ +export type DocumentParts = { + nodes: Readonly> + collections: Readonly> + materials: Readonly> + rootNodeIds: readonly string[] +} + +export function queryIds( + doc: DocumentParts, + query: Pick, +): string[] { + switch (query.entity) { + case 'node': + return Object.values(doc.nodes) + .filter((node) => matchesNodePredicate(node, query.predicate as NodePredicate | null)) + .map((node) => node.id) + case 'collection': + return Object.values(doc.collections) + .filter((collection) => + matchesCollectionPredicate(collection, query.predicate as CollectionPredicate | null), + ) + .map((collection) => collection.id) + case 'material': + return Object.keys(doc.materials) + case 'root': + return [...doc.rootNodeIds] + } +} + +/** The level a node belongs to in `nodes`, or null outside every level. */ +export function levelOf(nodes: Readonly>, id: string): string | null { + return findLevelAncestorId(id as AnyNodeId, nodes as Record) +} diff --git a/packages/core/src/runtime/detached-scene.test.ts b/packages/core/src/runtime/detached-scene.test.ts new file mode 100644 index 0000000000..d02585c745 --- /dev/null +++ b/packages/core/src/runtime/detached-scene.test.ts @@ -0,0 +1,1021 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { + AGENT_OPERATIONS, + applyAgentOutcome, + editCollection, + type SceneChanges, +} from '../agent-operations' +import { AGENT_TOOL_CASES } from '../agent-operations/__fixtures__/cases' +import { AGENT_TOOL_CONTRACTS } from '../agent-tools' +import { activeSpatialGrid } from '../hooks/spatial-grid/spatial-grid-manager' +import { initSpaceDetectionSync } from '../lib/space-detection' +import { encodeTerrainField } from '../lib/terrain-codec' +import { applyHeightPatch, createTerrainField, flattenPatch } from '../lib/terrain-field' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + CeilingNode, + type Collection, + ItemNode, + LevelNode, + RoofNode, + RoofSegmentNode, + SiteNode, + SlabNode, + StairNode, + StairSegmentNode, + WallNode, + ZoneNode, +} from '../schema' +import { SceneMaterial } from '../schema/scene-material' +import { type SceneCommit, subscribeSceneCommits } from '../store/history-control' +import useLiveTerrain from '../store/use-live-terrain' +import useScene, { + applySceneOperationPatch, + clearSceneHistory, + getSceneDocument, +} from '../store/use-scene' +import { planRoofElevationUpdates } from '../systems/roof/roof-elevation' +import { closeLiveScene, openLiveScene, settle, snapshotOf } from './__fixtures__/live-scene' +import { + applyPatchWithDependencies, + captureRegistryVersions, + createAgentHost, + createSceneEnvironment, + createSceneStore, + type DetachedScene, + effectIds, + isEmptyScenePatch, + liveSceneRuntime, + loadCanonicalSnapshot, + openDetachedScene, + orderedChildIds, + queryNodes, + SceneEnvironmentDisposedError, + type SceneSnapshot, + type SceneStore, + sceneDocumentOf, + untracked, +} from './index' + +const context = () => ({ + activeLevelId: null, + selection: [], + units: 'metric' as const, + catalogVersion: 'test', + registryVersions: captureRegistryVersions(), +}) + +function open(snapshot: SceneSnapshot, effectId = 'effect'): DetachedScene { + return openDetachedScene(snapshot, { + base: { kind: 'authority', seq: 7 }, + context: context(), + ids: effectIds(effectId), + }) +} + +const document = (...nodes: AnyNode[]): SceneSnapshot => + snapshotOf({ + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: nodes.filter((node) => !node.parentId).map((node) => node.id), + }) + +function caseScene(name: string): SceneSnapshot { + const c = AGENT_TOOL_CASES.find((candidate) => candidate.name === name)! + return snapshotOf(c.scene()) +} + +/** What a person does meanwhile, through the editor's own actions on a copy of `base`. */ +function editedBy(base: SceneSnapshot, edit: (store: SceneStore) => void): SceneSnapshot { + const store = createSceneStore(createSceneEnvironment('person')) + loadCanonicalSnapshot(store, base) + edit(store) + return sceneDocumentOf(store) +} + +function storeys() { + const level = LevelNode.parse({ id: 'level_a', parentId: 'building_a', level: 0, height: 3 }) + const upper = LevelNode.parse({ id: 'level_b', parentId: 'building_a', level: 1, height: 3 }) + const building = BuildingNode.parse({ id: 'building_a', children: [level.id, upper.id] }) + return { level, upper, building } +} + +describe('a detached scene is isolated', () => { + afterEach(() => { + closeLiveScene() + clearSceneHistory() + }) + + test("its writes, commits and history never reach the editor's store", async () => { + const live = await openLiveScene( + caseScene('a wall on the level named, numbered as the editor numbers walls'), + ) + const liveHistory = useScene.temporal.getState().pastStates + const commits: SceneCommit[] = [] + const stop = subscribeSceneCommits((commit) => commits.push(commit)) + let notified = 0 + const unsubscribe = useScene.subscribe(() => { + notified += 1 + }) + try { + const scene = open(live) + const other = open(live, 'other') + scene.host().applyChanges({ + create: [ + { node: WallNode.parse({ start: [0, 2], end: [4, 2] }), parentId: 'level_ground' }, + ], + }) + scene.store.temporal.getState().undo() + scene.store.temporal.getState().redo() + await settle() + expect(Object.keys(scene.store.getState().nodes).length).toBeGreaterThan( + Object.keys(live.nodes).length, + ) + expect(sceneDocumentOf(other.store)).toEqual(live) + expect(getSceneDocument()).toEqual(live) + expect(useScene.temporal.getState().pastStates).toBe(liveHistory) + expect(commits).toHaveLength(0) + expect(notified).toBe(0) + scene.dispose() + other.dispose() + } finally { + stop() + unsubscribe() + } + }) + + test('history is its own', () => { + const { level, upper, building } = storeys() + const scene = open(document(building, level, upper)) + scene.host().applyChanges({ update: [{ id: level.id, data: { name: 'Ground' } }] }) + expect(scene.store.temporal.getState().pastStates).toHaveLength(1) + scene.store.temporal.getState().undo() + expect(scene.store.getState().nodes[level.id]?.name).toBeUndefined() + expect(scene.patch().nodeUpdates).toEqual([]) + }) +}) + +describe('disposal', () => { + test('every later read or write throws, including a late async one', async () => { + const { level, upper, building } = storeys() + const scene = open(document(building, level, upper)) + const host = scene.host() + const view = scene.reader().get(level.id)! + const late = (async () => { + await Promise.resolve() + host.applyChanges({ update: [{ id: level.id, data: { name: 'Late' } }] }) + })() + scene.dispose() + expect(scene.disposed).toBe(true) + await expect(late).rejects.toBeInstanceOf(SceneEnvironmentDisposedError) + expect(() => view.height).toThrow(SceneEnvironmentDisposedError) + expect(() => scene.reader().get(level.id)).toThrow(SceneEnvironmentDisposedError) + expect(() => scene.reader().nodes).toThrow(SceneEnvironmentDisposedError) + expect(() => scene.patch()).toThrow(SceneEnvironmentDisposedError) + expect(() => scene.store.getState()).toThrow(SceneEnvironmentDisposedError) + }) +}) + +describe('ids', () => { + const run = (effectId: string) => { + const c = AGENT_TOOL_CASES.find((candidate) => candidate.tool === 'create_room')! + const scene = open(snapshotOf(c.scene()), effectId) + const input = z + .object(AGENT_TOOL_CONTRACTS.find((contract) => contract.name === 'create_room')!.input) + .parse(c.input) + scene.execute(AGENT_OPERATIONS.create_room, input as never, { activeLevelId: null }) + const patch = scene.patch() + scene.dispose() + return patch.nodeCreates.map(({ node }) => node.id) + } + + test('the same effect mints the same ids, derived construction included; another effect others', () => { + const first = run('effect_1') + expect(first.length).toBeGreaterThan(1) + expect(run('effect_1')).toEqual(first) + expect(run('effect_2').some((id) => first.includes(id))).toBe(false) + }) +}) + +describe('the recorder', () => { + test('records the fields read, not the fields copied through', () => { + const { level, upper, building } = storeys() + const scene = open(document(building, level, upper)) + const reader = scene.reader() + void reader.get(level.id)!.height + const copy = { ...untracked(reader.get(upper.id)!) } + expect(copy.height).toBe(3) + expect(scene.dependencies()).toEqual([ + { kind: 'node', id: level.id, fields: ['height'] }, + { kind: 'node', id: upper.id, fields: [] }, + ]) + }) + + test('records a query with its absent matches, and the order only where it is read', () => { + const { level, upper, building } = storeys() + const scene = open(document(building, level, upper)) + expect(queryNodes(scene.reader().nodes, { type: 'stair' })).toEqual([]) + queryNodes(scene.reader().nodes, { parentId: building.id }) + scene.reader().orderedChildren(building.id) + expect(scene.dependencies()).toEqual( + expect.arrayContaining([ + { kind: 'query', entity: 'node', predicate: { type: 'stair' }, resultIds: [] }, + { + kind: 'query', + entity: 'node', + predicate: { parentId: building.id }, + resultIds: [level.id, upper.id], + }, + { kind: 'order', parentId: building.id, childIds: [level.id, upper.id] }, + ]), + ) + }) +}) + +describe('applyPatchWithDependencies', () => { + test('a stair re-sized while its flights were reordered conflicts: the riser split read their order', () => { + const { level, upper, building } = storeys() + const first = StairSegmentNode.parse({ + id: 'sseg_first', + length: 2.5, + height: 1.5, + stepCount: 8, + }) + const second = StairSegmentNode.parse({ + id: 'sseg_second', + length: 2.5, + height: 1.5, + stepCount: 8, + }) + const stair = StairNode.parse({ + id: 'stair_a', + parentId: level.id, + children: [first.id, second.id], + }) + first.parentId = stair.id + second.parentId = stair.id + level.children = [stair.id] + const base = document(building, level, upper, stair, first, second) + const scene = open(base) + scene.execute(AGENT_OPERATIONS.fit_stair, { stairId: stair.id, fitRun: false } as never, { + activeLevelId: null, + }) + const patch = scene.patch() + expect(patch.nodeUpdates.map(({ id }) => id)).toContain(first.id) + const current = editedBy(base, (store) => + store.getState().updateNode(stair.id, { children: [second.id, first.id] }), + ) + const applied = applyPatchWithDependencies(current, patch, scene.dependencies(), base) + expect(applied.ok).toBe(false) + expect(!applied.ok && applied.changed).toContainEqual( + expect.objectContaining({ id: stair.id, reason: 'dependency' }), + ) + }) + + test('independent insertions on the same level both land, each where it was put', () => { + const c = AGENT_TOOL_CASES.find( + (candidate) => + candidate.name === 'items stand on the floor of the level named, turned in degrees', + )! + const base = snapshotOf(c.scene()) + const scene = open(base) + const input = z + .object(AGENT_TOOL_CONTRACTS.find((contract) => contract.name === 'place_items')!.input) + .parse(c.input) + scene.execute(AGENT_OPERATIONS.place_items, input as never, { + activeLevelId: null, + catalog: c.context?.catalog, + }) + const patch = scene.patch() + const placed = patch.nodeCreates.map(({ node }) => node.id) + expect(placed).toHaveLength(2) + const theirs = ItemNode.parse({ + id: 'item_theirs', + asset: { + id: 'chair', + name: 'Chair', + category: 'furniture', + thumbnail: '/items/chair/thumbnail.webp', + src: '/items/chair/model.glb', + }, + position: [5, 0, 5], + }) + const current = editedBy(base, (store) => store.getState().createNode(theirs, 'level_ground')) + const applied = applyPatchWithDependencies(current, patch, scene.dependencies(), base) + expect(applied.ok).toBe(true) + if (!applied.ok) return + const children = (applied.next.nodes['level_ground' as AnyNodeId] as { children: string[] }) + .children + expect(children).toEqual([ + ...(base.nodes['level_ground' as AnyNodeId] as { children: string[] }).children, + ...placed, + theirs.id, + ]) + for (const id of [...placed, theirs.id]) + expect(applied.next.nodes[id as AnyNodeId]).toBeDefined() + }) + + const lights: Collection = { + id: 'collection_lights' as Collection['id'], + name: 'Lights', + color: '#ffffff', + nodeIds: [], + } + const withLights = () => { + const { level, upper, building } = storeys() + return { ...document(building, level, upper), collections: { [lights.id]: lights } } + } + const editCollectionIn = (base: SceneSnapshot, input: Parameters[1]) => { + const scene = open(base) + const result = scene.run(() => + applyAgentOutcome( + editCollection( + { nodes: scene.reader().nodes, collections: scene.reader().collections }, + input, + ), + scene.host(), + ), + ) + return { scene, result, patch: scene.patch() } + } + + test('a rename lands beside a recolour made meanwhile', () => { + const base = withLights() + const { scene, patch } = editCollectionIn(base, { collectionId: lights.id, name: 'Lighting' }) + const current = editedBy(base, (store) => + store.getState().updateCollection(lights.id, { color: '#000000' }), + ) + const applied = applyPatchWithDependencies(current, patch, scene.dependencies(), base) + expect(applied.ok && applied.next.collections[lights.id]).toEqual({ + ...lights, + name: 'Lighting', + color: '#000000', + }) + }) + + test('two renames conflict', () => { + const base = withLights() + const { scene, patch } = editCollectionIn(base, { collectionId: lights.id, name: 'Lighting' }) + const current = editedBy(base, (store) => + store.getState().updateCollection(lights.id, { name: 'Lamps' }), + ) + const applied = applyPatchWithDependencies(current, patch, scene.dependencies(), base) + expect(!applied.ok && applied.changed).toEqual([ + { kind: 'collection', id: lights.id, field: 'name', reason: 'write' }, + ]) + }) + + test('a collection created because none had the name conflicts with one created meanwhile', () => { + const base = withLights() + const { scene, patch } = editCollectionIn(base, { name: 'Kitchen' }) + expect(patch.collectionChanges).toHaveLength(1) + const current = editedBy(base, (store) => { + store.getState().createCollection('kitchen') + }) + const applied = applyPatchWithDependencies(current, patch, scene.dependencies(), base) + expect(!applied.ok && applied.changed).toContainEqual({ + kind: 'query', + id: 'collection', + reason: 'dependency', + }) + }) + + test('a level added while another was added conflicts: the floor index read the levels', () => { + const { level, upper, building } = storeys() + const base = document(building, level, upper) + const scene = open(base) + scene.execute(AGENT_OPERATIONS.add_level, { buildingId: building.id } as never, { + activeLevelId: null, + }) + const patch = scene.patch() + const current = editedBy(base, (store) => + store.getState().createNode(LevelNode.parse({ level: 2, height: 3 }), building.id), + ) + const applied = applyPatchWithDependencies(current, patch, scene.dependencies(), base) + expect(!applied.ok && applied.changed).toContainEqual({ + kind: 'query', + id: 'node', + reason: 'dependency', + }) + }) + + test('a field written by both conflicts; other fields of the same node merge', () => { + const { level, upper, building } = storeys() + const base = document(building, level, upper) + const scene = open(base) + scene.host().applyChanges({ update: [{ id: level.id, data: { name: 'Ground' } }] }) + const patch = scene.patch() + const merged = applyPatchWithDependencies( + editedBy(base, (store) => store.getState().updateNode(level.id, { height: 3.2 })), + patch, + scene.dependencies(), + base, + ) + expect(merged.ok && merged.next.nodes[level.id]).toMatchObject({ name: 'Ground', height: 3.2 }) + const clashed = applyPatchWithDependencies( + editedBy(base, (store) => store.getState().updateNode(level.id, { name: 'Street' })), + patch, + scene.dependencies(), + base, + ) + expect(!clashed.ok && clashed.changed).toEqual([ + { kind: 'node', id: level.id, field: 'name', reason: 'write' }, + ]) + }) +}) + +describe('reconciliation', () => { + afterEach(closeLiveScene) + + /** Walls under a wall-supported roof one storey up, from the roof system's own case. */ + function roofScene(site?: { terrain?: unknown }, open = false): SceneSnapshot { + const level = LevelNode.parse({ + id: 'level_walls', + parentId: 'building_r', + height: 3, + level: 0, + }) + const upper = LevelNode.parse({ id: 'level_roof', parentId: 'building_r', height: 3, level: 1 }) + const corners: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + // Open (two walls, no room): the walls stand on the ground itself rather than a floor plate. + const walls = corners.slice(0, open ? 2 : 4).map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: level.id, + start, + end: corners[(index + 1) % 4]!, + height: 3, + }), + ) + const segment = RoofSegmentNode.parse({ id: 'rseg_r', parentId: 'roof_r', width: 4, depth: 3 }) + const roof = RoofNode.parse({ + id: 'roof_r', + parentId: upper.id, + support: { kind: 'walls' }, + position: [2, 0, 1.5], + children: open ? [segment.id] : [], + }) + level.children = walls.map((wall) => wall.id) + upper.children = [roof.id] + if (open) walls.push(segment as never) + const building = BuildingNode.parse({ + id: 'building_r', + children: [level.id, upper.id], + ...(site ? { parentId: 'site_r' } : {}), + }) + if (!site) return document(building, level, upper, ...walls, roof) + const ground = SiteNode.parse({ id: 'site_r', children: [building.id], ...site }) + return document(ground, building, level, upper, ...walls, roof) + } + + const flatGround = () => createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] }) + /** Ground raised 1 m under the walls. */ + function raisedGround() { + const flat = flatGround() + return applyHeightPatch( + flat, + flattenPatch(flat, { minX: -2, minZ: -2, maxX: 6, maxZ: 5 }, 1) as never, + ) + } + + test("an agent's edit on the editor's store commits once, with the roof it moved, in one undo step", async () => { + const base = await openLiveScene(roofScene()) + const past = useScene.temporal.getState().pastStates.length + const commits: SceneCommit[] = [] + const stop = subscribeSceneCommits((commit) => commits.push(commit)) + try { + createAgentHost(liveSceneRuntime).applyChanges({ + update: [{ id: 'wall_0', data: { height: 4.2 } }], + }) + await settle() + } finally { + stop() + } + const roof = getSceneDocument().nodes['roof_r' as AnyNodeId] as AnyNode & { + position: number[] + } + expect(roof.position[1]).toBeGreaterThan(1.2) + expect(commits).toHaveLength(1) + expect(commits[0]!.current.nodes['roof_r' as AnyNodeId]).toEqual(roof) + expect(commits[0]!.changedNodeIds).toContain('roof_r' as AnyNodeId) + expect(useScene.temporal.getState().pastStates).toHaveLength(past + 1) + useScene.temporal.getState().undo() + await settle() + expect(getSceneDocument().nodes['roof_r' as AnyNodeId]).toEqual( + base.nodes['roof_r' as AnyNodeId], + ) + expect(getSceneDocument().nodes['wall_0' as AnyNodeId]).toEqual( + base.nodes['wall_0' as AnyNodeId], + ) + }) + + test("a room an agent settles on the editor's store is not derived again by the person's next edit", async () => { + const { level, upper, building } = storeys() + await openLiveScene(document(building, level, upper)) + let published = 0 + const editor = { + getState: () => ({ + spaces: {}, + setSpaces: () => { + published += 1 + }, + }), + } + const stop = initSpaceDetectionSync(useScene, editor) + try { + const corners: [number, number][] = [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ] + createAgentHost(liveSceneRuntime).applyChanges({ + create: corners.map((start, i) => ({ + node: WallNode.parse({ + id: `wall_${i}`, + parentId: level.id, + start, + end: corners[(i + 1) % corners.length]!, + }), + parentId: level.id, + })), + }) + await settle() + expect( + queryNodes(useScene.getState().nodes as never, { type: 'zone', parentId: level.id }), + ).toHaveLength(1) + + // A write that changes no level's structure: space detection has nothing to derive. + published = 0 + const nodes = useScene.getState().nodes + useScene.setState({ + nodes: { ...nodes, [building.id]: { ...nodes[building.id]!, name: 'Renamed' } as AnyNode }, + }) + await settle() + expect(published).toBe(0) + } finally { + stop() + } + }) + + test('a terrain stroke in progress reaches no detached computation', () => { + const base = roofScene({ terrain: encodeTerrainField(flatGround()) }, true) + const raise = () => { + const scene = open(base) + scene.host().applyChanges({ update: [{ id: 'wall_0', data: { height: 4.2 } }] }) + const patch = scene.patch() + scene.dispose() + return patch + } + const still = raise() + useLiveTerrain.getState().begin('site_r', raisedGround()) + try { + expect(raise()).toEqual(still) + } finally { + useLiveTerrain.getState().end('site_r') + } + }) + + test("a ceiling placement read depends on the ceiling's level: a lamp moved there meanwhile conflicts", () => { + const lamp = ItemNode.parse({ + id: 'item_lamp', + parentId: 'ceiling_c', + position: [3, 0, 3], + asset: { + id: 'lamp', + name: 'Lamp', + category: 'lighting', + thumbnail: '/items/lamp/thumbnail.webp', + src: '/items/lamp/model.glb', + attachTo: 'ceiling', + dimensions: [0.5, 0.2, 0.5], + }, + }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_c', + parentId: 'level_c', + children: [lamp.id], + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + }) + const level = LevelNode.parse({ + id: 'level_c', + parentId: 'building_c', + level: 0, + height: 3, + children: [ceiling.id], + }) + const building = BuildingNode.parse({ id: 'building_c', children: [level.id] }) + const base = document(building, level, ceiling, lamp) + const scene = open(base) + const free = scene.run(() => + activeSpatialGrid().canPlaceOnCeiling(ceiling.id, [1, 0, 1], [0.5, 0.2, 0.5], [0, 0, 0]), + ) + expect(free.valid).toBe(true) + expect(scene.dependencies()).toContainEqual({ kind: 'scope', level: level.id }) + + const current = editedBy(base, (store) => + store.getState().updateNode(lamp.id, { position: [1.2, 0, 1] }), + ) + const applied = applyPatchWithDependencies(current, scene.patch(), scene.dependencies(), base) + expect(!applied.ok && applied.changed).toContainEqual({ + kind: 'scope', + id: level.id, + reason: 'dependency', + }) + }) + + test("support read on a level depends on what it stands on: the site's terrain", () => { + const base = roofScene({}) + const scene = open(base) + scene.host().applyChanges({ update: [{ id: 'wall_0', data: { height: 4.2 } }] }) + const patch = scene.patch() + const current = editedBy(base, (store) => + store.getState().updateNode( + 'site_r' as AnyNodeId, + { + terrain: encodeTerrainField(raisedGround()), + } as never, + ), + ) + const applied = applyPatchWithDependencies(current, patch, scene.dependencies(), base) + expect(!applied.ok && applied.changed).toContainEqual({ + kind: 'node', + id: 'site_r', + field: 'terrain', + reason: 'dependency', + }) + }) + + test('a wall raised detached carries the roof it holds up, and the editor then writes nothing', async () => { + const base = await openLiveScene(roofScene()) + const scene = open(base) + scene.host().applyChanges({ update: [{ id: 'wall_0', data: { height: 4.2 } }] }) + const patch = scene.patch() + const roofUpdate = patch.nodeUpdates.find(({ id }) => id === 'roof_r') + // The walls rose 1.2 m above the storey: the roof rises with them. + expect((roofUpdate?.data.position as number[] | undefined)?.[1]).toBeGreaterThan(1.2) + + // Live, the same edit through the editor's own systems lands on the same roof. + useScene.getState().updateNode('wall_0' as AnyNodeId, { height: 4.2 }) + await settle() + expect(getSceneDocument().nodes['roof_r' as AnyNodeId]).toEqual( + scene.store.getState().nodes['roof_r' as AnyNodeId], + ) + + const appliedCommits = async (applied: typeof patch) => { + await openLiveScene(base) + const commits: SceneCommit[] = [] + const stop = subscribeSceneCommits((commit) => commits.push(commit)) + applySceneOperationPatch(applied, { undoable: true }) + await settle() + stop() + return commits.length + } + expect(await appliedCommits(patch)).toBe(1) + // Without the derived write, the editor's roof system adds its own commit after the patch. + expect( + await appliedCommits({ + ...patch, + nodeUpdates: patch.nodeUpdates.filter(({ id }) => id !== 'roof_r'), + }), + ).toBe(2) + }) +}) + +describe('what a detached scene hands out', () => { + test('is its own, read-only data: nothing reached through it changes the snapshot or the scene', () => { + const { level, upper, building } = storeys() + const snapshot = document(building, level, upper) + const scene = open(snapshot) + const children = scene.reader().get(building.id)!.children as string[] + expect(() => children.push('level_x')).toThrow(TypeError) + expect((snapshot.nodes[building.id as AnyNodeId] as typeof building).children).toEqual([ + level.id, + upper.id, + ]) + ;(snapshot.nodes[building.id as AnyNodeId] as typeof building).children.push('level_y' as never) + expect(scene.reader().get(building.id)!.children).toEqual([level.id, upper.id]) + expect(isEmptyScenePatch(scene.patch())).toBe(true) + }) + + test("takes its own copy of what it is given: the caller's data stays the caller's", () => { + const { level, upper, building } = storeys() + const scene = open(document(building, level, upper)) + const metadata = { note: { tags: ['one'] } } + scene.host().applyChanges({ update: [{ id: level.id, data: { metadata } }] }) + metadata.note.tags.push('two') + expect(scene.store.getState().nodes[level.id as AnyNodeId]?.metadata).toEqual({ + note: { tags: ['one'] }, + }) + }) + + test('selections over an earlier version read that version', () => { + const { level, upper, building } = storeys() + const scene = open(document(building, level, upper)) + const before = scene.reader().nodes + scene.host().applyChanges({ + create: [{ node: RoofNode.parse({ id: 'roof_new' }), parentId: upper.id }], + }) + expect(before.roof_new).toBeUndefined() + expect(queryNodes(before, { type: 'roof' })).toEqual([]) + expect(queryNodes(scene.reader().nodes, { type: 'roof' })).toHaveLength(1) + expect(orderedChildIds(before, upper.id)).toEqual([]) + }) + + test('materials read are dependencies', () => { + const { level, upper, building } = storeys() + const red = SceneMaterial.parse({ id: 'mat_red', name: 'Red', material: { color: '#ff0000' } }) + const base = { ...document(building, level, upper), materials: { [red.id]: red } } + const scene = open(base as SceneSnapshot) + const [material] = scene.reader().query({ entity: 'material' }) + scene.host().applyChanges({ update: [{ id: level.id, data: { name: material!.name } }] }) + const current = editedBy(base as SceneSnapshot, (store) => + store.getState().updateSceneMaterial(red.id, { name: 'Blue' }), + ) + const applied = applyPatchWithDependencies( + current, + scene.patch(), + scene.dependencies(), + base as SceneSnapshot, + ) + expect(!applied.ok && applied.changed).toContainEqual({ + kind: 'material', + id: red.id, + reason: 'dependency', + }) + }) +}) + +describe('what the store actions plan', () => { + test('writes an action plans beyond the changes depend on their level', () => { + const level = LevelNode.parse({ id: 'level_r', parentId: 'building_g', level: 0, height: 3 }) + const segment = RoofSegmentNode.parse({ id: 'rseg_g', parentId: 'roof_g', width: 8, depth: 6 }) + const roof = RoofNode.parse({ id: 'roof_g', parentId: level.id, children: [segment.id] }) + level.children = [roof.id] + const building = BuildingNode.parse({ id: 'building_g', children: [level.id] }) + const base = document(building, level, roof, segment) + const scene = open(base) + scene.host().applyChanges({ + update: [{ id: segment.id, data: { metadata: { autoGutter: true } } }], + }) + const patch = scene.patch() + expect(patch.nodeCreates.some(({ node }) => node.type === 'gutter')).toBe(true) + const current = editedBy(base, (store) => + store.getState().updateNode(segment.id, { width: 12 }), + ) + const applied = applyPatchWithDependencies(current, patch, scene.dependencies(), base) + expect(!applied.ok && applied.changed).toContainEqual({ + kind: 'scope', + id: level.id, + reason: 'dependency', + }) + }) + + test("a rename runs no planner: a segment's name reads nothing of its level", () => { + const level = LevelNode.parse({ id: 'level_r', parentId: 'building_g', level: 0, height: 3 }) + const segment = RoofSegmentNode.parse({ id: 'rseg_g', parentId: 'roof_g', width: 8, depth: 6 }) + const roof = RoofNode.parse({ id: 'roof_g', parentId: level.id, children: [segment.id] }) + const wall = WallNode.parse({ id: 'wall_g', parentId: level.id, start: [9, 0], end: [12, 0] }) + level.children = [roof.id, wall.id] + const building = BuildingNode.parse({ id: 'building_g', children: [level.id] }) + const base = document(building, level, roof, segment, wall) + const scene = open(base) + scene.host().applyChanges({ update: [{ id: segment.id, data: { name: 'South slope' } }] }) + const current = editedBy(base, (store) => + store.getState().updateNode(wall.id, { name: 'Garden wall' }), + ) + const applied = applyPatchWithDependencies(current, scene.patch(), scene.dependencies(), base) + expect(applied.ok).toBe(true) + }) + + test('a planner that decided to write nothing was still read: disabled gutters', () => { + const level = LevelNode.parse({ id: 'level_r', parentId: 'building_g', level: 0, height: 3 }) + const segment = RoofSegmentNode.parse({ id: 'rseg_g', parentId: 'roof_g', width: 8, depth: 6 }) + const roof = RoofNode.parse({ id: 'roof_g', parentId: level.id, children: [segment.id] }) + level.children = [roof.id] + const building = BuildingNode.parse({ id: 'building_g', children: [level.id] }) + const base = document(building, level, roof, segment) + const scene = open(base) + scene.host().applyChanges({ update: [{ id: segment.id, data: { width: 12 } }] }) + const patch = scene.patch() + expect(patch.nodeCreates).toEqual([]) + const current = editedBy(base, (store) => + store.getState().updateNode(segment.id, { metadata: { autoGutter: true } }), + ) + const applied = applyPatchWithDependencies(current, patch, scene.dependencies(), base) + expect(!applied.ok && applied.changed).toContainEqual({ + kind: 'scope', + id: level.id, + reason: 'dependency', + }) + }) +}) + +describe('scoped reconciliation settles what complete reconciliation would', () => { + /** The same edit settled as an edit, and settled whole: every level, every roof. */ + function scopedAndComplete(base: SceneSnapshot, changes: SceneChanges) { + const scoped = open(base, 'scoped') + scoped.host().applyChanges(changes) + const complete = open(base, 'complete') + complete.host().applyChanges(changes) + complete.host().reconcile() + complete + .host() + .applyChanges({ update: planRoofElevationUpdates(complete.store.getState().nodes) }) + return { scoped: scoped.store.getState().nodes, complete: complete.store.getState().nodes } + } + + test('a roof segment added over a taller wall moves the roof', () => { + const level = LevelNode.parse({ id: 'level_t', parentId: 'building_t', level: 0, height: 6 }) + const corners: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: level.id, + start, + end: corners[(index + 1) % 4]!, + height: 2, + }), + ) + const tall = WallNode.parse({ + id: 'wall_tall', + parentId: level.id, + start: [6, 0], + end: [6, 3], + height: 5, + }) + const roof = RoofNode.parse({ + id: 'roof_t', + parentId: level.id, + support: { kind: 'walls' }, + position: [2, 2, 1.5], + }) + level.children = [...walls.map((wall) => wall.id), tall.id, roof.id] + const building = BuildingNode.parse({ id: 'building_t', children: [level.id] }) + const base = document(building, level, ...walls, tall, roof) + const segment = RoofSegmentNode.parse({ id: 'rseg_t', width: 10, depth: 4 }) + const { scoped, complete } = scopedAndComplete(base, { + create: [{ node: segment, parentId: roof.id }], + }) + const y = (nodes: Record) => (nodes.roof_t as RoofNode).position[1] + expect(y(complete)).toBeCloseTo(5) + expect(y(scoped)).toBeCloseTo(y(complete)) + }) + + /** A storey of plane-bound walls under a slab on the level above, its roof on the walls. */ + function slabRoofBuilding(id: string, x = 0): AnyNode[] { + const level = LevelNode.parse({ + id: `level_${id}0`, + parentId: `building_${id}`, + level: 0, + height: 3, + }) + const upper = LevelNode.parse({ + id: `level_${id}1`, + parentId: `building_${id}`, + level: 1, + height: 3, + }) + const corners: [number, number][] = [ + [x, 0], + [x + 4, 0], + [x + 4, 3], + [x, 3], + ] + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_${id}${index}`, + parentId: level.id, + start, + end: corners[(index + 1) % 4]!, + }), + ) + const roof = RoofNode.parse({ + id: `roof_${id}`, + parentId: level.id, + support: { kind: 'walls' }, + position: [x + 2, 0, 1.5], + }) + const slab = SlabNode.parse({ + id: `slab_${id}`, + parentId: upper.id, + polygon: [ + [x - 1, -1], + [x + 5, -1], + [x + 5, 4], + [x - 1, 4], + ], + thickness: 0.05, + }) + level.children = [...walls.map((wall) => wall.id), roof.id] + upper.children = [slab.id] + const building = BuildingNode.parse({ id: `building_${id}`, children: [level.id, upper.id] }) + return [building, level, upper, ...walls, roof, slab] + } + + function settledScene(...nodes: AnyNode[]): SceneSnapshot { + const settled = open(document(...nodes), 'settle') + settled.host().reconcile() + settled + .host() + .applyChanges({ update: planRoofElevationUpdates(settled.store.getState().nodes) }) + return sceneDocumentOf(settled.store) + } + + test('a thicker slab above lowers plane-bound walls and the roof they carry', () => { + const base = settledScene(...slabRoofBuilding('s')) + const { scoped, complete } = scopedAndComplete(base, { + update: [{ id: 'slab_s', data: { thickness: 0.55 } }], + }) + const y = (nodes: Record) => (nodes.roof_s as RoofNode).position[1] + expect(y(complete)).not.toBeCloseTo(y(base.nodes as Record)) + expect(y(scoped)).toBeCloseTo(y(complete)) + }) + + test("a slab edit in one building reads nothing of another's", () => { + const base = settledScene(...slabRoofBuilding('a'), ...slabRoofBuilding('b', 20)) + const scene = open(base) + scene.host().applyChanges({ update: [{ id: 'slab_a', data: { thickness: 0.55 } }] }) + const current = editedBy(base, (store) => + store.getState().updateNode('wall_b0' as AnyNodeId, { name: 'North' }), + ) + const applied = applyPatchWithDependencies(current, scene.patch(), scene.dependencies(), base) + expect(applied.ok).toBe(true) + }) + + test('an upper slab no longer recessed clamps the ceiling below', () => { + const level = LevelNode.parse({ id: 'level_0', parentId: 'building_c', level: 0, height: 2.5 }) + const upper = LevelNode.parse({ id: 'level_1', parentId: 'building_c', level: 1, height: 3 }) + const polygon: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + const walls = polygon.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: level.id, + start, + end: polygon[(index + 1) % 4]!, + }), + ) + const zone = ZoneNode.parse({ + id: 'zone_c', + parentId: level.id, + name: 'Room', + polygon, + spaceRole: 'room', + }) + const slab = SlabNode.parse({ + id: 'slab_above', + parentId: upper.id, + polygon: [ + [-1, -1], + [5, -1], + [5, 4], + [-1, 4], + ], + thickness: 0.3, + recessed: true, + }) + level.children = [...walls.map((wall) => wall.id), zone.id] + upper.children = [slab.id] + const building = BuildingNode.parse({ id: 'building_c', children: [level.id, upper.id] }) + const settled = open(document(building, level, upper, ...walls, zone, slab), 'settle') + settled.host().reconcile() + const ceiling = Object.values(settled.store.getState().nodes).find( + (node) => node.type === 'ceiling', + )! + // Just under the storey, as a recessed slab above allows. + settled.host().applyChanges({ update: [{ id: ceiling.id, data: { height: 2.49 } }] }) + const base = sceneDocumentOf(settled.store) + expect((base.nodes[ceiling.id] as { height?: number }).height).toBe(2.49) + const { scoped, complete } = scopedAndComplete(base, { + update: [{ id: slab.id, data: { recessed: false } }], + }) + const ceilings = (nodes: Record) => + Object.values(nodes) + .filter((node) => node.type === 'ceiling') + .map((node) => JSON.stringify(node)) + expect(ceilings(complete)).not.toEqual(ceilings(base.nodes as Record)) + expect(ceilings(scoped)).toEqual(ceilings(complete)) + }) +}) diff --git a/packages/core/src/runtime/fixture-parity.test.ts b/packages/core/src/runtime/fixture-parity.test.ts new file mode 100644 index 0000000000..433b2d246c --- /dev/null +++ b/packages/core/src/runtime/fixture-parity.test.ts @@ -0,0 +1,176 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { + AGENT_OPERATIONS, + type AgentContext, + type AgentOperation, + addObject, + applyAgentOutcome, +} from '../agent-operations' +import { ADD_OBJECT_CASES } from '../agent-operations/__fixtures__/add-object-cases' +import { AGENT_TOOL_CASES, type SceneGraph } from '../agent-operations/__fixtures__/cases' +import { AGENT_TOOL_CONTRACTS, addObjectTool } from '../agent-tools' +import { isAgentRefusal } from '../agent-tools/refusal' +import { subscribeSceneCommits } from '../store/history-control' +import { applySceneOperationPatch, getSceneDocument } from '../store/use-scene' +import { closeLiveScene, openLiveScene, settle, snapshotOf } from './__fixtures__/live-scene' +import { + captureRegistryVersions, + createAgentHost, + diffSceneDocuments, + effectIds, + isEmptyScenePatch, + liveSceneRuntime, + openDetachedScene, + type SceneCommit, + type SceneOperationPatch, + type SceneSnapshot, + withIdAllocator, +} from './index' + +/** + * The comparison contract. From the same canonical snapshot, context, id seed and registry, an + * operation computed on a detached scene and on the editor's store (its document systems mounted) + * yields the same complete patch and the same result, and nothing writes after the live call + * returns. Applying the detached patch to the editor then lands on the same document, with no + * further write from its systems. + */ + +type ParityCase = { + label: string + scene: () => SceneGraph + operation: AgentOperation + input: unknown + context: AgentContext +} + +const CASES: ParityCase[] = [ + ...AGENT_TOOL_CASES.map((c) => ({ + label: `${c.tool}: ${c.name}`, + scene: c.scene, + operation: AGENT_OPERATIONS[c.tool as keyof typeof AGENT_OPERATIONS] as AgentOperation, + // As on both surfaces, the operation gets the input its contract parsed. + input: z + .object(AGENT_TOOL_CONTRACTS.find((contract) => contract.name === c.tool)!.input) + .parse(c.input), + context: { activeLevelId: c.context?.activeLevelId ?? null, catalog: c.context?.catalog }, + })), + // The surfaces compile `add_object`'s code first; a case carries that compile. + ...ADD_OBJECT_CASES.map((c) => ({ + label: `add_object: ${c.name}`, + scene: c.scene, + operation: addObject as AgentOperation, + input: { ...z.object(addObjectTool.input).parse(c.input), compiled: c.compiled }, + context: { activeLevelId: c.context?.activeLevelId ?? null }, + })), +] + +/** Fixtures the contract does not cover today, and why. */ +const MALFORMED = + 'stores geometry its schema rejects, which the editor systems the live side mounts cannot load' +const EXCEPTIONS: Record = { + "verify_scene: validate_scene {} with malformed geometry (a zone's polygon, a wall's start null): every error, the other checks reading around those nodes": + MALFORMED, + 'get_node: describe_node on a wall stored without its start (its properties as stored): the node as stored, unresolved says why': + MALFORMED, + 'get_node: describe_node on a wall beside a slab stored without its polygon: the wall as stored, unresolved names what does not read': + MALFORMED, +} + +type Outcome = { refusal: string } | { result: Record; patch: SceneOperationPatch } + +function attempt(run: () => Outcome): Outcome { + try { + return run() + } catch (error) { + if (!isAgentRefusal(error)) throw error + return { refusal: error.code } + } +} + +function runDetached(c: ParityCase, snapshot: SceneSnapshot): Outcome { + const scene = openDetachedScene(snapshot, { + base: { kind: 'draft', id: 'fixture', updatedAt: '2026-10-10T00:00:00.000Z' }, + context: { + activeLevelId: c.context.activeLevelId, + selection: [], + units: 'metric', + catalogVersion: 'fixture', + registryVersions: captureRegistryVersions(), + }, + ids: effectIds(`effect:${c.label}`), + }) + try { + return attempt(() => ({ + result: scene.execute(c.operation, c.input as never, c.context), + patch: scene.patch(), + })) + } finally { + scene.dispose() + } +} + +/** On the editor's store, its document systems mounted. */ +async function runLive(c: ParityCase, snapshot: SceneSnapshot) { + const commits: SceneCommit[] = [] + const stop = subscribeSceneCommits((commit) => commits.push(commit)) + try { + const outcome = attempt(() => { + const result = withIdAllocator(effectIds(`effect:${c.label}`), () => + applyAgentOutcome( + c.operation(liveSceneRuntime.reader().nodes, c.input as never, c.context), + createAgentHost(liveSceneRuntime), + ), + ) + return { result, patch: diffSceneDocuments(snapshot, getSceneDocument()) } + }) + const returned = commits.length + const document = getSceneDocument() + await settle() + return { outcome, document, deferredCommits: commits.length - returned } + } finally { + stop() + } +} + +/** A canonical snapshot is one the editor's load-time normalisation leaves as it is. */ +async function canonicalSnapshot(graph: SceneGraph): Promise { + let snapshot = snapshotOf(graph) + for (let opened = 0; opened < 3; opened++) { + const settled = await openLiveScene(snapshot) + if (Bun.deepEquals(settled, snapshot)) break + snapshot = settled + } + return snapshot +} + +describe('detached and live give the same complete patch', () => { + afterAll(closeLiveScene) + + for (const c of CASES) { + test.skipIf(c.label in EXCEPTIONS)(c.label, async () => { + const snapshot = await canonicalSnapshot(c.scene()) + expect(await openLiveScene(snapshot)).toEqual(snapshot) + const detached = runDetached(c, snapshot) + const live = await runLive(c, snapshot) + expect(live.deferredCommits).toBe(0) + expect(getSceneDocument()).toEqual(live.document) + expect(live.outcome).toEqual(detached) + if ('refusal' in detached) return + + await openLiveScene(snapshot) + const commits: SceneCommit[] = [] + const stop = subscribeSceneCommits((commit) => commits.push(commit)) + try { + applySceneOperationPatch(detached.patch, { undoable: true }) + const applied = getSceneDocument() + await settle() + expect(commits).toHaveLength(isEmptyScenePatch(detached.patch) ? 0 : 1) + expect(getSceneDocument()).toEqual(applied) + expect(applied).toEqual(live.document) + } finally { + stop() + } + }) + } +}) diff --git a/packages/core/src/runtime/ids.ts b/packages/core/src/runtime/ids.ts new file mode 100644 index 0000000000..44898113be --- /dev/null +++ b/packages/core/src/runtime/ids.ts @@ -0,0 +1,84 @@ +import type { IdAllocator } from '../schema/base' + +export { type IdAllocator, withIdAllocator } from '../schema/base' + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]) + +/** SHA-256 of `text` (UTF-8), synchronously: ids are minted inside synchronous store writes. */ +export function sha256(text: string): Uint8Array { + const data = new TextEncoder().encode(text) + const length = Math.ceil((data.length + 9) / 64) * 64 + const bytes = new Uint8Array(length) + bytes.set(data) + bytes[data.length] = 0x80 + const view = new DataView(bytes.buffer) + view.setUint32(length - 8, Math.floor((data.length * 8) / 2 ** 32)) + view.setUint32(length - 4, (data.length * 8) >>> 0) + const hash = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]) + const w = new Uint32Array(64) + const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)) + for (let offset = 0; offset < length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4) + for (let i = 16; i < 64; i++) { + const a = w[i - 15]! + const b = w[i - 2]! + const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3) + const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10) + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0 + } + let [a, b, c, d, e, f, g, h] = hash as unknown as number[] + for (let i = 0; i < 64; i++) { + const t1 = + (h! + + (rotr(e!, 6) ^ rotr(e!, 11) ^ rotr(e!, 25)) + + ((e! & f!) ^ (~e! & g!)) + + K[i]! + + w[i]!) >>> + 0 + const t2 = + ((rotr(a!, 2) ^ rotr(a!, 13) ^ rotr(a!, 22)) + ((a! & b!) ^ (a! & c!) ^ (b! & c!))) >>> 0 + h = g + g = f + f = e + e = (d! + t1) >>> 0 + d = c + c = b + b = a + a = (t1 + t2) >>> 0 + } + const next = [a, b, c, d, e, f, g, h] + for (let i = 0; i < 8; i++) hash[i] = (hash[i]! + next[i]!) >>> 0 + } + const out = new Uint8Array(32) + const outView = new DataView(out.buffer) + for (let i = 0; i < 8; i++) outView.setUint32(i * 4, hash[i]!) + return out +} + +const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz' + +/** + * Deterministic ids for one effect: the n-th id it asks for is `prefix_` and 16 base-36 + * characters of SHA-256(`effectId:n`), the shape random ids have. Recomputing the same effect from + * the same scene allocates the same ids, so a retried commit is idempotent. + */ +export function effectIds(effectId: string): IdAllocator { + let counter = 0 + return (prefix) => { + const digest = sha256(`${effectId}:${counter++}`) + let suffix = '' + for (let i = 0; i < 16; i++) suffix += ALPHABET[digest[i]! % ALPHABET.length] + return `${prefix}_${suffix}` + } +} diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts new file mode 100644 index 0000000000..402cae5842 --- /dev/null +++ b/packages/core/src/runtime/index.ts @@ -0,0 +1,32 @@ +export type { AgentHostRuntime } from '../agent-operations/apply-outcome' +export { isSettlingDocument, runSettlingDocument } from '../store/document-settling' +export type { SceneCommit, SceneCommitListener, SceneSnapshot } from '../store/history-control' +export { + createSceneEnvironment, + type SceneEnvironment, + SceneEnvironmentDisposedError, +} from '../store/scene-environment' +export { + createSceneStore, + nextSceneOperationState, + runAsSettledSceneStep, + type SceneOperationPatch, + type SceneStore, + sceneDocumentOf, +} from '../store/use-scene' +export { reconcileDocument } from '../systems/document-reconcile' +export * from './apply' +export * from './dependencies' +export { effectIds, type IdAllocator, sha256, withIdAllocator } from './ids' +export { orderedChildIds, queryCollections, queryNodes } from './node-queries' +export { diffSceneDocuments, isEmptyScenePatch, UnexpressiblePatchError } from './patch' +export { + createDocumentReader, + DependencyRecorder, + type DocumentReader, + type DocumentSource, + materialize, + readerOf, + untracked, +} from './reader' +export * from './scene-runtime' diff --git a/packages/core/src/runtime/node-queries.ts b/packages/core/src/runtime/node-queries.ts new file mode 100644 index 0000000000..c43b94aba6 --- /dev/null +++ b/packages/core/src/runtime/node-queries.ts @@ -0,0 +1,45 @@ +import type { SceneNodes } from '../agent-operations/types' +import type { AnyNode, Collection } from '../schema' +import { + type CollectionPredicate, + matchesCollectionPredicate, + matchesNodePredicate, + type NodePredicate, +} from './dependencies' +import { readerOf } from './reader' + +/** + * The nodes `where` selects, in scene order. On a detached scene this records the selection (its + * members, absent ones included) instead of a read of every node, so an unrelated node created + * meanwhile does not invalidate the operation. + */ +export function queryNodes( + nodes: SceneNodes, + where: NodePredicate & { type?: T['type'] | readonly T['type'][] }, +): T[] { + const reader = readerOf(nodes) + if (reader) return reader.query({ entity: 'node', where }) as T[] + return Object.values(nodes).filter((node) => matchesNodePredicate(node, where)) as T[] +} + +/** A node's children in order, recorded as an ordered read: for results that depend on order. */ +export function orderedChildIds(nodes: SceneNodes, id: string): string[] { + const reader = readerOf(nodes) + if (reader) return reader.orderedChildren(id) + const node = nodes[id] + return node && 'children' in node && Array.isArray(node.children) + ? (node.children as unknown[]).filter((child): child is string => typeof child === 'string') + : [] +} + +/** The collections `where` selects (by name, case-insensitive, or template), recorded like `queryNodes`. */ +export function queryCollections( + collections: Readonly>, + where: CollectionPredicate, +): Collection[] { + const reader = readerOf(collections) + if (reader) return reader.query({ entity: 'collection', where }) + return Object.values(collections).filter((collection) => + matchesCollectionPredicate(collection, where), + ) +} diff --git a/packages/core/src/runtime/patch.ts b/packages/core/src/runtime/patch.ts new file mode 100644 index 0000000000..c92d99d92b --- /dev/null +++ b/packages/core/src/runtime/patch.ts @@ -0,0 +1,184 @@ +import type { AnyNode, AnyNodeId, Collection } from '../schema' +import type { SceneSnapshot } from '../store/history-control' +import { + nextSceneOperationState, + type SceneCollectionPatch, + type SceneMaterialPatch, + type SceneNodePatch, + type SceneNodeStructuralPatch, + type SceneOperationPatch, +} from '../store/use-scene' + +/** Structural equality of document values; a key holding `undefined` counts as absent. */ +export function sameValue(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true + if (typeof left !== typeof right || left === null || right === null) return false + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => sameValue(value, right[index])) + ) + } + if (typeof left !== 'object') return false + const a = left as Record + const b = right as Record + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + for (const key of keys) if (!sameValue(a[key], b[key])) return false + return true +} + +export function childIdsOf(node: AnyNode | undefined): AnyNodeId[] { + return node && 'children' in node && Array.isArray(node.children) + ? (node.children as AnyNodeId[]) + : [] +} + +/** Where `node` sits among its siblings in `doc`: its parent's children, or the roots. */ +function siblingPosition(doc: Pick, node: AnyNode) { + const parentId = node.parentId as AnyNodeId | null | undefined + const siblings = parentId ? childIdsOf(doc.nodes[parentId]) : doc.rootNodeIds + return siblings.indexOf(node.id as AnyNodeId) +} + +export class UnexpressiblePatchError extends Error { + constructor(message: string) { + super(message) + this.name = 'UnexpressiblePatchError' + } +} + +/** The fields of `next` that differ from `base`, and those `next` no longer has. */ +export function fieldChanges( + base: Readonly>, + next: Readonly>, + ignore: readonly string[] = [], +): { data: Record; removeFields: string[] } { + const data: Record = {} + const removeFields: string[] = [] + for (const field of new Set([...Object.keys(base), ...Object.keys(next)])) { + if (ignore.includes(field) || sameValue(base[field], next[field])) continue + if (next[field] === undefined) removeFields.push(field) + else data[field] = next[field] + } + return { data, removeFields } +} + +const documentParts = (doc: SceneSnapshot) => ({ + nodes: doc.nodes, + rootNodeIds: doc.rootNodeIds, + collections: doc.collections, + materials: doc.materials, + installedPlugins: doc.installedPlugins, +}) + +/** + * The patch that turns `base` into `next`, field by field: created nodes with the position they + * take among their siblings, deleted nodes as they were and where, each changed node field (and + * the fields removed), changed materials, collections and plugin state. A parent's `children` is + * written only where creates and deletes do not already account for it (a reorder). The patch is + * checked by applying it to `base`; one that does not land exactly on `next` (a reordering of the + * roots, which a patch cannot express) throws `UnexpressiblePatchError`. + */ +export function diffSceneDocuments(base: SceneSnapshot, next: SceneSnapshot): SceneOperationPatch { + const created = new Set() + const nodeCreates: SceneNodeStructuralPatch[] = [] + const nodeDeletes: SceneNodeStructuralPatch[] = [] + const nodeUpdates: SceneNodePatch[] = [] + for (const node of Object.values(next.nodes)) { + if (base.nodes[node.id]) continue + created.add(node.id) + nodeCreates.push({ node, position: siblingPosition(next, node) }) + } + for (const node of Object.values(base.nodes)) { + if (!next.nodes[node.id]) nodeDeletes.push({ node, position: siblingPosition(base, node) }) + } + const reordered: AnyNodeId[] = [] + for (const node of Object.values(next.nodes)) { + const before = base.nodes[node.id] + if (!before || before === node) continue + const { data, removeFields } = fieldChanges( + before as Record, + node as Record, + ['children'], + ) + if (Object.keys(data).length || removeFields.length) + nodeUpdates.push({ id: node.id, data: data as SceneNodePatch['data'], removeFields }) + if (!sameValue(childIdsOf(before), childIdsOf(node))) reordered.push(node.id) + } + + const materialChanges: SceneMaterialPatch[] = [] + for (const id of new Set([...Object.keys(base.materials), ...Object.keys(next.materials)])) { + const material = next.materials[id as keyof typeof next.materials] + if (!sameValue(base.materials[id as keyof typeof base.materials], material)) + materialChanges.push({ id: id as SceneMaterialPatch['id'], material: material ?? null }) + } + const collectionChanges: SceneCollectionPatch[] = [] + for (const id of new Set([...Object.keys(base.collections), ...Object.keys(next.collections)])) { + const collection = next.collections[id as Collection['id']] + if (!sameValue(base.collections[id as Collection['id']], collection)) + collectionChanges.push({ id: id as Collection['id'], collection: collection ?? null }) + } + const pluginsChanged = + !sameValue(base.installedPlugins, next.installedPlugins) || + (base.hasExplicitPluginInstallState ?? false) !== (next.hasExplicitPluginInstallState ?? false) + + const patch: SceneOperationPatch = { + nodeCreates, + nodeDeletes, + nodeUpdates, + materialChanges, + ...(collectionChanges.length ? { collectionChanges } : {}), + ...(pluginsChanged + ? { + pluginInstallState: { + installedPlugins: [...next.installedPlugins], + explicit: next.hasExplicitPluginInstallState ?? false, + }, + } + : {}), + } + + // A parent's children follow from the creates and deletes, unless the order itself changed. + if (reordered.length) { + const landed = nextSceneOperationState(documentParts(base), patch) + for (const id of reordered) { + const children = childIdsOf(next.nodes[id]) + if (landed && sameValue(childIdsOf(landed.nodes[id]), children)) continue + const kept = children.filter((child) => !created.has(child)) + const update = nodeUpdates.find((entry) => entry.id === id) + if (update) (update.data as Record).children = kept + else + nodeUpdates.push({ + id, + data: { children: kept } as SceneNodePatch['data'], + removeFields: [], + }) + } + } + + const landed = nextSceneOperationState(documentParts(base), patch) + if (!landed) throw new UnexpressiblePatchError('The scene change does not apply to its base.') + if (!sameValue(landed.rootNodeIds, next.rootNodeIds)) + throw new UnexpressiblePatchError('A scene patch cannot reorder the scene roots.') + for (const id of new Set([...Object.keys(landed.nodes), ...Object.keys(next.nodes)])) { + const key = id as AnyNodeId + if (!sameValue(landed.nodes[key], next.nodes[key])) + throw new UnexpressiblePatchError(`The scene patch does not reproduce node ${id}.`) + } + return patch +} + +/** Whether a patch changes nothing. */ +export function isEmptyScenePatch(patch: SceneOperationPatch): boolean { + return ( + patch.nodeCreates.length === 0 && + patch.nodeDeletes.length === 0 && + patch.nodeUpdates.length === 0 && + patch.materialChanges.length === 0 && + !patch.collectionChanges?.length && + !patch.pluginChanges?.length && + !patch.pluginInstallState + ) +} diff --git a/packages/core/src/runtime/reader.ts b/packages/core/src/runtime/reader.ts new file mode 100644 index 0000000000..0f0f0cdcb6 --- /dev/null +++ b/packages/core/src/runtime/reader.ts @@ -0,0 +1,469 @@ +import type { SceneNodes } from '../agent-operations/types' +import type { AnyNode, Collection } from '../schema' +import type { SceneMaterial } from '../schema/scene-material' +import { + type CollectionPredicate, + type Dependency, + type EntityQuery, + type NodePredicate, + type QueryDependency, + queryIds, +} from './dependencies' + +/** Where a reader reads: a scene store's current document. */ +export type DocumentSource = { + nodes(): Readonly> + collections(): Readonly> + materials(): Readonly> + rootNodeIds(): readonly string[] + installedPlugins(): readonly string[] + /** Throws once the scene is disposed. */ + assertActive(): void +} + +/** + * Reads a scene's document. A tracked reader records each read as a `Dependency`; values it + * returns (nodes, collections, the `nodes` and `collections` records) are read-only views whose + * field reads are recorded too. Code that only copies an entity through to a write wraps it in + * `untracked` first: copying is not a read. + */ +export interface DocumentReader { + /** The node, or undefined: either way its presence is recorded. */ + get(id: string): AnyNode | undefined + /** Entities matching a query; the matching ids are recorded, absent matches included. */ + query(query: { entity: 'node'; where?: NodePredicate }): AnyNode[] + query(query: { entity: 'collection'; where?: CollectionPredicate }): Collection[] + query(query: { entity: 'material' }): SceneMaterial[] + query(query: { entity: 'root' }): AnyNode[] + /** A parent's children in order, recording the order: for reads whose result depends on it. */ + orderedChildren(id: string): string[] + collection(id: string): Collection | undefined + material(id: string): SceneMaterial | undefined + installedPlugins(): readonly string[] + /** + * The nodes as a record, for operations written against `SceneNodes`. A node read through it is + * recorded like `get`; enumerating it records a query over every node (conservative: any + * created or deleted node then conflicts). Prefer `query`. + */ + readonly nodes: SceneNodes + /** The collections as a record, recorded like `nodes`. */ + readonly collections: Readonly> + /** + * The view of another version of the nodes record (one this scene held earlier), recorded like + * `nodes`; comparing two versions node by node by identity holds across views. + */ + track(nodes: SceneNodes): SceneNodes +} + +/** Accumulates a computation's reads. */ +export class DependencyRecorder { + private readonly nodes = new Map>() + private readonly collections = new Map>() + private readonly materials = new Set() + private readonly queries = new Map() + private readonly orders = new Map() + private readonly declared = new Map() + private plugins = false + + node(id: string, field?: string): void { + const fields = this.nodes.get(id) ?? new Set() + this.nodes.set(id, fields) + if (field !== undefined) fields.add(field) + } + + collection(id: string, field?: string): void { + const fields = this.collections.get(id) ?? new Set() + this.collections.set(id, fields) + if (field !== undefined) fields.add(field) + } + + material(id: string): void { + this.materials.add(id) + } + + installedPlugins(): void { + this.plugins = true + } + + query(dependency: QueryDependency): void { + this.queries.set(JSON.stringify([dependency.entity, dependency.predicate]), dependency) + } + + order(parentId: string, childIds: readonly string[]): void { + if (!this.orders.has(parentId)) this.orders.set(parentId, [...childIds]) + } + + /** A dependency the host declares: a file or artifact it read, or a conservative scope. */ + declare(dependency: Dependency): void { + this.declared.set(JSON.stringify(dependency), dependency) + } + + dependencies(): Dependency[] { + return [ + ...[...this.nodes].map(([id, fields]) => ({ + kind: 'node' as const, + id, + fields: [...fields].sort(), + })), + ...this.queries.values(), + ...[...this.orders].map(([parentId, childIds]) => ({ + kind: 'order' as const, + parentId, + childIds, + })), + ...[...this.collections].map(([id, fields]) => ({ + kind: 'collection' as const, + id, + fields: [...fields].sort(), + })), + ...[...this.materials].map((id) => ({ kind: 'material' as const, id })), + ...(this.plugins ? [{ kind: 'plugins' as const }] : []), + ...this.declared.values(), + ] + } +} + +const rawOfView = new WeakMap() + +/** Selections over one version of a record, recorded by the reader that viewed it. */ +export type VersionQueries = Pick +const queriesOfRecord = new WeakMap() + +/** + * The tracked selections behind a record view, when it is one (a detached scene's): helpers that + * receive `SceneNodes` query through them, so they record what they select rather than everything. + * They read the version the view is of, not the scene's current one. + */ +export function readerOf(record: object): VersionQueries | undefined { + return queriesOfRecord.get(record) +} + +/** The plain value behind a tracked view (copying it records nothing); other values as they are. */ +export function untracked(value: T): T { + return value && typeof value === 'object' ? ((rawOfView.get(value) as T) ?? value) : value +} + +/** + * `value` with every tracked view inside it replaced by a plain copy, recursively: what a + * computation hands out of its scene (a tool's result) so it outlives the scene. Copying a view + * reads all of it, and is recorded so. + */ +export function materialize(value: T): T { + if (!value || typeof value !== 'object') return value + if (Array.isArray(value)) { + const items = value.map((item) => materialize(item)) + return (items.some((item, index) => item !== value[index]) ? items : value) as T + } + const isView = rawOfView.has(value) + if (!isView && Object.getPrototypeOf(value) !== Object.prototype) return value + let copy: Record | null = isView ? {} : null + for (const [key, item] of Object.entries(value)) { + const next = materialize(item) + if (copy) copy[key] = next + else if (next !== item) { + copy = { ...(value as Record) } + copy[key] = next + } + } + return (copy ?? value) as T +} + +const readOnly = () => { + throw new TypeError('A scene read through a DocumentReader is read-only; write through the host.') +} + +/** + * A read-only view of `raw` whose every own-field access calls `onRead(field)`. The proxy target + * is an empty shell, not `raw`, so frozen records stay viewable. + */ +function fieldView( + raw: T, + assertActive: () => void, + onRead: (field: string) => void, +): T { + const view = new Proxy(Object.create(null) as T, { + get(_, key) { + assertActive() + if (typeof key === 'string') onRead(key) + return Reflect.get(raw, key) + }, + has(_, key) { + assertActive() + if (typeof key === 'string') onRead(key) + return Reflect.has(raw, key) + }, + ownKeys() { + assertActive() + const keys = Reflect.ownKeys(raw) + for (const key of keys) if (typeof key === 'string') onRead(key) + return keys + }, + getOwnPropertyDescriptor(_, key) { + assertActive() + if (typeof key === 'string') onRead(key) + const descriptor = Reflect.getOwnPropertyDescriptor(raw, key) + return ( + descriptor && { + value: descriptor.value, + writable: true, + enumerable: true, + configurable: true, + } + ) + }, + getPrototypeOf: () => Object.prototype, + set: readOnly, + defineProperty: readOnly, + deleteProperty: readOnly, + }) + rawOfView.set(view, raw) + return view +} + +/** + * A read-only view of one version of a record of entities: a key read records the entity's + * presence and returns its tracked view; enumerating the record records `enumerate()`. A view + * belongs to one version, as the record does, so identity-keyed memos stay correct. + */ +function recordView( + current: Readonly>, + assertActive: () => void, + entity: (id: string, raw: T | undefined) => T | undefined, + enumerate: () => void, +): Readonly> { + return new Proxy(Object.create(null) as Record, { + get(_, key) { + assertActive() + if (typeof key !== 'string') return Reflect.get(current, key) + if (Object.hasOwn(current, key)) return entity(key, current[key]) + entity(key, undefined) + return Reflect.get(current, key) + }, + has(_, key) { + assertActive() + if (typeof key === 'string') + entity(key, Object.hasOwn(current, key) ? current[key] : undefined) + return Reflect.has(current, key) + }, + ownKeys() { + assertActive() + enumerate() + return Reflect.ownKeys(current) + }, + getOwnPropertyDescriptor(_, key) { + assertActive() + if (typeof key !== 'string') return Reflect.getOwnPropertyDescriptor(current, key) + if (!Object.hasOwn(current, key)) { + entity(key, undefined) + return undefined + } + return { + value: entity(key, current[key]), + writable: true, + enumerable: true, + configurable: true, + } + }, + getPrototypeOf: () => Object.prototype, + set: readOnly, + defineProperty: readOnly, + deleteProperty: readOnly, + }) +} + +/** + * A reader over `source`. With a recorder, every read is recorded; without one (the editor's own + * runtime) it reads the raw document. + */ +export function createDocumentReader( + source: DocumentSource, + recorder: DependencyRecorder | null, +): DocumentReader { + if (!recorder) return createPlainReader(source) + return createTrackedReader(source, recorder) +} + +function createTrackedReader(source: DocumentSource, recorder: DependencyRecorder): DocumentReader { + const { assertActive } = source + const nodeViews = new WeakMap() + const collectionViews = new WeakMap() + const trackNode = (id: string, raw: AnyNode | undefined) => { + recorder.node(id) + if (!raw) return undefined + let view = nodeViews.get(raw) + if (!view) { + view = fieldView(raw, assertActive, (field) => recorder.node(raw.id, field)) + nodeViews.set(raw, view) + } + return view + } + const trackCollection = (id: string, raw: Collection | undefined) => { + recorder.collection(id) + if (!raw) return undefined + let view = collectionViews.get(raw) + if (!view) { + view = fieldView(raw, assertActive, (field) => recorder.collection(raw.id, field)) + collectionViews.set(raw, view) + } + return view + } + type Version = { nodes?: Record; collections?: Record } + const document = (version: Version = {}) => ({ + nodes: version.nodes ?? (source.nodes() as Record), + collections: version.collections ?? (source.collections() as Record), + materials: source.materials() as Record, + rootNodeIds: source.rootNodeIds() as string[], + }) + const recordQuery = ( + query: QueryDependency['entity'], + predicate: unknown, + version?: Version, + ): string[] => { + const resultIds = queryIds(document(version) as never, { + entity: query, + predicate: (predicate ?? null) as never, + }) + recorder.query({ kind: 'query', entity: query, predicate, resultIds } as QueryDependency) + return resultIds + } + + // One view per version of each record, created as the reader meets it. + const recordViews = new WeakMap>>() + const viewOf = ( + raw: Readonly>, + entity: (id: string, raw: T | undefined) => T | undefined, + version: Version, + kind: 'node' | 'collection', + ): Readonly> => { + let view = recordViews.get(raw) as Readonly> | undefined + if (!view) { + view = recordView(raw, assertActive, entity, () => recordQuery(kind, null, version)) + recordViews.set(raw, view) + queriesOfRecord.set(view, { + query: ((selection: EntityQuery) => queryIn(selection, version)) as DocumentReader['query'], + orderedChildren: (id) => orderedChildrenIn(id, version), + }) + rawOfView.set(view, raw) + } + return view + } + + function queryIn(query: EntityQuery, version: Version = {}): unknown[] { + assertActive() + const current = document(version) + switch (query.entity) { + case 'node': + return recordQuery('node', query.where ?? null, version).map((id) => + trackNode(id, current.nodes[id]), + ) + case 'collection': + return recordQuery('collection', query.where ?? null, version).map((id) => + trackCollection(id, current.collections[id]), + ) + case 'material': + // Each material returned is read whole. + return recordQuery('material', null).map((id) => { + recorder.material(id) + return current.materials[id] + }) + case 'root': + return recordQuery('root', null).flatMap((id) => trackNode(id, current.nodes[id]) ?? []) + } + } + + function orderedChildrenIn(id: string, version: Version = {}): string[] { + assertActive() + const node = document(version).nodes[id] + recorder.node(id) + const children = childIdsOf(node) + recorder.order(id, children) + return [...children] + } + + const reader: DocumentReader = { + get: (id) => { + assertActive() + return trackNode(id, source.nodes()[id]) + }, + query: ((selection: EntityQuery) => queryIn(selection)) as DocumentReader['query'], + orderedChildren: (id) => orderedChildrenIn(id), + collection: (id) => { + assertActive() + return trackCollection(id, source.collections()[id]) + }, + material: (id) => { + assertActive() + recorder.material(id) + return source.materials()[id] + }, + installedPlugins: () => { + assertActive() + recorder.installedPlugins() + return [...source.installedPlugins()] + }, + get nodes() { + assertActive() + const raw = source.nodes() as Record + return viewOf(raw, trackNode, { nodes: raw }, 'node') as SceneNodes + }, + track: (raw) => { + assertActive() + const version = raw as Record + return viewOf(version, trackNode, { nodes: version }, 'node') as SceneNodes + }, + get collections() { + assertActive() + const raw = source.collections() as Record + return viewOf(raw, trackCollection, { collections: raw }, 'collection') + }, + } + return reader +} + +function childIdsOf(node: AnyNode | undefined): string[] { + return node && 'children' in node && Array.isArray(node.children) + ? (node.children as unknown[]).filter((id): id is string => typeof id === 'string') + : [] +} + +function createPlainReader(source: DocumentSource): DocumentReader { + const { assertActive } = source + const document = () => ({ + nodes: source.nodes() as Record, + collections: source.collections() as Record, + materials: source.materials() as Record, + rootNodeIds: source.rootNodeIds() as string[], + }) + function query(query: EntityQuery): unknown[] { + assertActive() + const current = document() + const ids = queryIds(current, { + entity: query.entity, + predicate: ('where' in query ? (query.where ?? null) : null) as never, + }) + switch (query.entity) { + case 'node': + case 'root': + return ids.flatMap((id) => current.nodes[id] ?? []) + case 'collection': + return ids.map((id) => current.collections[id]) + case 'material': + return ids.map((id) => current.materials[id]) + } + } + return { + get: (id) => source.nodes()[id], + query: query as DocumentReader['query'], + orderedChildren: (id) => childIdsOf(source.nodes()[id]), + collection: (id) => source.collections()[id], + material: (id) => source.materials()[id], + installedPlugins: () => [...source.installedPlugins()], + get nodes() { + return source.nodes() as SceneNodes + }, + track: (raw) => raw, + get collections() { + return source.collections() + }, + } +} diff --git a/packages/core/src/runtime/scene-runtime.ts b/packages/core/src/runtime/scene-runtime.ts new file mode 100644 index 0000000000..8b75d777df --- /dev/null +++ b/packages/core/src/runtime/scene-runtime.ts @@ -0,0 +1,425 @@ +import { applySceneChanges } from '../agent-operations/apply-changes' +import { type AgentHostRuntime, applyAgentOutcome } from '../agent-operations/apply-outcome' +import { writeCollections } from '../agent-operations/collections' +import type { + AgentContext, + AgentOperation, + SceneChanges, + SceneNodes, +} from '../agent-operations/types' +import { + type SpatialGridManager, + supportIndexOf, + withSpatialGrid, +} from '../hooks/spatial-grid/spatial-grid-manager' +import { resolveLevelId } from '../lib/node-ancestry' +import { getNodePluginId, nodeRegistry } from '../registry/registry' +import type { AnyNode, AnyNodeId, Collection } from '../schema' +import { type IdAllocator, withIdAllocator } from '../schema/base' +import { + shouldRefreshDefaultGutters, + shouldRefreshDefaultRidgeVents, +} from '../store/actions/node-actions' +import { runSettlingDocument } from '../store/document-settling' +import { pauseSceneHistory, resumeSceneHistory, type SceneSnapshot } from '../store/history-control' +import { withoutLivePreviews } from '../store/live-previews' +import { createSceneEnvironment, type SceneEnvironment } from '../store/scene-environment' +import useScene, { + createSceneStore, + runAsSettledSceneStep, + type SceneStore, + sceneDocumentOf, +} from '../store/use-scene' +import { reconcileDocument } from '../systems/document-reconcile' +import { type Dependency, levelOf } from './dependencies' +import { diffSceneDocuments, sameValue } from './patch' +import { + createDocumentReader, + DependencyRecorder, + type DocumentReader, + type DocumentSource, + materialize, + untracked, +} from './reader' + +/** The saved scene a computation starts from: a draft revision, or an authority sequence. */ +export type SceneRevision = + | { kind: 'draft'; id: string; updatedAt: string } + | { kind: 'authority'; seq: number } + +/** + * What a computation knows besides the document, frozen when it starts: the floor the person was + * viewing, their selection and units, the catalog revision, and the node registry it ran against. + */ +export type ExecutionContext = { + activeLevelId: string | null + selection: readonly string[] + units: 'metric' | 'imperial' + catalogVersion: string + /** `captureRegistryVersions()` once the host's plugins are loaded. */ + registryVersions: Record +} + +/** Each registered node kind and the schema version it parses with (`plugin@version`). */ +export function captureRegistryVersions(): Record { + const versions: Record = {} + for (const [kind, definition] of [...nodeRegistry.entries()].sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + )) + versions[kind] = `${getNodePluginId(kind) ?? 'core'}@${definition.schemaVersion}` + return versions +} + +export class RegistryVersionMismatchError extends Error { + constructor(readonly kinds: string[]) { + super( + `The node registry differs from the one the computation was captured with: ${kinds.join(', ')}.`, + ) + this.name = 'RegistryVersionMismatchError' + } +} + +/** + * A scene store with the means to compute on it: the reader agent code reads through, the scope + * (environment, id allocator, support index) its writes run in, and the document reconciliation. + * The editor's store is one (`liveSceneRuntime`); a detached scene is another. + */ +export interface SceneRuntime { + readonly store: SceneStore + reader(): DocumentReader + /** Runs `run` in this scene's scope: its history environment, ids and support index. */ + run(run: () => T): T + /** Settles every derived write owed since `before` (`reconcileDocument`), reading through `reader`. */ + reconcile(before: SceneNodes, options?: { allLevels?: boolean }): void + /** Records a read the reader cannot see; absent where nothing is recorded (the editor's store). */ + declare?(dependency: Dependency): void +} + +function sourceOf(store: SceneStore, environment: SceneEnvironment | null): DocumentSource { + return { + nodes: () => store.getState().nodes, + collections: () => store.getState().collections, + materials: () => store.getState().materials, + rootNodeIds: () => store.getState().rootNodeIds, + installedPlugins: () => store.getState().installedPlugins, + assertActive: () => environment?.assertActive(), + } +} + +/** The editor's own store: plain reads, random ids, the editor's environment. */ +export const liveSceneRuntime: SceneRuntime = { + store: useScene, + reader: () => createDocumentReader(sourceOf(useScene, null), null), + run: (run) => run(), + reconcile: (before, options) => reconcileDocument(useScene, before, options), +} + +/** + * Loads an already-migrated document into `store` as it is: no migration, healing or + * normalisation runs again (a load through `setScene` would, and could change it — closing level + * gaps, re-cutting openings). The store's history starts empty at this document. + */ +export function loadCanonicalSnapshot(store: SceneStore, snapshot: SceneSnapshot): void { + pauseSceneHistory(store) + try { + store.setState({ + nodes: snapshot.nodes, + rootNodeIds: snapshot.rootNodeIds, + collections: snapshot.collections, + materials: snapshot.materials, + installedPlugins: [...snapshot.installedPlugins], + hasExplicitPluginInstallState: snapshot.hasExplicitPluginInstallState ?? false, + hydrationToken: null, + hydrationId: null, + readOnly: false, + }) + } finally { + resumeSceneHistory(store) + } + store.temporal.getState().clear() +} + +/** A scene detached from the editor: computed on, then turned into a patch. */ +export interface DetachedScene extends SceneRuntime { + readonly base: SceneRevision + readonly context: ExecutionContext + /** Applies agent changes and settles their derived writes, recording what they read. */ + host(): AgentHostRuntime + /** + * Runs an agent operation on this scene, as `applyAgentOutcome` through `host()`, and returns its + * result as plain data (`materialize`) that outlives the scene. + */ + execute( + operation: AgentOperation, + input: Input, + context: AgentContext, + ): Record + /** Records a read the reader cannot see: a project file, an artifact, a conservative scope. */ + declare(dependency: Dependency): void + dependencies(): Dependency[] + /** The document change since the snapshot, field by field, derived writes settled. */ + patch(): ReturnType + /** Ends the scene: every later read or write of it throws. */ + dispose(): void + readonly disposed: boolean +} + +/** Freezes plain data in place, deeply; returns it. */ +export function deepFreeze(value: T): T { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value + Object.freeze(value) + for (const key of Object.keys(value)) deepFreeze((value as Record)[key]) + return value +} + +/** Unwraps tracked views an operation put in its changes, so the store holds plain records. */ +function plainChanges(changes: SceneChanges): SceneChanges { + // The store takes its own copy: a detached scene freezes what it holds, and must not freeze the + // caller's objects (or share them with anyone else). + const own = (value: T): T => structuredClone(materialize(untracked(value))) + const plainData = (data: Partial) => + Object.fromEntries( + Object.entries(untracked(data)).map(([key, value]) => [key, own(value)]), + ) as Partial + return { + ...(changes.create && { + create: changes.create.map(({ node, parentId }) => ({ + node: own(node), + ...(parentId === undefined ? {} : { parentId }), + })), + }), + ...(changes.update && { + update: changes.update.map(({ id, data }) => ({ id, data: plainData(data) })), + }), + ...(changes.delete && { delete: [...changes.delete] }), + ...(changes.collections && { + collections: Object.fromEntries( + Object.entries(changes.collections).map(([id, collection]) => [id, own(collection)]), + ), + }), + } +} + +/** Applies agent changes to a store as the editor's actions do. */ +export function applySceneChangesToStore(store: SceneStore, changes: SceneChanges): void { + const { create, update, delete: remove, collections } = plainChanges(changes) + if (create?.length || update?.length || remove?.length) + store.getState().applyNodeChanges({ + create: create as { node: AnyNode; parentId?: AnyNodeId }[] | undefined, + update: update as { id: AnyNodeId; data: Partial }[] | undefined, + delete: remove as AnyNodeId[] | undefined, + }) + if (collections && Object.keys(collections).length) + store.setState({ + collections: writeCollections(store.getState().collections, collections) as Record< + Collection['id'], + Collection + >, + }) +} + +/** + * What a runtime lends an agent operation's outcome (`applyAgentOutcome`): its changes applied as + * the editor's actions apply them, each followed by the document reconciliation, and an explicit + * reconcile of every level. One undo step per call. + */ +export function createAgentHost(runtime: SceneRuntime): AgentHostRuntime { + const nodes = () => runtime.store.getState().nodes as SceneNodes + return { + getNodes: () => runtime.reader().nodes, + applyChanges: (changes) => + runtime.run(() => + runSettlingDocument(runtime.store, () => + runAsSettledSceneStep(runtime.store, () => { + const before = nodes() + applySceneChangesToStore(runtime.store, changes) + declareActionDependencies(runtime, before, changes) + runtime.reconcile(before) + }), + ), + ), + reconcile: () => + runtime.run(() => + runSettlingDocument(runtime.store, () => + runAsSettledSceneStep(runtime.store, () => + runtime.reconcile(nodes(), { allLevels: true }), + ), + ), + ), + } +} + +/** Kinds whose writes the store's actions plan around (gutters, ridge vents, downspouts). */ +const PLANNED_KINDS = new Set([ + 'roof', + 'roof-segment', + 'lean-to-extension', + 'gutter', + 'downspout', + 'ridge-vent', +]) + +/** + * The store's actions plan more than they are asked for, reading the scene directly: default + * gutters, ridge vents and downspouts around roof edits, merged walls and cleaned references + * around deletions, walls kept clear of each other on a curve, sanitised values. Whatever such a + * planner read is a dependency, whether or not it wrote anything (a disabled gutter is a decision + * too), so each level its triggers touch becomes a `scope` dependency (the whole document for a + * node outside every level); so does each level an unrequested write lands on. + */ +export function declareActionDependencies( + runtime: Pick, + before: SceneNodes, + changes: SceneChanges, +): void { + const declare = runtime.declare?.bind(runtime) + if (!declare) return + const plain = plainChanges(changes) + const intended = applySceneChanges(before, plain) + const after = runtime.store.getState().nodes as SceneNodes + const kindOf = (id: string) => (after[id] ?? before[id] ?? intended[id])?.type + const touched = new Set() + for (const id of plain.delete ?? []) touched.add(id) + for (const { node } of plain.create ?? []) if (PLANNED_KINDS.has(node.type)) touched.add(node.id) + for (const { id, data } of plain.update ?? []) { + const kind = kindOf(id) + // A segment's planners run on the fields they read (a rename runs none). + const planned = + kind === 'roof-segment' + ? shouldRefreshDefaultGutters(data) || shouldRefreshDefaultRidgeVents(data) + : PLANNED_KINDS.has(kind ?? '') || (kind === 'wall' && 'curveOffset' in data) + if (planned) touched.add(id) + } + let extra = false + for (const id of new Set([...Object.keys(intended), ...Object.keys(after)])) + if (intended[id] !== after[id] && !sameValue(intended[id], after[id])) { + extra = true + touched.add(id) + } + if (extra) { + for (const { node } of plain.create ?? []) touched.add(node.id) + for (const { id } of plain.update ?? []) touched.add(id) + } + const levels = new Set() + for (const id of touched) + for (const nodes of [before, after, intended]) if (nodes[id]) levels.add(levelOf(nodes, id)) + for (const level of levels) + declare(level ? { kind: 'scope', level } : { kind: 'scope', whole: true }) +} + +/** + * A support index over the scene's own nodes that records, for every query about a level, a + * `scope` dependency on that level: support reads walk spatial indexes the reader cannot see. A + * query names its level first, or a node on it (`canPlaceOnCeiling` its ceiling): the scope is the + * level that node is on, the whole document for one on none. + */ +function recordingSupportGrid( + store: SceneStore, + recorder: DependencyRecorder, +): () => SpatialGridManager { + const recordings = new WeakMap() + return () => { + const grid = supportIndexOf(store, resolveLevelId) + let recording = recordings.get(grid) + if (!recording) { + recording = new Proxy(grid, { + get(target, key, receiver) { + const value = Reflect.get(target, key, receiver) + if (typeof value !== 'function') return value + return (...args: unknown[]) => { + const [subject] = args + const nodes = store.getState().nodes + if (typeof subject === 'string' && nodes[subject as AnyNodeId]) { + const level = levelOf(nodes, subject) + recorder.declare(level ? { kind: 'scope', level } : { kind: 'scope', whole: true }) + } + return value.apply(target, args) + } + }, + }) + recordings.set(grid, recording) + } + return recording + } +} + +/** + * Opens a detached scene over a canonical snapshot (already migrated: `getSceneDocument()` of a + * loaded editor, or a saved draft). It has its own store, history, commit bus, drafts and support + * index, so nothing done on it reaches the editor. Every id it mints comes from `ids` (use + * `effectIds(effectId)` to make a recomputation mint the same ones), and every read made through + * its reader, host and reconciliation is recorded. + */ +export function openDetachedScene( + snapshot: SceneSnapshot, + options: { base: SceneRevision; context: ExecutionContext; ids: IdAllocator }, +): DetachedScene { + const current = captureRegistryVersions() + const mismatched = [ + ...new Set([...Object.keys(current), ...Object.keys(options.context.registryVersions)]), + ].filter((kind) => current[kind] !== options.context.registryVersions[kind]) + if (mismatched.length) throw new RegistryVersionMismatchError(mismatched) + + const environment = createSceneEnvironment(`detached at ${JSON.stringify(options.base)}`) + const store = createSceneStore(environment) + // The scene owns its data, read-only throughout: nothing reached through it (a nested array of + // a node, a material) can change the caller's snapshot, the editor's store, or the baseline its + // patch is taken against without going through its own writes. + loadCanonicalSnapshot(store, deepFreeze(structuredClone(snapshot))) + store.subscribe((state, previous) => { + for (const key of ['nodes', 'collections', 'materials'] as const) { + const next = state[key] as Record + const before = previous[key] as Record + if (next === before) continue + for (const id in next) if (next[id] !== before[id]) deepFreeze(next[id]) + Object.freeze(next) + } + }) + const base = sceneDocumentOf(store) + const recorder = new DependencyRecorder() + const reader = createDocumentReader(sourceOf(store, environment), recorder) + const grid = recordingSupportGrid(store, recorder) + const run = (work: () => T): T => + environment.run(() => + withIdAllocator(options.ids, () => withoutLivePreviews(() => withSpatialGrid(grid, work))), + ) + const rawNodes = () => store.getState().nodes as SceneNodes + const reconcile = (before: SceneNodes, reconcileOptions: { allLevels?: boolean } = {}) => { + run(() => reconcileDocument(store, before, { ...reconcileOptions, read: reader.track, grid })) + settled = rawNodes() + } + let settled = rawNodes() + + const scene: DetachedScene = { + base: options.base, + context: options.context, + store, + reader: () => reader, + run, + reconcile, + host: () => createAgentHost(scene), + execute: (operation, input, context) => + run(() => + materialize( + applyAgentOutcome(operation(reader.nodes, input, context), createAgentHost(scene)), + ), + ), + declare: (dependency) => { + environment.assertActive() + recorder.declare(dependency) + }, + dependencies: () => { + environment.assertActive() + return recorder.dependencies() + }, + patch: () => { + if (rawNodes() !== settled) reconcile(settled) + return diffSceneDocuments(base, sceneDocumentOf(store)) + }, + dispose: () => environment.dispose(), + get disposed() { + return environment.disposed + }, + } + return scene +} diff --git a/packages/core/src/schema/base.ts b/packages/core/src/schema/base.ts index 15fde76c17..e3cbe5390a 100644 --- a/packages/core/src/schema/base.ts +++ b/packages/core/src/schema/base.ts @@ -10,8 +10,29 @@ const customId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16) * @example 'white', 'brick', 'wood', 'glass', 'preview-valid' */ export const Material = z.string().optional() -export const generateId = (prefix: T): `${T}_${string}` => - `${prefix}_${customId()}` as `${T}_${string}` +/** Mints the id of a new entity from its prefix: `wall` → `wall_…`. */ +export type IdAllocator = (prefix: string) => string + +const idAllocators: IdAllocator[] = [] + +/** + * Runs `run` with `ids` minting every id it asks `generateId` (and so every schema default) for, + * synchronously: a detached scene allocates deterministic ids this way (`effectIds`). Outside a + * scope, ids are random. + */ +export function withIdAllocator(ids: IdAllocator, run: () => T): T { + idAllocators.push(ids) + try { + return run() + } finally { + idAllocators.pop() + } +} + +export const generateId = (prefix: T): `${T}_${string}` => { + const allocate = idAllocators.at(-1) + return (allocate ? allocate(prefix) : `${prefix}_${customId()}`) as `${T}_${string}` +} export const objectId = (prefix: T) => { const schema = z.templateLiteral([`${prefix}_`, z.string()]) diff --git a/packages/core/src/services/roof-level.ts b/packages/core/src/services/roof-level.ts index 96d7abf1ee..6fe92bc012 100644 --- a/packages/core/src/services/roof-level.ts +++ b/packages/core/src/services/roof-level.ts @@ -1,5 +1,7 @@ +import type { SceneNodes } from '../agent-operations/types' import { getDefaultLevelName, getLevelDisplayName } from '../lib/level-name' import { nodeRegistry } from '../registry' +import { queryNodes } from '../runtime/node-queries' import { type AnyNode, LevelNode, type RoofNode } from '../schema' import { getLevelElevations } from './storey' @@ -68,9 +70,8 @@ const holdsOnlyRoofs = (nodes: Nodes, level: LevelNode) => { function levelsOf(nodes: Nodes, buildingId: string | null | undefined): LevelNode[] { const building = buildingId ? nodes[buildingId] : undefined const listed = new Set(building?.type === 'building' ? building.children : []) - return Object.values(nodes).filter( - (node): node is LevelNode => - node.type === 'level' && (node.parentId === (buildingId ?? null) || listed.has(node.id)), + return queryNodes(nodes as SceneNodes, { type: 'level' }).filter( + (node) => node.parentId === (buildingId ?? null) || listed.has(node.id), ) } @@ -85,11 +86,12 @@ function levelsOf(nodes: Nodes, buildingId: string | null | undefined): LevelNod */ export function servesAsRoofLevel(nodes: Nodes, level: LevelNode): boolean { if (isRoofLevel(level)) return true - if (!holdsOnlyRoofs(nodes, level)) return false + // What a level holds is read last: a storey's children are what concurrent edits add to. const stack = levelsOf(nodes, level.parentId) return ( !stack.some(isRoofLevel) && stack.some((other) => other.level < level.level) && + holdsOnlyRoofs(nodes, level) && stack.every((other) => other.level <= level.level || holdsOnlyRoofs(nodes, other)) ) } diff --git a/packages/core/src/services/roof-move.ts b/packages/core/src/services/roof-move.ts index 3e8fc57f0f..d8c4dc1cf2 100644 --- a/packages/core/src/services/roof-move.ts +++ b/packages/core/src/services/roof-move.ts @@ -1,6 +1,7 @@ import { applySceneChanges } from '../agent-operations/apply-changes' -import type { SceneChanges } from '../agent-operations/types' +import type { SceneChanges, SceneNodes } from '../agent-operations/types' import { getLevelDisplayName } from '../lib/level-name' +import { queryNodes } from '../runtime/node-queries' import { type AnyNode, type AnyNodeId, @@ -22,6 +23,7 @@ import { getLevelBelow, getLevelElevations, type LevelElevation } from './storey */ type Nodes = Readonly> +type LevelElevations = ReturnType const segmentsOf = (nodes: Nodes, roof: RoofNode) => roof.children @@ -207,13 +209,13 @@ export function roofsKeptOverStorey( before: Nodes, after: Nodes, ): { id: AnyNodeId; data: Pick }[] { - const was = getLevelElevations(before as Record) - const now = getLevelElevations(after as Record) - const rise = (elevations: typeof now, levelId: string, storeyId: string) => - (elevations.get(levelId)?.baseY ?? 0) - (elevations.get(storeyId)?.baseY ?? 0) + // Read only once a roof stands over a storey: elevations read every level of the scene. + let elevations: { was: LevelElevations; now: LevelElevations } | undefined + const rise = (of: LevelElevations, levelId: string, storeyId: string) => + (of.get(levelId)?.baseY ?? 0) - (of.get(storeyId)?.baseY ?? 0) const updates: { id: AnyNodeId; data: Pick }[] = [] - for (const roof of Object.values(after)) { - if (roof.type !== 'roof' || roof.support?.kind !== 'level' || !roof.parentId) continue + for (const roof of queryNodes(after as SceneNodes, { type: 'roof' })) { + if (roof.support?.kind !== 'level' || !roof.parentId) continue const prior = before[roof.id] if ( prior?.type !== 'roof' || @@ -224,6 +226,11 @@ export function roofsKeptOverStorey( const storeyId = coveredStoreyId(after, roof) if (!storeyId || storeyId === roof.parentId || coveredStoreyId(before, prior) !== storeyId) continue + elevations ??= { + was: getLevelElevations(before as Record), + now: getLevelElevations(after as Record), + } + const { was, now } = elevations // A level put in, taken out or reordered between them: the roof moves with its level (#1039). const between = levelsBetween(now, storeyId, roof.parentId) if (between !== levelsBetween(was, storeyId, roof.parentId)) continue diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts index b211716d1a..4770059232 100644 --- a/packages/core/src/services/storey.ts +++ b/packages/core/src/services/storey.ts @@ -1,6 +1,7 @@ import { footprintLift } from '../lib/floor-foundation-datum' import { levelConstructionDisplacements } from '../lib/floor-foundation-stack' import { area, intersection } from '../lib/polygon-boolean' +import { queryNodes } from '../runtime/node-queries' import type { BuildingNode, LevelNode, SlabNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' import { @@ -74,7 +75,11 @@ export function getLevelElevations(nodes: Record): Map node.type === 'slab' && node.plateRole === 'base')) + if ( + queryNodes(nodes, { type: 'slab' }).some( + (node) => node.type === 'slab' && node.plateRole === 'base', + ) + ) for (const [id, lift] of levelConstructionDisplacements(nodes)) { const entry = elevations.get(id)! if (lift) elevations.set(id, { ...entry, baseY: entry.baseY + lift }) @@ -89,14 +94,10 @@ export function getAuthoredLevelElevations( const memoized = authoredElevationMemo.get(nodes) if (memoized) return memoized - const buildings = Object.values(nodes).filter( - (node): node is BuildingNode => node?.type === 'building', - ) + const buildings = queryNodes(nodes, { type: 'building' }) const entries: Array<{ baseElevation: number; levelId: string } & LevelElevation> = [] - for (const node of Object.values(nodes)) { - if (node?.type !== 'level') continue - const level = node as LevelNode + for (const level of queryNodes(nodes, { type: 'level' })) { entries.push({ levelId: level.id, baseElevation: level.baseElevation ?? 0, diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 8db316bb92..b33e82f1f2 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -59,6 +59,7 @@ import { noteDerivedNodeWrites, runWithSceneCommitNodeIds, } from '../history-control' +import { currentSceneEnvironment } from '../scene-environment' import type { SceneState } from '../use-scene' type AnyContainerNode = AnyNode & { children: string[] } @@ -680,7 +681,7 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode return mergeNodeUpdate(currentNode, sanitized.value as Partial) } -function shouldRefreshDefaultRidgeVents(data: Partial) { +export function shouldRefreshDefaultRidgeVents(data: Partial) { return Object.keys(data).some((key) => DEFAULT_RIDGE_VENT_REFRESH_FIELDS.has(key)) } @@ -718,7 +719,7 @@ function refreshDefaultRidgeVentsForSegment( return nextVents.map((vent) => vent.id as AnyNodeId) } -function shouldRefreshDefaultGutters(data: Partial) { +export function shouldRefreshDefaultGutters(data: Partial) { return Object.keys(data).some((key) => DEFAULT_GUTTER_REFRESH_FIELDS.has(key)) } @@ -1090,13 +1091,19 @@ function refreshDefaultGuttersForRoofIds( } } -// Track pending RAF for updateNodesAction to prevent multiple queued callbacks -let pendingRafId: number | null = null -let pendingUpdates: Set = new Set() +// One frame's pending dirty marks per scene environment, so a detached scene's update never +// cancels (or receives) the editor's. +type PendingDirtyMarks = { frame: number | null; ids: Set } +const PENDING_DIRTY_MARKS = Symbol('pending-dirty-marks') +const pendingDirtyMarks = () => + currentSceneEnvironment().slot(PENDING_DIRTY_MARKS, () => ({ + frame: null, + ids: new Set(), + })) -/** Authored updates whose deferred dirty intent has not reached render systems yet. */ +/** The current environment's authored updates whose deferred dirty intent has not reached render systems yet. */ export function getPendingNodeUpdateCount(): number { - return pendingUpdates.size + return pendingDirtyMarks().ids.size } function buildWallMergePlans( @@ -1625,6 +1632,9 @@ const updateNodesActionImpl = ( return } // Batch dirty-marking into a single RAF to avoid redundant callbacks during rapid updates + const environment = currentSceneEnvironment() + const pending = pendingDirtyMarks() + const pendingUpdates = pending.ids for (const u of updates) { // Visibility is applied by React before the deferred dirty callback. Mark // it now so render systems can release collective geometry in that same @@ -1642,17 +1652,23 @@ const updateNodesActionImpl = ( get().clearDirty(id) } - if (pendingRafId !== null) { - cancelAnimationFrame(pendingRafId) - } - - pendingRafId = requestAnimationFrame(() => { + const flush = () => { + pending.frame = null + // A disposed scene's frame reaches nothing. + if (environment.disposed) return pendingUpdates.forEach((id) => { get().markDirty(id) }) pendingUpdates.clear() - pendingRafId = null - }) + } + if (typeof requestAnimationFrame === 'function') { + if (pending.frame !== null) cancelAnimationFrame(pending.frame) + pending.frame = requestAnimationFrame(flush) + } else if (pending.frame === null) { + // Headless (a worker, a server) there is no frame: the marks land after the current task. + pending.frame = -1 + queueMicrotask(flush) + } } /** The scene record a node deletion reads and rewrites. */ diff --git a/packages/core/src/store/document-settling.ts b/packages/core/src/store/document-settling.ts new file mode 100644 index 0000000000..8cd9563cd4 --- /dev/null +++ b/packages/core/src/store/document-settling.ts @@ -0,0 +1,24 @@ +import { sceneEnvironmentOf } from './scene-environment' + +const SETTLING = Symbol('document-settling') +const settlingOf = (store: object) => sceneEnvironmentOf(store).slot(SETTLING, () => ({ depth: 0 })) + +/** + * Whether `store` is being written by a host that settles every derived write itself + * (`reconcileDocument`) before it returns: the editor's own derived writers (space detection, + * the opening and roof systems) stand down meanwhile, so derived ids are minted once, in the + * host's order. + */ +export function isSettlingDocument(store: object): boolean { + return settlingOf(store).depth > 0 +} + +export function runSettlingDocument(store: object, run: () => T): T { + const settling = settlingOf(store) + settling.depth += 1 + try { + return run() + } finally { + settling.depth -= 1 + } +} diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts index 8487bc690e..1417252d3d 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -2,45 +2,87 @@ import type { Collection, CollectionId } from '../schema/collections' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' import { withSceneHistoryDraftSuspended } from './history-drafts' +import { + currentSceneEnvironment, + type SceneEnvironment, + sceneEnvironmentOf, +} from './scene-environment' -let sceneHistoryRestoreDepth = 0 +type GesturePauseHolder = { release: (() => void) | null; ended: boolean } + +/** This module's state, one per scene environment (scene-environment.ts). */ +type HistoryControlState = { + restoreDepth: number + remoteOperationDepth: number + pauseDepth: number + pauseLeases: Set + commitListeners: Set + commitTransactionDepth: number + pendingCommit: SceneCommit | null + commitNodeIdScopes: Set[] + gesturePauseHolders: Map> + livePauseHolders: Set + writeGuards: Set<() => void> + derivedWrites: Map | 'all'> +} + +const HISTORY_CONTROL = Symbol('scene-history-control') + +function controlOf(environment: SceneEnvironment): HistoryControlState { + return environment.slot(HISTORY_CONTROL, () => ({ + restoreDepth: 0, + remoteOperationDepth: 0, + pauseDepth: 0, + pauseLeases: new Set(), + commitListeners: new Set(), + commitTransactionDepth: 0, + pendingCommit: null, + commitNodeIdScopes: [], + gesturePauseHolders: new Map(), + livePauseHolders: new Set(), + writeGuards: new Set(), + derivedWrites: new Map(), + })) +} + +/** The state of the environment whose store is acting, or the live one. */ +const current = () => controlOf(currentSceneEnvironment()) +/** The state of `store`'s own environment. */ +const of = (store: object) => controlOf(sceneEnvironmentOf(store)) export function isRestoringSceneHistory(): boolean { - return sceneHistoryRestoreDepth > 0 + return current().restoreDepth > 0 } export function runAsSceneHistoryRestore(run: () => T): T { - sceneHistoryRestoreDepth += 1 + const control = current() + control.restoreDepth += 1 try { return run() } finally { - sceneHistoryRestoreDepth -= 1 + control.restoreDepth -= 1 } } -let remoteSceneOperationDepth = 0 - /** * Whether the scene is being written by a collaborator's change (a shared * operation or snapshot applied from the host), not by this client — what a * live session that takes back its own writes must leave alone. */ export function isApplyingRemoteSceneChange(): boolean { - return remoteSceneOperationDepth > 0 + return current().remoteOperationDepth > 0 } export function runAsRemoteSceneChange(run: () => T): T { - remoteSceneOperationDepth += 1 + const control = current() + control.remoteOperationDepth += 1 try { return run() } finally { - remoteSceneOperationDepth -= 1 + control.remoteOperationDepth -= 1 } } -let sceneHistoryPauseDepth = 0 -const sceneHistoryPauseLeases = new Set() - export type SceneSnapshot = { nodes: Record rootNodeIds: AnyNodeId[] @@ -94,11 +136,6 @@ type TemporalHistoryStoreLike = { } } -const sceneCommitListeners = new Set() -let sceneCommitTransactionDepth = 0 -let pendingSceneCommit: SceneCommit | null = null -const sceneCommitNodeIdScopes: Set[] = [] - function mergedNodeIds( left: ReadonlySet | undefined, right: ReadonlySet | undefined, @@ -108,16 +145,17 @@ function mergedNodeIds( } export function activeSceneCommitNodeIds(): ReadonlySet | undefined { - if (sceneCommitNodeIdScopes.length === 0) return undefined + const scopes = current().commitNodeIdScopes + if (scopes.length === 0) return undefined const ids = new Set() - for (const scope of sceneCommitNodeIdScopes) { + for (const scope of scopes) { for (const id of scope) ids.add(id) } return ids } export function addActiveSceneCommitNodeIds(nodeIds: Iterable): void { - const scope = sceneCommitNodeIdScopes.at(-1) + const scope = current().commitNodeIdScopes.at(-1) if (!scope) return for (const id of nodeIds) scope.add(id) } @@ -126,34 +164,81 @@ export function runWithSceneCommitNodeIds( nodeIds: Iterable, run: () => TResult, ): TResult { + const scopes = current().commitNodeIdScopes const scope = new Set(nodeIds) - sceneCommitNodeIdScopes.push(scope) + scopes.push(scope) try { return run() } finally { - sceneCommitNodeIdScopes.pop() - const parentScope = sceneCommitNodeIdScopes.at(-1) + scopes.pop() + const parentScope = scopes.at(-1) if (parentScope) { for (const id of scope) parentScope.add(id) } } } -const sceneWriteGuards = new Set<() => void>() + +function areSemanticValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true + if (typeof left !== typeof right || left === null || right === null) return false + + if (Array.isArray(left) || Array.isArray(right)) { + if (!(Array.isArray(left) && Array.isArray(right)) || left.length !== right.length) return false + return left.every((value, index) => areSemanticValuesEqual(value, right[index])) + } + + if (typeof left !== 'object' || typeof right !== 'object') return false + + const leftRecord = left as Record + const rightRecord = right as Record + const leftKeys = Object.keys(leftRecord) + const rightKeys = Object.keys(rightRecord) + if (leftKeys.length !== rightKeys.length) return false + + for (const key of leftKeys) { + if (!(key in rightRecord) || !areSemanticValuesEqual(leftRecord[key], rightRecord[key])) { + return false + } + } + return true +} + +export function areSceneSnapshotsEqual(left: SceneSnapshot, right: SceneSnapshot): boolean { + return ( + areSemanticValuesEqual(left.nodes, right.nodes) && + areSemanticValuesEqual(left.rootNodeIds, right.rootNodeIds) && + areSemanticValuesEqual(left.collections, right.collections) && + areSemanticValuesEqual(left.materials, right.materials) && + areSemanticValuesEqual(left.installedPlugins, right.installedPlugins) && + (left.hasExplicitPluginInstallState ?? false) === (right.hasExplicitPluginInstallState ?? false) + ) +} + +/** Listens to the commits of the current environment's store: the live editor's, by default. */ +export function subscribeSceneCommits(listener: SceneCommitListener): () => void { + const listeners = current().commitListeners + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} /** - * Registers something that must let go of the scene before anything writes it: a preview that - * shows another state (`scene-preview.ts`) would otherwise swallow the write or have it land on - * the wrong state. Returns the unregister. + * Registers something that must let go of the current environment's scene before anything writes + * it: a preview that shows another state (`scene-preview.ts`) would otherwise swallow the write or + * have it land on the wrong state. Returns the unregister. */ export function registerSceneWriteGuard(guard: () => void): () => void { - sceneWriteGuards.add(guard) + const guards = current().writeGuards + guards.add(guard) return () => { - sceneWriteGuards.delete(guard) + guards.delete(guard) } } -export function runSceneWriteGuards(): void { - for (const guard of [...sceneWriteGuards]) guard() +/** Runs the guards of `sceneStore`'s environment, or of the current one. */ +export function runSceneWriteGuards(sceneStore?: object): void { + for (const guard of [...(sceneStore ? of(sceneStore) : current()).writeGuards]) guard() } const sceneCommitAuthorScopes: SceneCommitAuthor[] = [] @@ -195,58 +280,14 @@ export function acquireSceneCommitAuthor(author: SceneCommitAuthor): () => void } } -function areSemanticValuesEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) return true - if (typeof left !== typeof right || left === null || right === null) return false - - if (Array.isArray(left) || Array.isArray(right)) { - if (!(Array.isArray(left) && Array.isArray(right)) || left.length !== right.length) return false - return left.every((value, index) => areSemanticValuesEqual(value, right[index])) - } - - if (typeof left !== 'object' || typeof right !== 'object') return false - - const leftRecord = left as Record - const rightRecord = right as Record - const leftKeys = Object.keys(leftRecord) - const rightKeys = Object.keys(rightRecord) - if (leftKeys.length !== rightKeys.length) return false - - for (const key of leftKeys) { - if (!(key in rightRecord) || !areSemanticValuesEqual(leftRecord[key], rightRecord[key])) { - return false - } - } - return true -} - -export function areSceneSnapshotsEqual(left: SceneSnapshot, right: SceneSnapshot): boolean { - return ( - areSemanticValuesEqual(left.nodes, right.nodes) && - areSemanticValuesEqual(left.rootNodeIds, right.rootNodeIds) && - areSemanticValuesEqual(left.collections, right.collections) && - areSemanticValuesEqual(left.materials, right.materials) && - areSemanticValuesEqual(left.installedPlugins, right.installedPlugins) && - (left.hasExplicitPluginInstallState ?? false) === (right.hasExplicitPluginInstallState ?? false) - ) -} - -export function subscribeSceneCommits(listener: SceneCommitListener): () => void { - sceneCommitListeners.add(listener) - return () => { - sceneCommitListeners.delete(listener) - } -} - -let derivedWrites = new Map | 'all'>() - /** - * Records what the derived-writer capability writes, for the commit that follows: each node, and the - * fields written (none given: the node as a whole). + * Records what the derived-writer capability writes, for the current environment's commit that + * follows: each node, and the fields written (none given: the node as a whole). */ export function noteDerivedNodeWrites( writes: Iterable<{ id: AnyNodeId; fields?: Iterable }>, ): void { + const derivedWrites = current().derivedWrites for (const { id, fields } of writes) { const noted = derivedWrites.get(id) if (noted === 'all') continue @@ -255,11 +296,11 @@ export function noteDerivedNodeWrites( } } -function emitSceneCommit(authored: SceneCommit): void { - const derived = derivedWrites - derivedWrites = new Map() +function emitSceneCommit(control: HistoryControlState, authored: SceneCommit): void { + const derived = control.derivedWrites + control.derivedWrites = new Map() const commit = derived.size > 0 ? { ...authored, derivedWrites: derived } : authored - for (const listener of [...sceneCommitListeners]) { + for (const listener of [...control.commitListeners]) { try { listener(commit) } catch (error) { @@ -269,8 +310,9 @@ function emitSceneCommit(authored: SceneCommit): void { } export function notifySceneCommit(commit: SceneCommit): void { + const control = current() if (areSceneSnapshotsEqual(commit.before, commit.current)) { - if (sceneCommitTransactionDepth === 0) derivedWrites = new Map() + if (control.commitTransactionDepth === 0) control.derivedWrites = new Map() return } const author = commit.author ?? activeSceneCommitAuthor() @@ -280,81 +322,100 @@ export function notifySceneCommit(commit: SceneCommit): void { changedNodeIds: mergedNodeIds(commit.changedNodeIds, activeSceneCommitNodeIds()), } - if (sceneCommitTransactionDepth > 0) { - if (pendingSceneCommit) { - const pendingAuthor = pendingSceneCommit.author ?? contextualCommit.author - pendingSceneCommit = { - origin: pendingSceneCommit.origin, + if (control.commitTransactionDepth > 0) { + const pending = control.pendingCommit + if (pending) { + const pendingAuthor = pending.author ?? contextualCommit.author + control.pendingCommit = { + origin: pending.origin, ...(pendingAuthor ? { author: pendingAuthor } : {}), - before: pendingSceneCommit.before, + before: pending.before, current: contextualCommit.current, - changedNodeIds: mergedNodeIds( - pendingSceneCommit.changedNodeIds, - contextualCommit.changedNodeIds, - ), + changedNodeIds: mergedNodeIds(pending.changedNodeIds, contextualCommit.changedNodeIds), } } else { - pendingSceneCommit = contextualCommit + control.pendingCommit = contextualCommit } return } - emitSceneCommit(contextualCommit) + emitSceneCommit(control, contextualCommit) } -function beginSceneCommitTransaction(): void { - sceneCommitTransactionDepth += 1 +/** + * Makes `commit` the one `store`'s open transaction (`runAsSingleSceneHistoryStep`) emits: the + * pending commit, if any, keeps its origin, author and `before` and takes `commit`'s `current`, + * with both sets of changed ids. Outside a transaction, `commit` is emitted. + */ +export function settlePendingSceneCommit(store: object, commit: SceneCommit): void { + const control = of(store) + if (control.commitTransactionDepth === 0) { + sceneEnvironmentOf(store).run(() => notifySceneCommit(commit)) + return + } + const pending = control.pendingCommit + const author = pending?.author ?? commit.author ?? activeSceneCommitAuthor() + control.pendingCommit = { + origin: pending?.origin ?? commit.origin, + ...(author ? { author } : {}), + before: pending?.before ?? commit.before, + current: commit.current, + changedNodeIds: mergedNodeIds(pending?.changedNodeIds, commit.changedNodeIds), + } } -function pendingSceneCommitIsNoOp(): boolean { - return Boolean( - pendingSceneCommit && - areSceneSnapshotsEqual(pendingSceneCommit.before, pendingSceneCommit.current), - ) +function pendingSceneCommitIsNoOp(control: HistoryControlState): boolean { + const pending = control.pendingCommit + return Boolean(pending && areSceneSnapshotsEqual(pending.before, pending.current)) } -function endSceneCommitTransaction(): void { - if (sceneCommitTransactionDepth === 0) return - sceneCommitTransactionDepth -= 1 - if (sceneCommitTransactionDepth > 0) return +function endSceneCommitTransaction(control: HistoryControlState): void { + if (control.commitTransactionDepth === 0) return + control.commitTransactionDepth -= 1 + if (control.commitTransactionDepth > 0) return - const commit = pendingSceneCommit - pendingSceneCommit = null + const commit = control.pendingCommit + control.pendingCommit = null if (!commit) return - if (areSceneSnapshotsEqual(commit.before, commit.current)) derivedWrites = new Map() - else emitSceneCommit(commit) + if (areSceneSnapshotsEqual(commit.before, commit.current)) control.derivedWrites = new Map() + else emitSceneCommit(control, commit) } +const pauseDepthOf = (control: HistoryControlState) => control.pauseDepth + control.pauseLeases.size + export function pauseSceneHistory(sceneStore: TemporalStoreLike): void { - if (getSceneHistoryPauseDepth() === 0) { + const control = of(sceneStore) + if (pauseDepthOf(control) === 0) { sceneStore.temporal.getState().pause() } - sceneHistoryPauseDepth += 1 + control.pauseDepth += 1 } export function resumeSceneHistory(sceneStore: TemporalStoreLike): void { - if (sceneHistoryPauseDepth === 0) { + const control = of(sceneStore) + if (control.pauseDepth === 0) { return } - sceneHistoryPauseDepth -= 1 - if (getSceneHistoryPauseDepth() === 0) { + control.pauseDepth -= 1 + if (pauseDepthOf(control) === 0) { sceneStore.temporal.getState().resume() } } export function acquireSceneHistoryPause(sceneStore: TemporalStoreLike): () => void { - if (getSceneHistoryPauseDepth() === 0) { + const control = of(sceneStore) + if (pauseDepthOf(control) === 0) { sceneStore.temporal.getState().pause() } const lease = Symbol('scene-history-pause') - sceneHistoryPauseLeases.add(lease) + control.pauseLeases.add(lease) let released = false return () => { if (released) return released = true - sceneHistoryPauseLeases.delete(lease) - if (getSceneHistoryPauseDepth() === 0) { + control.pauseLeases.delete(lease) + if (pauseDepthOf(control) === 0) { sceneStore.temporal.getState().resume() } } @@ -375,10 +436,6 @@ export type SceneHistoryPauseSessionOptions = { gesture?: string } -type GesturePauseHolder = { release: (() => void) | null; ended: boolean } -const gesturePauseHolders = new Map>() -const livePauseHolders = new Set() - /** Lifts `holders`' pauses for `write`, then takes back those that have not ended. */ function runWithHoldersLifted( sceneStore: TemporalStoreLike, @@ -415,21 +472,25 @@ export function beginSceneHistoryPauseSession( options: SceneHistoryPauseSessionOptions = {}, ): SceneHistoryPauseSession { const { gesture } = options + const environment = sceneEnvironmentOf(sceneStore) + const control = controlOf(environment) const holder: GesturePauseHolder = { release: acquireSceneHistoryPause(sceneStore), ended: false, } - livePauseHolders.add(holder) + control.livePauseHolders.add(holder) let coOwners = new Set([holder]) if (gesture) { - coOwners = gesturePauseHolders.get(gesture) ?? new Set() + coOwners = control.gesturePauseHolders.get(gesture) ?? new Set() coOwners.add(holder) - gesturePauseHolders.set(gesture, coOwners) + control.gesturePauseHolders.set(gesture, coOwners) } return { commitStep(write) { - return runWithHoldersLifted(sceneStore, coOwners, () => - withSceneHistoryDraftSuspended(gesture, write), + return environment.run(() => + runWithHoldersLifted(sceneStore, coOwners, () => + withSceneHistoryDraftSuspended(gesture, write), + ), ) }, end() { @@ -437,24 +498,26 @@ export function beginSceneHistoryPauseSession( holder.ended = true holder.release?.() holder.release = null - livePauseHolders.delete(holder) + control.livePauseHolders.delete(holder) coOwners.delete(holder) - if (gesture && coOwners.size === 0) gesturePauseHolders.delete(gesture) + if (gesture && coOwners.size === 0) control.gesturePauseHolders.delete(gesture) }, } } -export function getSceneHistoryPauseDepth(): number { - return sceneHistoryPauseDepth + sceneHistoryPauseLeases.size +/** Pauses held on `sceneStore`'s history, or on the current environment's store's. */ +export function getSceneHistoryPauseDepth(sceneStore?: object): number { + return pauseDepthOf(sceneStore ? of(sceneStore) : current()) } -export function resetSceneHistoryPauseDepth(): void { - sceneHistoryPauseDepth = 0 - sceneHistoryPauseLeases.clear() +export function resetSceneHistoryPauseDepth(sceneStore?: object): void { + const control = sceneStore ? of(sceneStore) : current() + control.pauseDepth = 0 + control.pauseLeases.clear() // Open sessions no longer hold anything; a later commitStep must not re-take their pause. - for (const holder of livePauseHolders) holder.release = null - livePauseHolders.clear() - gesturePauseHolders.clear() + for (const holder of control.livePauseHolders) holder.release = null + control.livePauseHolders.clear() + control.gesturePauseHolders.clear() } function retainedPastStateCount(before: TPastState[], after: TPastState[]): number { @@ -477,15 +540,17 @@ export function runAsSingleSceneHistoryStep( sceneStore: TemporalHistoryStoreLike, run: () => TResult, ): TResult { + const environment = sceneEnvironmentOf(sceneStore) + const control = controlOf(environment) const beforePastStates = sceneStore.temporal.getState().pastStates - beginSceneCommitTransaction() + control.commitTransactionDepth += 1 try { - const result = run() + const result = environment.run(run) const afterPastStates = sceneStore.temporal.getState().pastStates const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates) const addedCount = afterPastStates.length - retainedCount - if (addedCount > 0 && pendingSceneCommitIsNoOp()) { + if (addedCount > 0 && pendingSceneCommitIsNoOp(control)) { sceneStore.temporal.setState({ pastStates: afterPastStates.slice(0, retainedCount) }) } else if (addedCount > 1) { const firstAddedState = afterPastStates[retainedCount] @@ -497,6 +562,6 @@ export function runAsSingleSceneHistoryStep( } return result } finally { - endSceneCommitTransaction() + endSceneCommitTransaction(control) } } diff --git a/packages/core/src/store/history-drafts.ts b/packages/core/src/store/history-drafts.ts index bd65c63108..a82dee2433 100644 --- a/packages/core/src/store/history-drafts.ts +++ b/packages/core/src/store/history-drafts.ts @@ -1,4 +1,5 @@ import type { AnyNode, AnyNodeId } from '../schema/types' +import { currentSceneEnvironment } from './scene-environment' /** * Nodes an interaction is carrying (a placement or move draft). History never sees the carry: @@ -44,7 +45,11 @@ type OwnedField = { heldKeys?: string[] } -const sceneHistoryDrafts = new Map() +const HISTORY_DRAFTS = Symbol('scene-history-drafts') + +/** The carried drafts of the current environment's store (scene-environment.ts). */ +const sceneHistoryDrafts = () => + currentSceneEnvironment().slot(HISTORY_DRAFTS, () => new Map()) type NodeMap = Record @@ -66,10 +71,11 @@ export function beginSceneHistoryDraft( original: AnyNode | null, nodes: NodeMap, ): () => void { - const existing = sceneHistoryDrafts.get(id) + const drafts = sceneHistoryDrafts() + const existing = drafts.get(id) if (existing && !existing.ended) { existing.refs += 1 - return releaseOnce(id, existing) + return releaseOnce(drafts, id, existing) } const parent = original?.parentId ? nodes[original.parentId as AnyNodeId] : undefined const draft: SceneHistoryDraft = { @@ -82,11 +88,15 @@ export function beginSceneHistoryDraft( ended: false, suspended: false, } - sceneHistoryDrafts.set(id, draft) - return releaseOnce(id, draft) + drafts.set(id, draft) + return releaseOnce(drafts, id, draft) } -function releaseOnce(id: AnyNodeId, draft: SceneHistoryDraft): () => void { +function releaseOnce( + drafts: Map, + id: AnyNodeId, + draft: SceneHistoryDraft, +): () => void { let released = false return () => { if (released) return @@ -94,7 +104,7 @@ function releaseOnce(id: AnyNodeId, draft: SceneHistoryDraft): () => void { draft.refs -= 1 if (draft.refs > 0) return draft.ended = true - if (sceneHistoryDrafts.get(id) === draft) sceneHistoryDrafts.delete(id) + if (drafts.get(id) === draft) drafts.delete(id) } } @@ -103,7 +113,7 @@ function releaseOnce(id: AnyNodeId, draft: SceneHistoryDraft): () => void { * gesture drops it (the 3D mover or the 2D move overlay) records the change. */ export function withSceneHistoryDraftSuspended(id: string | undefined, write: () => T): T { - const draft = id ? sceneHistoryDrafts.get(id as AnyNodeId) : undefined + const draft = id ? sceneHistoryDrafts().get(id as AnyNodeId) : undefined if (!(id && draft)) return write() // Visible to history for this write, but still the carry's: its own restore can revert it. const wasSuspended = draft.suspended @@ -142,7 +152,7 @@ function heldHostEntries( } export function hasSceneHistoryDrafts(): boolean { - return sceneHistoryDrafts.size > 0 + return sceneHistoryDrafts().size > 0 } /** @@ -151,7 +161,7 @@ export function hasSceneHistoryDrafts(): boolean { * the carry wrote. A write back to that earlier value ends the hold. */ export function noteSceneHistoryDraftWrite(before: NodeMap, after: NodeMap): void { - for (const [id, draft] of sceneHistoryDrafts) { + for (const [id, draft] of sceneHistoryDrafts()) { const previous = before[id] as Record | undefined const next = after[id] as Record | undefined if (!(draft.original && previous && next)) continue @@ -233,7 +243,7 @@ export function sceneHistoryDraftRevertUpdates( ): Array<{ id: AnyNodeId; data: Record }> { const updates: Array<{ id: AnyNodeId; data: Record }> = [] for (const id of ids) { - const draft = sceneHistoryDrafts.get(id) + const draft = sceneHistoryDrafts().get(id) const live = nodes[id] if (!(draft?.original && live)) continue const data: Record = {} @@ -260,7 +270,7 @@ export function sceneHistoryDraftRevertUpdates( */ export function settleSceneHistoryDrafts(ids: Iterable): void { for (const id of ids) { - const draft = sceneHistoryDrafts.get(id) + const draft = sceneHistoryDrafts().get(id) if (!draft) continue draft.owned.clear() draft.hostEntries.clear() @@ -268,12 +278,12 @@ export function settleSceneHistoryDrafts(ids: Iterable): void { } export function clearSceneHistoryDrafts(): void { - sceneHistoryDrafts.clear() + sceneHistoryDrafts().clear() } /** Created drafts: history excludes them (and their subtrees) like fresh-placement nodes. */ export function createdSceneHistoryDraftIds(): AnyNodeId[] { - return [...sceneHistoryDrafts] + return [...sceneHistoryDrafts()] .filter(([, draft]) => !(draft.original || draft.suspended)) .map(([id]) => id) } @@ -331,7 +341,7 @@ function placeChild( */ export function withAdoptedDraftsAsOriginal(nodes: NodeMap, historyNodes: NodeMap): NodeMap { let result: NodeMap | null = null - for (const [id, draft] of sceneHistoryDrafts) { + for (const [id, draft] of sceneHistoryDrafts()) { if (draft.suspended) continue const live = nodes[id] const held = draft.original && live ? heldFields(draft, live) : [] @@ -373,7 +383,7 @@ export function withAdoptedDraftsAsOriginal(nodes: NodeMap, historyNodes: NodeMa */ export function withDraftsRestored(before: NodeMap, after: NodeMap): NodeMap | null { let result: NodeMap | null = null - for (const [id, draft] of sceneHistoryDrafts) { + for (const [id, draft] of sceneHistoryDrafts()) { if (draft.suspended) continue const live = before[id] if (!live) continue diff --git a/packages/core/src/store/live-previews.ts b/packages/core/src/store/live-previews.ts new file mode 100644 index 0000000000..3f9023afe3 --- /dev/null +++ b/packages/core/src/store/live-previews.ts @@ -0,0 +1,20 @@ +let hidden = 0 + +/** + * Whether the editor's live previews (field overrides, drag transforms, an in-flight terrain + * stroke) are part of what is being read. They are not inside `withoutLivePreviews`: a detached + * scene, and the document reconciliation, compute from saved state only, since a preview is + * neither a dependency anyone can check nor a value that will necessarily be committed. + */ +export function livePreviewsVisible(): boolean { + return hidden === 0 +} + +export function withoutLivePreviews(run: () => T): T { + hidden += 1 + try { + return run() + } finally { + hidden -= 1 + } +} diff --git a/packages/core/src/store/scene-environment.test.ts b/packages/core/src/store/scene-environment.test.ts new file mode 100644 index 0000000000..3d72695c14 --- /dev/null +++ b/packages/core/src/store/scene-environment.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { LevelNode } from '../schema' +import { + getSceneHistoryPauseDepth, + pauseSceneHistory, + resumeSceneHistory, + type SceneCommit, + subscribeSceneCommits, +} from './history-control' +import { createSceneEnvironment, SceneEnvironmentDisposedError } from './scene-environment' +import useScene, { clearSceneHistory, createSceneStore } from './use-scene' + +const liveState = useScene.getState() +afterEach(() => { + useScene.setState(liveState) + clearSceneHistory() +}) + +function sceneWithLevel() { + const environment = createSceneEnvironment('test') + const store = createSceneStore(environment) + const level = LevelNode.parse({ level: 0 }) + store.setState({ nodes: { [level.id]: level }, rootNodeIds: [level.id] }) + store.temporal.getState().clear() + return { environment, store, level } +} + +describe('scene environments', () => { + test("a second store's writes, commits and history never reach the editor's", () => { + const commits: SceneCommit[] = [] + const stop = subscribeSceneCommits((commit) => commits.push(commit)) + try { + const liveBefore = useScene.getState().nodes + const livePast = useScene.temporal.getState().pastStates + const { store, level } = sceneWithLevel() + const ownCommits: SceneCommit[] = [] + store.getState().updateNode(level.id, { name: 'Ground' }) + expect(store.getState().nodes[level.id]?.name).toBe('Ground') + expect(store.temporal.getState().pastStates).toHaveLength(1) + expect(useScene.getState().nodes).toBe(liveBefore) + expect(useScene.temporal.getState().pastStates).toBe(livePast) + expect(commits).toHaveLength(0) + // The store's own commit bus is reached from inside its environment. + const environment = createSceneEnvironment('listener') + const other = createSceneStore(environment) + const stopOwn = environment.run(() => + subscribeSceneCommits((commit) => ownCommits.push(commit)), + ) + other.getState().loadScene() + stopOwn() + expect(ownCommits).toHaveLength(1) + expect(commits).toHaveLength(0) + } finally { + stop() + } + }) + + test('pauses and undo are per store', () => { + const { store, level } = sceneWithLevel() + pauseSceneHistory(store) + expect(getSceneHistoryPauseDepth(store)).toBe(1) + expect(getSceneHistoryPauseDepth()).toBe(0) + expect(useScene.temporal.getState().isTracking).toBe(true) + resumeSceneHistory(store) + store.getState().updateNode(level.id, { name: 'Ground' }) + store.temporal.getState().undo() + expect(store.getState().nodes[level.id]?.name).toBeUndefined() + }) + + test('a disposed environment refuses every later read and write', () => { + const { environment, store, level } = sceneWithLevel() + const { updateNode } = store.getState() + environment.dispose() + expect(() => store.getState()).toThrow(SceneEnvironmentDisposedError) + expect(() => store.setState({ readOnly: true })).toThrow(SceneEnvironmentDisposedError) + expect(() => updateNode(level.id, { name: 'Late' })).toThrow(SceneEnvironmentDisposedError) + }) +}) diff --git a/packages/core/src/store/scene-environment.ts b/packages/core/src/store/scene-environment.ts new file mode 100644 index 0000000000..54edcf8546 --- /dev/null +++ b/packages/core/src/store/scene-environment.ts @@ -0,0 +1,81 @@ +/** + * The bookkeeping a scene store's history keeps outside the store: pause depths and leases, the + * commit bus and its pending commit, gesture drafts, the hydration boundary, the space-detection + * pause. Each scene store owns one environment; the editor's store owns the live one. A detached + * scene (`@pascal-app/core/runtime`) owns its own, so its commits, pauses and drafts never reach + * the live editor's subscribers. + * + * The modules that keep this state read it through `currentSceneEnvironment()`: the environment + * of the store whose action is running (a store runs its actions and writes inside its own), or + * the live one. Functions that take a store use that store's environment. + */ +export type SceneEnvironment = { + readonly label: string + /** Lazily created per-module state, keyed by the module's own symbol. */ + slot(key: symbol, create: () => T): T + /** Runs `run` with this environment current; throws once the environment is disposed. */ + run(run: () => T): T + /** Throws once disposed: what makes a late read or write of a disposed scene fail. */ + assertActive(): void + dispose(): void + readonly disposed: boolean +} + +export class SceneEnvironmentDisposedError extends Error { + constructor(label: string) { + super(`The scene ${label} is disposed; it can no longer be read or written.`) + this.name = 'SceneEnvironmentDisposedError' + } +} + +const stack: SceneEnvironment[] = [] + +export function createSceneEnvironment(label: string): SceneEnvironment { + const slots = new Map() + let disposed = false + const environment: SceneEnvironment = { + label, + slot(key: symbol, create: () => T): T { + if (!slots.has(key)) slots.set(key, create()) + return slots.get(key) as T + }, + run(run: () => T): T { + environment.assertActive() + stack.push(environment) + try { + return run() + } finally { + stack.pop() + } + }, + assertActive() { + if (disposed) throw new SceneEnvironmentDisposedError(label) + }, + dispose() { + disposed = true + slots.clear() + }, + get disposed() { + return disposed + }, + } + return environment +} + +/** The editor's environment: the singleton store's, and the default everywhere else. */ +export const liveSceneEnvironment = createSceneEnvironment('live') + +export function currentSceneEnvironment(): SceneEnvironment { + return stack.at(-1) ?? liveSceneEnvironment +} + +const environmentsByStore = new WeakMap() + +export function bindSceneEnvironment(store: object, environment: SceneEnvironment): void { + environmentsByStore.set(store, environment) +} + +/** The environment of `store`: its own, or the live one for a store created without one. */ +export function sceneEnvironmentOf(store: object): SceneEnvironment { + return environmentsByStore.get(store) ?? liveSceneEnvironment +} diff --git a/packages/core/src/store/scene-hydration.ts b/packages/core/src/store/scene-hydration.ts index 6316d813d8..2145c11829 100644 --- a/packages/core/src/store/scene-hydration.ts +++ b/packages/core/src/store/scene-hydration.ts @@ -1,56 +1,70 @@ +import { currentSceneEnvironment, type SceneEnvironment } from './scene-environment' + type Hydration = { pending: number; publish: () => void } +type HydrationState = { pending: Hydration | null; normalizing: Hydration | null } + +const HYDRATION = Symbol('scene-hydration') -let pendingHydration: Hydration | null = null -let normalizing: Hydration | null = null +/** The hydration boundary of the current environment's store (scene-environment.ts). */ +const stateOf = (environment: SceneEnvironment = currentSceneEnvironment()) => + environment.slot(HYDRATION, () => ({ pending: null, normalizing: null })) export function invalidatePendingHydration() { - pendingHydration = null + stateOf().pending = null } export function isHydrationNormalization() { - return normalizing !== null && normalizing === pendingHydration + const state = stateOf() + return state.normalizing !== null && state.normalizing === state.pending } -function finishNormalization(hydration: Hydration) { +function finishNormalization(state: HydrationState, hydration: Hydration) { hydration.pending-- - if (hydration.pending === 0 && pendingHydration === hydration) { - pendingHydration = null + if (hydration.pending === 0 && state.pending === hydration) { + state.pending = null hydration.publish() } } export function runSceneHydration(normalize: () => void, publish: () => void) { + const state = stateOf() const hydration = { pending: 1, publish } - pendingHydration = hydration - const previous = normalizing - normalizing = hydration + state.pending = hydration + const previous = state.normalizing + state.normalizing = hydration try { normalize() } catch (error) { - if (pendingHydration === hydration) invalidatePendingHydration() + if (state.pending === hydration) state.pending = null throw error } finally { - normalizing = previous - finishNormalization(hydration) + state.normalizing = previous + finishNormalization(state, hydration) } } // Only normalization queued by this hydration may extend its boundary. A // subsequent edit cancels publication even if these microtasks are still queued. export function queueSceneNormalization(normalize: () => void) { - const hydration = pendingHydration + const environment = currentSceneEnvironment() + const state = stateOf(environment) + const hydration = state.pending if (hydration) hydration.pending++ queueMicrotask(() => { - const previous = normalizing - normalizing = hydration - try { - normalize() - } catch (error) { - if (pendingHydration === hydration) invalidatePendingHydration() - throw error - } finally { - normalizing = previous - if (hydration) finishNormalization(hydration) - } + // A disposed scene's queued work reaches nothing. + if (environment.disposed) return + environment.run(() => { + const previous = state.normalizing + state.normalizing = hydration + try { + normalize() + } catch (error) { + if (state.pending === hydration) state.pending = null + throw error + } finally { + state.normalizing = previous + if (hydration) finishNormalization(state, hydration) + } + }) }) } diff --git a/packages/core/src/store/use-live-node-overrides.ts b/packages/core/src/store/use-live-node-overrides.ts index 50e6c98b55..4ca2abe779 100644 --- a/packages/core/src/store/use-live-node-overrides.ts +++ b/packages/core/src/store/use-live-node-overrides.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { livePreviewsVisible } from './live-previews' export type LiveNodeOverrides = Record @@ -68,6 +69,7 @@ const useLiveNodeOverrides = create((set, get) => ({ * without an extra "did anything change" check. */ export function getEffectiveNode(node: T): T { + if (!livePreviewsVisible()) return node const override = useLiveNodeOverrides.getState().overrides.get(node.id) if (!override || Object.keys(override).length === 0) return node return { ...node, ...override } as T diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index afd69ad08b..7792a8c1c2 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -65,10 +65,12 @@ import { resumeSceneHistory, runAsRemoteSceneChange, runAsSceneHistoryRestore, + runAsSingleSceneHistoryStep, runSceneWriteGuards, runWithSceneCommitNodeIds, type SceneCommitOrigin, type SceneSnapshot, + settlePendingSceneCommit, } from './history-control' import { beginSceneHistoryDraft as beginSceneHistoryDraftIn, @@ -88,6 +90,12 @@ import { withoutSceneNodeAnnotations, writeSceneNodeField, } from './scene-annotations' +import { + bindSceneEnvironment, + liveSceneEnvironment, + type SceneEnvironment, + sceneEnvironmentOf, +} from './scene-environment' import { invalidatePendingHydration, isHydrationNormalization, @@ -1352,16 +1360,26 @@ function sceneHistorySnapshotFromState( * still carry `metadata.isNew`, so only explicitly owned drafts are left out. */ export function getSceneDocument(drafts: Iterable = []): SceneSnapshot { - const state = useScene.getState() - const kept = withoutDraftSubtrees(state, [...createdSceneHistoryDraftIds(), ...drafts]) - return { - nodes: withAdoptedDraftsAsOriginal(state.nodes, kept.nodes), - rootNodeIds: kept.rootNodeIds, - collections: kept.collections, - materials: state.materials, - installedPlugins: state.installedPlugins, - hasExplicitPluginInstallState: state.hasExplicitPluginInstallState, - } + return sceneDocumentOf(useScene, drafts) +} + +/** `getSceneDocument` for any scene store: its document as a save writes it. */ +export function sceneDocumentOf( + store: SceneStore, + drafts: Iterable = [], +): SceneSnapshot { + const state = store.getState() + return sceneEnvironmentOf(store).run(() => { + const kept = withoutDraftSubtrees(state, [...createdSceneHistoryDraftIds(), ...drafts]) + return { + nodes: withAdoptedDraftsAsOriginal(state.nodes, kept.nodes), + rootNodeIds: kept.rootNodeIds, + collections: kept.collections, + materials: state.materials, + installedPlugins: state.installedPlugins, + hasExplicitPluginInstallState: state.hasExplicitPluginInstallState, + } + }) } /** @@ -1407,54 +1425,44 @@ class GuardedDirtySet extends Set { type TemporalSceneCreator = StateCreator -function createSceneStore(config: TemporalSceneCreator): UseSceneStore { - const hydratedConfig: TemporalSceneCreator = (set, get, store) => { - const setWithHydration: typeof set = (partial, replace) => { - const state = get() - let next = typeof partial === 'function' ? partial(state) : partial - const documentChanged = ( - ['nodes', 'rootNodeIds', 'materials', 'collections', 'installedPlugins'] as const - ).some((key) => (replace || key in next) && next[key] !== state[key]) - if (documentChanged && !isHydrationNormalization()) { - invalidatePendingHydration() - if (state.hydrationToken) next = { ...next, hydrationToken: null } - } - if (replace) set(next as SceneState, true) - else set(next) - } - store.setState = setWithHydration - return config(setWithHydration, get, store) - } - return create()(hydratedConfig) -} +/** A scene store: the document state and its own undo history. */ +export type SceneStore = UseSceneStore -function runTemporalJump(target: Partial | undefined, restore: () => void): void { - const draftsBefore = useScene.getState().nodes +function runTemporalJump( + store: UseSceneStore, + target: Partial | undefined, + restore: () => void, +): void { + const draftsBefore = store.getState().nodes const restoreDrafts = () => { - const current = useScene.getState().nodes + const current = store.getState().nodes const nodes = retainSceneAnnotations( draftsBefore, withDraftsRestored(draftsBefore, current) ?? current, ) if (nodes === current) return // The carry's own state, not a new step: a tracked write here would clear redo. - const pause = beginSceneHistoryPauseSession(useScene) + const pause = beginSceneHistoryPauseSession(store) try { - useScene.setState({ nodes }) + store.setState({ nodes }) } finally { pause.end() } } - runTemporalJumpOnly(target, () => runAsSceneHistoryRestore(restore)) + runTemporalJumpOnly(store, target, () => runAsSceneHistoryRestore(restore)) restoreDrafts() } -function runTemporalJumpOnly(target: Partial | undefined, jump: () => void): void { +function runTemporalJumpOnly( + store: UseSceneStore, + target: Partial | undefined, + jump: () => void, +): void { if (!target?.nodes) { jump() return } - const before = useScene.getState().nodes + const before = store.getState().nodes const changed = new Set() for (const id of new Set([...Object.keys(before), ...Object.keys(target.nodes)])) { const nodeId = id as AnyNodeId @@ -1472,8 +1480,60 @@ function runTemporalJumpOnly(target: Partial | undefined, jump: ( runWithSceneCommitNodeIds(changed, jump) } -const useScene: UseSceneStore = createSceneStore( - temporal( +/** Every function field of `state` runs inside `environment` (scene-environment.ts). */ +function inEnvironment(environment: SceneEnvironment, state: T): T { + const wrapped = { ...state } as Record + for (const [key, value] of Object.entries(state)) { + if (typeof value !== 'function') continue + const action = value as (...args: unknown[]) => unknown + wrapped[key] = (...args: unknown[]) => environment.run(() => action(...args)) + } + return wrapped as T +} + +/** + * A scene store bound to `environment`: its writes, actions, history and commits run in that + * environment, so a second store (a detached scene, `@pascal-app/core/runtime`) never pauses, + * commits to or drafts in the editor's. Once the environment is disposed, every read and write + * of the store throws. + */ +export function createSceneStore(environment: SceneEnvironment): SceneStore { + const store = create()((set, get, api) => { + const setWithHydration: typeof set = (partial, replace) => { + const state = get() + let next = typeof partial === 'function' ? partial(state) : partial + const documentChanged = ( + ['nodes', 'rootNodeIds', 'materials', 'collections', 'installedPlugins'] as const + ).some((key) => (replace || key in next) && next[key] !== state[key]) + if (documentChanged && !isHydrationNormalization()) { + invalidatePendingHydration() + if (state.hydrationToken) next = { ...next, hydrationToken: null } + } + if (replace) set(next as SceneState, true) + else set(next) + } + api.setState = setWithHydration + const temporalApi = api as typeof api & { temporal: UseSceneStore['temporal'] } + return inEnvironment( + environment, + sceneStoreConfig(() => temporalApi as UseSceneStore)(setWithHydration, get, temporalApi), + ) + }) as UseSceneStore + bindSceneEnvironment(store, environment) + const { getState, setState } = store + store.getState = () => { + environment.assertActive() + return getState() + } + store.setState = ((partial, replace) => + environment.run(() => setState(partial as SceneState, replace as true))) as typeof setState + const temporalState = store.temporal.getState() + store.temporal.setState(inEnvironment(environment, temporalState)) + return store +} + +function sceneStoreConfig(storeOf: () => UseSceneStore): TemporalSceneCreator { + return temporal( (set, get) => ({ // 1. Flat dictionary of all nodes nodes: {}, @@ -1585,11 +1645,11 @@ const useScene: UseSceneStore = createSceneStore( if (get().hydrationId !== hydrationId) return const { updates } = ensureSceneOpenings(get().nodes) if (!updates.length) return - pauseSceneHistory(useScene) + pauseSceneHistory(storeOf()) try { get().updateNodes(updates) } finally { - resumeSceneHistory(useScene) + resumeSceneHistory(storeOf()) } }) } @@ -1805,23 +1865,29 @@ const useScene: UseSceneStore = createSceneStore( return { ...state, undo: (steps = 1) => { - runSceneWriteGuards() - return runTemporalJump(get().pastStates.slice().splice(-steps, steps)[0], () => - state.undo(steps), + runSceneWriteGuards(storeOf()) + return runTemporalJump( + storeOf(), + get().pastStates.slice().splice(-steps, steps)[0], + () => state.undo(steps), ) }, redo: (steps = 1) => { - runSceneWriteGuards() - return runTemporalJump(get().futureStates.slice().splice(-steps, steps)[0], () => - state.redo(steps), + runSceneWriteGuards(storeOf()) + return runTemporalJump( + storeOf(), + get().futureStates.slice().splice(-steps, steps)[0], + () => state.redo(steps), ) }, } }, limit: 50, // Limit to last 50 actions }, - ), -) + ) +} + +const useScene: UseSceneStore = createSceneStore(liveSceneEnvironment) // Live state belongs to the hydration owner so even a lazy consumer cannot // miss an override that was set and cleared before its first frame. @@ -2024,8 +2090,16 @@ function insertSceneStructuralPlacements( return result as AnyNodeId[] } -function sceneOperationPatchNextState( - beforeState: SceneState, +/** + * The document a patch makes of `beforeState`, without a store, or null when the patch does not + * apply to it (a delete of a node that changed, a create at a taken position, a node that fails + * its schema). `applySceneOperationPatch` writes exactly this. + */ +export function nextSceneOperationState( + beforeState: Pick< + SceneState, + 'nodes' | 'rootNodeIds' | 'collections' | 'materials' | 'installedPlugins' + >, changes: SceneOperationPatch, ): | (Pick & @@ -2281,7 +2355,7 @@ export function applySceneOperationPatch( changes: SceneOperationPatch, options: ApplySceneOperationPatchOptions = {}, ): boolean { - runSceneWriteGuards() + runSceneWriteGuards(useScene) const beforeState = useScene.getState() if ( changes.nodeUpdates.length === 0 && @@ -2328,7 +2402,7 @@ export function applySceneOperationPatch( ) return false if (sceneOperationPatchHasLiveConflict(beforeState, changes)) return false - const next = sceneOperationPatchNextState(beforeState, changes) + const next = nextSceneOperationState(beforeState, changes) if (!next) return false const before = sceneHistorySnapshotFromState(beforeState) @@ -2409,6 +2483,38 @@ export function applySceneOperationPatch( return true } +/** + * Runs `run` on `store` as one undo step and one commit that carries everything it changed: its + * own writes and the history-paused derived writes it settles after them (`reconcileDocument`), + * which a commit captured when the first write landed would miss. Collaboration then receives the + * derived construction, with its ids, in the same commit as the edit that caused it. + */ +export function runAsSettledSceneStep(store: SceneStore, run: () => T): T { + const environment = sceneEnvironmentOf(store) + const snapshot = () => environment.run(() => sceneHistorySnapshotFromState(store.getState())) + const before = snapshot() + const pastBefore = store.temporal.getState().pastStates.length + return runAsSingleSceneHistoryStep(store, () => { + const result = run() + const current = snapshot() + if (areSceneSnapshotsEqual(before, current)) return result + const temporal = store.temporal.getState() + // Only paused (derived) writes landed: they still make one step of their own. + if ( + temporal.pastStates.length === pastBefore && + temporal.isTracking && + getSceneHistoryPauseDepth(store) === 0 + ) + store.temporal.setState({ pastStates: [...temporal.pastStates, before], futureStates: [] }) + const changedNodeIds = new Set() + for (const id of new Set([...Object.keys(before.nodes), ...Object.keys(current.nodes)])) + if (before.nodes[id as AnyNodeId] !== current.nodes[id as AnyNodeId]) + changedNodeIds.add(id as AnyNodeId) + settlePendingSceneCommit(store, { origin: 'local', before, current, changedNodeIds }) + return result + }) +} + export function applyScenePatch(changes: ScenePatch): boolean { return applySceneOperationPatch({ ...changes, @@ -2427,7 +2533,7 @@ export function applySceneSnapshot( snapshot: SceneSnapshot, options: ApplySceneSnapshotOptions, ): boolean { - runSceneWriteGuards() + runSceneWriteGuards(useScene) const before = sceneHistorySnapshotFromState(useScene.getState()) const temporalState = useScene.temporal.getState() if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0 || hasSceneHistoryDrafts()) { diff --git a/packages/core/src/systems/document-reconcile.ts b/packages/core/src/systems/document-reconcile.ts new file mode 100644 index 0000000000..bab30018c4 --- /dev/null +++ b/packages/core/src/systems/document-reconcile.ts @@ -0,0 +1,198 @@ +import { structureChangeBatch } from '../commands/structure/shared' +import { + type SpatialGridManager, + supportIndexOf, + withSpatialGrid, +} from '../hooks/spatial-grid/spatial-grid-manager' +import { resolveLevelId } from '../lib/node-ancestry' +import { structureLevelsForChange } from '../lib/space-detection' +import { applyStructureReconciliation } from '../lib/structure-commit' +import type { SceneNodes } from '../lib/structure-kernel' +import type { AnyNode, AnyNodeId } from '../schema' +import { generateId } from '../schema/base' +import { roofsKeptOverStorey } from '../services/roof-move' +import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control' +import { withoutLivePreviews } from '../store/live-previews' +import type { SceneStore } from '../store/use-scene' +import { planOwnedFloorOpenings } from './owned-floor-openings' +import { reconcileOwnedFloorOpeningChanges } from './reconcile-owned-floor-openings' +import { + isRoofElevationInput, + planRoofElevationUpdates, + roofsAffectedBy, +} from './roof/roof-elevation' +import { changedStairOpeningOwners } from './stair/stair-opening-sync' +import { syncStairRises } from './stair/stair-rise' + +/** Kinds whose change re-sizes a straight stair's flights or re-cuts its floor openings. */ +export function isStairOpeningInput(node: AnyNode | undefined): boolean { + return ( + node?.type === 'building' || + node?.type === 'ceiling' || + node?.type === 'level' || + node?.type === 'slab' || + node?.type === 'stair' || + node?.type === 'stair-segment' + ) +} + +/** Kinds whose change re-cuts an elevator's (and so every owner's) floor openings. */ +export function isElevatorOpeningInput(node: AnyNode | undefined): boolean { + return ( + node?.type === 'building' || + node?.type === 'ceiling' || + node?.type === 'elevator' || + node?.type === 'level' || + node?.type === 'slab' + ) +} + +/** Whether any node of an input kind differs between two versions of a scene. */ +export function changedInputs( + before: SceneNodes, + after: SceneNodes, + isInput: (node: AnyNode | undefined) => boolean, + options: { ignoreChildLists?: boolean } = {}, +): boolean { + if (before === after) return false + const differs = (previous: AnyNode | undefined, next: AnyNode | undefined) => + previous !== next && + (isInput(previous) || isInput(next)) && + !(options.ignoreChildLists && onlyChildListChanged(previous, next)) + for (const id in after) if (differs(before[id], after[id])) return true + for (const id in before) if (!(id in after) && differs(before[id], undefined)) return true + return false +} + +/** Only a node's child list changed: its children's own changes are what passes react to. */ +function onlyChildListChanged(previous: AnyNode | undefined, next: AnyNode | undefined) { + if (!(previous && next)) return false + const a = previous as Record + const b = next as Record + for (const key in b) if (key !== 'children' && a[key] !== b[key]) return false + for (const key in a) if (key !== 'children' && !(key in b)) return false + return true +} + +export type DocumentReconcileOptions = { + /** + * The view the passes read a version of the nodes through: a detached scene's tracked view, so + * their reads are recorded. Applied to every version a pass compares, so identity holds between. + */ + read?: (nodes: SceneNodes) => SceneNodes + /** Re-derive every level's structure, not only the levels whose structure changed since `before`. */ + allLevels?: boolean + /** The support index the passes query; by default one built from the store's own nodes. */ + grid?: () => SpatialGridManager +} + +/** + * Every derived write the document owes after an edit, in one synchronous pass, so a patch taken + * afterwards is complete and applying it to the editor leaves its systems nothing to write: + * + * - room structure (zones, auto ceilings, floor plates) for the levels whose structure changed, + * as space detection derives it for a batch; + * - stair rises and the floor openings stairs and elevators own, as the opening systems do; + * - the elevation of wall-supported roofs, and of a roof kept over the storey it covers when its + * level moves, as the roof system does. + * + * The opening and roof systems still run in the editor (on a microtask, for edits made by hand); + * they plan with the same functions, so after this pass they find nothing to do. Writes are + * history-paused: they join the triggering edit's undo step. Support queries read an index built + * from the store's nodes, never the editor's previews. + */ +export function reconcileDocument( + store: SceneStore, + before: SceneNodes, + options: DocumentReconcileOptions = {}, +): void { + const read = options.read ?? ((nodes: SceneNodes) => nodes) + const nodes = () => store.getState().nodes as SceneNodes + const view = () => read(nodes()) + const grid = options.grid ?? (() => supportIndexOf(store, resolveLevelId)) + // The kernel and opening planners read the store's nodes through `read`; their writes go to it. + const readingStore = { + getState: () => ({ ...store.getState(), nodes: view() as Record }), + } + const write = (apply: () => void) => { + pauseSceneHistory(store) + try { + apply() + } finally { + resumeSceneHistory(store) + } + } + const applyOpenings = (changes: ReturnType) => { + if (!changes.length) return + const previous = nodes() + write(() => { + store.getState().applyNodeChanges(structureChangeBatch(changes)) + reconcileOwnedFloorOpeningChanges(readingStore, read(previous), changes) + }) + } + + withoutLivePreviews(() => + withSpatialGrid(grid, () => { + const levelIds = options.allLevels ? undefined : structureLevelsForChange(before, nodes()) + if (!levelIds || levelIds.length) + write(() => + applyStructureReconciliation(readingStore, { + levelIds, + previousNodes: options.allLevels ? undefined : read(before), + mintId: generateId, + }), + ) + + // A parent's child list is bookkeeping: what was added or removed is an input of its own. + const settled = { ignoreChildLists: true } + let stairsFrom = before + let elevatorsFrom = before + let roofsFrom = before + // Each pass can feed another (a stair's openings change slabs, slabs carry roofs); a pass runs + // again only on a change to its own inputs, so this settles in a few rounds. + for (let round = 0; round < 8; round++) { + const start = nodes() + if (changedInputs(stairsFrom, start, isStairOpeningInput, settled)) { + const from = stairsFrom + stairsFrom = start + const rises = syncStairRises(view() as Record) + if (rises.length) write(() => store.getState().updateNodes(rises)) + // Which stairs this edit moved is the edit's own bookkeeping; the re-cut reads the scene. + const owners = changedStairOpeningOwners( + from as Record, + nodes() as Record, + ) + applyOpenings( + planOwnedFloorOpenings(view() as Record, { ownerIds: owners }), + ) + stairsFrom = nodes() + } + if (changedInputs(elevatorsFrom, nodes(), isElevatorOpeningInput, settled)) { + applyOpenings(planOwnedFloorOpenings(view() as Record)) + elevatorsFrom = nodes() + } + // A roof's own child list is an input: a segment added reshapes its footprint. + const current = nodes() + const changed: string[] = [] + for (const id in current) + if ( + current[id] !== roofsFrom[id] && + (current[id]?.type === 'roof' || !onlyChildListChanged(roofsFrom[id], current[id])) + ) + changed.push(id) + for (const id in roofsFrom) if (!(id in current)) changed.push(id) + const roofInput = (node: AnyNode | undefined) => + isRoofElevationInput(node) || node?.type === 'roof-segment' + if (changed.some((id) => roofInput(roofsFrom[id]) || roofInput(current[id]))) { + const roofs = [ + ...roofsKeptOverStorey(roofsFrom, view()), + ...planRoofElevationUpdates(view(), roofsAffectedBy(roofsFrom, current, changed)), + ] + if (roofs.length) write(() => store.getState().updateNodes(roofs)) + roofsFrom = nodes() + } + if (nodes() === start) break + } + }), + ) +} diff --git a/packages/core/src/systems/elevator/elevator-opening-system.tsx b/packages/core/src/systems/elevator/elevator-opening-system.tsx index 8d147cdd84..e392a69c4a 100644 --- a/packages/core/src/systems/elevator/elevator-opening-system.tsx +++ b/packages/core/src/systems/elevator/elevator-opening-system.tsx @@ -2,40 +2,14 @@ import { useEffect } from 'react' import { structureChangeBatch } from '../../commands/structure/shared' -import type { AnyNode } from '../../schema' +import { isSettlingDocument } from '../../store/document-settling' import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' import { isHydrationNormalization } from '../../store/scene-hydration' import useScene from '../../store/use-scene' +import { changedInputs, isElevatorOpeningInput } from '../document-reconcile' import { planOwnedFloorOpenings } from '../owned-floor-openings' import { reconcileOwnedFloorOpeningChanges } from '../reconcile-owned-floor-openings' -function isOpeningRelevantNode(node: AnyNode | undefined) { - return ( - node?.type === 'building' || - node?.type === 'ceiling' || - node?.type === 'elevator' || - node?.type === 'level' || - node?.type === 'slab' - ) -} - -function hasOpeningRelevantNodeChange( - nextNodes: Record, - prevNodes: Record, -) { - if (nextNodes === prevNodes) return false - - const ids = new Set([...Object.keys(nextNodes), ...Object.keys(prevNodes)]) - for (const id of ids) { - const nextNode = nextNodes[id] - const prevNode = prevNodes[id] - if (nextNode === prevNode) continue - if (isOpeningRelevantNode(nextNode) || isOpeningRelevantNode(prevNode)) return true - } - - return false -} - export function initializeElevatorOpeningSync() { let syncingAutoOpenings = false @@ -47,7 +21,7 @@ export function initializeElevatorOpeningSync() { try { const before = useScene.getState().nodes useScene.getState().applyNodeChanges(structureChangeBatch(changes)) - reconcileOwnedFloorOpeningChanges(before, changes) + reconcileOwnedFloorOpeningChanges(useScene, before, changes) } finally { resumeSceneHistory(useScene) syncingAutoOpenings = false @@ -57,8 +31,8 @@ export function initializeElevatorOpeningSync() { applyChanges(true) return useScene.subscribe((state, prevState) => { - if (syncingAutoOpenings) return - if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + if (syncingAutoOpenings || isSettlingDocument(useScene)) return + if (!changedInputs(prevState.nodes, state.nodes, isElevatorOpeningInput)) return applyChanges(isHydrationNormalization() || state.hydrationId !== prevState.hydrationId) }) } diff --git a/packages/core/src/systems/owned-floor-openings.ts b/packages/core/src/systems/owned-floor-openings.ts index d580815978..f97756949a 100644 --- a/packages/core/src/systems/owned-floor-openings.ts +++ b/packages/core/src/systems/owned-floor-openings.ts @@ -1,5 +1,6 @@ import { canonicalOpeningRing } from '../lib/canonical-opening-ring' import type { NodePatch } from '../lib/structure-kernel' +import { queryNodes } from '../runtime/node-queries' import type { AnyNode, AnyNodeId, FloorOpeningNode, SurfaceHoleMetadata } from '../schema' import { FloorOpeningNode as FloorOpeningSchema } from '../schema/nodes/floor-opening' import { resolveCeilingHeight } from '../services/level-height' @@ -135,9 +136,10 @@ export function planOwnedFloorOpenings( materializedNodes?: Record } = {}, ): NodePatch[] { - const values = Object.values(nodes) + // Owners and their openings first: a scene with none has nothing to plan. + const owned = queryNodes(nodes, { type: ['stair', 'elevator', 'floor-opening'] }) if ( - !values.some( + !owned.some( (node) => node.type === 'stair' || node.type === 'elevator' || @@ -145,6 +147,7 @@ export function planOwnedFloorOpenings( ) ) return [] + const values = Object.values(nodes) const existing = values.filter( (node): node is FloorOpeningNode => node.type === 'floor-opening' && diff --git a/packages/core/src/systems/reconcile-owned-floor-openings.ts b/packages/core/src/systems/reconcile-owned-floor-openings.ts index de0c626b9d..e6f5054300 100644 --- a/packages/core/src/systems/reconcile-owned-floor-openings.ts +++ b/packages/core/src/systems/reconcile-owned-floor-openings.ts @@ -3,10 +3,15 @@ import { applyStructureReconciliation } from '../lib/structure-commit' import type { NodePatch, SceneNodes } from '../lib/structure-kernel' import type { AnyNode } from '../schema' import { generateId } from '../schema/base' -import useScene from '../store/use-scene' +import type { SceneStore } from '../store/use-scene' -export function reconcileOwnedFloorOpeningChanges(before: SceneNodes, changes: NodePatch[]) { - const after = useScene.getState().nodes +/** Re-derives the structure of every level an owned opening change cut into or out of. */ +export function reconcileOwnedFloorOpeningChanges( + store: Pick, + before: SceneNodes, + changes: NodePatch[], +) { + const after = store.getState().nodes const oldIndex = createFloorOpeningIndex(before) const newIndex = createFloorOpeningIndex(after) const levels = new Set() @@ -22,5 +27,5 @@ export function reconcileOwnedFloorOpeningChanges(before: SceneNodes, changes: N } } if (levels.size) - applyStructureReconciliation(useScene, { levelIds: [...levels], mintId: generateId }) + applyStructureReconciliation(store, { levelIds: [...levels], mintId: generateId }) } diff --git a/packages/core/src/systems/roof/roof-elevation-system.tsx b/packages/core/src/systems/roof/roof-elevation-system.tsx index 9edd951530..46ecb2ce7e 100644 --- a/packages/core/src/systems/roof/roof-elevation-system.tsx +++ b/packages/core/src/systems/roof/roof-elevation-system.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react' import type { AnyNode, AnyNodeId } from '../../schema' import { roofsKeptOverStorey } from '../../services/roof-move' +import { isSettlingDocument } from '../../store/document-settling' import { getSceneHistoryPauseDepth, notifySceneCommit, @@ -11,26 +12,13 @@ import { type SceneSnapshot, } from '../../store/history-control' import useScene from '../../store/use-scene' -import { resolveRoofElevation } from './roof-elevation' - -const ROOF_ELEVATION_EPSILON = 1e-4 +import { isRoofElevationInput, planRoofElevationUpdates } from './roof-elevation' function sceneSnapshot(): SceneSnapshot { const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState() return { nodes, rootNodeIds, collections, materials, installedPlugins } } -function isElevationRelevant(node: AnyNode | undefined): boolean { - return ( - node?.type === 'roof' || - node?.type === 'wall' || - node?.type === 'slab' || - node?.type === 'level' || - node?.type === 'building' || - node?.type === 'site' - ) -} - export function initializeRoofElevationSync(): () => void { let disposed = false let queued = false @@ -48,18 +36,10 @@ export function initializeRoofElevationSync(): () => void { if (disposed) return const { nodes, updateNodes, readOnly } = useScene.getState() if (readOnly) return - const updates: Array<{ id: AnyNodeId; data: Partial }> = changedFrom - ? roofsKeptOverStorey(changedFrom, nodes) - : [] - for (const node of Object.values(nodes)) { - if (node.type !== 'roof' || node.support?.kind !== 'walls') continue - const elevation = resolveRoofElevation(node, nodes) - if (Math.abs(node.position[1] - elevation) <= ROOF_ELEVATION_EPSILON) continue - updates.push({ - id: node.id, - data: { position: [node.position[0], elevation, node.position[2]] }, - }) - } + const updates = [ + ...(changedFrom ? roofsKeptOverStorey(changedFrom, nodes) : []), + ...planRoofElevationUpdates(nodes), + ] if (updates.length === 0) return const before = sceneSnapshot() const publishCommit = @@ -86,12 +66,12 @@ export function initializeRoofElevationSync(): () => void { } const unsubscribe = useScene.subscribe((state, previous) => { - if (syncing || state.nodes === previous.nodes) return + if (syncing || state.nodes === previous.nodes || isSettlingDocument(useScene)) return const ids = new Set([...Object.keys(state.nodes), ...Object.keys(previous.nodes)]) for (const id of ids) { const next = state.nodes[id as AnyNodeId] const prev = previous.nodes[id as AnyNodeId] - if (next !== prev && (isElevationRelevant(next) || isElevationRelevant(prev))) { + if (next !== prev && (isRoofElevationInput(next) || isRoofElevationInput(prev))) { schedule(previous.nodes) return } diff --git a/packages/core/src/systems/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts index 57f2d10215..a3da613fde 100644 --- a/packages/core/src/systems/roof/roof-elevation.ts +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -1,8 +1,21 @@ -import { resolveLevelId } from '../../lib/node-ancestry' +import { findLevelAncestorId, resolveLevelId } from '../../lib/node-ancestry' import { wallSupportForNodes } from '../../lib/opening-floor-datum' -import type { AnyNode, LevelNode, RoofNode, RoofSegmentNode, WallNode } from '../../schema' +import { queryNodes } from '../../runtime/node-queries' +import type { + AnyNode, + AnyNodeId, + LevelNode, + RoofNode, + RoofSegmentNode, + WallNode, +} from '../../schema' import { coveredStoreyId, servesAsRoofLevel } from '../../services/roof-level' -import { findLevelBelowId, getLevelElevations, getWallTop } from '../../services/storey' +import { + findLevelAboveId, + findLevelBelowId, + getLevelElevations, + getWallTop, +} from '../../services/storey' import { wallOverlapsSlabFootprint } from '../slab/slab-support' import { getWallArcData } from '../wall/wall-curve' import { resolveRoomRoofFootprintOnLevel } from './roof-footprint' @@ -158,3 +171,82 @@ export function roofWallTop( highestWallTop(resolveLevelId(roof, nodes), wallIds, nodes, getLevelElevations(nodes)) ?? null ) } + +const ROOF_ELEVATION_EPSILON = 1e-4 + +/** Kinds whose change can move a wall-supported roof. */ +export function isRoofElevationInput(node: AnyNode | undefined): boolean { + return ( + node?.type === 'roof' || + node?.type === 'wall' || + node?.type === 'slab' || + node?.type === 'level' || + node?.type === 'building' || + node?.type === 'site' + ) +} + +/** Wall-supported roofs whose stored Y no longer sits on their walls, and where it should. */ +export function planRoofElevationUpdates( + nodes: Readonly>, + only?: ReadonlySet | null, +): Array<{ id: AnyNodeId; data: Partial }> { + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + for (const node of queryNodes(nodes, { type: 'roof' })) { + if (node.support?.kind !== 'walls' || (only && !only.has(node.id))) continue + const elevation = resolveRoofElevation(node, nodes) + if (Math.abs(node.position[1] - elevation) <= ROOF_ELEVATION_EPSILON) continue + updates.push({ + id: node.id, + data: { position: [node.position[0], elevation, node.position[2]] }, + }) + } + return updates +} + +/** + * The roofs an edit can move, from the nodes it changed. A roof rests on the walls of its own level + * or the level below, so: a changed roof (or segment); for a changed wall, the roofs on its level + * and the level above; for a changed slab, the levels whose walls it touches (its own, which it + * carries, and the one below, whose plane-bound tops it caps) and every level above it in its + * building (a base plate lifts them). Null, for every roof, when a level, building or site changed. + */ +export function roofsAffectedBy( + before: Readonly>, + after: Readonly>, + changedIds: Iterable, +): Set | null { + const roofs = new Set() + const levels = new Set() + for (const id of changedIds) + for (const nodes of [before, after] as const) { + const node = nodes[id] + if (!node) continue + if (node.type === 'level' || node.type === 'building' || node.type === 'site') return null + if (node.type === 'roof') roofs.add(id) + else if (node.type === 'roof-segment' && node.parentId) roofs.add(node.parentId) + else if (node.type === 'wall' || node.type === 'slab') { + const levelId = findLevelAncestorId(id as AnyNodeId, nodes as Record) + if (!levelId) return null + const elevations = getLevelElevations(nodes as Record) + levels.add(levelId) + if (node.type === 'wall') { + const above = findLevelAboveId(levelId, elevations) + if (above) levels.add(above) + continue + } + const below = findLevelBelowId(levelId, elevations) + if (below) levels.add(below) + const own = elevations.get(levelId) + for (const [otherId, other] of elevations) + if (own && other.buildingId === own.buildingId && other.ordinal > own.ordinal) + levels.add(otherId) + } + } + if (levels.size) + for (const roof of queryNodes(after as Record, { type: 'roof' })) { + const levelId = findLevelAncestorId(roof.id, after as Record) + if (levelId && levels.has(levelId)) roofs.add(roof.id) + } + return roofs +} diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index d3364d925f..727ad07b35 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -3,12 +3,14 @@ import { useEffect } from 'react' import { structureChangeBatch } from '../../commands/structure/shared' import type { AnyNode, AnyNodeId } from '../../schema' +import { isSettlingDocument } from '../../store/document-settling' import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' import { isHydrationNormalization, queueSceneNormalization } from '../../store/scene-hydration' import useLiveNodeOverrides from '../../store/use-live-node-overrides' import useLiveTransforms from '../../store/use-live-transforms' import useScene from '../../store/use-scene' import { ensureSceneOpenings } from '../../utils/ensure-scene-openings' +import { changedInputs, isStairOpeningInput } from '../document-reconcile' import { planOwnedFloorOpenings } from '../owned-floor-openings' import { reconcileOwnedFloorOpeningChanges } from '../reconcile-owned-floor-openings' import { @@ -19,34 +21,6 @@ import { import { changedStairOpeningOwners, syncAutoStairOpenings } from './stair-opening-sync' import { syncStairRises } from './stair-rise' -function isOpeningRelevantNode(node: AnyNode | undefined) { - return ( - node?.type === 'building' || - node?.type === 'ceiling' || - node?.type === 'level' || - node?.type === 'slab' || - node?.type === 'stair' || - node?.type === 'stair-segment' - ) -} - -function hasOpeningRelevantNodeChange( - nextNodes: Record, - prevNodes: Record, -) { - if (nextNodes === prevNodes) return false - - const ids = new Set([...Object.keys(nextNodes), ...Object.keys(prevNodes)]) - for (const id of ids) { - const nextNode = nextNodes[id] - const prevNode = prevNodes[id] - if (nextNode === prevNode) continue - if (isOpeningRelevantNode(nextNode) || isOpeningRelevantNode(prevNode)) return true - } - - return false -} - export function initializeStairOpeningSync() { let syncingAutoOpenings = false let syncingPreviewOpenings = false @@ -82,7 +56,7 @@ export function initializeStairOpeningSync() { try { const before = useScene.getState().nodes useScene.getState().applyNodeChanges(structureChangeBatch(changes)) - reconcileOwnedFloorOpeningChanges(before, changes) + reconcileOwnedFloorOpeningChanges(useScene, before, changes) } finally { resumeSceneHistory(useScene) syncingAutoOpenings = false @@ -156,8 +130,8 @@ export function initializeStairOpeningSync() { scheduleAutoSync(true) const unsubscribeScene = useScene.subscribe((state, prevState) => { - if (syncingAutoOpenings) return - if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + if (syncingAutoOpenings || isSettlingDocument(useScene)) return + if (!changedInputs(prevState.nodes, state.nodes, isStairOpeningInput)) return scheduleAutoSync( isHydrationNormalization() || state.hydrationId !== prevState.hydrationId, prevState.nodes, diff --git a/packages/core/src/systems/stair/stair-rise-query.ts b/packages/core/src/systems/stair/stair-rise-query.ts index 52e9c62cff..6244b5585f 100644 --- a/packages/core/src/systems/stair/stair-rise-query.ts +++ b/packages/core/src/systems/stair/stair-rise-query.ts @@ -1,4 +1,5 @@ import { containsPoint } from '../../lib/polygon-boolean' +import { queryNodes } from '../../runtime/node-queries' import type { AnyNode, AnyNodeId, LevelNode, StairNode, StairSegmentNode } from '../../schema' import { StairDesignTargets } from '../../schema/nodes/stair-design-targets' import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height' @@ -120,8 +121,8 @@ export function syncStairRises( ): Array<{ id: AnyNodeId; data: Partial }> { const updates: Array<{ id: AnyNodeId; data: Partial }> = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'stair' || (node.stairType ?? 'straight') !== 'straight') continue + for (const node of queryNodes(nodes, { type: 'stair' })) { + if ((node.stairType ?? 'straight') !== 'straight') continue const deck = node.deckSlabId ? nodes[node.deckSlabId] : undefined if (node.totalRise !== undefined && deck?.type !== 'slab' && !node.uniformRisers) continue diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts index ffc810f1c3..b1cedcaa7e 100644 --- a/packages/core/src/systems/stair/stair-rise.ts +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -1,5 +1,7 @@ import { getFloorStackedPosition } from '../../hooks/spatial-grid/floor-placed-elevation' +import { nodeRegistry } from '../../registry/registry' import type { AnyNode, StairNode } from '../../schema' +import { stairBaseElevation } from './stair-base-elevation' import { resolveStairTotalRise as resolveRise, syncStairRises as syncRises, @@ -10,6 +12,10 @@ function liveBaseElevation( nodes: Record, levelId: string | null, ) { + // A host without the built-in stair kind (a bare MCP server) has no floor-placed footprint to + // elect support with: the stair's own support query answers instead of the level base. + if (!nodeRegistry.get('stair')?.capabilities?.floorPlaced) + return stairBaseElevation(stair, nodes, levelId) return getFloorStackedPosition({ node: stair, nodes, diff --git a/packages/core/src/systems/stair/stair-sizing.ts b/packages/core/src/systems/stair/stair-sizing.ts index 307bf17c0c..a08c453508 100644 --- a/packages/core/src/systems/stair/stair-sizing.ts +++ b/packages/core/src/systems/stair/stair-sizing.ts @@ -1,3 +1,4 @@ +import { orderedChildIds } from '../../runtime/node-queries' import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' import { StairDesignTargets } from '../../schema/nodes/stair' import { measureStairHeadroom } from './stair-clearance' @@ -113,7 +114,8 @@ export function measureStair(stair: StairNode, nodes: Record) { stair.id, 'Landing depth exceeds a full circle at the walking line; the visible landing is limited to one circle.', ) - const segments = stair.children + // Riser counts are shared out in flight order: the order is read, not only the members. + const segments = orderedChildIds(nodes, stair.id) .map((id) => nodes[id]) .filter((node): node is StairSegmentNode => node?.type === 'stair-segment') const segmentsById = new Map(segments.map((segment) => [segment.id as string, segment])) diff --git a/packages/editor/src/lib/geometry-script/author.test.ts b/packages/editor/src/lib/geometry-script/author.test.ts new file mode 100644 index 0000000000..b91836e465 --- /dev/null +++ b/packages/editor/src/lib/geometry-script/author.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type ArtifactStore, + artifactUrl, + BuildingNode, + GeometryArtifactManifest, + generateId, + ItemNode, + LevelNode, + scriptSource, +} from '@pascal-app/core' +import { + captureRegistryVersions, + effectIds, + openDetachedScene, + SceneEnvironmentDisposedError, + type SceneSnapshot, +} from '@pascal-app/core/runtime' +import { compileAndStoreGeometryScript } from './author' + +// A compile of an object for a detached scene reads the scene and mints the object's id before +// its first await: the scene records those reads and allocates that id in its synchronous scope, +// and a compile that resolves after the scene is disposed reaches nothing of it. + +const sha = 'a'.repeat(64) +const compiled = { + sha256: sha, + script: 'b'.repeat(64), + language: 'three' as const, + mount: 'floor' as const, + params: {}, + manifest: GeometryArtifactManifest.parse({ + bounds: { min: [-0.5, 0, -0.5], max: [0.5, 1, 0.5] }, + triangles: 12, + }), +} + +function scene() { + const screen = ItemNode.parse({ + id: 'item_screen', + parentId: 'level_0', + name: 'Screen', + source: scriptSource(compiled), + asset: { + id: 'script_screen', + category: 'screen', + name: 'Screen', + thumbnail: '', + source: 'mine', + src: artifactUrl(sha), + dimensions: [1, 1, 1], + }, + }) + const level = LevelNode.parse({ id: 'level_0', parentId: 'building_0', children: [screen.id] }) + const building = BuildingNode.parse({ id: 'building_0', children: [level.id] }) + const snapshot = { + nodes: Object.fromEntries([building, level, screen].map((node) => [node.id, node])), + rootNodeIds: [building.id], + collections: {}, + materials: {}, + installedPlugins: [], + } as unknown as SceneSnapshot + return openDetachedScene(snapshot, { + base: { kind: 'authority', seq: 1 }, + context: { + activeLevelId: 'level_0', + selection: [], + units: 'metric', + catalogVersion: 'test', + registryVersions: captureRegistryVersions(), + }, + ids: effectIds('effect_compile'), + }) +} + +const artifacts = { + url: () => null, + put: async (hash: string) => hash, + text: async () => 'export default function build() {}', +} as unknown as ArtifactStore + +describe('compileAndStoreGeometryScript on a detached scene', () => { + test('reads the node it rebuilds before the first await', async () => { + const detached = scene() + const compiling = detached.run(() => + compileAndStoreGeometryScript( + { nodeId: 'item_screen', params: {} }, + { nodes: detached.reader().nodes as Record, artifacts }, + ), + ) + expect(detached.dependencies()).toContainEqual( + expect.objectContaining({ kind: 'node', id: 'item_screen' }), + ) + detached.dispose() + const error = await compiling.catch((thrown: unknown) => thrown) + expect(error).not.toBeInstanceOf(SceneEnvironmentDisposedError) + }) + + test("mints a new object's id in the scene's scope, before the first await", async () => { + const detached = scene() + const next = detached.run(() => { + void compileAndStoreGeometryScript( + { code: 'export default function build() {}', kind: 'item' }, + { nodes: detached.reader().nodes as Record, artifacts }, + ).catch(() => {}) + return generateId('item') + }) + const ids = effectIds('effect_compile') + ids('item') + expect(next as string).toBe(ids('item')) + detached.dispose() + }) +}) diff --git a/packages/editor/src/lib/geometry-script/author.ts b/packages/editor/src/lib/geometry-script/author.ts index 8bdf0d078d..6ab490bdbf 100644 --- a/packages/editor/src/lib/geometry-script/author.ts +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -1,5 +1,7 @@ import { + type AnyNode, type AnyNodeId, + type ArtifactStore, type CompiledGeometryScript, GEOMETRY_SCRIPT_MIME_TYPES, GeometryArtifactMetadata, @@ -41,28 +43,46 @@ export function configureBlenderScriptBuilder(next: BlenderScriptBuilder | null) blenderBuilder = next } +/** + * What a compile reads from the scene and the artifact service it stores to. By default the + * editor's own; a detached scene passes its tracked nodes, so the reads are recorded there. + */ +export type GeometryScriptScene = { + nodes?: Readonly> + artifacts?: ArtifactStore +} + /** * The editor's compile step for `add_object`: runs the module (a three.js one * in the worker, a bpy one through the host's Blender builder) and stores the * GLB and the module text, so the core operation can reference both by hash. * Without `code`, the node's stored script is rebuilt; either way an edit keeps * the node's param values it does not override. + * + * Everything it reads from the scene, and the id of a new object, are taken + * synchronously, before the first await: a detached scene records reads and + * mints ids only inside its synchronous scope, and is disposed by the time a + * late compile resolves. */ -export async function compileAndStoreGeometryScript(input: { - code?: string - language?: GeometryScriptLanguage - nodeId?: string - params?: Record - kind?: 'item' | 'door' | 'window' | 'column' - inputs?: Record - name?: string - description?: string - category?: string - tags?: string[] - provenance?: GeometryArtifactMetadata -}): Promise { - const code = input.code ?? (await storedScript(input.nodeId)) - const node = input.nodeId ? useScene.getState().nodes[input.nodeId as AnyNodeId] : undefined +export async function compileAndStoreGeometryScript( + input: { + code?: string + language?: GeometryScriptLanguage + nodeId?: string + params?: Record + kind?: 'item' | 'door' | 'window' | 'column' + inputs?: Record + name?: string + description?: string + category?: string + tags?: string[] + provenance?: GeometryArtifactMetadata + }, + scene: GeometryScriptScene = {}, +): Promise { + const nodes = scene.nodes ?? useScene.getState().nodes + const artifacts = scene.artifacts ?? getArtifactStore() + const node = input.nodeId ? nodes[input.nodeId as AnyNodeId] : undefined const language = editedScriptLanguage(node, input) const params = editedScriptParams(node, input.params) const kind = @@ -71,12 +91,16 @@ export async function compileAndStoreGeometryScript(input: { ? node.type : 'item') const nodeId = input.nodeId ?? generateId(kind) + const authored = isScriptedNode(node) ? scriptedObjectMeta(node) : {} + const script = input.code === undefined ? authoredScript(nodes, input.nodeId) : null const metadata = GeometryArtifactMetadata.parse({ - ...(isScriptedNode(node) ? scriptedObjectMeta(node) : {}), + ...authored, ...input.provenance, ...GeometryReuseFields.parse(input), kind, }) + + const code = input.code ?? (await readScript(artifacts, script!, input.nodeId!)) if (language === 'bpy') { if (!blenderBuilder) throw new Error('This editor cannot run Blender (bpy) scripts') return { ...(await blenderBuilder({ code, params, nodeId, metadata })), nodeId } @@ -87,10 +111,9 @@ export async function compileAndStoreGeometryScript(input: { inputs: input.inputs, }) const context = { nodeId, metadata: { ...metadata, mount: compiled.mount } } - const store = getArtifactStore() const [sha256] = await Promise.all([ - store.put(compiled.sha256, glb, 'model/gltf-binary', context), - store.put( + artifacts.put(compiled.sha256, glb, 'model/gltf-binary', context), + artifacts.put( compiled.script, new TextEncoder().encode(code), GEOMETRY_SCRIPT_MIME_TYPES.three, @@ -100,15 +123,24 @@ export async function compileAndStoreGeometryScript(input: { return { ...compiled, sha256, nodeId } } -/** The module text of an authored object, read back from the artifact store. */ -export async function storedScript(nodeId: string | undefined): Promise { +/** The hash of an authored object's script, read from the scene. */ +function authoredScript(nodes: Readonly>, nodeId: string | undefined) { if (!nodeId) throw new Error('Pass the code to build a new object') - const node = authoredObject(useScene.getState().nodes, nodeId) - const code = await getArtifactStore().text(node.source.script) + return authoredObject(nodes as Record, nodeId).source.script +} + +async function readScript(artifacts: ArtifactStore, script: string, nodeId: string) { + const code = await artifacts.text(script) if (code === null) throw new Error(`The script of ${nodeId} could not be read`) return code } +/** The module text of an authored object, read back from the artifact store. */ +export async function storedScript(nodeId: string | undefined): Promise { + const script = authoredScript(useScene.getState().nodes, nodeId) + return readScript(getArtifactStore(), script, nodeId!) +} + const rebuildGeneration = new Map() /** diff --git a/packages/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts index 1207180ec7..f288e2ab64 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -488,7 +488,8 @@ describe('standalone history source invalidation', () => { const effects = [] mockShared('react', () => ({ ...react, useEffect: effect => effects.push(effect), useRef: current => ({ current }) })) const segment = core.StairSegmentNode.parse({ id: 'sseg_history', parentId: 'stair_history', height: 2.5 }) - const stair = core.StairNode.parse({ id: 'stair_history', parentId: level.id, children: [segment.id] }) + // Off the slab: the flight rises from the level base, whatever elects the stair's support. + const stair = core.StairNode.parse({ id: 'stair_history', parentId: level.id, position: [10, 0, 10], children: [segment.id] }) scene.setState({ nodes: { ...baseline, [level.id]: { ...level, height: 2.5, children: [stair.id] }, [stair.id]: stair, [segment.id]: segment } }) const { StairOpeningSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../core/src/systems/stair/stair-opening-system.tsx'))}) StairOpeningSystem() diff --git a/packages/mcp/README.md b/packages/mcp/README.md index fa9b2bebac..bddf724faa 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -236,6 +236,9 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' const bridge = new SceneBridge() +// Or over a detached scene a host opened (`@pascal-app/core/runtime`): every tool then reads and +// writes that scene, with its ids, and the host turns it into a patch. +// const bridge = new SceneBridge({ runtime: openDetachedScene(snapshot, options) }) bridge.loadDefault() const server = createPascalMcpServer({ bridge }) @@ -250,6 +253,14 @@ const scene = await client.callTool({ name: 'get_scene', arguments: {} }) console.log(scene) ``` +A host that passes its own `operations` (`createPascalMcpServer({ operations })`) may implement +`runInScene(run)`: every tool handler starts inside it, and tools re-enter it after their awaits, +so the ids a tool mints and the operations it plans run in the scene's synchronous scope. It is +optional; without it handlers run as they always have, with random ids. An implementation that +computes on a detached scene must provide it, delegating to `SceneBridge.runInScene`, or its tools' +ids will not be the scene's and a recomputed effect will not match. + + See [`examples/embed-in-agent.ts`](./examples/embed-in-agent.ts) for a compilable version. diff --git a/packages/mcp/src/bridge/detached-bridge.test.ts b/packages/mcp/src/bridge/detached-bridge.test.ts new file mode 100644 index 0000000000..20deb32d74 --- /dev/null +++ b/packages/mcp/src/bridge/detached-bridge.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { type SceneCommit, subscribeSceneCommits } from '@pascal-app/core' +import { + captureRegistryVersions, + effectIds, + openDetachedScene, + SceneEnvironmentDisposedError, + type SceneSnapshot, +} from '@pascal-app/core/runtime' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + GeometryArtifactManifest, + LevelNode, + RoofNode, + WallNode, +} from '@pascal-app/core/schema' +import { createSceneOperations, type SceneOperations } from '../operations' +import { createPascalMcpServer } from '../server' +import { SqliteSceneStore } from '../storage/sqlite-scene-store' +import type { GeometryScriptHost } from '../tools/add-object' +import { registerSharedTools } from '../tools/shared-tools' +import { SceneBridge } from './scene-bridge' + +function snapshot(): SceneSnapshot { + const level = LevelNode.parse({ id: 'level_g', parentId: 'building_b', level: 0, height: 2.8 }) + const building = BuildingNode.parse({ id: 'building_b', children: [level.id] }) + return { + nodes: Object.fromEntries([building, level].map((node) => [node.id, node])) as Record< + AnyNode['id'], + AnyNode + >, + rootNodeIds: [building.id], + collections: {}, + materials: {}, + installedPlugins: [], + } as SceneSnapshot +} + +function detached(effectId: string, from: SceneSnapshot = snapshot()) { + return openDetachedScene(from, { + base: { kind: 'authority', seq: 1 }, + context: { + activeLevelId: 'level_g', + selection: [], + units: 'metric', + catalogVersion: 'test', + registryVersions: captureRegistryVersions(), + }, + ids: effectIds(effectId), + }) +} + +async function connect(server: ReturnType) { + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + return client +} + +async function addWallDetached(effectId: string) { + const scene = openDetachedScene(snapshot(), { + base: { kind: 'authority', seq: 1 }, + context: { + activeLevelId: 'level_g', + selection: [], + units: 'metric', + catalogVersion: 'test', + registryVersions: captureRegistryVersions(), + }, + ids: effectIds(effectId), + }) + const bridge = new SceneBridge({ runtime: scene }) + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerSharedTools(server, bridge) + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + const result = await client.callTool({ + name: 'add_wall', + arguments: { levelId: 'level_g', start: [0, 0], end: [4, 0] }, + }) + return { scene, bridge, result } +} + +describe('SceneBridge over a detached scene', () => { + test('a shared tool computes on the detached scene, with its ids and reads, and leaves the editor alone', async () => { + const editor = new SceneBridge() + const editorBefore = editor.exportJSON() + const { scene, bridge, result } = await addWallDetached('effect_wall') + expect(result.isError).toBeFalsy() + + const patch = scene.patch() + const walls = patch.nodeCreates.filter(({ node }) => node.type === 'wall') + expect(walls).toHaveLength(1) + expect(bridge.getNode(walls[0]!.node.id)).not.toBeNull() + expect(editor.exportJSON()).toEqual(editorBefore) + // The default name counts the walls: that selection is a dependency. + expect(scene.dependencies()).toContainEqual( + expect.objectContaining({ kind: 'query', entity: 'node', predicate: { type: 'wall' } }), + ) + + // The same effect recomputed mints the same ids. + const again = await addWallDetached('effect_wall') + expect(again.scene.patch()).toEqual(patch) + + scene.dispose() + expect(() => bridge.getNodes()).toThrow(SceneEnvironmentDisposedError) + expect(() => bridge.updateNode(walls[0]!.node.id, { name: 'Late' })).toThrow( + SceneEnvironmentDisposedError, + ) + again.scene.dispose() + }) +}) + +describe('every MCP tool on a detached scene', () => { + const compiledBox = { + sha256: 'a'.repeat(64), + script: 'b'.repeat(64), + mount: 'floor' as const, + params: {}, + manifest: GeometryArtifactManifest.parse({ + bounds: { min: [-0.5, 0, -0.5], max: [0.5, 1, 0.5] }, + triangles: 12, + }), + } + const host: GeometryScriptHost = { + compile: async () => ({ ...compiledBox, glb: new Uint8Array() }) as never, + storeArtifact: async ({ sha256 }) => sha256, + readArtifact: async () => null, + } + + async function build(effectId: string) { + const base = snapshot() + const wall = WallNode.parse({ + id: 'wall_g', + parentId: 'level_g', + start: [0, 0], + end: [6, 0], + height: 2.8, + }) + const level = base.nodes['level_g' as AnyNodeId] as LevelNode + const scene = detached(effectId, { + ...base, + nodes: { ...base.nodes, [wall.id]: wall, [level.id]: { ...level, children: [wall.id] } }, + } as SceneSnapshot) + const bridge = new SceneBridge({ runtime: scene }) + bridge.setActiveScene({ id: 'scene_detached', name: 'Detached' } as never) + bridge.agentSession.keep({ + designBrief: { + fixed: ['the test’s pieces'], + open: [], + source: 'catalog', + vision: 'The catalog as it is.', + beyond: 'none: the test places what the request names.', + }, + }) + const client = await connect(createPascalMcpServer({ bridge, geometryScripts: host })) + const calls = [ + ['create_unit', { buildingId: 'building_b', name: 'Flat' }], + ['create_roof', { levelId: 'level_g', width: 6, depth: 4 }], + ['add_column', { levelId: 'level_g', x: 1, z: 1 }], + // These await their (absent) compile before planning. + ['add_door', { wallId: 'wall_g', t: 0.25 }], + ['add_window', { wallId: 'wall_g', t: 0.7 }], + [ + 'add_object', + { + code: 'export default function build() {}', + levelId: 'level_g', + name: 'Planter', + reason: 'No planter type.', + x: 2, + z: 2, + }, + ], + ] as const + for (const [name, args] of calls) { + const result = await client.callTool({ name, arguments: args }) + expect(result.isError, `${name}: ${JSON.stringify(result.content)}`).toBeFalsy() + } + const patch = scene.patch() + scene.dispose() + return patch + } + + test('mint the scene ids: the same effect recomputed gives the same patch', async () => { + const first = await build('effect_tools') + expect(first.nodeCreates.map(({ node }) => node.type)).toEqual( + expect.arrayContaining(['unit', 'roof', 'column', 'item', 'door', 'window']), + ) + expect(await build('effect_tools')).toEqual(first) + }) +}) + +describe('SceneBridge commits', () => { + test('an edit and the roof it moves land in one commit and one undo step', () => { + const bridge = new SceneBridge() + const level = LevelNode.parse({ id: 'level_w', parentId: 'building_w', level: 0, height: 3 }) + const upper = LevelNode.parse({ id: 'level_u', parentId: 'building_w', level: 1, height: 3 }) + const corners: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + const walls = corners.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: level.id, + start, + end: corners[(index + 1) % 4]!, + height: 3, + }), + ) + const roof = RoofNode.parse({ + id: 'roof_w', + parentId: upper.id, + support: { kind: 'walls' }, + position: [2, 0, 1.5], + }) + level.children = walls.map((wall) => wall.id) + upper.children = [roof.id] + const building = BuildingNode.parse({ id: 'building_w', children: [level.id, upper.id] }) + bridge.setScene( + Object.fromEntries( + [building, level, upper, ...walls, roof].map((node) => [node.id, node]), + ) as never, + [building.id] as never, + ) + bridge.clearHistory() + const roofY = () => (bridge.getNode('roof_w' as AnyNodeId) as RoofNode).position[1] + const settledY = roofY() + const commits: SceneCommit[] = [] + const stop = subscribeSceneCommits((commit) => commits.push(commit)) + try { + bridge.updateNode('wall_0' as AnyNodeId, { height: 4.2 } as never) + } finally { + stop() + } + expect(roofY()).toBeGreaterThan(settledY) + expect(commits).toHaveLength(1) + expect((commits[0]!.current.nodes['roof_w' as AnyNodeId] as RoofNode).position[1]).toBe(roofY()) + expect(commits[0]!.changedNodeIds).toContain('roof_w' as AnyNodeId) + expect(bridge.getHistory().pastCount).toBe(1) + bridge.undo() + expect(roofY()).toBe(settledY) + }) +}) + +describe('SceneOperations without runInScene', () => { + test('a custom implementation serves the shared tools, its ids unscoped', async () => { + const operations = createSceneOperations({ bridge: new SceneBridge() }) + // A host's own implementation predating the scene scope. + const custom = new Proxy(operations, { + get: (target, key) => { + if (key === 'runInScene') return undefined + const value = Reflect.get(target, key, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) as SceneOperations + const bridge = new SceneBridge() + bridge.setScene(snapshot().nodes as never, snapshot().rootNodeIds as never) + const client = await connect(createPascalMcpServer({ bridge, operations: custom })) + const result = await client.callTool({ + name: 'add_wall', + arguments: { levelId: 'level_g', start: [0, 0], end: [3, 0] }, + }) + expect(result.isError, JSON.stringify(result.content)).toBeFalsy() + }) +}) + +describe('SceneBridge writes on a detached scene', () => { + test("copy what they are given: the caller's objects stay theirs", () => { + const scene = detached('effect_copy') + const bridge = new SceneBridge({ runtime: scene }) + const metadata = { note: { tags: ['one'] } } + bridge.updateNode('level_g' as AnyNodeId, { metadata } as never) + metadata.note.tags.push('two') + expect(bridge.getNode('level_g' as AnyNodeId)?.metadata).toEqual({ note: { tags: ['one'] } }) + + const collections = { + collection_a: { id: 'collection_a', name: 'A', nodeIds: ['level_g'] }, + } as never as Record + bridge.setCollections(collections as never) + collections.collection_a!.nodeIds.push('building_b') + expect(bridge.getCollections()['collection_a' as never]).toMatchObject({ nodeIds: ['level_g'] }) + + const replacement = snapshot() + const level = replacement.nodes['level_g' as AnyNodeId] as AnyNode & { + metadata: Record + } + level.metadata = { tags: ['one'] } + bridge.setScene(replacement.nodes, replacement.rootNodeIds) + ;(level.metadata.tags as string[]).push('two') + expect(bridge.getNode('level_g' as AnyNodeId)?.metadata).toEqual({ tags: ['one'] }) + scene.dispose() + }) +}) + +describe('SceneBridge export', () => { + test('reads materials and the plugin list as dependencies', () => { + const base = { + ...snapshot(), + materials: { mat_red: { id: 'mat_red', name: 'Red', material: { color: '#ff0000' } } }, + installedPlugins: ['pascal:test'], + } as unknown as SceneSnapshot + const scene = detached('effect_export', base) + const exported = new SceneBridge({ runtime: scene }).exportJSON() as Record + expect(Object.keys(exported.materials as object)).toEqual(['mat_red']) + expect(scene.dependencies()).toEqual( + expect.arrayContaining([{ kind: 'material', id: 'mat_red' }, { kind: 'plugins' }]), + ) + scene.dispose() + }) +}) + +describe('store-only operations', () => { + test('serve their tools: the scene scope needs no bridge', async () => { + const directory = mkdtempSync(join(tmpdir(), 'pascal-store-only-')) + const operations = createSceneOperations({ + store: new SqliteSceneStore({ databasePath: join(directory, 'pascal.db') }), + }) + const client = await connect(createPascalMcpServer({ bridge: new SceneBridge(), operations })) + const result = await client.callTool({ name: 'list_scenes', arguments: {} }) + expect(result.isError, JSON.stringify(result.content)).toBeFalsy() + }) +}) diff --git a/packages/mcp/src/bridge/scene-bridge.ts b/packages/mcp/src/bridge/scene-bridge.ts index a0e844fb5b..fb78cf3969 100644 --- a/packages/mcp/src/bridge/scene-bridge.ts +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -1,26 +1,27 @@ // Side-effect import MUST come first: installs RAF polyfill before core loads. import './node-shims' import { - adjacentLevelId, applyStructureReconciliation, assertDerivedNodeWrites, - changedStairOpeningOwners, - floorOpeningTargets, HIDDEN_SITE_NOTE, type NodeDeletionPlan, type NodeDeletionScene, pauseSceneHistory, pauseSpaceDetection, planNodeDeletion, - planOwnedFloorOpenings, planWallDeletion, resumeSceneHistory, resumeSpaceDetection, - runAsSingleSceneHistoryStep, - stairDeckLevelId, structureChangeBatch, } from '@pascal-app/core' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { + declareActionDependencies, + liveSceneRuntime, + materialize, + runAsSettledSceneStep, + type SceneRuntime, +} from '@pascal-app/core/runtime' import type { AnyNode, Collection, CollectionId, SceneMaterialId } from '@pascal-app/core/schema' import { type AnyNodeId, @@ -30,8 +31,6 @@ import { parseNode, SceneMaterial, } from '@pascal-app/core/schema' -// Per PLAN §0.6: `useScene` is the DEFAULT export from `@pascal-app/core/store`. -import useScene from '@pascal-app/core/store' import type { SceneMeta } from '../storage/types' import { InMemoryAgentSession } from './agent-session' @@ -55,19 +54,34 @@ export type ActiveSceneMeta = Pick< > /** The `extra` bag `setScene` accepts — collections, materials, plugin state. */ -type SetSceneExtra = Parameters['setScene']>[2] +type SetSceneExtra = Parameters['setScene']>[2] /** - * Headless bridge to the `@pascal-app/core` Zustand store. + * Headless bridge to a `@pascal-app/core` scene store: the editor's own (the default), or a + * detached scene's (`@pascal-app/core/runtime`), whose host passes it as `runtime`. * - * All mutation flows through the real core store so undo/redo works via Zundo. - * No renderer is attached; `dirtyNodes` accumulates and can be drained via - * `flushDirty()` for observability. + * All mutation flows through the real core store so undo/redo works via Zundo. Reads go through + * the runtime's reader (recorded on a detached scene), and writes run in its scope: its history + * environment, its id allocator, its disposal guard. No renderer is attached; `dirtyNodes` + * accumulates and can be drained via `flushDirty()` for observability. */ export class SceneBridge { /** `applyPatch` takes `upsert_material` patches, in the patch's one history step. */ readonly supportsMaterialUpserts = true private activeScene: ActiveSceneMeta | null = null + private readonly runtime: SceneRuntime + + constructor(options: { runtime?: SceneRuntime } = {}) { + this.runtime = options.runtime ?? liveSceneRuntime + } + + private get store() { + return this.runtime.store + } + + private write(run: () => T): T { + return this.runtime.run(run) + } /** What the agent's session has kept: its named moments, and what it made. */ readonly agentSession = new InMemoryAgentSession() @@ -99,7 +113,7 @@ export class SceneBridge { /** Load initial state; if empty, creates default Site → Building → Level. */ loadDefault(): void { this.agentSession.replace() - useScene.getState().loadScene() + this.write(() => this.store.getState().loadScene()) } /** Replace entire scene (undoable via Zundo). */ @@ -109,21 +123,29 @@ export class SceneBridge { extra?: SetSceneExtra, ): void { this.agentSession.replace() - useScene.getState().setScene(nodes, rootNodeIds, extra) + // The store takes its own copy: a detached scene freezes what it holds. + const owned = structuredClone(materialize({ nodes, rootNodeIds, extra })) + this.write(() => this.store.getState().setScene(owned.nodes, owned.rootNodeIds, owned.extra)) } /** Full snapshot for export, including collections and the material palette. */ exportJSON(): SceneGraph & { collections: Record } { - const state = useScene.getState() + const state = this.store.getState() + const reader = this.runtime.reader() + // Everything exported is read through the reader: materials and the plugin list included. + const materials = Object.fromEntries( + reader.query({ entity: 'material' }).map((material) => [material.id, material]), + ) + const installedPlugins = reader.installedPlugins() // Deep-clone so callers can't mutate store state directly. return JSON.parse( JSON.stringify({ - nodes: state.nodes, - rootNodeIds: state.rootNodeIds, - collections: state.collections ?? {}, - materials: state.materials ?? {}, - ...(state.hasExplicitPluginInstallState || state.installedPlugins.length > 0 - ? { installedPlugins: state.installedPlugins } + nodes: reader.nodes, + rootNodeIds: this.getRootNodeIds(), + collections: reader.collections ?? {}, + materials, + ...(state.hasExplicitPluginInstallState || installedPlugins.length > 0 + ? { installedPlugins } : {}), }), ) @@ -192,31 +214,40 @@ export class SceneBridge { /** Read a single node, or `null` if not present. */ getNode(id: AnyNodeId): AnyNode | null { - const node = useScene.getState().nodes[id] - return node ?? null + return this.runtime.reader().get(id) ?? null } - /** All nodes (live reference into the store — do NOT mutate). */ + /** All nodes (a read-only view of the store's current nodes — do NOT mutate). */ getNodes(): Record { - return useScene.getState().nodes + return this.runtime.reader().nodes as Record } getCollections(): Record { - return useScene.getState().collections + return this.runtime.reader().collections as Record } getMaterials(): Record { - return useScene.getState().materials + return Object.fromEntries( + this.runtime + .reader() + .query({ entity: 'material' }) + .map((material) => [material.id, material]), + ) as Record } setCollections(collections: Record): void { - if (useScene.getState().readOnly) return - useScene.setState({ collections }) + this.write(() => { + if (this.store.getState().readOnly) return + this.store.setState({ collections: structuredClone(materialize(collections)) }) + }) } /** Root node IDs. */ getRootNodeIds(): AnyNodeId[] { - return useScene.getState().rootNodeIds + return this.runtime + .reader() + .query({ entity: 'root' }) + .map((node) => node.id as AnyNodeId) } /** @@ -235,7 +266,7 @@ export class SceneBridge { * Results are de-duplicated by id, in flat-dict iteration order. */ getChildren(parentId: AnyNodeId): AnyNode[] { - const nodes = useScene.getState().nodes + const nodes = this.getNodes() const out: AnyNode[] = [] const seen = new Set() @@ -280,7 +311,7 @@ export class SceneBridge { * unset (see the default-scene quirk documented on `getChildren`). */ getAncestry(id: AnyNodeId): AnyNode[] { - const nodes = useScene.getState().nodes + const nodes = this.getNodes() const out: AnyNode[] = [] let current: AnyNode | undefined = nodes[id] const seen = new Set() @@ -306,14 +337,16 @@ export class SceneBridge { parentId?: AnyNodeId | null levelId?: AnyNodeId }): AnyNode[] { - const nodes = useScene.getState().nodes + // A query, so a detached scene records the selection rather than a read of every node. + const selected = this.runtime.reader().query({ + entity: 'node', + where: { + ...(filter.type !== undefined && { type: filter.type }), + ...(filter.parentId !== undefined && { parentId: filter.parentId }), + }, + }) const out: AnyNode[] = [] - for (const node of Object.values(nodes)) { - if (filter.type !== undefined && node.type !== filter.type) continue - if (filter.parentId !== undefined) { - const np = (node.parentId ?? null) as AnyNodeId | null - if (np !== filter.parentId) continue - } + for (const node of selected) { if (filter.levelId !== undefined) { if (this.resolveLevelId(node.id as AnyNodeId) !== filter.levelId) continue } @@ -336,26 +369,37 @@ export class SceneBridge { * `id`, generated by the schema default if they did `XxxNode.parse({...})`). * Returns the generated id. */ - createNode(node: AnyNode, parentId?: AnyNodeId): AnyNodeId { - const before = useScene.getState().nodes - assertDerivedNodeWrites(before, { create: [{ node: parentId ? { ...node, parentId } : node }] }) - runAsSingleSceneHistoryStep(useScene, () => { - useScene.getState().createNode(node, parentId) - this.reconcileDependentStructure(before) + createNode(input: AnyNode, parentId?: AnyNodeId): AnyNodeId { + // The store takes its own copy: a detached scene freezes what it holds. + const node = structuredClone(materialize(input)) + this.write(() => { + const before = this.store.getState().nodes + assertDerivedNodeWrites(this.getNodes(), { + create: [{ node: parentId ? { ...node, parentId } : node }], + }) + runAsSettledSceneStep(this.store, () => { + this.store.getState().createNode(node, parentId) + declareActionDependencies(this.runtime, before, { create: [{ node, parentId }] }) + this.runtime.reconcile(before) + }) }) return node.id as AnyNodeId } /** Update node fields (shallow merge through the core store). */ - updateNode(id: AnyNodeId, data: Partial): void { - if (!useScene.getState().nodes[id]) { + updateNode(id: AnyNodeId, input: Partial): void { + const data = structuredClone(materialize(input)) + if (!this.getNode(id)) { throw new Error(`node not found: ${id}`) } - const before = useScene.getState().nodes - assertDerivedNodeWrites(before, { update: [{ id, data }] }) - runAsSingleSceneHistoryStep(useScene, () => { - useScene.getState().updateNode(id, data) - this.reconcileDependentStructure(before) + this.write(() => { + const before = this.store.getState().nodes + assertDerivedNodeWrites(this.getNodes(), { update: [{ id, data }] }) + runAsSettledSceneStep(this.store, () => { + this.store.getState().updateNode(id, data) + declareActionDependencies(this.runtime, before, { update: [{ id, data }] }) + this.runtime.reconcile(before) + }) }) } @@ -367,8 +411,8 @@ export class SceneBridge { * Returns the list of ids actually removed from the scene. */ deleteNode(id: AnyNodeId, cascade = false): string[] { - const state = useScene.getState() - const node = state.nodes[id] + const nodes = this.getNodes() + const node = nodes[id] if (!node) { throw new Error(`node not found: ${id}`) } @@ -380,21 +424,16 @@ export class SceneBridge { ) } - const before = new Set(Object.keys(state.nodes)) - const wallLevels = [ - ...new Set( - descendants.flatMap((nodeId) => { - const deleted = state.nodes[nodeId] - return deleted?.type === 'wall' && deleted.parentId ? [deleted.parentId as AnyNodeId] : [] - }), - ), - ] - runAsSingleSceneHistoryStep(useScene, () => { - useScene.getState().deleteNode(id) - if (wallLevels.length) this.deriveStructure(wallLevels) - this.reconcileDependentStructure(state.nodes) - }) - const afterNodes = useScene.getState().nodes + const beforeNodes = this.store.getState().nodes + const before = new Set(Object.keys(beforeNodes)) + this.write(() => + runAsSettledSceneStep(this.store, () => { + this.store.getState().deleteNode(id) + declareActionDependencies(this.runtime, beforeNodes, { delete: [id] }) + this.runtime.reconcile(beforeNodes) + }), + ) + const afterNodes = this.store.getState().nodes const removed: string[] = [] for (const prevId of before) { if (!(prevId in afterNodes)) removed.push(prevId) @@ -412,9 +451,17 @@ export class SceneBridge { deletedIds: AnyNodeId[] createdIds: AnyNodeId[] } { - const state = useScene.getState() - const nodes = state.nodes - if (state.readOnly) throw new Error('scene_read_only') + return this.write(() => this.applyPatchInScope(structuredClone(materialize(patches)))) + } + + private applyPatchInScope(patches: Patch[]): { + appliedOps: number + deletedIds: AnyNodeId[] + createdIds: AnyNodeId[] + } { + const nodes = this.getNodes() + const beforeNodes = this.store.getState().nodes + if (this.store.getState().readOnly) throw new Error('scene_read_only') // Track synthesized state as we dry-run so later ops can reference // earlier-created ids and reflect earlier-deleted ids. @@ -523,38 +570,36 @@ export class SceneBridge { mintId: generateId, }).changes, ) + // What was asked for, before the deletion planner adds its own writes. + const requested = { + create: [...createOps.values()], + update: [...updateOps].map(([id, data]) => ({ id, data })), + delete: [...deleteIds], + } for (const { id, data } of planned.update) updateOps.set(id, { ...updateOps.get(id), ...data } as Partial) - const wallLevels = [ - ...new Set( - planned.delete.flatMap((id) => { - const node = nodes[id] - return node?.type === 'wall' && node.parentId ? [node.parentId as AnyNodeId] : [] - }), - ), - ] - runAsSingleSceneHistoryStep(useScene, () => { + runAsSettledSceneStep(this.store, () => { pauseSpaceDetection() try { for (const material of materialUpserts.values()) - useScene.getState().addSceneMaterial(material) - useScene.getState().createNodes([...createOps.values(), ...planned.create]) - useScene.getState().updateNodes([...updateOps].map(([id, data]) => ({ id, data }))) - useScene.getState().deleteNodes(planned.delete) + this.store.getState().addSceneMaterial(material) + this.store.getState().createNodes([...createOps.values(), ...planned.create]) + this.store.getState().updateNodes([...updateOps].map(([id, data]) => ({ id, data }))) + this.store.getState().deleteNodes(planned.delete) } finally { resumeSpaceDetection() } - if (wallLevels.length) this.deriveStructure(wallLevels) - this.reconcileDependentStructure(nodes) + declareActionDependencies(this.runtime, beforeNodes, requested) + this.runtime.reconcile(beforeNodes) }) - const createdIds = Object.keys(useScene.getState().nodes).filter( - (id) => !nodes[id as AnyNodeId], + const postNodes = this.store.getState().nodes + const createdIds = Object.keys(postNodes).filter( + (id) => !beforeNodes[id as AnyNodeId], ) as AnyNodeId[] // Compute actual deleted ids by diffing pre/post snapshots. - const postNodes = useScene.getState().nodes const deletedIds: AnyNodeId[] = [] - for (const prevId of Object.keys(nodes)) { + for (const prevId of Object.keys(beforeNodes)) { if (!(prevId in postNodes)) deletedIds.push(prevId as AnyNodeId) } @@ -585,9 +630,22 @@ export class SceneBridge { * before the triggering tool call rather than stranding derived nodes. */ deriveStructure(levelIds?: AnyNodeId[]): { createdIds: AnyNodeId[]; deletedIds: AnyNodeId[] } { - pauseSceneHistory(useScene) + return this.write(() => + runAsSettledSceneStep(this.store, () => this.deriveStructureInScope(levelIds)), + ) + } + + private deriveStructureInScope(levelIds?: AnyNodeId[]): { + createdIds: AnyNodeId[] + deletedIds: AnyNodeId[] + } { + pauseSceneHistory(this.store) try { - const patches = applyStructureReconciliation(useScene, { + // The kernel reads through the runtime's reader; its writes go to the store. + const reading = { + getState: () => ({ ...this.store.getState(), nodes: this.getNodes() }), + } + const patches = applyStructureReconciliation(reading, { levelIds, mintId: (kind) => generateId(kind), }) @@ -598,24 +656,26 @@ export class SceneBridge { deletedIds: patches.flatMap((patch) => (patch.op === 'delete' ? [patch.id] : [])), } } finally { - resumeSceneHistory(useScene) + resumeSceneHistory(this.store) } } /** Undo. Returns the number of steps actually undone. */ undo(steps = 1): number { - const before = useScene.temporal.getState().pastStates.length - useScene.temporal.getState().undo(steps) - const after = useScene.temporal.getState().pastStates.length - return Math.max(0, before - after) + return this.write(() => { + const before = this.store.temporal.getState().pastStates.length + this.store.temporal.getState().undo(steps) + return Math.max(0, before - this.store.temporal.getState().pastStates.length) + }) } /** Redo. Returns the number of steps actually redone. */ redo(steps = 1): number { - const before = useScene.temporal.getState().futureStates.length - useScene.temporal.getState().redo(steps) - const after = useScene.temporal.getState().futureStates.length - return Math.max(0, before - after) + return this.write(() => { + const before = this.store.temporal.getState().futureStates.length + this.store.temporal.getState().redo(steps) + return Math.max(0, before - this.store.temporal.getState().futureStates.length) + }) } /** @@ -625,7 +685,7 @@ export class SceneBridge { validateScene(): ValidationResult { const errors: ValidationError[] = [] const warnings: ValidationError[] = [] - const nodes = useScene.getState().nodes + const nodes = this.getNodes() for (const [id, node] of Object.entries(nodes)) { if (node.type === 'site' && node.visible === false) { warnings.push({ nodeId: id, path: 'visible', message: HIDDEN_SITE_NOTE }) @@ -648,7 +708,7 @@ export class SceneBridge { * renderer (there is no renderer in MCP mode); useful for observability. */ flushDirty(): string[] { - const state = useScene.getState() + const state = this.store.getState() const ids = Array.from(state.dirtyNodes) for (const id of ids) { state.clearDirty(id as AnyNodeId) @@ -658,7 +718,7 @@ export class SceneBridge { /** Current temporal history pointers. */ getHistory(): { pastCount: number; futureCount: number } { - const t = useScene.temporal.getState() + const t = this.store.temporal.getState() return { pastCount: t.pastStates.length, futureCount: t.futureStates.length, @@ -667,85 +727,16 @@ export class SceneBridge { /** Clear the temporal undo/redo history. */ clearHistory(): void { - useScene.temporal.getState().clear() + this.store.temporal.getState().clear() + } + + /** Runs `run` in the scene's scope: an operation planned here mints the runtime's ids. */ + runInScene(run: () => T): T { + return this.write(run) } runAsSingleHistoryStep(run: () => T): T { - return runAsSingleSceneHistoryStep(useScene, run) - } - - private reconcileDependentStructure(before: Record): void { - const currentNodes: Record = this.getNodes() - const authoredInputChanged = [ - ...new Set([...Object.keys(before), ...Object.keys(currentNodes)]), - ].some((id) => { - if (before[id] === currentNodes[id]) return false - const type = (currentNodes[id] ?? before[id])?.type - return ( - type === 'stair' || - type === 'stair-segment' || - type === 'elevator' || - type === 'level' || - type === 'building' || - type === 'slab' || - type === 'ceiling' - ) - }) - if (authoredInputChanged) { - const ownerIds = changedStairOpeningOwners(before, currentNodes) - for (const node of [...Object.values(before), ...Object.values(currentNodes)]) { - if ( - node.type === 'elevator' && - (before[node.id] !== currentNodes[node.id] || - Object.values(currentNodes).some( - (level) => level.type === 'level' && before[level.id] !== level, - )) - ) - ownerIds.add(node.id) - } - const owned = planOwnedFloorOpenings(currentNodes, { ownerIds }) - if (owned.length) { - pauseSpaceDetection() - try { - useScene.getState().applyNodeChanges(structureChangeBatch(owned)) - } finally { - resumeSpaceDetection() - } - } - } - const current: Record = this.getNodes() - const levels = new Set() - for (const id of new Set([...Object.keys(before), ...Object.keys(current)])) { - if (before[id] === current[id]) continue - for (const nodes of [before, current]) { - const level = stairDeckLevelId(nodes, nodes[id]) - if (level) levels.add(level as AnyNodeId) - const node = nodes[id] - if (node?.type === 'level') { - levels.add(node.id as AnyNodeId) - const lower = adjacentLevelId(nodes, node.id, -1) - if (lower) levels.add(lower as AnyNodeId) - } - if (node?.type === 'stair') - for (const opening of Object.values(nodes)) - if (opening.type === 'floor-opening' && opening.ownerId === node.id) - for (const target of floorOpeningTargets(nodes, opening)) - levels.add(target.levelId as AnyNodeId) - if (node?.type === 'floor-opening') - for (const target of floorOpeningTargets(nodes, node)) - levels.add(target.levelId as AnyNodeId) - if (node?.type === 'door' || node?.type === 'window') { - const wall = nodes[node.parentId!] - if (wall?.type === 'wall' && wall.parentId) levels.add(wall.parentId as AnyNodeId) - } else if ( - (node?.type === 'zone' || node?.type === 'slab' || node?.type === 'wall') && - node.parentId - ) { - levels.add(node.parentId as AnyNodeId) - } - } - } - if (levels.size) this.deriveStructure([...levels]) + return this.write(() => runAsSettledSceneStep(this.store, run)) } // ---- internal helpers ---- @@ -756,7 +747,7 @@ export class SceneBridge { * missing on a node. */ private _findParentByChildrenScan(id: AnyNodeId): AnyNode | null { - const nodes = useScene.getState().nodes + const nodes = this.getNodes() for (const candidate of Object.values(nodes)) { if (!('children' in candidate && Array.isArray(candidate.children))) continue for (const child of candidate.children as unknown[]) { @@ -782,7 +773,7 @@ export class SceneBridge { * the SiteNode quirk and the default-scene parentId-unset case both work. */ private _collectDescendants(id: AnyNodeId): AnyNodeId[] { - const nodes = useScene.getState().nodes + const nodes = this.getNodes() if (!nodes[id]) return [] const out: AnyNodeId[] = [] const stack: AnyNodeId[] = [id] diff --git a/packages/mcp/src/operations/scene-operations.ts b/packages/mcp/src/operations/scene-operations.ts index 1656d59741..70ab1c0469 100644 --- a/packages/mcp/src/operations/scene-operations.ts +++ b/packages/mcp/src/operations/scene-operations.ts @@ -107,6 +107,13 @@ export interface SceneOperations { getHistory(): { pastCount: number; futureCount: number } clearHistory(): void runAsSingleHistoryStep(run: () => T): T + /** + * Runs `run` synchronously in the scene's scope: its ids, history environment and disposal guard + * (`SceneBridge` over a runtime). Optional: an implementation without it runs `run` as is, which + * is right for a scene with random ids. One that computes on a detached scene must provide it, + * or the ids its tools mint are not the scene's. + */ + runInScene?(run: () => T): T createProject(options: ProjectCreateOptions): Promise getProjectStatus(id: string): Promise @@ -332,6 +339,11 @@ class SceneOperationsFacade implements SceneOperations { return this.requireBridge().runAsSingleHistoryStep(run) } + runInScene(run: () => T): T { + // Store-only operations have no scene to scope: their tools (list_scenes) run as they are. + return this.#bridge ? this.#bridge.runInScene(run) : run() + } + async createProject(options: ProjectCreateOptions): Promise { const store = this.requireStore() if (!store.createProject) { diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 113c25fdab..45334f1363 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -76,6 +76,7 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe opts.operations ?? createSceneOperations({ bridge: opts.bridge, store: opts.store }) const executeTool = recordingWhatToolsMake(operations, opts.executeTool) if (executeTool) installToolExecutor(server, executeTool) + installSceneScope(server, operations) const catalog = opts.catalog ? cachedCatalog(opts.catalog) : undefined registerTools(server, operations, { catalog, @@ -143,6 +144,23 @@ function recordingWhatToolsMake( return outer ? (input) => outer({ ...input, execute: () => recording(input) }) : recording } +/** + * Every handler starts in the scene's scope (`SceneOperations.runInScene`): ids it mints and + * schema defaults it fills before its first await come from the scene's allocator, so a detached + * scene recomputed from the same snapshot and effect mints the same ones. Work after an await + * re-enters the scope itself (add_object, add_column). + */ +function installSceneScope(server: McpServer, operations: SceneOperations): void { + const runInScene = operations.runInScene?.bind(operations) + if (!runInScene) return + const registerTool = server.registerTool.bind(server) + server.registerTool = ((name, config, callback) => { + const runtimeCallback = callback as unknown as RuntimeToolCallback + return registerTool(name, config, ((...args: unknown[]) => + runInScene(() => Reflect.apply(runtimeCallback, undefined, args))) as typeof callback) + }) as McpServer['registerTool'] +} + function installToolExecutor(server: McpServer, executeTool: PascalMcpToolExecutor): void { const registerTool = server.registerTool.bind(server) const wrappedRegisterTool: McpServer['registerTool'] = (name, config, callback) => { diff --git a/packages/mcp/src/tools/add-column.ts b/packages/mcp/src/tools/add-column.ts index 03d8dfa3bd..c57d8f4e32 100644 --- a/packages/mcp/src/tools/add-column.ts +++ b/packages/mcp/src/tools/add-column.ts @@ -7,12 +7,13 @@ import { editedScriptLanguage, } from '@pascal-app/core/agent-operations' import { addColumnTool, isAgentRefusal, refuse } from '@pascal-app/core/agent-tools' -import { type AnyNode, GeometryReuseFields } from '@pascal-app/core/schema' +import { type AnyNode, GeometryReuseFields, generateId } from '@pascal-app/core/schema' import type { SceneOperations } from '../operations' import { compileAndStore, type GeometryScriptHost, readScript } from './add-object' import { DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' import { refusalResult, toolError } from './errors' import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { inScene } from './scene-scope' import { toPatches } from './shared-tools' export function registerAddColumn( @@ -39,20 +40,24 @@ export function registerAddColumn( refuse('scripts_unavailable', 'This Pascal server cannot run geometry scripts.') const scene = bridge.getActiveScene() if (!scene) refuse('no_active_scene', 'Open or save a scene first.') - const code = input.code ?? (await readScript(host, scene.id, bridge, input.nodeId!)) + // A new column's id is the scene's, minted before anything is awaited. + const nodeId = input.nodeId ?? inScene(bridge, () => generateId('column')) const language = editedScriptLanguage(previous, input) + const code = input.code ?? (await readScript(host, scene.id, bridge, input.nodeId!)) compiled = await compileAndStore(host, scene.id, { code, language, params }, 'column', { - nodeId: input.nodeId, + nodeId, metadata: { ...(isScriptedNode(previous) ? scriptedObjectMeta(previous) : {}), ...GeometryReuseFields.parse(input), }, }) } - const outcome = addColumn( - nodes, - { ...input, compiled }, - { activeLevelId: bridge.activeLevelId?.() ?? null }, + const outcome = inScene(bridge, () => + addColumn( + nodes, + { ...input, compiled }, + { activeLevelId: bridge.activeLevelId?.() ?? null }, + ), ) if (outcome.changes) bridge.applyPatch(toPatches(outcome.changes)) const payload = { diff --git a/packages/mcp/src/tools/add-object.ts b/packages/mcp/src/tools/add-object.ts index 025eeb7a3e..0827a1df26 100644 --- a/packages/mcp/src/tools/add-object.ts +++ b/packages/mcp/src/tools/add-object.ts @@ -21,6 +21,7 @@ import { isAgentRefusal, refuse, } from '@pascal-app/core/agent-tools' +import { untracked } from '@pascal-app/core/runtime' import { type AnyNode, type CompiledGeometryScript, @@ -35,6 +36,7 @@ import type { SceneOperations } from '../operations' import { DESTRUCTIVE_TOOL_ANNOTATIONS, READ_ONLY_TOOL_ANNOTATIONS } from './annotations' import { refusalResult, toolError } from './errors' import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { inScene } from './scene-scope' import { briefInForce, toPatches } from './shared-tools' /** @@ -169,28 +171,28 @@ export function readScript( /** * One object's module compiled and stored by the host: a new one from its code, an edit from its * code or its stored script, with the params it keeps. The step a lone add_object and each - * add_object of a run_batch share. + * add_object of a run_batch share. What it reads from the scene it reads before its first await; + * `nodeId` is a new object's id, minted by the caller in the scene's scope. */ export async function compileObject( host: GeometryScriptHost, sceneId: string, bridge: SceneOperations, args: Omit, + nodeId = args.nodeId, ): Promise { - const code = - args.code ?? - (args.nodeId ? await readScript(host, sceneId, bridge, args.nodeId) : refuseMissingCode()) const nodes = bridge.getNodes() as Record const previous = args.nodeId ? nodes[args.nodeId] : undefined const language = editedScriptLanguage(previous, args) const params = editedScriptParams(previous, args.params) - return compileAndStore(host, sceneId, { code, language, params }, 'object', { - nodeId: args.nodeId, - metadata: { - ...(isScriptedNode(previous) ? scriptedObjectMeta(previous) : {}), - ...GeometryReuseFields.parse(args), - }, - }) + const metadata = { + ...(isScriptedNode(previous) ? scriptedObjectMeta(previous) : {}), + ...GeometryReuseFields.parse(args), + } + const code = + args.code ?? + (args.nodeId ? await readScript(host, sceneId, bridge, args.nodeId) : refuseMissingCode()) + return compileAndStore(host, sceneId, { code, language, params }, 'object', { nodeId, metadata }) } /** What a batch's add_object builds with: the host's compile, on the scene open now. */ @@ -206,7 +208,8 @@ export async function compileObjectOnHost( 'no_active_scene', 'Open or save a scene first: authored objects are stored with a scene.', ) - return compileObject(host, scene.id, bridge, args) + const nodeId = args.nodeId ?? inScene(bridge, () => generateId('item')) + return compileObject(host, scene.id, bridge, args, nodeId) } /** `add_object` on the MCP: the shared contract and operation, with the host's compile in front. */ @@ -241,10 +244,12 @@ export function registerAddObject( }) } const args = input as Omit + // A new object's id is the scene's, minted before anything is awaited. + const nodeId = args.nodeId ?? inScene(bridge, () => generateId('item')) let compiled: CompiledGeometryScript try { requireAddObjectReason(args) - compiled = await compileObject(host, scene.id, bridge, args) + compiled = await compileObject(host, scene.id, bridge, args, nodeId) } catch (error) { if (isAgentRefusal(error)) return refusalResult(error) return toolError( @@ -254,13 +259,17 @@ export function registerAddObject( } // A copy: the hosted bridge writes its map in place, and a "before" that grows with the call // reads every creation as unchanged. - const before = { ...(bridge.getNodes() as Record) } + // Copying is not a read: on a detached scene the operation records what it selects. + const nodes = bridge.getNodes() as Record + const before = { ...untracked(nodes) } let outcome: ReturnType try { - outcome = addObject( - before, - { ...args, compiled }, - { activeLevelId: bridge.activeLevelId?.() ?? null }, + outcome = inScene(bridge, () => + addObject( + nodes, + { ...args, compiled }, + { activeLevelId: bridge.activeLevelId?.() ?? null }, + ), ) } catch (error) { return refusalResult(error) diff --git a/packages/mcp/src/tools/collections.ts b/packages/mcp/src/tools/collections.ts index 6f71cafc23..9b2a8fa562 100644 --- a/packages/mcp/src/tools/collections.ts +++ b/packages/mcp/src/tools/collections.ts @@ -8,11 +8,13 @@ import { writeCollections, } from '@pascal-app/core/agent-operations' import { editCollectionTool, listCollectionsTool } from '@pascal-app/core/agent-tools' +import { untracked } from '@pascal-app/core/runtime' import type { AnyNodeId } from '@pascal-app/core/schema' import type { SceneOperations } from '../operations' import { DESTRUCTIVE_TOOL_ANNOTATIONS, READ_ONLY_TOOL_ANNOTATIONS } from './annotations' import { refusalResult } from './errors' import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { inScene } from './scene-scope' // The collection tools share contracts and operations with the chat; they read the scene's // collections beside its nodes, which the node-only shared operations never see. @@ -54,7 +56,7 @@ export function registerCollectionTools(server: McpServer, bridge: SceneOperatio async (input: EditCollectionInput) => { let outcome: AgentOperationOutcome try { - outcome = editCollection(sceneOf(bridge), input) + outcome = inScene(bridge, () => editCollection(sceneOf(bridge), input)) } catch (error) { return refusalResult(error) } @@ -64,7 +66,8 @@ export function registerCollectionTools(server: McpServer, bridge: SceneOperatio bridge.applyPatch( update.map(({ id, data }) => ({ op: 'update' as const, id: id as AnyNodeId, data })), ) - bridge.setCollections(writeCollections(bridge.getCollections(), collections)) + // Writing the other collections back is a copy, not a read of them. + bridge.setCollections(writeCollections(untracked(bridge.getCollections()), collections)) }) return reply({ ...outcome.result, diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index cb88f8b497..7b1a161431 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -28,6 +28,7 @@ import { compileAndStore, type GeometryScriptHost, readScript } from './add-obje import { ADDITIVE_TOOL_ANNOTATIONS } from './annotations' import { refusalResult, toolError } from './errors' import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { inScene } from './scene-scope' import { toPatches } from './shared-tools' /** The contract's one result (core `addWallOpening`), and the live sync's note. */ @@ -80,10 +81,12 @@ async function rebuildOpening( ...GeometryReuseFields.parse(input), }, }) - outcome = rescriptOpening( - nodes, - { ...input, compiled }, - { activeLevelId: bridge.activeLevelId?.() ?? null }, + outcome = inScene(bridge, () => + rescriptOpening( + nodes, + { ...input, compiled }, + { activeLevelId: bridge.activeLevelId?.() ?? null }, + ), ) } catch (error) { if (isAgentRefusal(error)) return refusalResult(error) @@ -173,11 +176,14 @@ export function registerAddDoor( if ('error' in compiled) return compiled.error let opening: ReturnType try { - opening = addWallOpening(bridge.getNodes() as SceneNodes, { - kind: 'door', - ...input, - compiled: compiled.script, - }) + // Planned after the compile was awaited: back in the scene's scope for its ids. + opening = inScene(bridge, () => + addWallOpening(bridge.getNodes() as SceneNodes, { + kind: 'door', + ...input, + compiled: compiled.script, + }), + ) } catch (error) { return refusalResult(error) } @@ -209,11 +215,14 @@ export function registerAddWindow( if ('error' in compiled) return compiled.error let opening: ReturnType try { - opening = addWallOpening(bridge.getNodes() as SceneNodes, { - kind: 'window', - ...input, - compiled: compiled.script, - }) + // Planned after the compile was awaited: back in the scene's scope for its ids. + opening = inScene(bridge, () => + addWallOpening(bridge.getNodes() as SceneNodes, { + kind: 'window', + ...input, + compiled: compiled.script, + }), + ) } catch (error) { return refusalResult(error) } diff --git a/packages/mcp/src/tools/scene-query.test.ts b/packages/mcp/src/tools/scene-query.test.ts index c7da4d58d7..23a7e82cb7 100644 --- a/packages/mcp/src/tools/scene-query.test.ts +++ b/packages/mcp/src/tools/scene-query.test.ts @@ -306,7 +306,9 @@ describe('scene query tools', () => { expect(parsed.occupiedStoryCount).toBe(2) expect(parsed.supportLevelCount).toBe(1) expect(parsed.roofLevelIds).toEqual([roofLevel.id]) - expect(parsed.hasIssues).toBe(true) + // The bridge settles the stair's rise to the storey and its floor opening, as the editor does + // (reconcileDocument): only the informational design targets remain. + expect(parsed.hasIssues).toBe(false) expect( parsed.issues.some((issue: { type: string }) => issue.type === 'stair_riser_target'), ).toBe(true) diff --git a/packages/mcp/src/tools/scene-scope.ts b/packages/mcp/src/tools/scene-scope.ts new file mode 100644 index 0000000000..d5698191c5 --- /dev/null +++ b/packages/mcp/src/tools/scene-scope.ts @@ -0,0 +1,6 @@ +import type { SceneOperations } from '../operations' + +/** Runs `run` in the scene's scope when the operations have one (`SceneOperations.runInScene`). */ +export function inScene(operations: SceneOperations, run: () => T): T { + return operations.runInScene ? operations.runInScene(run) : run() +} diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index 468412b3d1..7a245860db 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -62,6 +62,7 @@ import { viewAssetsTool, } from '@pascal-app/core/agent-tools' import { DesignValidationSchema } from '@pascal-app/core/procedural-items' +import { untracked } from '@pascal-app/core/runtime' import { type AnyNode, type AnyNodeId, @@ -81,6 +82,7 @@ import { type AssetCatalog, builtInCatalog } from './asset-catalog' import { registerCollectionTools } from './collections' import { refusalResult } from './errors' import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { inScene } from './scene-scope' import { ROOM_TOOL_ANNOTATIONS, type RoomToolName, structureOutput } from './structure-tools' // Tools the MCP and the hosted chat share whole: one contract, one core operation. The MCP only @@ -602,9 +604,11 @@ export function registerOperationTool( }, async (input: Record) => { let outcome: ReturnType + const nodes = bridge.getNodes() as Record // A copy of the map: a host may write its own in place (the hosted bridge does), and a - // "before" that grows with the call reads every creation as unchanged. - const before = { ...(bridge.getNodes() as Record) } + // "before" that grows with the call reads every creation as unchanged. Copying is not a + // read: on a detached scene, the operation records what it selects. + const before = { ...untracked(nodes) } // The agent's session begins with its first call, before anything is written. const first = bridge.agentSession && startOfSession(bridge.agentSession.read(), before) if (first) bridge.agentSession?.keep(first) @@ -659,7 +663,7 @@ export function registerOperationTool( pictures: needs.pictures, }) try { - outcome = tool.operation(before, input as never, context) + outcome = inScene(bridge, () => tool.operation(nodes, input as never, context)) } catch (error) { return refusalResult(error) } diff --git a/scripts/biome/raw-scene-enumeration.grit b/scripts/biome/raw-scene-enumeration.grit new file mode 100644 index 0000000000..76f9e22be2 --- /dev/null +++ b/scripts/biome/raw-scene-enumeration.grit @@ -0,0 +1,15 @@ +// Agent operations run on detached scenes, whose reader records what they read (DECISIONS.md +// E-018). Enumerating a whole `nodes` or collections record reads every entry, so any entity +// created meanwhile conflicts. Select through `queryNodes` / `queryCollections` instead; an +// operation that genuinely reads everything says why with a biome-ignore comment. +language js + +`Object.$method($record)` where { + $method <: or { `values`, `keys`, `entries` }, + $record <: or { `nodes`, `scene.nodes`, `scene.collections` }, + register_diagnostic( + span = $record, + message = "Enumerating the scene records a read of every node on a detached scene: select with queryNodes / queryCollections. See DECISIONS.md E-018.", + severity = "error" + ) +} diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index 3c337f6454..c28ac118df 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -16,6 +16,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 | | [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and the snapping rule (Shift taps cycle the mode, Alt hold forces) | | [agent-surfaces](agent-surfaces.md) | MCP ↔ hosted AI chat ↔ published skill parity: shared contracts and operations, knowledge parity, tells of drift | +| [scene-runtime](scene-runtime.md) | Detached scenes: per-store history environments, tracked reads, deterministic ids, complete reconciliation, field patches, `applyPatchWithDependencies` | | [authored-objects](authored-objects.md) | Items an agent writes as three.js: script source, artifacts, naming conventions, surfaces, clips, compile hosts | | [measurements](measurements.md) | Persistent measurement data, 2D/3D draft ownership, snapping, units, and visibility | | [point-and-ask](point-and-ask.md) | Point and ask: the mode that lets a person point at the scene and ask the agent about it: the `pointing` scope, hover picks, the anchored bubble, the pin, the context shape, and the scene/chat boundary | diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 48beae0c94..2e16f52793 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -36,7 +36,7 @@ A tool has three layers, and only the last one may differ between surfaces: A tool is written once and registered twice; never build the same tool separately for the chat and the MCP. 1. **Contract** in `packages/core/src/agent-tools/.ts`: `{ name, title, description, input }`, zod only, and added to `AGENT_TOOL_CONTRACTS` (`agent-tools/index.ts`). The description is what both agents read: say what it does, its defaults, and its refusal codes. Every length and angle is `measurement(kind, unit)` in the unit the field states, and a roof pitch is `roofPitch('deg')` (or `'twelfths'`): a bare number keeps that unit, text in any unit is converted. A default goes in their `default` option, which states it in the description (a field that converts loses its JSON Schema `default`). Coordinates (x, z, points, rectangles), fractions and counts stay numbers; `agent-tools/notation.test.ts` fails on any other bare number and on a default the model is not shown, through `bareNumberPaths()` and `unshownDefaultPaths()` (`notation-inventory.ts`), which a plugin's notation test runs over its own contracts. -2. **Operation** in `packages/core/src/agent-operations/`: a pure function over the scene's nodes that returns `{ result, changes }` and refuses with `refuse(code, message, details)`; registered by tool name in `AGENT_OPERATIONS`. +2. **Operation** in `packages/core/src/agent-operations/`: a pure function over the scene's nodes that returns `{ result, changes }` and refuses with `refuse(code, message, details)`; registered by tool name in `AGENT_OPERATIONS`. It selects what it reads (`queryNodes`, `orderedChildIds`) rather than enumerating `nodes`, so it runs precisely on a detached scene ([Computing off the live scene](#computing-off-the-live-scene)). 3. **Cases** in the shared fixture table, written failing first; the core and MCP runners pick them up. 4. **MCP**: an entry in `SHARED_TOOLS` (`packages/mcp/src/tools/shared-tools.ts`) with its annotations and output schema; the annotation policy (`scripts/openai-tool-annotation-policy.ts`) and `plugin-evals/tool-annotation-justifications.json` list it. 5. **Hosted chat**: the hosted repo defines its chat tool from the same contract and runs the same operation through its shared-tool executor; its `agent-surface-parity.test.ts` checks that the two surfaces' name, description and input schema match. @@ -61,6 +61,15 @@ Beyond the rules above: - **One name each.** A plugin tool's name collides with no core tool and no other plugin's; prefix it with the plugin. - **Tests follow the layers.** The plugin's edge cases as one table, run against its operation and through `registerOperationTool` with a real client; `shared-tools.test.ts` does this for a synthetic plugin. +## Computing off the live scene + +A host may run any shared operation, or a whole MCP bridge, on a **detached scene** instead of the live store (`@pascal-app/core/runtime`, [scene runtime](scene-runtime.md)): the same contract and operation, computed on a copy opened from a canonical snapshot, with deterministic ids (`effectIds`) and a record of what it read. The result is a complete field-level patch, applied to the live scene with `applyPatchWithDependencies` only if nothing it read has changed, or refused with what changed. Both surfaces keep one implementation: + +- **Hosted chat**: `scene.execute(operation, input, context)`, or `applyAgentOutcome(op(scene.reader().nodes, …), scene.host())`. +- **MCP**: `new SceneBridge({ runtime: scene })`; every registered tool then reads and writes the detached scene. The shared-tool runner plans each operation inside the scene's scope (`runInScene`), so its ids are the scene's. + +Derived construction settles inside the computation (`reconcileDocument`: rooms, stair rises and openings, roof elevation), so the patch is complete and applying it gives the editor's systems nothing more to write. The comparison contract (`runtime/fixture-parity.test.ts`) runs every shared fixture detached and live and requires identical patches. + ## What stays surface-specific Hosted service tools have public contracts in `core/agent-tools` and a shared request operation in `core/agent-operations`. A host opts into them with `createPascalMcpServer({ services })` or `registerHostedServiceTools`. Without that executor, the open-source server exposes its local scene tools only. Tool arguments carry project/plugin references and an approved credit ceiling; the host supplies verified identity and enforces access, billing and retained-result ownership. Provider selection, workflow prompts and orchestration belong to the host. Public discovery includes only the released contract inventory. diff --git a/wiki/architecture/scene-runtime.md b/wiki/architecture/scene-runtime.md new file mode 100644 index 0000000000..6da152e199 --- /dev/null +++ b/wiki/architecture/scene-runtime.md @@ -0,0 +1,93 @@ +# Scene runtime + +*Computing on a copy of the scene: detached scenes, tracked reads, deterministic ids, complete patches, and applying them with a dependency check.* + +Applies to: `packages/core/src/runtime/**`, `packages/core/src/store/**` (environments, history control), `packages/core/src/systems/document-reconcile.ts`, the shared operations in `packages/core/src/agent-operations/**`, and `packages/mcp/src/bridge/**`. + +A host that runs a long agent tool (a scene program, a compile, a batch of operations) does not want to hold the person's editor while it computes, nor let the computation touch the live store before it is known to fit. `@pascal-app/core/runtime` gives it a scene of its own: compute there, get a patch and the list of what was read, then apply the patch to the live scene only if nothing that was read has changed meanwhile. The runtime is library code; when and where a host applies is the host's (DECISIONS.md E-018). + +## One store, one environment + +Every scene store is built by `createSceneStore(environment)`. The **environment** holds what history keeps outside the store: pause depths and leases, the commit bus and its pending commit, commit node-id scopes, gesture drafts, the hydration boundary, the space-detection pause, the batched dirty-mark frame. The editor's store is `createSceneStore(liveSceneEnvironment)`. + +- A store runs its actions, writes and undo inside its own environment; a function that takes a store (`pauseSceneHistory(store)`, `runAsSingleSceneHistoryStep(store, …)`) uses that store's; any other call uses the environment of the store acting, or the live one. +- So a second store never pauses, commits to, or drafts in the editor's, and `subscribeSceneCommits` at module level only ever hears the editor's commits. +- `environment.dispose()` makes every later read or write of its store throw `SceneEnvironmentDisposedError`, from actions captured earlier and from late async continuations alike. + +The node and material registries stay shared. A detached scene checks the registry against the versions its `ExecutionContext` captured (`captureRegistryVersions()`, taken once the host's plugins are loaded) and refuses to open on a different one. + +## A detached scene + +```ts +const scene = openDetachedScene(snapshot, { base, context, ids: effectIds(effectId) }) +const result = scene.execute(AGENT_OPERATIONS.add_wall, input, agentContext) // plain data +const patch = scene.patch() // field-level SceneOperationPatch since `snapshot` +const dependencies = scene.dependencies() // what the computation read +scene.dispose() +``` + +- **Canonical snapshot.** `snapshot` is a document as the editor holds it (`getSceneDocument()`, or a saved draft): already migrated. `loadCanonicalSnapshot` puts it in the store as it is; no migration, healing or load-time normalisation runs again, so nothing (a level gap, an opening) changes on load. +- **Owned, read-only data.** The scene loads a deep-frozen copy of the snapshot, copies what it is given to write (host changes, bridge writes) before freezing it, and freezes every record its writes produce: nothing reached through it, a nested array of a node or a material included, can change the caller's data, the editor's store, or the baseline of its patch. Nested values already handed out stay readable after `dispose()`: they are frozen and belong to no store, so reading one is reading a copy. +- **No previews.** Inside the scene's scope (and inside `reconcileDocument`), the editor's live previews are hidden (`withoutLivePreviews`): field overrides, drag transforms and an in-flight terrain stroke. A computation reads saved state only. +- **Scope.** `scene.run(fn)` runs `fn` in the scene's environment, with its id allocator and a support index built from its own nodes. `host()` (an `AgentHostRuntime`) and `execute()` run there. Code that awaits must read and mint before its first await (as `compileAndStoreGeometryScript` does): the scope is synchronous. +- **Ids.** `generateId`, and so every schema default, reads the allocator in scope (`withIdAllocator`). `effectIds(effectId)` mints `prefix_` and 16 base-36 characters of SHA-256(`effectId:n`): recomputing an effect from the same snapshot mints the same ids, so a retried commit is idempotent. Outside a scope, ids stay random. +- **Patch.** `patch()` settles any derived writes still owed, then diffs the document against the snapshot: creates and deletes with their position among their siblings, changed and removed fields, materials, collections, plugin state. A parent's `children` is written only when its order changed beyond what the creates and deletes account for. The patch is verified by applying it to the snapshot; a root reorder, which a patch cannot express, throws. +- **Results.** Views handed out of the scene die with it; `execute` returns its result through `materialize`, a plain copy. + +`liveSceneRuntime` is the editor's store behind the same `SceneRuntime` interface (plain reads, random ids), so code written against a runtime runs on either. + +## Tracked reads + +A detached scene's `DocumentReader` records each read as a `Dependency`: + +| Read | Recorded as | +|---|---| +| `get(id)`, a field of the node it returns | `node` with the fields read (`[]`: only its presence, including an absent node) | +| `query({ entity, where })`, `queryNodes`, `queryCollections` | `query` with the ids it matched; an empty result is recorded too | +| `orderedChildren(id)`, `orderedChildIds` | `order`: where an order is consumed (a stair's riser split) | +| a collection, a material, the plugin list | `collection` (fields), `material`, `plugins` | +| a support query (slab, wall, item support) | `scope` on the level asked about: the spatial index is not visible to the reader. A level scope also covers the level's ancestors (building transform, site terrain) | +| what the store's actions plan around the changes (default gutters, ridge vents and downspouts on roof edits, merges and reference cleanup on deletions, curve clearance between walls, sanitised values) | `scope` on the level of every change that runs a planner, written or not (a disabled gutter is a decision too; a segment's planners run only on the fields they read, so a rename runs none), and on every level an unrequested write lands on (`declareActionDependencies`): the actions read the scene directly | +| a project file, an artifact | `file` / `artifact`, declared by the host (`scene.declare`) | + +Rules that keep the record precise: + +- **Select, don't enumerate.** Enumerating the `nodes` record records a query over every node: any node created meanwhile then conflicts. Shared operations select with `queryNodes(nodes, { type, parentId })`; a Biome rule refuses `Object.values / keys / entries` of `nodes` in `agent-operations/`, and an operation that genuinely reads the whole scene (verify_scene) says so in a `biome-ignore`. +- **Copying is not reading.** Spreading a node or collection reads all of it. Code that only carries untouched fields through to a write copies `untracked(value)`. +- **Views are per version.** `reader.nodes` is a read-only view of the current record version; identity-keyed memos (level elevations) keep working, and `reader.track(older)` views an earlier version so two versions compare by identity. `queryNodes` and `orderedChildIds` on a view select within the version it is of. +- **Order is a dependency only where it is read.** A parent's child list changed by an independent create or delete is bookkeeping (below); reading the list itself (`node.children`) records it exactly. + +## Complete reconciliation + +`reconcileDocument(store, before)` settles every derived write an edit owes, synchronously, to a fixed point: + +1. room structure (zones, auto ceilings, floor plates) for the levels whose structure changed, as space detection derives a batch; +2. stair rises, and the floor openings stairs and elevators own, re-deriving the levels they cut; +3. the elevation of wall-supported roofs. + +Levels are chosen as space detection chooses them for a commit (from the nodes the edit changed; a parent whose child list alone changed adds nothing), and each pass runs only on a change to its own inputs; the roof pass resolves only the roofs the edit can move (`roofsAffectedBy`: a changed roof or segment, the roofs on a changed wall's level and the level above; for a slab, its own level, the level below and every level above in its building; every roof when a level, building or site changed). Each scoping is tested against the complete reconciliation of the same edit. Support queries read a support index kept per store (`supportIndexOf`): slabs, walls and ceilings bulk-loaded once, then brought up to date in place from the nodes each edit changed; item collision cells are built only when a placement query asks. It never reads the editor's previews. The editor's systems (`StairOpeningSystem`, `ElevatorOpeningSystem`, `RoofElevationSystem`) plan with the same functions, so after a host settles an edit they find nothing to write; while a host settles (`runSettlingDocument`), they and space detection stand down, so derived ids are minted once, in the host's order. A runtime's `reconcile` is `reconcileDocument` reading through its reader; the MCP bridge settles every node edit with it. + +## Commits + +A host that edits the editor's store writes, then settles derived construction under a history pause. `runAsSettledSceneStep(store, run)` makes that one undo step and one commit: the commit carries the document as settled, with the derived nodes' ids among its changed ids, so collaboration receives the roof a wall edit moved in the same commit as the wall. `createAgentHost` and the MCP bridge write through it. + +## Applying: `applyPatchWithDependencies` + +```ts +const applied = applyPatchWithDependencies(current, patch, dependencies, baseDoc, external) +// { ok: true, next, patch: rebased } | { ok: false, changed: ChangedEntity[] } +``` + +- **Dependencies** are compared between `baseDoc` (the snapshot) and `current`: node fields and presence, query membership (absence included), order, collection fields, materials, plugins, scopes, and files and artifacts through the host's `external` lookups. Any difference is a conflict (`reason: 'dependency'`). +- **Writes**: a field the patch writes that someone changed meanwhile to another value is a conflict (`reason: 'write'`); so is deleting a node that changed. +- **Merge**: otherwise the patch lands field by field. Fields others changed on the same node or collection are kept (a rename lands beside a recolour). Children created independently under one parent each keep their place after the sibling they followed. + +The result's `patch` is rebased onto `current`: what an editor applies (`applySceneOperationPatch`). + +## The comparison contract + +`runtime/fixture-parity.test.ts` runs every shared-tool and `add_object` fixture both ways from the same canonical snapshot, context, id seed and registry: detached, and on the editor's store with its document systems mounted. The patches and results must be identical, nothing may write after the live call returns, and the detached patch applied to the editor must land on the same document with no further write. A fixture that cannot is listed in the test's `EXCEPTIONS` with its reason; there are none today. + +## MCP + +`new SceneBridge({ runtime })` binds the bridge to a runtime; the default is the editor's store. The bridge reads through the runtime's reader and runs every write in its scope. Core does not import the MCP: the host opens the detached scene and constructs the bridge. `createPascalMcpServer` starts every tool handler in the scene's scope (`SceneOperations.runInScene`), and tools that await (add_object, add_column) mint their ids before the first await and re-enter the scope to plan; a custom `SceneOperations` without `runInScene` keeps unscoped, random ids. diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index f8fd3d225e..6ff8b858a9 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -25,6 +25,8 @@ Plain data: no Three.js, no `useFrame` (DECISIONS.md E-001). Pure helpers, plus `owned-floor-openings.ts` / `reconcile-owned-floor-openings.ts` at the top level reconcile floor openings owned by other nodes. +`document-reconcile.ts` is the one synchronous pass of every derived write a saved document owes (room structure, stair rises, owned openings, roof elevation): hosts that edit outside the editor (the MCP bridge, a detached scene) settle with it. The stair, elevator and roof systems plan with the same functions on a microtask for edits made in the editor, and stand down while a host settles (`isSettlingDocument`). A new system that writes saved fields adds its pass there, or a host's patch misses it ([scene-runtime](scene-runtime.md)). + ### Viewer — `packages/viewer/src/systems/` Three.js side-effects on registered objects (`sceneRegistry`). `` mounts the framework systems directly: `FloorElevationSystem`, `GeometrySystem`, core's `StairOpeningSystem` and `RoofElevationSystem`, and ``, which mounts every registered kind's `def.system`. The per-kind implementations below are exported by `@pascal-app/viewer` and wrapped by the kinds' `def.system` modules in `packages/nodes`. diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 4d54e21516..5ecb7961b1 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -400,10 +400,10 @@ Because community autosave only persists after the first post-load edit, the mig ## Gotchas -- **Only `support.kind: 'walls'` roofs follow walls.** The resolver projects the roof’s XZ centre onto its parent level’s lower neighbour, selected by `findLevelBelowId` from `getLevelElevations` in the same building stack (ordinals need not be consecutive). It uses the smallest enclosing room at that point and takes the highest resolved wall top without clamping to the storey. Conical roofs match curved walls by arc centre and radius against their transformed segment footprint, without a wall-ID binding. No matching enclosure or arc freezes Y and preserves follow intent, so redrawing walls resumes following. Negative level-local Y is valid: 2.5 m walls under a 3 m storey put the roof at −0.5 m. `RoofElevationSystem` re-derives Y only for following roofs on wall, slab, level, building, site, and roof edits, history-paused and one microtask after store updates so the spatial grid has settled. Settled updates publish a separate scene commit without adding an undo step; an outer gesture’s history pause retains commit ownership. The panel’s “Follows walls” choice enables this rule; “Custom” writes `level` and keeps Y. An explicit Y change exceeding 1e-4 m in the panel or 3D move handle switches to `level` in the same patch; XZ-only moves retain `walls` and re-resolve spatially. Undo/redo restores mode and Y together. Roof-surface attachments hide this mode control and retain their own rule. Schema version stays 3 and load never opts existing roofs into following. +- **Only `support.kind: 'walls'` roofs follow walls.** The resolver projects the roof’s XZ centre onto its parent level’s lower neighbour, selected by `findLevelBelowId` from `getLevelElevations` in the same building stack (ordinals need not be consecutive). It uses the smallest enclosing room at that point and takes the highest resolved wall top without clamping to the storey. Conical roofs match curved walls by arc centre and radius against their transformed segment footprint, without a wall-ID binding. No matching enclosure or arc freezes Y and preserves follow intent, so redrawing walls resumes following. Negative level-local Y is valid: 2.5 m walls under a 3 m storey put the roof at −0.5 m. `RoofElevationSystem` re-derives Y only for following roofs on wall, slab, level, building, site, and roof edits, history-paused and one microtask after store updates so the spatial grid has settled. Settled updates publish a separate scene commit without adding an undo step; an outer gesture’s history pause retains commit ownership. Hosts that edit outside the editor apply the same rule synchronously through `reconcileDocument`, so their commit already carries the roof. The panel’s “Follows walls” choice enables this rule; “Custom” writes `level` and keeps Y. An explicit Y change exceeding 1e-4 m in the panel or 3D move handle switches to `level` in the same patch; XZ-only moves retain `walls` and re-resolve spatially. Undo/redo restores mode and Y together. Roof-surface attachments hide this mode control and retain their own rule. Schema version stays 3 and load never opts existing roofs into following. - **Ordinals are semantic.** `level < 0` renders "Basement N"; `level === 0` is the ground-floor lookup. Never renumber without the zero anchor. - **Boundary geometry.** Auto plates store full wall footprints; manual slabs and older snapshots can still put wall/ceiling clamp samples exactly on polygon edges — always use the boundary-inclusive band-overlap helpers (`wallOverlapsSlabFootprint`, `slabCoversPoint`), never raw ray-cast point-in-polygon on those paths. -- **Straight stairs build from stored segment heights**, not the resolved rise — any rise change must go through `syncStairRises` (applied by `StairOpeningSystem`, history-paused, one microtask after store updates so the spatial grid has settled). +- **Straight stairs build from stored segment heights**, not the resolved rise — any rise change must go through `syncStairRises` (applied by `StairOpeningSystem`, history-paused, one microtask after store updates so the spatial grid has settled; and synchronously by `reconcileDocument` for hosts that edit outside the editor). Without the built-in stair kind registered (a bare MCP server), the rise elects the stair's support with `stairBaseElevation`, which agrees with the registered kind's floor-placed election. - **Reactivity is explicit.** A `level.height` change dirties that level's walls/stairs/ceilings/fences; a slab change dirties overlapping same-level supports, the level below's walls/ceilings, and deck-attached stairs. Every live terrain dab dirties ground-hosted structures, `fillToTerrain` walls/slabs, every `floorPlaced` node at grade, and every kind whose builder asked for `ctx.levelBaseAt`, through the transient `useLiveTerrain` subscription in `spatial-grid-sync.ts`; the scene graph and undo history are still written only once when the stroke commits. Ending or canceling a stroke runs the same sweep so dependents settle back onto the persisted field. Slab handles reuse the slab-change dependency helper for live previews. If a new consumer reads these bounds, wire its dirty rule there. - **Auto floors derive once per terrain stroke.** Floors without authored elevation follow persisted boundary-wall bases; explicit zone intent remains fixed. - **Host lifecycle.** Retiring a plate remaps `supportSlabId`/`deckSlabId` to its surviving plate in the same commit. Deleting a slab without a successor strips those references; a host merely reshaped away falls back silently and resumes if the slab returns.