From a0f6a506aea88cd9ad63a8731469d7ce7cf0d4c8 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 10 Oct 2026 23:56:13 -0400 Subject: [PATCH 01/21] core: scene stores own their history environment The scene store's history bookkeeping lived in module globals: pause depths and leases, the commit bus and its pending commit, commit node-id scopes, gesture drafts, the hydration boundary, the space-detection pause and the batched dirty-mark frame. A second store would pause, commit to and draft in the editor's. Each of these is now a slot of a SceneEnvironment. `createSceneStore` (exported) binds a store to one; its actions, writes and undo run inside it, and functions that take a store use that store's. The editor's store is `createSceneStore(liveSceneEnvironment)`, and module-level calls fall back to the live environment, so existing callers are unchanged. Disposing an environment makes every later read or write of its store throw. Dirty marks fall back to a microtask where there is no animation frame (headless), instead of throwing. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/lib/space-detection.ts | 48 ++-- .../core/src/store/actions/node-actions.ts | 36 ++- packages/core/src/store/history-control.ts | 217 ++++++++++-------- packages/core/src/store/history-drafts.ts | 42 ++-- .../core/src/store/scene-environment.test.ts | 78 +++++++ packages/core/src/store/scene-environment.ts | 81 +++++++ packages/core/src/store/scene-hydration.ts | 64 ++++-- packages/core/src/store/use-scene.ts | 152 ++++++++---- 8 files changed, 516 insertions(+), 202 deletions(-) create mode 100644 packages/core/src/store/scene-environment.test.ts create mode 100644 packages/core/src/store/scene-environment.ts diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 7902be6723..41028961db 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -16,6 +16,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' @@ -492,24 +497,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 +542,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 +569,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 +587,16 @@ export function initSpaceDetectionSync( adoptSceneBaseline(state.nodes) return } - if (getSceneHistoryPauseDepth() > 0 || sceneStore.temporal.getState().isTracking === false) + 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) { @@ -686,12 +704,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/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index ee7d0d90f7..70a12bcfbb 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -56,6 +56,7 @@ import { addActiveSceneCommitNodeIds, runWithSceneCommitNodeIds, } from '../history-control' +import { currentSceneEnvironment } from '../scene-environment' import type { SceneState } from '../use-scene' type AnyContainerNode = AnyNode & { children: string[] } @@ -1064,9 +1065,15 @@ 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(), + })) function buildWallMergePlans( nodes: Record, @@ -1584,6 +1591,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 @@ -1601,17 +1611,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/history-control.ts b/packages/core/src/store/history-control.ts index d426e51247..4c6e785c9a 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -2,45 +2,83 @@ 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 +} + +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(), + })) +} + +/** 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[] @@ -79,11 +117,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, @@ -93,16 +126,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) } @@ -111,13 +145,14 @@ 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) } @@ -160,15 +195,17 @@ export function areSceneSnapshotsEqual(left: SceneSnapshot, right: SceneSnapshot ) } +/** Listens to the commits of the current environment's store: the live editor's, by default. */ export function subscribeSceneCommits(listener: SceneCommitListener): () => void { - sceneCommitListeners.add(listener) + const listeners = current().commitListeners + listeners.add(listener) return () => { - sceneCommitListeners.delete(listener) + listeners.delete(listener) } } -function emitSceneCommit(commit: SceneCommit): void { - for (const listener of [...sceneCommitListeners]) { +function emitSceneCommit(control: HistoryControlState, commit: SceneCommit): void { + for (const listener of [...control.commitListeners]) { try { listener(commit) } catch (error) { @@ -179,84 +216,80 @@ function emitSceneCommit(commit: SceneCommit): void { export function notifySceneCommit(commit: SceneCommit): void { if (areSceneSnapshotsEqual(commit.before, commit.current)) return + const control = current() const contextualCommit = { ...commit, changedNodeIds: mergedNodeIds(commit.changedNodeIds, activeSceneCommitNodeIds()), } - if (sceneCommitTransactionDepth > 0) { - if (pendingSceneCommit) { - pendingSceneCommit = { - origin: pendingSceneCommit.origin, - before: pendingSceneCommit.before, - current: contextualCommit.current, - changedNodeIds: mergedNodeIds( - pendingSceneCommit.changedNodeIds, - contextualCommit.changedNodeIds, - ), - } - } else { - pendingSceneCommit = contextualCommit - } + if (control.commitTransactionDepth > 0) { + const pending = control.pendingCommit + control.pendingCommit = pending + ? { + origin: pending.origin, + before: pending.before, + current: contextualCommit.current, + changedNodeIds: mergedNodeIds(pending.changedNodeIds, contextualCommit.changedNodeIds), + } + : contextualCommit return } - emitSceneCommit(contextualCommit) + emitSceneCommit(control, contextualCommit) } -function beginSceneCommitTransaction(): void { - sceneCommitTransactionDepth += 1 -} - -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 && !areSceneSnapshotsEqual(commit.before, commit.current)) { - emitSceneCommit(commit) + 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() } } @@ -277,10 +310,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, @@ -317,21 +346,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() { @@ -339,24 +372,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 { @@ -379,15 +414,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] @@ -399,6 +436,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/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-scene.ts b/packages/core/src/store/use-scene.ts index 94a3493603..657fc03055 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -86,6 +86,12 @@ import { withoutSceneNodeAnnotations, writeSceneNodeField, } from './scene-annotations' +import { + bindSceneEnvironment, + liveSceneEnvironment, + type SceneEnvironment, + sceneEnvironmentOf, +} from './scene-environment' import { invalidatePendingHydration, isHydrationNormalization, @@ -1344,16 +1350,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, + } + }) } /** @@ -1399,54 +1415,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 @@ -1464,8 +1470,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: {}, @@ -1575,11 +1633,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()) } }) } @@ -1795,19 +1853,21 @@ const useScene: UseSceneStore = createSceneStore( return { ...state, undo: (steps = 1) => - runTemporalJump(get().pastStates.slice().splice(-steps, steps)[0], () => + runTemporalJump(storeOf(), get().pastStates.slice().splice(-steps, steps)[0], () => state.undo(steps), ), redo: (steps = 1) => - runTemporalJump(get().futureStates.slice().splice(-steps, steps)[0], () => + 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. From c09adf17204539ac1a419b9f9503767610375867 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:02:00 -0400 Subject: [PATCH 02/21] core: reconcileDocument settles every saved-field writer synchronously Derived document writes were spread across viewer-mounted systems: room structure in the space-detection subscription, stair rises and owned floor openings in the stair and elevator opening systems (a microtask after the commit), and wall-supported roof elevation in the roof system (another microtask, publishing a second commit). Anything that applies an edit outside the editor, or takes a patch right after one, missed the deferred writes; applying that patch in the editor then produced unreceipted follow-up commits. `reconcileDocument(store, before)` runs all of them in one synchronous pass to a fixed point: the structure of the levels whose structure changed, stair rises, stair and elevator openings (re-deriving the levels they cut), then roof elevation. The live systems plan with the same functions (`planRoofElevationUpdates`, `isStairOpeningInput`, `isElevatorOpeningInput`, `planOwnedFloorOpenings`), so after the pass they find nothing to write. Support queries were bound to the singleton spatial grid, which reads the editor's store and its live previews. `SpatialGridManager` now reads a source; the singleton keeps the live one, and `createSnapshotSpatialGrid` indexes a fixed set of nodes without previews. Support helpers read `activeSpatialGrid()`, which `withSpatialGrid` scopes; reconcileDocument queries a grid built from the store it reconciles. Co-Authored-By: Claude Opus 5.5 --- .../spatial-grid/floor-placed-elevation.ts | 8 +- .../spatial-grid/spatial-grid-manager.ts | 114 +++++++++--- .../hooks/spatial-grid/support-host-patch.ts | 20 +-- packages/core/src/lib/space-detection.ts | 19 +- .../core/src/systems/document-reconcile.ts | 165 ++++++++++++++++++ .../elevator/elevator-opening-system.tsx | 33 +--- .../systems/reconcile-owned-floor-openings.ts | 13 +- .../systems/roof/roof-elevation-system.tsx | 30 +--- .../core/src/systems/roof/roof-elevation.ts | 40 ++++- .../systems/stair/stair-opening-system.tsx | 33 +--- 10 files changed, 340 insertions(+), 135 deletions(-) create mode 100644 packages/core/src/systems/document-reconcile.ts 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 14e97bf936..d58c48c28a 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 { getWallPlaneTop } 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, @@ -200,6 +200,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 @@ -210,9 +244,11 @@ export class SpatialGridManager { private readonly itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) private readonly cellSize: number + private readonly source: SpatialGridSource - constructor(cellSize = 0.5) { + constructor(cellSize = 0.5, source: SpatialGridSource = liveSpatialGridSource) { this.cellSize = cellSize + this.source = source } private getFloorGrid(levelId: string): SpatialGrid { @@ -241,7 +277,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, @@ -302,17 +338,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 } @@ -329,8 +365,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) { @@ -344,7 +380,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]> { @@ -360,7 +396,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) @@ -562,7 +598,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), @@ -664,7 +700,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, @@ -939,7 +975,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) { @@ -968,7 +1004,7 @@ export class SpatialGridManager { inputs.terrainPlates.some((slab) => wallOverlapsPolygon({ start, end, curveOffset, thickness, justification }, slab.polygon), ) - ? useLiveTerrain.getState() + ? this.source.terrain() : undefined, ) return support @@ -1000,9 +1036,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 && @@ -1029,11 +1065,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 @@ -1051,7 +1087,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 @@ -1066,7 +1102,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() @@ -1126,7 +1162,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] @@ -1185,13 +1221,43 @@ 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)) + const current = nodes() + for (const node of Object.values(current)) grid.handleNodeCreated(node, levelIdOf(node, current)) + 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, @@ -1217,7 +1283,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 c77effaf6b..9fa94079ac 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, @@ -503,7 +503,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 41028961db..c8eb8224ea 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -472,6 +472,18 @@ 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), + ) +} + function syncSceneStructure( levelIds: string[], sceneStore: any, @@ -682,12 +694,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) diff --git a/packages/core/src/systems/document-reconcile.ts b/packages/core/src/systems/document-reconcile.ts new file mode 100644 index 0000000000..ec3ffac0c3 --- /dev/null +++ b/packages/core/src/systems/document-reconcile.ts @@ -0,0 +1,165 @@ +import { structureChangeBatch } from '../commands/structure/shared' +import { + createSnapshotSpatialGrid, + type SpatialGridManager, + withSpatialGrid, +} from '../hooks/spatial-grid/spatial-grid-manager' +import { resolveLevelId } from '../lib/node-ancestry' +import { changedStructureLevelIds } 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 { pauseSceneHistory, resumeSceneHistory } from '../store/history-control' +import type { SceneStore } from '../store/use-scene' +import { planOwnedFloorOpenings } from './owned-floor-openings' +import { reconcileOwnedFloorOpeningChanges } from './reconcile-owned-floor-openings' +import { isRoofElevationInput, planRoofElevationUpdates } 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, +): boolean { + if (before === after) return false + for (const id of new Set([...Object.keys(before), ...Object.keys(after)])) { + const previous = before[id] + const next = after[id] + if (previous !== next && (isInput(previous) || isInput(next))) return true + } + return false +} + +export type DocumentReconcileOptions = { + /** What the passes read: a detached scene passes its tracked view, so their reads are recorded. */ + 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, 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()) + let cached: { nodes: SceneNodes; grid: SpatialGridManager } | null = null + const grid = + options.grid ?? + (() => { + const current = nodes() + if (cached?.nodes !== current) + cached = { nodes: current, grid: createSnapshotSpatialGrid(nodes, resolveLevelId) } + return cached.grid + }) + // 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, previous, changes) + }) + } + + withSpatialGrid(grid, () => { + const levelIds = options.allLevels ? undefined : changedStructureLevelIds(before, view()) + if (!levelIds || levelIds.length) + write(() => + applyStructureReconciliation(readingStore, { + levelIds, + previousNodes: options.allLevels ? undefined : before, + mintId: generateId, + }), + ) + + 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)) { + const from = stairsFrom + stairsFrom = start + const rises = syncStairRises(view() as Record) + if (rises.length) write(() => store.getState().updateNodes(rises)) + applyOpenings( + planOwnedFloorOpenings(view() as Record, { + ownerIds: changedStairOpeningOwners( + from as Record, + view() as Record, + ), + }), + ) + stairsFrom = nodes() + } + if (changedInputs(elevatorsFrom, nodes(), isElevatorOpeningInput)) { + applyOpenings(planOwnedFloorOpenings(view() as Record)) + elevatorsFrom = nodes() + } + if (changedInputs(roofsFrom, nodes(), isRoofElevationInput)) { + const roofs = planRoofElevationUpdates(view()) + 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..ef818bcd74 100644 --- a/packages/core/src/systems/elevator/elevator-opening-system.tsx +++ b/packages/core/src/systems/elevator/elevator-opening-system.tsx @@ -2,40 +2,13 @@ import { useEffect } from 'react' import { structureChangeBatch } from '../../commands/structure/shared' -import type { AnyNode } from '../../schema' 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 +20,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 @@ -58,7 +31,7 @@ export function initializeElevatorOpeningSync() { return useScene.subscribe((state, prevState) => { if (syncingAutoOpenings) return - if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + if (!changedInputs(prevState.nodes, state.nodes, isElevatorOpeningInput)) return applyChanges(isHydrationNormalization() || state.hydrationId !== prevState.hydrationId) }) } 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 e5b9cd7ead..4523545afe 100644 --- a/packages/core/src/systems/roof/roof-elevation-system.tsx +++ b/packages/core/src/systems/roof/roof-elevation-system.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect } from 'react' -import type { AnyNode, AnyNodeId } from '../../schema' +import type { AnyNodeId } from '../../schema' import { getSceneHistoryPauseDepth, notifySceneCommit, @@ -10,26 +10,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 @@ -44,16 +31,7 @@ export function initializeRoofElevationSync(): () => void { if (disposed) return const { nodes, updateNodes, readOnly } = useScene.getState() if (readOnly) return - const updates: Array<{ id: AnyNodeId; data: Partial }> = [] - 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 = planRoofElevationUpdates(nodes) if (updates.length === 0) return const before = sceneSnapshot() const publishCommit = @@ -85,7 +63,7 @@ export function initializeRoofElevationSync(): () => void { 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() return } diff --git a/packages/core/src/systems/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts index 1e75ab60f7..820d0b560d 100644 --- a/packages/core/src/systems/roof/roof-elevation.ts +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -3,7 +3,14 @@ import { getWallEffectiveHeightForNodes, } from '../../hooks/spatial-grid/spatial-grid-manager' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import type { AnyNode, LevelNode, RoofNode, RoofSegmentNode, WallNode } from '../../schema' +import type { + AnyNode, + AnyNodeId, + LevelNode, + RoofNode, + RoofSegmentNode, + WallNode, +} from '../../schema' import { findLevelBelowId, getLevelElevations } from '../../services/storey' import { wallOverlapsSlabFootprint } from '../slab/slab-support' import { getWallArcData } from '../wall/wall-curve' @@ -117,3 +124,34 @@ export function resolveRoofElevation( } return highest ?? roof.position[1] } + +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>, +): Array<{ id: AnyNodeId; data: Partial }> { + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + 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]] }, + }) + } + return updates +} diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index d3364d925f..1644c2a639 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -9,6 +9,7 @@ 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 +20,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 +55,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 @@ -157,7 +130,7 @@ export function initializeStairOpeningSync() { const unsubscribeScene = useScene.subscribe((state, prevState) => { if (syncingAutoOpenings) return - if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + if (!changedInputs(prevState.nodes, state.nodes, isStairOpeningInput)) return scheduleAutoSync( isHydrationNormalization() || state.hydrationId !== prevState.hydrationId, prevState.nodes, From 3787f74265ceda9295a3a9fa15b0757295e95d98 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:20:06 -0400 Subject: [PATCH 03/21] core/runtime: detached scenes with tracked reads and complete patches A new `@pascal-app/core/runtime` subpath lets a computation run on its own copy of the scene and hand back a patch, without touching the editor's store, history, commit bus or subscribers. - `openDetachedScene(snapshot, { base, context, ids })` loads a canonical (already migrated) snapshot as it is: no migration, healing or normalisation runs again, so loading never closes level gaps or re-cuts openings. The scene owns a store, environment, history and a support index built from its own nodes, and checks the node registry against the versions captured in its ExecutionContext. - Ids: `generateId` (and so every schema default) reads a synchronous allocator scope, `withIdAllocator`; `effectIds(effectId)` mints `prefix_` + 16 base-36 characters of SHA-256(effectId:n), so recomputing an effect mints the same ids. - Reads: the scene's `DocumentReader` records each read as a Dependency: node fields, entity queries with their (possibly empty) results, ordered children where order is read, collection fields, materials, plugins; hosts declare file, artifact and scope dependencies. Views are read-only and per record version (identity-keyed memos stay correct); `untracked` marks copy-through, `materialize` hands results out of the scene. - Writes: `host()` applies an outcome as the editor's actions do and settles it with `reconcileDocument`, reading through the recorder; `execute(operation, input, context)` runs a shared operation end to end. While a host settles, the editor's own derived writers stand down (`runSettlingDocument`), so derived ids are minted once, in one order. - `patch()` is the field-level SceneOperationPatch since the snapshot: creates and deletes with their positions, changed and removed fields, materials, collections and plugin state, a parent's children only when reordered; it is checked by applying it to the base. - `dispose()` makes every later read or write throw, including from views and hosts captured earlier and from late async continuations. `liveSceneRuntime` is the editor's store behind the same interface. The comparison contract is tested over every shared-tool and add_object fixture (195 cases): from the same canonical snapshot, context, id seed and registry, the detached run and the live run (document systems mounted) give identical patches and results, nothing writes after the live call, and the detached patch applied to the editor lands on the same document with no further write. No fixture is excepted, including the duplicate_level gap case the live runners skipped. Co-Authored-By: Claude Opus 5.5 --- packages/core/package.json | 5 + packages/core/src/lib/space-detection.ts | 6 + .../src/runtime/__fixtures__/live-scene.ts | 69 +++ packages/core/src/runtime/dependencies.ts | 109 +++++ .../core/src/runtime/fixture-parity.test.ts | 167 +++++++ packages/core/src/runtime/ids.ts | 84 ++++ packages/core/src/runtime/index.ts | 30 ++ packages/core/src/runtime/node-queries.ts | 45 ++ packages/core/src/runtime/patch.ts | 184 ++++++++ packages/core/src/runtime/reader.ts | 442 ++++++++++++++++++ packages/core/src/runtime/scene-runtime.ts | 324 +++++++++++++ packages/core/src/schema/base.ts | 25 +- packages/core/src/store/document-settling.ts | 24 + packages/core/src/store/use-scene.ts | 14 +- .../core/src/systems/document-reconcile.ts | 23 +- .../elevator/elevator-opening-system.tsx | 3 +- .../systems/roof/roof-elevation-system.tsx | 3 +- .../systems/stair/stair-opening-system.tsx | 3 +- 18 files changed, 1542 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/runtime/__fixtures__/live-scene.ts create mode 100644 packages/core/src/runtime/dependencies.ts create mode 100644 packages/core/src/runtime/fixture-parity.test.ts create mode 100644 packages/core/src/runtime/ids.ts create mode 100644 packages/core/src/runtime/index.ts create mode 100644 packages/core/src/runtime/node-queries.ts create mode 100644 packages/core/src/runtime/patch.ts create mode 100644 packages/core/src/runtime/reader.ts create mode 100644 packages/core/src/runtime/scene-runtime.ts create mode 100644 packages/core/src/store/document-settling.ts diff --git a/packages/core/package.json b/packages/core/package.json index 6cc5dc56ca..60d4a7651c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -100,6 +100,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/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index c8eb8224ea..efe198fdf3 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, @@ -604,6 +605,11 @@ export function initSpaceDetectionSync( sceneStore.temporal.getState().isTracking === false ) return + // A settling host derives this edit's structure itself (reconcileDocument). + if (isSettlingDocument(sceneStore)) { + adoptSceneBaseline(state.nodes) + return + } const nodes = state.nodes const candidateIds = activeSceneCommitNodeIds() 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/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/fixture-parity.test.ts b/packages/core/src/runtime/fixture-parity.test.ts new file mode 100644 index 0000000000..f24656bc03 --- /dev/null +++ b/packages/core/src/runtime/fixture-parity.test.ts @@ -0,0 +1,167 @@ +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 EXCEPTIONS: Record = {} + +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..a64730c33c --- /dev/null +++ b/packages/core/src/runtime/index.ts @@ -0,0 +1,30 @@ +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, + type SceneOperationPatch, + type SceneStore, + sceneDocumentOf, +} from '../store/use-scene' +export { reconcileDocument } from '../systems/document-reconcile' +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..2568f2a12f --- /dev/null +++ b/packages/core/src/runtime/reader.ts @@ -0,0 +1,442 @@ +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() +const readerOfRecord = new WeakMap() + +/** + * The tracked reader behind a `nodes` record, when it is one (a detached scene's): helpers that + * receive `SceneNodes` query through it, so they record what they select rather than everything. + */ +export function readerOf(nodes: object): DocumentReader | undefined { + return readerOfRecord.get(nodes) +} + +/** 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) + 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 + } + const document = () => ({ + nodes: source.nodes() as Record, + collections: source.collections() as Record, + materials: source.materials() as Record, + rootNodeIds: source.rootNodeIds() as string[], + }) + const recordQuery = (query: QueryDependency['entity'], predicate: unknown): string[] => { + const resultIds = queryIds(document() 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, + enumerate: () => void, + ): Readonly> => { + let view = recordViews.get(raw) as Readonly> | undefined + if (!view) { + view = recordView(raw, assertActive, entity, enumerate) + recordViews.set(raw, view) + readerOfRecord.set(view, reader) + } + return view + } + + function query(query: EntityQuery): unknown[] { + assertActive() + const current = document() + switch (query.entity) { + case 'node': + return recordQuery('node', query.where ?? null).map((id) => + trackNode(id, current.nodes[id]), + ) + case 'collection': + return recordQuery('collection', query.where ?? null).map((id) => + trackCollection(id, current.collections[id]), + ) + case 'material': + return recordQuery('material', null).map((id) => current.materials[id]) + case 'root': + return recordQuery('root', null).flatMap((id) => trackNode(id, current.nodes[id]) ?? []) + } + } + + const reader: DocumentReader = { + get: (id) => { + assertActive() + return trackNode(id, source.nodes()[id]) + }, + query: query as DocumentReader['query'], + orderedChildren: (id) => { + assertActive() + const node = source.nodes()[id] + recorder.node(id) + const children = childIdsOf(node) + recorder.order(id, children) + return [...children] + }, + 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() + return viewOf(source.nodes(), trackNode, () => recordQuery('node', null)) as SceneNodes + }, + track: (raw) => { + assertActive() + return viewOf(raw, trackNode, () => recordQuery('node', null)) as SceneNodes + }, + get collections() { + assertActive() + return viewOf(source.collections(), trackCollection, () => recordQuery('collection', null)) + }, + } + 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..4743fe606a --- /dev/null +++ b/packages/core/src/runtime/scene-runtime.ts @@ -0,0 +1,324 @@ +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 { + createSnapshotSpatialGrid, + type SpatialGridManager, + 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 { runSettlingDocument } from '../store/document-settling' +import { + pauseSceneHistory, + resumeSceneHistory, + runAsSingleSceneHistoryStep, + type SceneSnapshot, +} from '../store/history-control' +import { createSceneEnvironment, type SceneEnvironment } from '../store/scene-environment' +import useScene, { createSceneStore, type SceneStore, sceneDocumentOf } from '../store/use-scene' +import { reconcileDocument } from '../systems/document-reconcile' +import type { Dependency } from './dependencies' +import { diffSceneDocuments } 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 +} + +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 +} + +/** Unwraps tracked views an operation put in its changes, so the store holds plain records. */ +function plainChanges(changes: SceneChanges): SceneChanges { + const plainData = (data: Partial) => + Object.fromEntries( + Object.entries(untracked(data)).map(([key, value]) => [key, untracked(value)]), + ) as Partial + return { + ...(changes.create && { + create: changes.create.map(({ node, parentId }) => ({ + node: untracked(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, untracked(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, () => + runAsSingleSceneHistoryStep(runtime.store, () => { + const before = nodes() + applySceneChangesToStore(runtime.store, changes) + runtime.reconcile(before) + }), + ), + ), + reconcile: () => + runtime.run(() => + runSettlingDocument(runtime.store, () => runtime.reconcile(nodes(), { allLevels: 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. + */ +function recordingSupportGrid( + store: SceneStore, + recorder: DependencyRecorder, +): () => SpatialGridManager { + let cached: { nodes: SceneNodes; grid: SpatialGridManager } | null = null + return () => { + const nodes = store.getState().nodes as SceneNodes + if (cached?.nodes !== nodes) { + const grid = createSnapshotSpatialGrid(() => store.getState().nodes, resolveLevelId) + const 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 [levelId] = args + if (typeof levelId === 'string' && store.getState().nodes[levelId as AnyNodeId]) + recorder.declare({ kind: 'scope', level: levelId }) + return value.apply(target, args) + } + }, + }) + cached = { nodes, grid: recording } + } + return cached.grid + } +} + +/** + * 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) + loadCanonicalSnapshot(store, snapshot) + 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, () => 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/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/use-scene.ts b/packages/core/src/store/use-scene.ts index 657fc03055..66617650bb 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -2070,8 +2070,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 & @@ -2373,7 +2381,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) diff --git a/packages/core/src/systems/document-reconcile.ts b/packages/core/src/systems/document-reconcile.ts index ec3ffac0c3..6f6fd404a0 100644 --- a/packages/core/src/systems/document-reconcile.ts +++ b/packages/core/src/systems/document-reconcile.ts @@ -57,7 +57,10 @@ export function changedInputs( } export type DocumentReconcileOptions = { - /** What the passes read: a detached scene passes its tracked view, so their reads are recorded. */ + /** + * 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 @@ -113,17 +116,17 @@ export function reconcileDocument( const previous = nodes() write(() => { store.getState().applyNodeChanges(structureChangeBatch(changes)) - reconcileOwnedFloorOpeningChanges(readingStore, previous, changes) + reconcileOwnedFloorOpeningChanges(readingStore, read(previous), changes) }) } withSpatialGrid(grid, () => { - const levelIds = options.allLevels ? undefined : changedStructureLevelIds(before, view()) + const levelIds = options.allLevels ? undefined : changedStructureLevelIds(before, nodes()) if (!levelIds || levelIds.length) write(() => applyStructureReconciliation(readingStore, { levelIds, - previousNodes: options.allLevels ? undefined : before, + previousNodes: options.allLevels ? undefined : read(before), mintId: generateId, }), ) @@ -140,13 +143,13 @@ export function reconcileDocument( 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: changedStairOpeningOwners( - from as Record, - view() as Record, - ), - }), + planOwnedFloorOpenings(view() as Record, { ownerIds: owners }), ) stairsFrom = nodes() } diff --git a/packages/core/src/systems/elevator/elevator-opening-system.tsx b/packages/core/src/systems/elevator/elevator-opening-system.tsx index ef818bcd74..e392a69c4a 100644 --- a/packages/core/src/systems/elevator/elevator-opening-system.tsx +++ b/packages/core/src/systems/elevator/elevator-opening-system.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { structureChangeBatch } from '../../commands/structure/shared' +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' @@ -30,7 +31,7 @@ export function initializeElevatorOpeningSync() { applyChanges(true) return useScene.subscribe((state, prevState) => { - if (syncingAutoOpenings) 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/roof/roof-elevation-system.tsx b/packages/core/src/systems/roof/roof-elevation-system.tsx index 4523545afe..f48dbd0c22 100644 --- a/packages/core/src/systems/roof/roof-elevation-system.tsx +++ b/packages/core/src/systems/roof/roof-elevation-system.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import type { AnyNodeId } from '../../schema' +import { isSettlingDocument } from '../../store/document-settling' import { getSceneHistoryPauseDepth, notifySceneCommit, @@ -58,7 +59,7 @@ 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] diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index 1644c2a639..727ad07b35 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -3,6 +3,7 @@ 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' @@ -129,7 +130,7 @@ export function initializeStairOpeningSync() { scheduleAutoSync(true) const unsubscribeScene = useScene.subscribe((state, prevState) => { - if (syncingAutoOpenings) return + if (syncingAutoOpenings || isSettlingDocument(useScene)) return if (!changedInputs(prevState.nodes, state.nodes, isStairOpeningInput)) return scheduleAutoSync( isHydrationNormalization() || state.hydrationId !== prevState.hydrationId, From 84d47ca84b90b30e7a492ece2edd858378e51c08 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:20:33 -0400 Subject: [PATCH 04/21] core: operations and reconcile passes read through tracked queries On a detached scene, an operation that enumerates `nodes` records a read of every node, so any node created meanwhile invalidates it. The shared operations and the helpers they and the document passes reach now select what they read: - `queryNodes(nodes, { type, parentId })` and `queryCollections(...)` record the selection (absent matches included) through the scene's reader, and filter plainly elsewhere: levels (`levelsOf`, storey elevations), buildings and sites (`add_level`), walls, columns and stairs counted for default names, a level's walls, rooms and children (`create_room`, `furnish_room`, `place_items`, floor fit), door keepouts, and the owners, stairs and roofs the opening, rise and roof passes plan for. - `fit_stair` shares risers out in flight order, so the sizing reads the stair's children through `orderedChildIds`, recording the order. - `edit_collection` looks a collection up by template or name as a collection query (so a collection created meanwhile with that name conflicts) and copies the fields it does not set through `untracked`, so a rename does not depend on the colour. Behaviour on the editor's store is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/agent-operations/add-column.ts | 5 ++--- packages/core/src/agent-operations/add-level.ts | 10 ++++------ packages/core/src/agent-operations/add-wall.ts | 4 ++-- packages/core/src/agent-operations/collections.ts | 14 ++++++++------ packages/core/src/agent-operations/create-room.ts | 8 +++----- .../core/src/agent-operations/create-stair.ts | 4 ++-- .../core/src/agent-operations/furnish-room.ts | 7 +++---- packages/core/src/agent-operations/place-items.ts | 14 ++++++++------ .../core/src/agent-operations/scene-queries.ts | 9 ++++++--- packages/core/src/building/floor-item-fit.ts | 9 +++------ packages/core/src/services/storey.ts | 15 ++++++++------- packages/core/src/systems/owned-floor-openings.ts | 7 +++++-- packages/core/src/systems/roof/roof-elevation.ts | 5 +++-- .../core/src/systems/stair/stair-rise-query.ts | 5 +++-- packages/core/src/systems/stair/stair-sizing.ts | 4 +++- 15 files changed, 63 insertions(+), 57 deletions(-) diff --git a/packages/core/src/agent-operations/add-column.ts b/packages/core/src/agent-operations/add-column.ts index 5550a64bb1..2668f4c901 100644 --- a/packages/core/src/agent-operations/add-column.ts +++ b/packages/core/src/agent-operations/add-column.ts @@ -11,6 +11,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> & { @@ -80,9 +81,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-level.ts b/packages/core/src/agent-operations/add-level.ts index eaa6c5f5c7..4e986d47b7 100644 --- a/packages/core/src/agent-operations/add-level.ts +++ b/packages/core/src/agent-operations/add-level.ts @@ -1,7 +1,7 @@ import { refuse } from '../agent-tools/refusal' import { BuildingNode, LevelNode, SiteNode } from '../schema' import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' -import { levelsOf } from './scene-queries' +import { levelsOf, queryNodes } from './scene-queries' import type { AgentContext, AgentOperation, SceneNodes } from './types' type AddLevelInput = { @@ -30,9 +30,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 to add a level to.') return refuse( @@ -47,7 +45,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: [] }) @@ -81,7 +79,7 @@ function freshStart(nodes: SceneNodes, input: AddLevelInput) { /** `add_level`: an empty level over the building's highest, or under its lowest — 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-wall.ts b/packages/core/src/agent-operations/add-wall.ts index 4a95d9a71c..28f36c9198 100644 --- a/packages/core/src/agent-operations/add-wall.ts +++ b/packages/core/src/agent-operations/add-wall.ts @@ -3,7 +3,7 @@ import { WallNode } from '../schema' import { normalizeWallCurveOffset } from '../systems/wall/wall-curve' import { WALL_MIN_LENGTH } from '../systems/wall/wall-topology' import { type LevelTargetInput, targetLevel } from './level-target' -import { levelRole } from './scene-queries' +import { levelRole, queryNodes } from './scene-queries' import type { AgentOperation, SceneNodes } from './types' type Pt = [number, number] @@ -42,7 +42,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/collections.ts b/packages/core/src/agent-operations/collections.ts index 512fbfdcf9..e4dc8d965f 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 } : {}), diff --git a/packages/core/src/agent-operations/create-room.ts b/packages/core/src/agent-operations/create-room.ts index 253c2e2445..30ba3feca2 100644 --- a/packages/core/src/agent-operations/create-room.ts +++ b/packages/core/src/agent-operations/create-room.ts @@ -6,7 +6,7 @@ import { type AnyNode, generateId, type WallNode } from '../schema' 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 = { @@ -56,9 +56,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) { @@ -88,7 +86,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 a3c355d6e4..2883ff67f4 100644 --- a/packages/core/src/agent-operations/create-stair.ts +++ b/packages/core/src/agent-operations/create-stair.ts @@ -11,7 +11,7 @@ import { applySceneChanges } from './apply-changes' import { type LevelTargetInput, requireLevel, targetLevel } from './level-target' import { finishSurface, requireMaterialRef } from './material-refs' import { pointInPolygon, type Vec2 } from './plan-geometry' -import { levelsOf } from './scene-queries' +import { levelsOf, queryNodes } from './scene-queries' import type { AgentOperation, SceneChanges } from './types' type CreateStairInput = LevelTargetInput & { @@ -158,7 +158,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' const draft = StairNode.parse({ parentId: from.id, diff --git a/packages/core/src/agent-operations/furnish-room.ts b/packages/core/src/agent-operations/furnish-room.ts index db78921c8e..0827a2d47d 100644 --- a/packages/core/src/agent-operations/furnish-room.ts +++ b/packages/core/src/agent-operations/furnish-room.ts @@ -1,6 +1,6 @@ import type { FURNISHED_ROOM_TYPES } from '../agent-tools/furnish-room' import { refuse } from '../agent-tools/refusal' -import { type AnyNode, type AssetInput, ItemNode } from '../schema' +import { type AnyNode, type AssetInput, ItemNode, type WallNode } from '../schema' import { edgeProjection } from './create-room' import { collectDoorKeepouts, @@ -13,7 +13,7 @@ import { import { collectOccupiedFootprints, findValidPlacement } 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] @@ -58,8 +58,7 @@ function roomToFurnish(nodes: SceneNodes, input: FurnishRoomInput, context: Agen /** 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) { diff --git a/packages/core/src/agent-operations/place-items.ts b/packages/core/src/agent-operations/place-items.ts index d3f80a30c6..54d69b9ee2 100644 --- a/packages/core/src/agent-operations/place-items.ts +++ b/packages/core/src/agent-operations/place-items.ts @@ -6,7 +6,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' @@ -16,6 +16,7 @@ import { type Vec2, wallLength, } from './plan-geometry' +import { queryNodes } from './scene-queries' import type { AgentOperation, SceneChanges, SceneNodes } from './types' type Entry = { @@ -55,10 +56,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[]] : [], ) } @@ -292,7 +291,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.`, ) - 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-queries.ts b/packages/core/src/agent-operations/scene-queries.ts index 942bd465df..c598832b1c 100644 --- a/packages/core/src/agent-operations/scene-queries.ts +++ b/packages/core/src/agent-operations/scene-queries.ts @@ -1,6 +1,9 @@ +import { queryNodes } from '../runtime/node-queries' import type { AnyNode } from '../schema' import type { SceneNodes } from './types' +export { orderedChildIds, queryNodes } from '../runtime/node-queries' + export type LevelRole = 'occupied' | 'roof' | 'support' export type ContentCounts = { walls: number @@ -21,9 +24,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/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/services/storey.ts b/packages/core/src/services/storey.ts index 60ce246204..3432157c2e 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 { @@ -70,7 +71,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 }) @@ -85,14 +90,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/systems/owned-floor-openings.ts b/packages/core/src/systems/owned-floor-openings.ts index e3235b470c..fb648bcad7 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' @@ -118,9 +119,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' || @@ -128,6 +130,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/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts index 820d0b560d..d297da1aa5 100644 --- a/packages/core/src/systems/roof/roof-elevation.ts +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -3,6 +3,7 @@ import { getWallEffectiveHeightForNodes, } from '../../hooks/spatial-grid/spatial-grid-manager' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import { queryNodes } from '../../runtime/node-queries' import type { AnyNode, AnyNodeId, @@ -144,8 +145,8 @@ export function planRoofElevationUpdates( nodes: Readonly>, ): Array<{ id: AnyNodeId; data: Partial }> { const updates: Array<{ id: AnyNodeId; data: Partial }> = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'roof' || node.support?.kind !== 'walls') continue + for (const node of queryNodes(nodes, { type: 'roof' })) { + if (node.support?.kind !== 'walls') continue const elevation = resolveRoofElevation(node, nodes) if (Math.abs(node.position[1] - elevation) <= ROOF_ELEVATION_EPSILON) continue updates.push({ diff --git a/packages/core/src/systems/stair/stair-rise-query.ts b/packages/core/src/systems/stair/stair-rise-query.ts index 2f36e9c964..bc11b6d443 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, StairNode, StairSegmentNode } from '../../schema' import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height' import { @@ -116,8 +117,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-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])) From 2eecf1e1695756a569f0bc669898c5752f593029 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:20:43 -0400 Subject: [PATCH 05/21] core/runtime: applyPatchWithDependencies Applies a patch computed on a detached scene to the scene as it is now, or says what changed meanwhile so it cannot be: - every dependency is compared between the computation's base and the current document: node fields and presence, entity-query membership (absent matches included), ordered children, collection fields, materials, plugins, scopes (a level, or the whole document), and files and artifacts through host lookups; - every field the patch writes must not have been changed meanwhile to another value (node fields, collection fields, materials, plugin state); a deleted node must be as it was; - otherwise the patch lands field by field: fields others changed on the same node or collection are kept, and children created independently under the same parent each keep their place after the sibling they followed. Only that parent-list bookkeeping is exempt; a list whose order was read is a dependency. Returns the next document and the patch rebased onto it, or the changed entities (kind, id, field, reason). Tests cover isolation (two scenes and the editor never see each other's writes, commits, history or subscribers), per-scene undo, disposal (including a late async write), deterministic ids, recorder cases (fields vs copy-through, absent query matches, order), the conflict table (stair flights reordered while re-sized, independent insertions on one level, rename beside a recolour, two renames, a collection created by name, two levels added, field merge and clash) and roof reconciliation parity: a detached wall edit carries the roof it supports, and the editor writes nothing after the patch, while the same patch without the roof update draws the roof system's own extra commit. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/runtime/apply.ts | 348 +++++++++++++ .../core/src/runtime/detached-scene.test.ts | 481 ++++++++++++++++++ packages/core/src/runtime/index.ts | 1 + 3 files changed, 830 insertions(+) create mode 100644 packages/core/src/runtime/apply.ts create mode 100644 packages/core/src/runtime/detached-scene.test.ts diff --git a/packages/core/src/runtime/apply.ts b/packages/core/src/runtime/apply.ts new file mode 100644 index 0000000000..aa74feb982 --- /dev/null +++ b/packages/core/src/runtime/apply.ts @@ -0,0 +1,348 @@ +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 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 + const touched = changedIds().some( + (id) => + id === level || 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/detached-scene.test.ts b/packages/core/src/runtime/detached-scene.test.ts new file mode 100644 index 0000000000..c3fe9e5110 --- /dev/null +++ b/packages/core/src/runtime/detached-scene.test.ts @@ -0,0 +1,481 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { AGENT_OPERATIONS, applyAgentOutcome, editCollection } from '../agent-operations' +import { AGENT_TOOL_CASES } from '../agent-operations/__fixtures__/cases' +import { AGENT_TOOL_CONTRACTS } from '../agent-tools' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + type Collection, + ItemNode, + LevelNode, + RoofNode, + StairNode, + StairSegmentNode, + WallNode, +} from '../schema' +import { type SceneCommit, subscribeSceneCommits } from '../store/history-control' +import useScene, { + applySceneOperationPatch, + clearSceneHistory, + getSceneDocument, +} from '../store/use-scene' +import { closeLiveScene, openLiveScene, settle, snapshotOf } from './__fixtures__/live-scene' +import { + applyPatchWithDependencies, + captureRegistryVersions, + createSceneEnvironment, + createSceneStore, + type DetachedScene, + effectIds, + loadCanonicalSnapshot, + openDetachedScene, + 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(): 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], + ] + 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_r', + 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_r', children: [level.id, upper.id] }) + return document(building, level, upper, ...walls, roof) + } + + 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) + }) +}) diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts index a64730c33c..4ec5e42e2d 100644 --- a/packages/core/src/runtime/index.ts +++ b/packages/core/src/runtime/index.ts @@ -14,6 +14,7 @@ export { 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' From 6556c9c930e6fb426a2f6b4ce1a50a404dc75dfb Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:21:38 -0400 Subject: [PATCH 06/21] lint: shared operations select what they read A Grit rule (Biome plugin) on `packages/core/src/agent-operations` refuses `Object.values / keys / entries` of a `nodes` or collections record: on a detached scene that reads every entity, so anything created meanwhile conflicts. Operations select through `queryNodes` / `queryCollections`. The read-only operations that genuinely read the whole scene (verify_scene, find_by_type, scene views, furnish_room's keep-outs) say so in a biome-ignore comment; list_collections now lists through a collection query. Co-Authored-By: Claude Opus 5.5 --- biome.jsonc | 5 +++++ packages/core/src/agent-operations/collections.ts | 2 +- .../core/src/agent-operations/find-by-type.ts | 1 + .../core/src/agent-operations/furnish-room.ts | 1 + packages/core/src/agent-operations/scene-view.ts | 1 + .../core/src/agent-operations/verify-scene.ts | 5 +++++ scripts/biome/raw-scene-enumeration.grit | 15 +++++++++++++++ 7 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 scripts/biome/raw-scene-enumeration.grit diff --git a/biome.jsonc b/biome.jsonc index aa4381846b..32a650ce48 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -274,6 +274,11 @@ } } }, + // E-017: 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/src/agent-operations/collections.ts b/packages/core/src/agent-operations/collections.ts index e4dc8d965f..dde2d6b23b 100644 --- a/packages/core/src/agent-operations/collections.ts +++ b/packages/core/src/agent-operations/collections.ts @@ -165,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/find-by-type.ts b/packages/core/src/agent-operations/find-by-type.ts index e3fc9012a7..baa50ee784 100644 --- a/packages/core/src/agent-operations/find-by-type.ts +++ b/packages/core/src/agent-operations/find-by-type.ts @@ -56,6 +56,7 @@ export const findByType: AgentOperation<{ type: string; levelId?: string }> = ( if (levelId) requireLevel(nodes, levelId) const word = type.trim().toLowerCase().replace(/s$/, '') const results: Record[] = [] + // biome-ignore lint/plugin: any node, or any typed part of an authored object, may match for (const node of Object.values(nodes)) { const level = levelIdOf(nodes, node.id) if (levelId && level !== levelId) continue diff --git a/packages/core/src/agent-operations/furnish-room.ts b/packages/core/src/agent-operations/furnish-room.ts index 0827a2d47d..8e9ccec0b3 100644 --- a/packages/core/src/agent-operations/furnish-room.ts +++ b/packages/core/src/agent-operations/furnish-room.ts @@ -252,6 +252,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/scene-view.ts b/packages/core/src/agent-operations/scene-view.ts index 4f6d3ced9c..31c1acb679 100644 --- a/packages/core/src/agent-operations/scene-view.ts +++ b/packages/core/src/agent-operations/scene-view.ts @@ -319,6 +319,7 @@ export function sceneViewBounds( // With no walls yet, what the level holds frames it: furniture placed from a plan before the // walls are built, a site with its paving and fences. const held: SceneViewBox[] = [] + // 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/verify-scene.ts b/packages/core/src/agent-operations/verify-scene.ts index 9ca3ed26a3..387254ba8d 100644 --- a/packages/core/src/agent-operations/verify-scene.ts +++ b/packages/core/src/agent-operations/verify-scene.ts @@ -197,6 +197,7 @@ function parentListsChild(parent: AnyNode, childId: string): boolean { function schemaErrors(nodes: SceneNodes) { const errors: { nodeId: string; path: string; message: string }[] = [] + // biome-ignore lint/plugin: verify_scene reads the whole scene for (const [id, node] of Object.entries(nodes)) { const parsed = AnyNodeSchema.safeParse(node) if (parsed.success) continue @@ -339,6 +340,7 @@ export const verifyScene: AgentOperation = ( } } + // biome-ignore lint/plugin: verify_scene reads the whole scene for (const node of Object.values(nodes)) { if (node.type !== 'door' && node.type !== 'window') continue const parent = node.parentId ? nodes[node.parentId] : undefined @@ -363,6 +365,7 @@ export const verifyScene: AgentOperation = ( ) } + // biome-ignore lint/plugin: verify_scene reads the whole scene for (const stair of Object.values(nodes).filter( (node): node is StairNode => node.type === 'stair', )) { @@ -460,6 +463,7 @@ export const verifyScene: AgentOperation = ( report('schema_invalid', `Schema: ${errors.length - 5} additional validation errors`) // Door keep-outs and item–item footprint overlaps (rotation-aware). + // biome-ignore lint/plugin: verify_scene reads the whole scene issues.push(...layoutIssuesFromScene(Object.values(nodes))) for (const check of checks) if (check.order < CHECKPOINT_ORDER) issues.push(...check.run(nodes, input ?? {})) @@ -496,6 +500,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/scripts/biome/raw-scene-enumeration.grit b/scripts/biome/raw-scene-enumeration.grit new file mode 100644 index 0000000000..fff6fe72d9 --- /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-017). 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-017.", + severity = "error" + ) +} From b9e5ad4ad817584c09ab7e79bab917b60a05955d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:28:07 -0400 Subject: [PATCH 07/21] core: document reconciliation runs on headless hosts Two things kept reconcileDocument from running where no editor (and no built-in node plugin) is loaded, such as a bare MCP server: - the structure signature formatted a wall's height with `toFixed`, so a legacy node with a non-numeric height threw; it now signs it as is; - stair rise sync elected the stair's support through its registered floor-placed footprint, so without the built-in stair kind every stair rose from the level base; it now falls back to the stair's own support query (`stairBaseElevation`) there. A record view's raw record is reachable through `untracked`, so copying a whole record through (as a write-back or a before-state) is not a read. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/lib/space-detection.ts | 7 ++++++- packages/core/src/runtime/reader.ts | 1 + packages/core/src/systems/stair/stair-rise.ts | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index efe198fdf3..97b06ae403 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -95,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), diff --git a/packages/core/src/runtime/reader.ts b/packages/core/src/runtime/reader.ts index 2568f2a12f..6a90beb951 100644 --- a/packages/core/src/runtime/reader.ts +++ b/packages/core/src/runtime/reader.ts @@ -326,6 +326,7 @@ export function createDocumentReader( view = recordView(raw, assertActive, entity, enumerate) recordViews.set(raw, view) readerOfRecord.set(view, reader) + rawOfView.set(view, raw) } return view } 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, From ce4f3f571088c8725f32e133b231d3b67fd5d6aa Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:28:07 -0400 Subject: [PATCH 08/21] mcp: SceneBridge over a scene runtime `new SceneBridge({ runtime })` binds the bridge to a runtime from `@pascal-app/core/runtime`: the editor's store by default (unchanged for the MCP server), or a detached scene a host opened. Core does not import the MCP; the host constructs the bridge. The bridge now reads through the runtime's reader (recorded on a detached scene; `findNodes` selects through a query), and every write, undo and derivation runs in the runtime's scope: its history environment, its id allocator, its support index and its disposal guard. After each edit it settles the document with the runtime's `reconcile`, the core `reconcileDocument`, instead of its own partial copy (owned openings and level structure only): stair rises and roof elevation now settle in the MCP as they do in the editor, so a stair built between two storeys reaches the upper one and verify_scene no longer reports it. The shared-tool runner and edit_collection plan their operations inside the scene's scope (`SceneOperations.runInScene`), so their ids come from the runtime's allocator, and copy records through `untracked`, so the operation's own selections are what a detached scene records. Co-Authored-By: Claude Opus 5.5 --- .../mcp/src/bridge/detached-bridge.test.ts | 84 +++++ packages/mcp/src/bridge/scene-bridge.ts | 309 ++++++++---------- .../mcp/src/operations/scene-operations.ts | 6 + packages/mcp/src/tools/collections.ts | 6 +- packages/mcp/src/tools/scene-query.test.ts | 4 +- packages/mcp/src/tools/shared-tools.ts | 9 +- 6 files changed, 237 insertions(+), 181 deletions(-) create mode 100644 packages/mcp/src/bridge/detached-bridge.test.ts 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..ef91acf9a0 --- /dev/null +++ b/packages/mcp/src/bridge/detached-bridge.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + captureRegistryVersions, + effectIds, + openDetachedScene, + SceneEnvironmentDisposedError, + type SceneSnapshot, +} from '@pascal-app/core/runtime' +import { type AnyNode, BuildingNode, LevelNode } from '@pascal-app/core/schema' +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 +} + +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() + }) +}) diff --git a/packages/mcp/src/bridge/scene-bridge.ts b/packages/mcp/src/bridge/scene-bridge.ts index ae1ae67866..4e63ac76b7 100644 --- a/packages/mcp/src/bridge/scene-bridge.ts +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -1,26 +1,22 @@ // 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 { liveSceneRuntime, type SceneRuntime } from '@pascal-app/core/runtime' import type { AnyNode, Collection, CollectionId } from '@pascal-app/core/schema' import { type AnyNodeId, @@ -29,8 +25,6 @@ import { generateId, parseNode, } 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' export type ValidationError = { nodeId: string; path: string; message: string } @@ -51,17 +45,32 @@ 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 { 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) + } /** * Scene identity currently bound to this bridge. MCP tools use this to know @@ -89,7 +98,7 @@ export class SceneBridge { /** Load initial state; if empty, creates default Site → Building → Level. */ loadDefault(): void { - useScene.getState().loadScene() + this.write(() => this.store.getState().loadScene()) } /** Replace entire scene (undoable via Zundo). */ @@ -98,18 +107,19 @@ export class SceneBridge { rootNodeIds: AnyNodeId[], extra?: SetSceneExtra, ): void { - useScene.getState().setScene(nodes, rootNodeIds, extra) + this.write(() => this.store.getState().setScene(nodes, rootNodeIds, 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() // Deep-clone so callers can't mutate store state directly. return JSON.parse( JSON.stringify({ - nodes: state.nodes, - rootNodeIds: state.rootNodeIds, - collections: state.collections ?? {}, + nodes: reader.nodes, + rootNodeIds: this.getRootNodeIds(), + collections: reader.collections ?? {}, materials: state.materials ?? {}, ...(state.hasExplicitPluginInstallState || state.installedPlugins.length > 0 ? { installedPlugins: state.installedPlugins } @@ -181,27 +191,31 @@ 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 } setCollections(collections: Record): void { - if (useScene.getState().readOnly) return - useScene.setState({ collections }) + this.write(() => { + if (this.store.getState().readOnly) return + this.store.setState({ collections }) + }) } /** Root node IDs. */ getRootNodeIds(): AnyNodeId[] { - return useScene.getState().rootNodeIds + return this.runtime + .reader() + .query({ entity: 'root' }) + .map((node) => node.id as AnyNodeId) } /** @@ -220,7 +234,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() @@ -265,7 +279,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() @@ -291,14 +305,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 } @@ -322,25 +338,31 @@ export class SceneBridge { * 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) + this.write(() => { + const before = this.store.getState().nodes + assertDerivedNodeWrites(this.getNodes(), { + create: [{ node: parentId ? { ...node, parentId } : node }], + }) + runAsSingleSceneHistoryStep(this.store, () => { + this.store.getState().createNode(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]) { + 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 }] }) + runAsSingleSceneHistoryStep(this.store, () => { + this.store.getState().updateNode(id, data) + this.runtime.reconcile(before) + }) }) } @@ -352,8 +374,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}`) } @@ -365,21 +387,15 @@ 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(() => + runAsSingleSceneHistoryStep(this.store, () => { + this.store.getState().deleteNode(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) @@ -397,8 +413,16 @@ export class SceneBridge { deletedIds: AnyNodeId[] createdIds: AnyNodeId[] } { - const state = useScene.getState() - const nodes = state.nodes + return this.write(() => this.applyPatchInScope(patches)) + } + + private applyPatchInScope(patches: Patch[]): { + appliedOps: number + deletedIds: AnyNodeId[] + createdIds: AnyNodeId[] + } { + const nodes = this.getNodes() + const beforeNodes = this.store.getState().nodes // Track synthesized state as we dry-run so later ops can reference // earlier-created ids and reflect earlier-deleted ids. @@ -501,34 +525,25 @@ export class SceneBridge { ) 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, () => { + runAsSingleSceneHistoryStep(this.store, () => { pauseSpaceDetection() try { - useScene.getState().createNodes([...createOps.values(), ...planned.create]) - useScene.getState().updateNodes([...updateOps].map(([id, data]) => ({ id, data }))) - useScene.getState().deleteNodes(planned.delete) + 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) + 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) } @@ -559,9 +574,20 @@ 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(() => 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), }) @@ -572,24 +598,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) + }) } /** @@ -599,7 +627,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 }) @@ -622,7 +650,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) @@ -632,7 +660,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, @@ -641,85 +669,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(() => runAsSingleSceneHistoryStep(this.store, run)) } // ---- internal helpers ---- @@ -730,7 +689,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[]) { @@ -756,7 +715,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 0d581431f0..42f3546b5d 100644 --- a/packages/mcp/src/operations/scene-operations.ts +++ b/packages/mcp/src/operations/scene-operations.ts @@ -81,6 +81,8 @@ export interface SceneOperations { getHistory(): { pastCount: number; futureCount: number } clearHistory(): void runAsSingleHistoryStep(run: () => T): T + /** Runs `run` in the scene's scope (its ids, history environment and disposal guard). */ + runInScene(run: () => T): T createProject(options: ProjectCreateOptions): Promise getProjectStatus(id: string): Promise @@ -284,6 +286,10 @@ class SceneOperationsFacade implements SceneOperations { return this.requireBridge().runAsSingleHistoryStep(run) } + runInScene(run: () => T): T { + return this.requireBridge().runInScene(run) + } + async createProject(options: ProjectCreateOptions): Promise { const store = this.requireStore() if (!store.createProject) { diff --git a/packages/mcp/src/tools/collections.ts b/packages/mcp/src/tools/collections.ts index 6f71cafc23..9c3902cea1 100644 --- a/packages/mcp/src/tools/collections.ts +++ b/packages/mcp/src/tools/collections.ts @@ -8,6 +8,7 @@ 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' @@ -54,7 +55,7 @@ export function registerCollectionTools(server: McpServer, bridge: SceneOperatio async (input: EditCollectionInput) => { let outcome: AgentOperationOutcome try { - outcome = editCollection(sceneOf(bridge), input) + outcome = bridge.runInScene(() => editCollection(sceneOf(bridge), input)) } catch (error) { return refusalResult(error) } @@ -64,7 +65,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/scene-query.test.ts b/packages/mcp/src/tools/scene-query.test.ts index 935235435b..acd27cb984 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/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index 4442b63c54..8b3cd0be88 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -30,6 +30,7 @@ import { searchAssetsTool, verifySceneTool, } from '@pascal-app/core/agent-tools' +import { untracked } from '@pascal-app/core/runtime' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { Patch } from '../bridge/scene-bridge' @@ -376,15 +377,17 @@ 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) } const context = { activeLevelId: null, ...(tool.catalog && { catalog: await catalog() }), } try { - outcome = tool.operation(before, input as never, context) + outcome = bridge.runInScene(() => tool.operation(nodes, input as never, context)) } catch (error) { return refusalResult(error) } From 938bc78b5330a3f5d63c9e19a8e3572e129f9210 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:31:15 -0400 Subject: [PATCH 09/21] editor: the history stair case stands off the slab The standalone history case mounts the stair opening system without the built-in stair kind. Its stair stood over the baseline slab, and the flight rose from the level base only because, unregistered, nothing elected the slab. Now that rise sync falls back to the stair's own support query there (as the registered kind elects it), the stair is placed off the slab so the case keeps testing what it tests: history restoring derived flight heights. Co-Authored-By: Claude Opus 5.5 --- packages/editor/src/lib/history.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts index 785bbe08ba..d706c34648 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -483,7 +483,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() From 3f775721ce1b499f4f041a8abd05ff711168ba1f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:31:15 -0400 Subject: [PATCH 10/21] editor: geometry-script compiles take captured inputs `compileAndStoreGeometryScript` read the live scene and minted a new object's id after awaiting the compile. On a detached scene that bypasses its reader and its synchronous id scope, and a compile resolving after the scene is disposed would read the editor's store instead. It now takes the scene's nodes and the artifact service to use (the editor's by default) and, before its first await, reads the node it rebuilds, its stored script hash and its metadata, and mints the new object's id. A compile for a detached scene called inside `scene.run` records those reads there and allocates the id from the effect's ids. Co-Authored-By: Claude Opus 5.5 --- .../src/lib/geometry-script/author.test.ts | 113 ++++++++++++++++++ .../editor/src/lib/geometry-script/author.ts | 81 +++++++++---- 2 files changed, 172 insertions(+), 22 deletions(-) create mode 100644 packages/editor/src/lib/geometry-script/author.test.ts 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..fb698339dd --- /dev/null +++ b/packages/editor/src/lib/geometry-script/author.test.ts @@ -0,0 +1,113 @@ +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), + 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 4f49c8cd21..f8332c6b1c 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_TYPE, GeometryArtifactMetadata, @@ -21,58 +23,93 @@ import { } from '@pascal-app/core/agent-operations' import { compileGeometryScriptInWorker } from './client' +/** + * 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 in the * worker 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 - nodeId?: string - params?: Record - kind?: 'item' | 'door' | 'window' | 'column' - 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 + nodeId?: string + params?: Record + kind?: 'item' | 'door' | 'window' | 'column' + 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 params = editedScriptParams(node, input.params) - const { glb, ...compiled } = await compileGeometryScriptInWorker({ code, params }) const kind = input.kind ?? (node?.type === 'door' || node?.type === 'window' || node?.type === 'column' ? 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 code = input.code ?? (await readScript(artifacts, script!, input.nodeId!)) + const { glb, ...compiled } = await compileGeometryScriptInWorker({ code, params }) const metadata = GeometryArtifactMetadata.parse({ - ...(isScriptedNode(node) ? scriptedObjectMeta(node) : {}), + ...authored, ...input.provenance, ...GeometryReuseFields.parse(input), kind, mount: compiled.mount, }) const context = { nodeId, metadata } - const store = getArtifactStore() const [sha256] = await Promise.all([ - store.put(compiled.sha256, glb, 'model/gltf-binary', context), - store.put(compiled.script, new TextEncoder().encode(code), GEOMETRY_SCRIPT_MIME_TYPE, context), + artifacts.put(compiled.sha256, glb, 'model/gltf-binary', context), + artifacts.put( + compiled.script, + new TextEncoder().encode(code), + GEOMETRY_SCRIPT_MIME_TYPE, + context, + ), ]) 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() /** From f646e323931705a1380a1b1d9ad9724c975bc5fe Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 00:32:39 -0400 Subject: [PATCH 11/21] docs: the scene runtime, and E-017 - `wiki/architecture/scene-runtime.md`: per-store history environments, detached scenes (canonical loading, scope, ids, patch), tracked reads and their rules, complete reconciliation, applyPatchWithDependencies, the comparison contract, and the MCP bridge over a runtime. - agent-surfaces: operations select what they read; computing off the live scene on both surfaces. - systems, vertical-model: reconcileDocument is the synchronous form of the stair, elevator and roof systems' writes; the headless stair rise. - DECISIONS.md E-017: agent computation runs on a scene runtime, with what enforces it. - review-architecture reads the new page for diffs to operations, stores, derived writers and the runtime; the MCP README shows the bridge over a detached scene. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/review-architecture/SKILL.md | 1 + DECISIONS.md | 2 + packages/mcp/README.md | 3 + wiki/architecture/README.md | 1 + wiki/architecture/agent-surfaces.md | 11 ++- wiki/architecture/scene-runtime.md | 86 +++++++++++++++++++++ wiki/architecture/systems.md | 2 + wiki/architecture/vertical-model.md | 4 +- 8 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 wiki/architecture/scene-runtime.md 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 66244949ef..da02a0f2c7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -67,6 +67,8 @@ Each entry: decision · why · enforced by · revisit when. Detail and examples **E-016 Plugins own their agent tools (owner, 2026-10-10).** A plugin's agent tools ship with the plugin, not in core: contracts as a `PluginAgentTools` in its `./agent-tools` export (zod only, like `core/agent-tools`, and versioned by the plugin), pure operations as an `AgentOperations` in `./agent-operations`, browser execution and cards as a `PluginChatExtension`. The `Plugin` manifest does not change (E-014), and one contract still serves both surfaces. Contract annotations are hints: the host decides which tools are offered, to whom, and with what authority. These are reviewed, trusted contributions, not a sandbox. · Why: a plugin with dozens of specialist tools keeps them versioned with its own code, and installing it extends every agent the way a connector does. · Enforced: `core/src/agent-tools/contracts-purity.test.ts` (the rule each plugin entry follows), `mcp/src/tools/shared-tools.test.ts` (a plugin's operation tool through a real client), the hosted parity test. · See `plugin-authoring.md`, `agent-surfaces.md`. · Revisit: when plugins are loaded without review. +**E-017 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/packages/mcp/README.md b/packages/mcp/README.md index a78eb5a0e0..c32f9d6693 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -234,6 +234,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 }) diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index 88b7ec9f5a..dcf5f8c118 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -15,6 +15,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 | | [interaction-scope](interaction-scope.md) | The authoritative interaction state machine ("the spine"): `InteractionScope` union, the begin/update/end/endIf contract, the raycast hot-set, and the overlay scope matrix | diff --git a/wiki/architecture/agent-surfaces.md b/wiki/architecture/agent-surfaces.md index 0d7fbc26de..fd2b36f47a 100644 --- a/wiki/architecture/agent-surfaces.md +++ b/wiki/architecture/agent-surfaces.md @@ -34,7 +34,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. -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. @@ -59,6 +59,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..da7870a2e6 --- /dev/null +++ b/wiki/architecture/scene-runtime.md @@ -0,0 +1,86 @@ +# 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-017). + +## 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. +- **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 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. +- **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. + +Support queries read a spatial index built from the store's own nodes, never 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. + +## 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. diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index a4f13c808d..03abc96404 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 d003fc4641..42ae4edd15 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -254,10 +254,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. From b63e07bb3f5fc1bdae1d2a56708b1bb44e162992 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 01:26:20 -0400 Subject: [PATCH 12/21] core/runtime: complete commits, owned data and complete dependencies Follow-ups from review of the detached runtime: - A host's commit carries what it settled. `runAsSettledSceneStep(store, run)` makes one undo step and one commit of everything `run` changed: its own writes and the history-paused derived writes reconciliation makes after them (`settlePendingSceneCommit`), with the derived ids among the changed ids. Before, an agent host's commit was captured at its first write: raising a wall moved its roof, but the commit carried the old Y and only the wall. `createAgentHost` writes through it. - Live previews stay out of computations. Inside a detached scene's scope and inside `reconcileDocument`, `withoutLivePreviews` hides field overrides and an in-flight terrain stroke (`terrainFieldOf`, `getEffectiveNode`): an uncommitted stroke raised a detached roof. - A detached scene owns its data: it loads a deep-frozen copy of the snapshot and freezes what its writes produce, so a nested array reached through its reader can no longer change the caller's snapshot, the editor's store, or its own patch baseline. - Writes the store's actions plan beyond the changes asked for (default gutters, merged walls, sanitised values) read the scene directly; they now declare a scope dependency on every level they and the changes touch (`declareActionDependencies`). `SceneRuntime.declare` is optional: the editor's store records nothing. - A level scope also covers the level's building and site: committed terrain under a level now conflicts with a support read on it. - Materials returned by a material query are recorded one by one. - Selections over an earlier version of the nodes record (`queryNodes`, `orderedChildIds` on a view) read that version, not the current one. Each comes with a test that fails without it. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/lib/terrain-source.ts | 3 +- packages/core/src/runtime/apply.ts | 18 +- .../core/src/runtime/detached-scene.test.ts | 187 +++++++++++++++++- packages/core/src/runtime/index.ts | 1 + packages/core/src/runtime/reader.ts | 86 +++++--- packages/core/src/runtime/scene-runtime.ts | 89 +++++++-- packages/core/src/store/history-control.ts | 20 ++ packages/core/src/store/live-previews.ts | 20 ++ .../core/src/store/use-live-node-overrides.ts | 2 + packages/core/src/store/use-scene.ts | 34 ++++ .../core/src/systems/document-reconcile.ts | 89 +++++---- 11 files changed, 457 insertions(+), 92 deletions(-) create mode 100644 packages/core/src/store/live-previews.ts 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/apply.ts b/packages/core/src/runtime/apply.ts index aa74feb982..8d909537d8 100644 --- a/packages/core/src/runtime/apply.ts +++ b/packages/core/src/runtime/apply.ts @@ -59,6 +59,16 @@ function changedNodeIds(base: Doc, current: Doc): string[] { 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 || @@ -138,9 +148,15 @@ function dependencyChanges( 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 || levelOf(base.nodes, id) === level || levelOf(current.nodes, id) === level, + id === level || + ancestors.has(id) || + levelOf(base.nodes, id) === level || + levelOf(current.nodes, id) === level, ) return touched ? changed({ kind: 'scope', id: level }) : [] } diff --git a/packages/core/src/runtime/detached-scene.test.ts b/packages/core/src/runtime/detached-scene.test.ts index c3fe9e5110..16a87b2122 100644 --- a/packages/core/src/runtime/detached-scene.test.ts +++ b/packages/core/src/runtime/detached-scene.test.ts @@ -3,6 +3,8 @@ import { z } from 'zod' import { AGENT_OPERATIONS, applyAgentOutcome, editCollection } from '../agent-operations' import { AGENT_TOOL_CASES } from '../agent-operations/__fixtures__/cases' import { AGENT_TOOL_CONTRACTS } from '../agent-tools' +import { encodeTerrainField } from '../lib/terrain-codec' +import { applyHeightPatch, createTerrainField, flattenPatch } from '../lib/terrain-field' import { type AnyNode, type AnyNodeId, @@ -11,11 +13,15 @@ import { ItemNode, LevelNode, RoofNode, + RoofSegmentNode, + SiteNode, StairNode, StairSegmentNode, WallNode, } 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, @@ -25,12 +31,16 @@ import { closeLiveScene, openLiveScene, settle, snapshotOf } from './__fixtures_ import { applyPatchWithDependencies, captureRegistryVersions, + createAgentHost, createSceneEnvironment, createSceneStore, type DetachedScene, effectIds, + isEmptyScenePatch, + liveSceneRuntime, loadCanonicalSnapshot, openDetachedScene, + orderedChildIds, queryNodes, SceneEnvironmentDisposedError, type SceneSnapshot, @@ -409,7 +419,7 @@ describe('reconciliation', () => { afterEach(closeLiveScene) /** Walls under a wall-supported roof one storey up, from the roof system's own case. */ - function roofScene(): SceneSnapshot { + function roofScene(site?: { terrain?: unknown }, open = false): SceneSnapshot { const level = LevelNode.parse({ id: 'level_walls', parentId: 'building_r', @@ -423,7 +433,8 @@ describe('reconciliation', () => { [4, 3], [0, 3], ] - const walls = corners.map((start, index) => + // 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, @@ -432,18 +443,107 @@ describe('reconciliation', () => { 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] - const building = BuildingNode.parse({ id: 'building_r', children: [level.id, upper.id] }) - return document(building, level, upper, ...walls, roof) + 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 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("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: 'scope', + id: 'level_walls', + 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) @@ -479,3 +579,82 @@ describe('reconciliation', () => { ).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('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', + }) + }) +}) diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts index 4ec5e42e2d..402cae5842 100644 --- a/packages/core/src/runtime/index.ts +++ b/packages/core/src/runtime/index.ts @@ -9,6 +9,7 @@ export { export { createSceneStore, nextSceneOperationState, + runAsSettledSceneStep, type SceneOperationPatch, type SceneStore, sceneDocumentOf, diff --git a/packages/core/src/runtime/reader.ts b/packages/core/src/runtime/reader.ts index 6a90beb951..0f0f0cdcb6 100644 --- a/packages/core/src/runtime/reader.ts +++ b/packages/core/src/runtime/reader.ts @@ -124,14 +124,18 @@ export class DependencyRecorder { } const rawOfView = new WeakMap() -const readerOfRecord = 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 reader behind a `nodes` record, when it is one (a detached scene's): helpers that - * receive `SceneNodes` query through it, so they record what they select rather than everything. + * 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(nodes: object): DocumentReader | undefined { - return readerOfRecord.get(nodes) +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. */ @@ -276,6 +280,10 @@ export function createDocumentReader( 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() @@ -299,14 +307,19 @@ export function createDocumentReader( } return view } - const document = () => ({ - nodes: source.nodes() as Record, - collections: source.collections() as Record, + 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): string[] => { - const resultIds = queryIds(document() as never, { + const recordQuery = ( + query: QueryDependency['entity'], + predicate: unknown, + version?: Version, + ): string[] => { + const resultIds = queryIds(document(version) as never, { entity: query, predicate: (predicate ?? null) as never, }) @@ -319,51 +332,61 @@ export function createDocumentReader( const viewOf = ( raw: Readonly>, entity: (id: string, raw: T | undefined) => T | undefined, - enumerate: () => void, + version: Version, + kind: 'node' | 'collection', ): Readonly> => { let view = recordViews.get(raw) as Readonly> | undefined if (!view) { - view = recordView(raw, assertActive, entity, enumerate) + view = recordView(raw, assertActive, entity, () => recordQuery(kind, null, version)) recordViews.set(raw, view) - readerOfRecord.set(view, reader) + queriesOfRecord.set(view, { + query: ((selection: EntityQuery) => queryIn(selection, version)) as DocumentReader['query'], + orderedChildren: (id) => orderedChildrenIn(id, version), + }) rawOfView.set(view, raw) } return view } - function query(query: EntityQuery): unknown[] { + function queryIn(query: EntityQuery, version: Version = {}): unknown[] { assertActive() - const current = document() + const current = document(version) switch (query.entity) { case 'node': - return recordQuery('node', query.where ?? null).map((id) => + return recordQuery('node', query.where ?? null, version).map((id) => trackNode(id, current.nodes[id]), ) case 'collection': - return recordQuery('collection', query.where ?? null).map((id) => + return recordQuery('collection', query.where ?? null, version).map((id) => trackCollection(id, current.collections[id]), ) case 'material': - return recordQuery('material', null).map((id) => current.materials[id]) + // 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: query as DocumentReader['query'], - orderedChildren: (id) => { - assertActive() - const node = source.nodes()[id] - recorder.node(id) - const children = childIdsOf(node) - recorder.order(id, children) - return [...children] - }, + query: ((selection: EntityQuery) => queryIn(selection)) as DocumentReader['query'], + orderedChildren: (id) => orderedChildrenIn(id), collection: (id) => { assertActive() return trackCollection(id, source.collections()[id]) @@ -380,15 +403,18 @@ export function createDocumentReader( }, get nodes() { assertActive() - return viewOf(source.nodes(), trackNode, () => recordQuery('node', null)) as SceneNodes + const raw = source.nodes() as Record + return viewOf(raw, trackNode, { nodes: raw }, 'node') as SceneNodes }, track: (raw) => { assertActive() - return viewOf(raw, trackNode, () => recordQuery('node', null)) as SceneNodes + const version = raw as Record + return viewOf(version, trackNode, { nodes: version }, 'node') as SceneNodes }, get collections() { assertActive() - return viewOf(source.collections(), trackCollection, () => recordQuery('collection', null)) + const raw = source.collections() as Record + return viewOf(raw, trackCollection, { collections: raw }, 'collection') }, } return reader diff --git a/packages/core/src/runtime/scene-runtime.ts b/packages/core/src/runtime/scene-runtime.ts index 4743fe606a..d1927987a6 100644 --- a/packages/core/src/runtime/scene-runtime.ts +++ b/packages/core/src/runtime/scene-runtime.ts @@ -1,3 +1,4 @@ +import { applySceneChanges } from '../agent-operations/apply-changes' import { type AgentHostRuntime, applyAgentOutcome } from '../agent-operations/apply-outcome' import { writeCollections } from '../agent-operations/collections' import type { @@ -16,17 +17,18 @@ import { getNodePluginId, nodeRegistry } from '../registry/registry' import type { AnyNode, AnyNodeId, Collection } from '../schema' import { type IdAllocator, withIdAllocator } from '../schema/base' import { runSettlingDocument } from '../store/document-settling' -import { - pauseSceneHistory, - resumeSceneHistory, - runAsSingleSceneHistoryStep, - type SceneSnapshot, -} from '../store/history-control' +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, type SceneStore, sceneDocumentOf } from '../store/use-scene' +import useScene, { + createSceneStore, + runAsSettledSceneStep, + type SceneStore, + sceneDocumentOf, +} from '../store/use-scene' import { reconcileDocument } from '../systems/document-reconcile' -import type { Dependency } from './dependencies' -import { diffSceneDocuments } from './patch' +import { type Dependency, levelOf } from './dependencies' +import { diffSceneDocuments, sameValue } from './patch' import { createDocumentReader, DependencyRecorder, @@ -85,6 +87,8 @@ export interface SceneRuntime { 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 { @@ -156,6 +160,14 @@ export interface DetachedScene extends SceneRuntime { 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 { const plainData = (data: Partial) => @@ -211,20 +223,57 @@ export function createAgentHost(runtime: SceneRuntime): AgentHostRuntime { applyChanges: (changes) => runtime.run(() => runSettlingDocument(runtime.store, () => - runAsSingleSceneHistoryStep(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, () => runtime.reconcile(nodes(), { allLevels: true })), + runSettlingDocument(runtime.store, () => + runAsSettledSceneStep(runtime.store, () => + runtime.reconcile(nodes(), { allLevels: true }), + ), + ), ), } } +/** + * The store's actions plan more than they are asked for (default gutters on a roof, merged walls + * after a deletion, sanitised values), reading the scene directly. Where the result of `changes` + * differs from applying them as given, the extra writes depend on reads the reader did not see: + * each level they touch, and each level the changes touch, becomes a `scope` dependency (the + * whole document for a node outside every level). + */ +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 extra = new Set() + for (const id of new Set([...Object.keys(intended), ...Object.keys(after)])) + if (intended[id] !== after[id] && !sameValue(intended[id], after[id])) extra.add(id) + if (!extra.size) return + const touched = new Set(extra) + for (const { node } of plain.create ?? []) touched.add(node.id) + for (const { id } of plain.update ?? []) touched.add(id) + for (const id of plain.delete ?? []) 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. @@ -275,13 +324,27 @@ export function openDetachedScene( const environment = createSceneEnvironment(`detached at ${JSON.stringify(options.base)}`) const store = createSceneStore(environment) - loadCanonicalSnapshot(store, snapshot) + // 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, () => withSpatialGrid(grid, work))) + 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 })) diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts index 4c6e785c9a..41bb627889 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -238,6 +238,26 @@ export function notifySceneCommit(commit: SceneCommit): void { emitSceneCommit(control, contextualCommit) } +/** + * Makes `commit` the one `store`'s open transaction (`runAsSingleSceneHistoryStep`) emits: the + * pending commit, if any, keeps its origin 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 + control.pendingCommit = { + origin: pending?.origin ?? commit.origin, + before: pending?.before ?? commit.before, + current: commit.current, + changedNodeIds: mergedNodeIds(pending?.changedNodeIds, commit.changedNodeIds), + } +} + function pendingSceneCommitIsNoOp(control: HistoryControlState): boolean { const pending = control.pendingCommit return Boolean(pending && areSceneSnapshotsEqual(pending.before, pending.current)) 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/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 66617650bb..9f8d0f35fd 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -64,9 +64,11 @@ import { resumeSceneHistory, runAsRemoteSceneChange, runAsSceneHistoryRestore, + runAsSingleSceneHistoryStep, runWithSceneCommitNodeIds, type SceneCommitOrigin, type SceneSnapshot, + settlePendingSceneCommit, } from './history-control' import { beginSceneHistoryDraft as beginSceneHistoryDraftIn, @@ -2462,6 +2464,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, diff --git a/packages/core/src/systems/document-reconcile.ts b/packages/core/src/systems/document-reconcile.ts index 6f6fd404a0..b1b28f2d0b 100644 --- a/packages/core/src/systems/document-reconcile.ts +++ b/packages/core/src/systems/document-reconcile.ts @@ -11,6 +11,7 @@ import type { SceneNodes } from '../lib/structure-kernel' import type { AnyNode, AnyNodeId } from '../schema' import { generateId } from '../schema/base' 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' @@ -120,49 +121,51 @@ export function reconcileDocument( }) } - withSpatialGrid(grid, () => { - const levelIds = options.allLevels ? undefined : changedStructureLevelIds(before, nodes()) - if (!levelIds || levelIds.length) - write(() => - applyStructureReconciliation(readingStore, { - levelIds, - previousNodes: options.allLevels ? undefined : read(before), - mintId: generateId, - }), - ) - - 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)) { - 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 }), + withoutLivePreviews(() => + withSpatialGrid(grid, () => { + const levelIds = options.allLevels ? undefined : changedStructureLevelIds(before, nodes()) + if (!levelIds || levelIds.length) + write(() => + applyStructureReconciliation(readingStore, { + levelIds, + previousNodes: options.allLevels ? undefined : read(before), + mintId: generateId, + }), ) - stairsFrom = nodes() - } - if (changedInputs(elevatorsFrom, nodes(), isElevatorOpeningInput)) { - applyOpenings(planOwnedFloorOpenings(view() as Record)) - elevatorsFrom = nodes() - } - if (changedInputs(roofsFrom, nodes(), isRoofElevationInput)) { - const roofs = planRoofElevationUpdates(view()) - if (roofs.length) write(() => store.getState().updateNodes(roofs)) - roofsFrom = nodes() + + 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)) { + 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)) { + applyOpenings(planOwnedFloorOpenings(view() as Record)) + elevatorsFrom = nodes() + } + if (changedInputs(roofsFrom, nodes(), isRoofElevationInput)) { + const roofs = planRoofElevationUpdates(view()) + if (roofs.length) write(() => store.getState().updateNodes(roofs)) + roofsFrom = nodes() + } + if (nodes() === start) break } - if (nodes() === start) break - } - }) + }), + ) } From 9aa95f8e41179ec35898083808edeefe81788468 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 01:26:48 -0400 Subject: [PATCH 13/21] core: reconciliation keeps a support index per store and settles incrementally Measured on generated multi-storey scenes (Bun, synchronous cost of one edit through `createAgentHost(liveSceneRuntime)` with the editor's document systems mounted, p50/p95 ms): | scene | edit | origin/main | before | after | |---|---|---|---|---| | 2.9k nodes | wall | 141.8/158.6 | 229.8/243.2 | 103.4/110.3 | | 2.9k nodes | stair | 80.6/98.6 | 175.6/223.4 | 73.6/77.4 | | 2.9k nodes | roof | 63.3/74.8 | 156.1/175.5 | 30.0/34.7 | | 10k nodes | wall | 731.8/846.1 | 1164.9/1242.2 | 437.2/461.1 | | 10k nodes | stair | 450.9/500.1 | 999.2/1041.2 | 454.4/481.3 | | 10k nodes | roof | 431.6/457.4 | 903.2/969.7 | 158.4/209.5 | (origin/main: the store action plus the systems' deferred writes.) The editor's own path (an edit by hand, undo, a drag preview) is unchanged within noise; a drag preview writes nothing and reconciles nothing. - While a host settles, space detection rebuilt its whole topology index and every level's spaces on each write (`adoptSceneBaseline`). It now rebuilds only the levels whose walls the write changed. - The support index was rebuilt for every node-map version, slab by slab with a cache invalidation per insertion (S(S+1)/2 for S slabs on a level), and indexed item collision cells nothing in reconciliation reads. `supportIndexOf(store)` keeps one per store: bulk-loaded once (slabs, walls, ceilings), brought up to date in place from the nodes an edit changed (`syncSnapshot`), rebuilt only on a change of kind or level or a large batch; item cells are built on the first placement query. - Structure levels are chosen as space detection chooses them for a commit (`structureLevelsForChange`), from the changed nodes rather than a signature of every level; a parent whose child list alone changed adds nothing, in that selection and in the passes' input checks. - The roof pass resolves only the roofs an edit can move (`roofsAffectedBy`: a changed roof, and roofs on a changed wall's or slab's level and the level above), not every roof in the scene. Co-Authored-By: Claude Opus 5.5 --- .../spatial-grid/spatial-grid-manager.ts | 122 +++++++++++++++++- packages/core/src/lib/space-detection.ts | 40 +++++- packages/core/src/runtime/scene-runtime.ts | 16 +-- .../core/src/systems/document-reconcile.ts | 63 +++++---- .../core/src/systems/roof/roof-elevation.ts | 43 +++++- 5 files changed, 243 insertions(+), 41 deletions(-) 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 d58c48c28a..045f8ff05f 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -245,10 +245,88 @@ export class SpatialGridManager { 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, source: SpatialGridSource = liveSpatialGridSource) { + 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 { @@ -436,7 +514,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 @@ -495,7 +573,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 @@ -565,7 +643,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 @@ -677,6 +755,7 @@ export class SpatialGridManager { side?: 'front' | 'back', ignoreIds?: string[], ) { + this.ensureItemIndex() const wallLength = this.getWallLength(wallId) if (wallLength === 0) { return { valid: false, conflictIds: [] } @@ -738,6 +817,7 @@ export class SpatialGridManager { } getWallForItem(levelId: string, itemId: string): string | undefined { + this.ensureItemIndex() return this.getWallGrid(levelId).getWallForItem(itemId) } @@ -1140,6 +1220,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: [] } @@ -1245,9 +1326,36 @@ export function createSnapshotSpatialGrid( nodes: () => Record, levelIdOf: (node: AnyNode, nodes: Record) => string, ): SpatialGridManager { - const grid = new SpatialGridManager(0.5, snapshotSpatialGridSource(nodes)) - const current = nodes() - for (const node of Object.values(current)) grid.handleNodeCreated(node, levelIdOf(node, current)) + 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 } diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 97b06ae403..f38b9b8864 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -490,6 +490,36 @@ export function changedStructureLevelIds(before: SceneNodes, after: SceneNodes): ) } +/** + * 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, @@ -610,9 +640,15 @@ export function initSpaceDetectionSync( sceneStore.temporal.getState().isTracking === false ) return - // A settling host derives this edit's structure itself (reconcileDocument). + // 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. if (isSettlingDocument(sceneStore)) { - adoptSceneBaseline(state.nodes) + 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 } diff --git a/packages/core/src/runtime/scene-runtime.ts b/packages/core/src/runtime/scene-runtime.ts index d1927987a6..bd148d5efa 100644 --- a/packages/core/src/runtime/scene-runtime.ts +++ b/packages/core/src/runtime/scene-runtime.ts @@ -8,8 +8,8 @@ import type { SceneNodes, } from '../agent-operations/types' import { - createSnapshotSpatialGrid, type SpatialGridManager, + supportIndexOf, withSpatialGrid, } from '../hooks/spatial-grid/spatial-grid-manager' import { resolveLevelId } from '../lib/node-ancestry' @@ -282,12 +282,12 @@ function recordingSupportGrid( store: SceneStore, recorder: DependencyRecorder, ): () => SpatialGridManager { - let cached: { nodes: SceneNodes; grid: SpatialGridManager } | null = null + const recordings = new WeakMap() return () => { - const nodes = store.getState().nodes as SceneNodes - if (cached?.nodes !== nodes) { - const grid = createSnapshotSpatialGrid(() => store.getState().nodes, resolveLevelId) - const recording = new Proxy(grid, { + 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 @@ -299,9 +299,9 @@ function recordingSupportGrid( } }, }) - cached = { nodes, grid: recording } + recordings.set(grid, recording) } - return cached.grid + return recording } } diff --git a/packages/core/src/systems/document-reconcile.ts b/packages/core/src/systems/document-reconcile.ts index b1b28f2d0b..3ef90b9b04 100644 --- a/packages/core/src/systems/document-reconcile.ts +++ b/packages/core/src/systems/document-reconcile.ts @@ -1,11 +1,11 @@ import { structureChangeBatch } from '../commands/structure/shared' import { - createSnapshotSpatialGrid, type SpatialGridManager, + supportIndexOf, withSpatialGrid, } from '../hooks/spatial-grid/spatial-grid-manager' import { resolveLevelId } from '../lib/node-ancestry' -import { changedStructureLevelIds } from '../lib/space-detection' +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' @@ -15,7 +15,11 @@ 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 } from './roof/roof-elevation' +import { + isRoofElevationInput, + planRoofElevationUpdates, + roofsAffectedBy, +} from './roof/roof-elevation' import { changedStairOpeningOwners } from './stair/stair-opening-sync' import { syncStairRises } from './stair/stair-rise' @@ -47,16 +51,28 @@ export function changedInputs( before: SceneNodes, after: SceneNodes, isInput: (node: AnyNode | undefined) => boolean, + options: { ignoreChildLists?: boolean } = {}, ): boolean { if (before === after) return false - for (const id of new Set([...Object.keys(before), ...Object.keys(after)])) { - const previous = before[id] - const next = after[id] - if (previous !== next && (isInput(previous) || isInput(next))) return true - } + 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 @@ -91,15 +107,7 @@ export function reconcileDocument( const read = options.read ?? ((nodes: SceneNodes) => nodes) const nodes = () => store.getState().nodes as SceneNodes const view = () => read(nodes()) - let cached: { nodes: SceneNodes; grid: SpatialGridManager } | null = null - const grid = - options.grid ?? - (() => { - const current = nodes() - if (cached?.nodes !== current) - cached = { nodes: current, grid: createSnapshotSpatialGrid(nodes, resolveLevelId) } - return cached.grid - }) + 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 }), @@ -123,7 +131,7 @@ export function reconcileDocument( withoutLivePreviews(() => withSpatialGrid(grid, () => { - const levelIds = options.allLevels ? undefined : changedStructureLevelIds(before, nodes()) + const levelIds = options.allLevels ? undefined : structureLevelsForChange(before, nodes()) if (!levelIds || levelIds.length) write(() => applyStructureReconciliation(readingStore, { @@ -133,6 +141,8 @@ export function reconcileDocument( }), ) + // 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 @@ -140,7 +150,7 @@ export function reconcileDocument( // 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)) { + if (changedInputs(stairsFrom, start, isStairOpeningInput, settled)) { const from = stairsFrom stairsFrom = start const rises = syncStairRises(view() as Record) @@ -155,12 +165,21 @@ export function reconcileDocument( ) stairsFrom = nodes() } - if (changedInputs(elevatorsFrom, nodes(), isElevatorOpeningInput)) { + if (changedInputs(elevatorsFrom, nodes(), isElevatorOpeningInput, settled)) { applyOpenings(planOwnedFloorOpenings(view() as Record)) elevatorsFrom = nodes() } - if (changedInputs(roofsFrom, nodes(), isRoofElevationInput)) { - const roofs = planRoofElevationUpdates(view()) + if (changedInputs(roofsFrom, nodes(), isRoofElevationInput, settled)) { + const current = nodes() + const changed: string[] = [] + for (const id in current) + if (current[id] !== roofsFrom[id] && !onlyChildListChanged(roofsFrom[id], current[id])) + changed.push(id) + for (const id in roofsFrom) if (!(id in current)) changed.push(id) + const roofs = planRoofElevationUpdates( + view(), + roofsAffectedBy(roofsFrom, current, changed), + ) if (roofs.length) write(() => store.getState().updateNodes(roofs)) roofsFrom = nodes() } diff --git a/packages/core/src/systems/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts index d297da1aa5..00fdbc9149 100644 --- a/packages/core/src/systems/roof/roof-elevation.ts +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -3,6 +3,7 @@ import { getWallEffectiveHeightForNodes, } from '../../hooks/spatial-grid/spatial-grid-manager' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import { findLevelAncestorId } from '../../lib/node-ancestry' import { queryNodes } from '../../runtime/node-queries' import type { AnyNode, @@ -12,7 +13,7 @@ import type { RoofSegmentNode, WallNode, } from '../../schema' -import { findLevelBelowId, getLevelElevations } from '../../services/storey' +import { findLevelAboveId, findLevelBelowId, getLevelElevations } from '../../services/storey' import { wallOverlapsSlabFootprint } from '../slab/slab-support' import { getWallArcData } from '../wall/wall-curve' import { resolveRoomRoofFootprintOnLevel } from './roof-footprint' @@ -143,10 +144,11 @@ export function isRoofElevationInput(node: AnyNode | undefined): boolean { /** 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') continue + 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({ @@ -156,3 +158,40 @@ export function planRoofElevationUpdates( } return updates } + +/** + * The roofs an edit can move, from the nodes it changed: a roof itself, and the roofs on the level + * of a changed wall or slab and on the level above it (a roof rests on its own level's walls or + * the level's below). 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]) { + 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 === 'wall' || node.type === 'slab') { + const levelId = findLevelAncestorId(id as AnyNodeId, nodes as Record) + if (!levelId) return null + levels.add(levelId) + const above = findLevelAboveId( + levelId, + getLevelElevations(nodes as Record), + ) + if (above) levels.add(above) + } + } + 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 +} From 576ed87274bc45dd82fa3f3a79d22d05b8bd9bd9 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 01:27:16 -0400 Subject: [PATCH 14/21] mcp: complete commits, scene-scoped handlers, optional runInScene Follow-ups from review of the bridge over a runtime: - Every bridge write (create, update, delete, apply_patch, derivation, `runAsSingleHistoryStep`) is a settled step: one commit and one undo step carrying the derived construction it settles, with derived ids. - Writes the store's actions plan beyond the patch asked for declare their levels as scope dependencies on a detached scene. - `createPascalMcpServer` starts every tool handler in the scene's scope, so ids minted and schema defaults filled before a handler's first await are the scene's (create_unit, create_roof). add_object and add_column mint a new object's id before awaiting the compile and re-enter the scope to plan; edit_collection and the shared-tool runner plan inside it. - `SceneOperations.runInScene` is optional: a host's own implementation without it keeps working, with unscoped (random) ids; the README says when a host must provide it. - `exportJSON` reads materials and the plugin list through the runtime's reader, so an export on a detached scene records them. Tests: the same effect recomputed through create_unit, create_roof, add_column and add_object on a real server gives the same patch; a wall edit and the roof it moves land in one commit and one undo step; a custom SceneOperations without runInScene serves the shared tools; an export records its material and plugin reads. Co-Authored-By: Claude Opus 5.5 --- packages/mcp/README.md | 8 + .../mcp/src/bridge/detached-bridge.test.ts | 185 +++++++++++++++++- packages/mcp/src/bridge/scene-bridge.ts | 43 ++-- .../mcp/src/operations/scene-operations.ts | 9 +- packages/mcp/src/server.ts | 18 ++ packages/mcp/src/tools/add-column.ts | 11 +- packages/mcp/src/tools/add-object.ts | 14 +- packages/mcp/src/tools/collections.ts | 3 +- packages/mcp/src/tools/scene-scope.ts | 6 + packages/mcp/src/tools/shared-tools.ts | 3 +- 10 files changed, 278 insertions(+), 22 deletions(-) create mode 100644 packages/mcp/src/tools/scene-scope.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index c32f9d6693..3ad51d5e55 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -251,6 +251,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 index ef91acf9a0..a946fd8f9d 100644 --- a/packages/mcp/src/bridge/detached-bridge.test.ts +++ b/packages/mcp/src/bridge/detached-bridge.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' 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, @@ -9,7 +10,18 @@ import { SceneEnvironmentDisposedError, type SceneSnapshot, } from '@pascal-app/core/runtime' -import { type AnyNode, BuildingNode, LevelNode } from '@pascal-app/core/schema' +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 type { GeometryScriptHost } from '../tools/add-object' import { registerSharedTools } from '../tools/shared-tools' import { SceneBridge } from './scene-bridge' @@ -28,6 +40,27 @@ function snapshot(): SceneSnapshot { } 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 }, @@ -82,3 +115,153 @@ describe('SceneBridge over a detached scene', () => { 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 scene = detached(effectId) + const bridge = new SceneBridge({ runtime: scene }) + bridge.setActiveScene({ id: 'scene_detached', name: 'Detached' } as never) + 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 }], + [ + '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']), + ) + 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 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() + }) +}) diff --git a/packages/mcp/src/bridge/scene-bridge.ts b/packages/mcp/src/bridge/scene-bridge.ts index 4e63ac76b7..269b4b5427 100644 --- a/packages/mcp/src/bridge/scene-bridge.ts +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -12,11 +12,15 @@ import { planWallDeletion, resumeSceneHistory, resumeSpaceDetection, - runAsSingleSceneHistoryStep, structureChangeBatch, } from '@pascal-app/core' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' -import { liveSceneRuntime, type SceneRuntime } from '@pascal-app/core/runtime' +import { + declareActionDependencies, + liveSceneRuntime, + runAsSettledSceneStep, + type SceneRuntime, +} from '@pascal-app/core/runtime' import type { AnyNode, Collection, CollectionId } from '@pascal-app/core/schema' import { type AnyNodeId, @@ -114,15 +118,20 @@ export class SceneBridge { exportJSON(): SceneGraph & { collections: Record } { 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: reader.nodes, rootNodeIds: this.getRootNodeIds(), collections: reader.collections ?? {}, - materials: state.materials ?? {}, - ...(state.hasExplicitPluginInstallState || state.installedPlugins.length > 0 - ? { installedPlugins: state.installedPlugins } + materials, + ...(state.hasExplicitPluginInstallState || installedPlugins.length > 0 + ? { installedPlugins } : {}), }), ) @@ -343,8 +352,9 @@ export class SceneBridge { assertDerivedNodeWrites(this.getNodes(), { create: [{ node: parentId ? { ...node, parentId } : node }], }) - runAsSingleSceneHistoryStep(this.store, () => { + runAsSettledSceneStep(this.store, () => { this.store.getState().createNode(node, parentId) + declareActionDependencies(this.runtime, before, { create: [{ node, parentId }] }) this.runtime.reconcile(before) }) }) @@ -359,8 +369,9 @@ export class SceneBridge { this.write(() => { const before = this.store.getState().nodes assertDerivedNodeWrites(this.getNodes(), { update: [{ id, data }] }) - runAsSingleSceneHistoryStep(this.store, () => { + runAsSettledSceneStep(this.store, () => { this.store.getState().updateNode(id, data) + declareActionDependencies(this.runtime, before, { update: [{ id, data }] }) this.runtime.reconcile(before) }) }) @@ -390,8 +401,9 @@ export class SceneBridge { const beforeNodes = this.store.getState().nodes const before = new Set(Object.keys(beforeNodes)) this.write(() => - runAsSingleSceneHistoryStep(this.store, () => { + runAsSettledSceneStep(this.store, () => { this.store.getState().deleteNode(id) + declareActionDependencies(this.runtime, beforeNodes, { delete: [id] }) this.runtime.reconcile(beforeNodes) }), ) @@ -523,9 +535,15 @@ 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) - runAsSingleSceneHistoryStep(this.store, () => { + runAsSettledSceneStep(this.store, () => { pauseSpaceDetection() try { this.store.getState().createNodes([...createOps.values(), ...planned.create]) @@ -534,6 +552,7 @@ export class SceneBridge { } finally { resumeSpaceDetection() } + declareActionDependencies(this.runtime, beforeNodes, requested) this.runtime.reconcile(beforeNodes) }) const postNodes = this.store.getState().nodes @@ -574,7 +593,9 @@ export class SceneBridge { * before the triggering tool call rather than stranding derived nodes. */ deriveStructure(levelIds?: AnyNodeId[]): { createdIds: AnyNodeId[]; deletedIds: AnyNodeId[] } { - return this.write(() => this.deriveStructureInScope(levelIds)) + return this.write(() => + runAsSettledSceneStep(this.store, () => this.deriveStructureInScope(levelIds)), + ) } private deriveStructureInScope(levelIds?: AnyNodeId[]): { @@ -678,7 +699,7 @@ export class SceneBridge { } runAsSingleHistoryStep(run: () => T): T { - return this.write(() => runAsSingleSceneHistoryStep(this.store, run)) + return this.write(() => runAsSettledSceneStep(this.store, run)) } // ---- internal helpers ---- diff --git a/packages/mcp/src/operations/scene-operations.ts b/packages/mcp/src/operations/scene-operations.ts index 42f3546b5d..9e5a0df904 100644 --- a/packages/mcp/src/operations/scene-operations.ts +++ b/packages/mcp/src/operations/scene-operations.ts @@ -81,8 +81,13 @@ export interface SceneOperations { getHistory(): { pastCount: number; futureCount: number } clearHistory(): void runAsSingleHistoryStep(run: () => T): T - /** Runs `run` in the scene's scope (its ids, history environment and disposal guard). */ - runInScene(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 diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 29554cf0dd..ed656affe6 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -59,6 +59,7 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe if (opts.executeTool) installToolExecutor(server, opts.executeTool) const operations = opts.operations ?? createSceneOperations({ bridge: opts.bridge, store: opts.store }) + installSceneScope(server, operations) const catalog = opts.catalog ? cachedCatalog(opts.catalog) : undefined registerTools(server, operations, { catalog, @@ -73,6 +74,23 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe return server } +/** + * 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 6f41615154..4fd29a51e4 100644 --- a/packages/mcp/src/tools/add-column.ts +++ b/packages/mcp/src/tools/add-column.ts @@ -6,12 +6,13 @@ import { columnScriptParams, } 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( @@ -38,16 +39,20 @@ 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.') + // A new column's id is the scene's, minted before anything is awaited. + const nodeId = input.nodeId ?? inScene(bridge, () => generateId('column')) const code = input.code ?? (await readScript(host, scene.id, bridge, input.nodeId!)) compiled = await compileAndStore(host, scene.id, code, params, 'column', { - nodeId: input.nodeId, + nodeId, metadata: { ...(isScriptedNode(previous) ? scriptedObjectMeta(previous) : {}), ...GeometryReuseFields.parse(input), }, }) } - const outcome = addColumn(nodes, { ...input, compiled }, { activeLevelId: null }) + const outcome = inScene(bridge, () => + addColumn(nodes, { ...input, compiled }, { activeLevelId: null }), + ) if (outcome.changes) bridge.applyPatch(toPatches(outcome.changes)) const payload = { ...outcome.result, diff --git a/packages/mcp/src/tools/add-object.ts b/packages/mcp/src/tools/add-object.ts index bbacd2b446..0969307d03 100644 --- a/packages/mcp/src/tools/add-object.ts +++ b/packages/mcp/src/tools/add-object.ts @@ -11,6 +11,7 @@ import { type SceneNodes, } from '@pascal-app/core/agent-operations' import { addObjectTool, getSourceTool, isAgentRefusal, refuse } from '@pascal-app/core/agent-tools' +import { untracked } from '@pascal-app/core/runtime' import { type AnyNode, type CompiledGeometryScript, @@ -24,6 +25,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 { toPatches } from './shared-tools' /** @@ -141,6 +143,8 @@ 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) @@ -153,7 +157,7 @@ export function registerAddObject( const previous = args.nodeId ? nodes[args.nodeId] : undefined const params = editedScriptParams(previous, args.params) compiled = await compileAndStore(host, scene.id, code, params, 'object', { - nodeId: args.nodeId, + nodeId, metadata: { ...(isScriptedNode(previous) ? scriptedObjectMeta(previous) : {}), ...GeometryReuseFields.parse(args), @@ -167,10 +171,14 @@ 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: null }) + outcome = inScene(bridge, () => + addObject(nodes, { ...args, compiled }, { activeLevelId: null }), + ) } catch (error) { return refusalResult(error) } diff --git a/packages/mcp/src/tools/collections.ts b/packages/mcp/src/tools/collections.ts index 9c3902cea1..9b2a8fa562 100644 --- a/packages/mcp/src/tools/collections.ts +++ b/packages/mcp/src/tools/collections.ts @@ -14,6 +14,7 @@ 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. @@ -55,7 +56,7 @@ export function registerCollectionTools(server: McpServer, bridge: SceneOperatio async (input: EditCollectionInput) => { let outcome: AgentOperationOutcome try { - outcome = bridge.runInScene(() => editCollection(sceneOf(bridge), input)) + outcome = inScene(bridge, () => editCollection(sceneOf(bridge), input)) } catch (error) { return refusalResult(error) } 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 8b3cd0be88..210a8c3b93 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -44,6 +44,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 @@ -387,7 +388,7 @@ export function registerOperationTool( ...(tool.catalog && { catalog: await catalog() }), } try { - outcome = bridge.runInScene(() => tool.operation(nodes, input as never, context)) + outcome = inScene(bridge, () => tool.operation(nodes, input as never, context)) } catch (error) { return refusalResult(error) } From bd5db97cf8ef38f6aa2d04c320be04fb391a9b84 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 01:27:16 -0400 Subject: [PATCH 15/21] docs: scene runtime follow-ups The scene-runtime page covers settled commits, owned read-only data, hidden live previews, action-planned writes and level ancestors as dependencies, the per-store support index and incremental level choice, roof scoping, and the scene scope of MCP handlers. Co-Authored-By: Claude Opus 5.5 --- wiki/architecture/scene-runtime.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/wiki/architecture/scene-runtime.md b/wiki/architecture/scene-runtime.md index da7870a2e6..5203831502 100644 --- a/wiki/architecture/scene-runtime.md +++ b/wiki/architecture/scene-runtime.md @@ -27,6 +27,8 @@ 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, and freezes every record its own writes produce: nothing reached through it, a nested array of a node or a material included, can change the caller's snapshot, the editor's store, or the baseline of its patch. +- **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. @@ -44,14 +46,15 @@ A detached scene's `DocumentReader` records each read as a `Dependency`: | `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 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) | +| a write the store's actions plan beyond the changes asked for (default gutters, merged walls, sanitised values) | `scope` on every level the changes and the extra writes touch (`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. +- **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 @@ -62,7 +65,11 @@ Rules that keep the record precise: 2. stair rises, and the floor openings stairs and elevators own, re-deriving the levels they cut; 3. the elevation of wall-supported roofs. -Support queries read a spatial index built from the store's own nodes, never 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. +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, and the roofs on a changed wall's or slab's level and the level above). 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` @@ -83,4 +90,4 @@ The result's `patch` is rebased onto `current`: what an editor applies (`applySc ## 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. +`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. From 03ba205e55e61de08ab98a1e71c070f1c8508fe7 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 01:39:07 -0400 Subject: [PATCH 16/21] core: scoped reconciliation and recorded planners cover what they read Follow-ups from the second review of the detached runtime, each with a test that fails without it: - A planner that decides to write nothing was still read: a detached roof-segment widening with gutters disabled recorded no dependency, so gutters enabled meanwhile (8.52 m) survived the 12 m roof. Changes that run a store-action planner (roof, roof-segment, lean-to-extension, gutter, downspout and ridge-vent writes, deletions, a wall's curve) now always declare their level scope. - The roof pass missed two inputs. A roof's own child list is one (a segment added over a taller wall moves the roof); a segment edit selects its roof; and a slab change selects every roof, since a slab carries the walls on its level, bounds plane-bound wall tops below, and a base plate lifts the storeys above. - An upper slab's `recessed` is part of the covering underside below, so its change now re-derives the level below (also in the editor's own space detection): the ceiling under it is clamped as complete reconciliation clamps it. - What a detached scene is given to write is copied before the scene freezes it: the caller's nested objects stay the caller's. The scoped-versus-complete tests run the same edit settled as an edit and settled whole (every level, every roof) and require the same document. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/lib/space-detection.ts | 3 +- .../core/src/runtime/detached-scene.test.ts | 210 +++++++++++++++++- packages/core/src/runtime/scene-runtime.ts | 53 +++-- .../core/src/systems/document-reconcile.ts | 20 +- .../core/src/systems/roof/roof-elevation.ts | 19 +- 5 files changed, 276 insertions(+), 29 deletions(-) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index f38b9b8864..265b069a04 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -424,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]), diff --git a/packages/core/src/runtime/detached-scene.test.ts b/packages/core/src/runtime/detached-scene.test.ts index 16a87b2122..e15ff852e3 100644 --- a/packages/core/src/runtime/detached-scene.test.ts +++ b/packages/core/src/runtime/detached-scene.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, test } from 'bun:test' import { z } from 'zod' -import { AGENT_OPERATIONS, applyAgentOutcome, editCollection } from '../agent-operations' +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 { encodeTerrainField } from '../lib/terrain-codec' @@ -15,9 +20,11 @@ import { RoofNode, RoofSegmentNode, SiteNode, + SlabNode, StairNode, StairSegmentNode, WallNode, + ZoneNode, } from '../schema' import { SceneMaterial } from '../schema/scene-material' import { type SceneCommit, subscribeSceneCommits } from '../store/history-control' @@ -27,6 +34,7 @@ import useScene, { clearSceneHistory, getSceneDocument, } from '../store/use-scene' +import { planRoofElevationUpdates } from '../systems/roof/roof-elevation' import { closeLiveScene, openLiveScene, settle, snapshotOf } from './__fixtures__/live-scene' import { applyPatchWithDependencies, @@ -596,6 +604,17 @@ describe('what a detached scene hands out', () => { 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)) @@ -657,4 +676,193 @@ describe('what the store actions plan', () => { reason: 'dependency', }) }) + + 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)) + }) + + test('a thicker slab above lowers plane-bound walls and the roof they carry', () => { + const level = LevelNode.parse({ id: 'level_0', parentId: 'building_s', level: 0, height: 3 }) + const upper = LevelNode.parse({ id: 'level_1', parentId: 'building_s', 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]!, + }), + ) + const roof = RoofNode.parse({ + id: 'roof_s', + parentId: level.id, + support: { kind: 'walls' }, + position: [2, 0, 1.5], + }) + const slab = SlabNode.parse({ + id: 'slab_above', + parentId: upper.id, + polygon: [ + [-1, -1], + [5, -1], + [5, 4], + [-1, 4], + ], + thickness: 0.05, + }) + level.children = [...walls.map((wall) => wall.id), roof.id] + upper.children = [slab.id] + const building = BuildingNode.parse({ id: 'building_s', children: [level.id, upper.id] }) + const settled = open(document(building, level, upper, ...walls, roof, slab), 'settle') + settled.host().reconcile() + settled + .host() + .applyChanges({ update: planRoofElevationUpdates(settled.store.getState().nodes) }) + const base = sceneDocumentOf(settled.store) + const { scoped, complete } = scopedAndComplete(base, { + update: [{ id: slab.id, 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('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/scene-runtime.ts b/packages/core/src/runtime/scene-runtime.ts index bd148d5efa..971ce621d3 100644 --- a/packages/core/src/runtime/scene-runtime.ts +++ b/packages/core/src/runtime/scene-runtime.ts @@ -170,14 +170,17 @@ export function deepFreeze(value: T): T { /** 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, untracked(value)]), + Object.entries(untracked(data)).map(([key, value]) => [key, own(value)]), ) as Partial return { ...(changes.create && { create: changes.create.map(({ node, parentId }) => ({ - node: untracked(node), + node: own(node), ...(parentId === undefined ? {} : { parentId }), })), }), @@ -187,7 +190,7 @@ function plainChanges(changes: SceneChanges): SceneChanges { ...(changes.delete && { delete: [...changes.delete] }), ...(changes.collections && { collections: Object.fromEntries( - Object.entries(changes.collections).map(([id, collection]) => [id, untracked(collection)]), + Object.entries(changes.collections).map(([id, collection]) => [id, own(collection)]), ), }), } @@ -242,12 +245,23 @@ export function createAgentHost(runtime: SceneRuntime): AgentHostRuntime { } } +/** 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 (default gutters on a roof, merged walls - * after a deletion, sanitised values), reading the scene directly. Where the result of `changes` - * differs from applying them as given, the extra writes depend on reads the reader did not see: - * each level they touch, and each level the changes touch, becomes a `scope` dependency (the - * whole document for a node outside every level). + * 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, @@ -259,14 +273,23 @@ export function declareActionDependencies( const plain = plainChanges(changes) const intended = applySceneChanges(before, plain) const after = runtime.store.getState().nodes as SceneNodes - const extra = new Set() - for (const id of new Set([...Object.keys(intended), ...Object.keys(after)])) - if (intended[id] !== after[id] && !sameValue(intended[id], after[id])) extra.add(id) - if (!extra.size) return - const touched = new Set(extra) - for (const { node } of plain.create ?? []) touched.add(node.id) - for (const { id } of plain.update ?? []) touched.add(id) + 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 ?? []) + if (PLANNED_KINDS.has(kindOf(id) ?? '') || (kindOf(id) === 'wall' && 'curveOffset' in data)) + 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)) diff --git a/packages/core/src/systems/document-reconcile.ts b/packages/core/src/systems/document-reconcile.ts index 3ef90b9b04..3d0ff05ea5 100644 --- a/packages/core/src/systems/document-reconcile.ts +++ b/packages/core/src/systems/document-reconcile.ts @@ -169,13 +169,19 @@ export function reconcileDocument( applyOpenings(planOwnedFloorOpenings(view() as Record)) elevatorsFrom = nodes() } - if (changedInputs(roofsFrom, nodes(), isRoofElevationInput, settled)) { - const current = nodes() - const changed: string[] = [] - for (const id in current) - if (current[id] !== roofsFrom[id] && !onlyChildListChanged(roofsFrom[id], current[id])) - changed.push(id) - for (const id in roofsFrom) if (!(id in current)) changed.push(id) + // 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 = planRoofElevationUpdates( view(), roofsAffectedBy(roofsFrom, current, changed), diff --git a/packages/core/src/systems/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts index 00fdbc9149..df0508bb61 100644 --- a/packages/core/src/systems/roof/roof-elevation.ts +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -160,9 +160,11 @@ export function planRoofElevationUpdates( } /** - * The roofs an edit can move, from the nodes it changed: a roof itself, and the roofs on the level - * of a changed wall or slab and on the level above it (a roof rests on its own level's walls or - * the level's below). Null, for every roof, when a level, building or site changed. + * The roofs an edit can move, from the nodes it changed: a roof itself (its segments included), + * and the roofs on the level of a changed wall and on the level above it (a roof rests on its own + * level's walls or the level's below). Null, for every roof, when a slab (it carries walls on its + * level and bounds plane-bound wall tops below, and a base plate lifts the storeys above), a + * level, a building or a site changed. */ export function roofsAffectedBy( before: Readonly>, @@ -175,9 +177,16 @@ export function roofsAffectedBy( for (const nodes of [before, after]) { const node = nodes[id] if (!node) continue - if (node.type === 'level' || node.type === 'building' || node.type === 'site') return null + if ( + node.type === 'level' || + node.type === 'building' || + node.type === 'site' || + node.type === 'slab' + ) + return null if (node.type === 'roof') roofs.add(id) - else if (node.type === 'wall' || node.type === 'slab') { + else if (node.type === 'roof-segment' && node.parentId) roofs.add(node.parentId) + else if (node.type === 'wall') { const levelId = findLevelAncestorId(id as AnyNodeId, nodes as Record) if (!levelId) return null levels.add(levelId) From fc1e0ffa4614e907724ed8c1e24965a44367dcc7 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 01:39:07 -0400 Subject: [PATCH 17/21] mcp: openings plan in scene scope; store-only operations; owned writes - add_door and add_window await their (possibly absent) compile before planning; the planning now re-enters the scene's scope, as a rebuild of either does, so a detached scene recomputed from the same effect mints the same door and window ids (tested through the registered handlers). - Operations built over a store alone (`createSceneOperations({ store })`) run `runInScene` callbacks as they are: list_scenes no longer fails with `scene_bridge_unavailable` under the server's scene scope. - Bridge writes copy what they are given before a detached scene freezes it. Co-Authored-By: Claude Opus 5.5 --- .../mcp/src/bridge/detached-bridge.test.ts | 47 ++++++++++++++++++- packages/mcp/src/bridge/scene-bridge.ts | 10 ++-- .../mcp/src/operations/scene-operations.ts | 3 +- packages/mcp/src/tools/room-tools.ts | 31 +++++++----- 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/packages/mcp/src/bridge/detached-bridge.test.ts b/packages/mcp/src/bridge/detached-bridge.test.ts index a946fd8f9d..f19cbb1240 100644 --- a/packages/mcp/src/bridge/detached-bridge.test.ts +++ b/packages/mcp/src/bridge/detached-bridge.test.ts @@ -1,4 +1,7 @@ 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' @@ -21,6 +24,7 @@ import { } 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' @@ -134,7 +138,19 @@ describe('every MCP tool on a detached scene', () => { } async function build(effectId: string) { - const scene = detached(effectId) + 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) const client = await connect(createPascalMcpServer({ bridge, geometryScripts: host })) @@ -142,6 +158,9 @@ describe('every MCP tool on a detached scene', () => { ['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', { @@ -166,7 +185,7 @@ describe('every MCP tool on a detached scene', () => { 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']), + expect.arrayContaining(['unit', 'roof', 'column', 'item', 'door', 'window']), ) expect(await build('effect_tools')).toEqual(first) }) @@ -249,6 +268,18 @@ describe('SceneOperations without runInScene', () => { }) }) +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'] } }) + scene.dispose() + }) +}) + describe('SceneBridge export', () => { test('reads materials and the plugin list as dependencies', () => { const base = { @@ -265,3 +296,15 @@ describe('SceneBridge export', () => { 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 269b4b5427..a33f1d02c7 100644 --- a/packages/mcp/src/bridge/scene-bridge.ts +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -18,6 +18,7 @@ import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { declareActionDependencies, liveSceneRuntime, + materialize, runAsSettledSceneStep, type SceneRuntime, } from '@pascal-app/core/runtime' @@ -346,7 +347,9 @@ export class SceneBridge { * `id`, generated by the schema default if they did `XxxNode.parse({...})`). * Returns the generated id. */ - createNode(node: AnyNode, parentId?: AnyNodeId): AnyNodeId { + 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(), { @@ -362,7 +365,8 @@ export class SceneBridge { } /** Update node fields (shallow merge through the core store). */ - updateNode(id: AnyNodeId, data: Partial): void { + updateNode(id: AnyNodeId, input: Partial): void { + const data = structuredClone(materialize(input)) if (!this.getNode(id)) { throw new Error(`node not found: ${id}`) } @@ -425,7 +429,7 @@ export class SceneBridge { deletedIds: AnyNodeId[] createdIds: AnyNodeId[] } { - return this.write(() => this.applyPatchInScope(patches)) + return this.write(() => this.applyPatchInScope(structuredClone(materialize(patches)))) } private applyPatchInScope(patches: Patch[]): { diff --git a/packages/mcp/src/operations/scene-operations.ts b/packages/mcp/src/operations/scene-operations.ts index 9e5a0df904..523af4bc27 100644 --- a/packages/mcp/src/operations/scene-operations.ts +++ b/packages/mcp/src/operations/scene-operations.ts @@ -292,7 +292,8 @@ class SceneOperationsFacade implements SceneOperations { } runInScene(run: () => T): T { - return this.requireBridge().runInScene(run) + // 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 { diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 8c9ce81a79..030189bfd7 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -26,6 +26,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. */ @@ -76,7 +77,9 @@ async function rebuildOpening( ...GeometryReuseFields.parse(input), }, }) - outcome = rescriptOpening(nodes, { ...input, compiled }, { activeLevelId: null }) + outcome = inScene(bridge, () => + rescriptOpening(nodes, { ...input, compiled }, { activeLevelId: null }), + ) } catch (error) { if (isAgentRefusal(error)) return refusalResult(error) return toolError(error instanceof Error ? error.message : String(error), { @@ -160,11 +163,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) } @@ -196,11 +202,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) } From 26edcd540f90e56fe1c5c7c990b745e3b443b2db Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 01:39:07 -0400 Subject: [PATCH 18/21] docs: scene runtime, second review follow-ups Planner inputs as dependencies, copied writes and what disposal does to values already handed out, and the roof pass's selection. Co-Authored-By: Claude Opus 5.5 --- wiki/architecture/scene-runtime.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wiki/architecture/scene-runtime.md b/wiki/architecture/scene-runtime.md index 5203831502..e6da19f9d1 100644 --- a/wiki/architecture/scene-runtime.md +++ b/wiki/architecture/scene-runtime.md @@ -27,7 +27,7 @@ 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, and freezes every record its own writes produce: nothing reached through it, a nested array of a node or a material included, can change the caller's snapshot, the editor's store, or the baseline of its patch. +- **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. @@ -47,7 +47,7 @@ A detached scene's `DocumentReader` records each read as a `Dependency`: | `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) | -| a write the store's actions plan beyond the changes asked for (default gutters, merged walls, sanitised values) | `scope` on every level the changes and the extra writes touch (`declareActionDependencies`): the actions read the scene directly | +| 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), 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: @@ -65,7 +65,7 @@ Rules that keep the record precise: 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, and the roofs on a changed wall's or slab's level and the level above). 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. +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, every roof when a slab, 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 From e00bbbfb5a85280b4238bf7f76fc2717d377a105 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 01:50:45 -0400 Subject: [PATCH 19/21] core: slab edits move only their building's roofs; renames run no planner Follow-ups from the third review: - A slab change made the roof pass resolve every roof in the scene: slow with many roofs, and every roof read became a dependency, so a slab edited in one building conflicted with a wall renamed in another. A slab now selects the roofs on its own level (it carries the walls there), the level below (it caps plane-bound wall tops there) and every level above it in its building (a base plate lifts them). The scoped-versus-complete slab test still holds; a new test applies a slab edit in one building beside a concurrent rename in another. - A roof segment's planners (default gutters, ridge vents) run on the fields they read; an edit touching none of them (a rename) no longer declares its level a dependency, so it merges with a same-level edit. Co-Authored-By: Claude Opus 5.5 --- .../core/src/runtime/detached-scene.test.ts | 85 ++++++++++++++----- packages/core/src/runtime/scene-runtime.ts | 16 +++- .../core/src/store/actions/node-actions.ts | 4 +- .../core/src/systems/roof/roof-elevation.ts | 39 ++++----- 4 files changed, 101 insertions(+), 43 deletions(-) diff --git a/packages/core/src/runtime/detached-scene.test.ts b/packages/core/src/runtime/detached-scene.test.ts index e15ff852e3..f60cfa61c2 100644 --- a/packages/core/src/runtime/detached-scene.test.ts +++ b/packages/core/src/runtime/detached-scene.test.ts @@ -677,6 +677,23 @@ describe('what the store actions plan', () => { }) }) + 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 }) @@ -756,57 +773,87 @@ describe('scoped reconciliation settles what complete reconciliation would', () expect(y(scoped)).toBeCloseTo(y(complete)) }) - test('a thicker slab above lowers plane-bound walls and the roof they carry', () => { - const level = LevelNode.parse({ id: 'level_0', parentId: 'building_s', level: 0, height: 3 }) - const upper = LevelNode.parse({ id: 'level_1', parentId: 'building_s', level: 1, height: 3 }) + /** 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][] = [ - [0, 0], - [4, 0], - [4, 3], - [0, 3], + [x, 0], + [x + 4, 0], + [x + 4, 3], + [x, 3], ] const walls = corners.map((start, index) => WallNode.parse({ - id: `wall_${index}`, + id: `wall_${id}${index}`, parentId: level.id, start, end: corners[(index + 1) % 4]!, }), ) const roof = RoofNode.parse({ - id: 'roof_s', + id: `roof_${id}`, parentId: level.id, support: { kind: 'walls' }, - position: [2, 0, 1.5], + position: [x + 2, 0, 1.5], }) const slab = SlabNode.parse({ - id: 'slab_above', + id: `slab_${id}`, parentId: upper.id, polygon: [ - [-1, -1], - [5, -1], - [5, 4], - [-1, 4], + [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_s', children: [level.id, upper.id] }) - const settled = open(document(building, level, upper, ...walls, roof, slab), 'settle') + 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) }) - const base = sceneDocumentOf(settled.store) + 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.id, data: { thickness: 0.55 } }], + 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 }) diff --git a/packages/core/src/runtime/scene-runtime.ts b/packages/core/src/runtime/scene-runtime.ts index 971ce621d3..3ba7c0b5a1 100644 --- a/packages/core/src/runtime/scene-runtime.ts +++ b/packages/core/src/runtime/scene-runtime.ts @@ -16,6 +16,10 @@ 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' @@ -277,9 +281,15 @@ export function declareActionDependencies( 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 ?? []) - if (PLANNED_KINDS.has(kindOf(id) ?? '') || (kindOf(id) === 'wall' && 'curveOffset' in data)) - touched.add(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])) { diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 70a12bcfbb..9c0f079811 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -655,7 +655,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)) } @@ -693,7 +693,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)) } diff --git a/packages/core/src/systems/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts index df0508bb61..ef7c5a132a 100644 --- a/packages/core/src/systems/roof/roof-elevation.ts +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -160,11 +160,11 @@ export function planRoofElevationUpdates( } /** - * The roofs an edit can move, from the nodes it changed: a roof itself (its segments included), - * and the roofs on the level of a changed wall and on the level above it (a roof rests on its own - * level's walls or the level's below). Null, for every roof, when a slab (it carries walls on its - * level and bounds plane-bound wall tops below, and a base plate lifts the storeys above), a - * level, a building or a site changed. + * 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>, @@ -174,27 +174,28 @@ export function roofsAffectedBy( const roofs = new Set() const levels = new Set() for (const id of changedIds) - for (const nodes of [before, after]) { + 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' || - node.type === 'slab' - ) - return null + 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') { + 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) - const above = findLevelAboveId( - levelId, - getLevelElevations(nodes as Record), - ) - if (above) levels.add(above) + 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) From 63e3f8f8366eb07f702a33eb9628569c58e6ed9a Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 01:50:45 -0400 Subject: [PATCH 20/21] mcp: bridge replacements copy what they are given `setScene` (nodes, roots and extras) and `setCollections` now copy their inputs before writing, as the other bridge writes do: a detached scene froze the caller's metadata and collection arrays. Co-Authored-By: Claude Opus 5.5 --- packages/mcp/src/bridge/detached-bridge.test.ts | 16 ++++++++++++++++ packages/mcp/src/bridge/scene-bridge.ts | 6 ++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/bridge/detached-bridge.test.ts b/packages/mcp/src/bridge/detached-bridge.test.ts index f19cbb1240..608f00683b 100644 --- a/packages/mcp/src/bridge/detached-bridge.test.ts +++ b/packages/mcp/src/bridge/detached-bridge.test.ts @@ -276,6 +276,22 @@ describe('SceneBridge writes on a detached scene', () => { 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() }) }) diff --git a/packages/mcp/src/bridge/scene-bridge.ts b/packages/mcp/src/bridge/scene-bridge.ts index a33f1d02c7..1f16eb19e7 100644 --- a/packages/mcp/src/bridge/scene-bridge.ts +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -112,7 +112,9 @@ export class SceneBridge { rootNodeIds: AnyNodeId[], extra?: SetSceneExtra, ): void { - this.write(() => this.store.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. */ @@ -216,7 +218,7 @@ export class SceneBridge { setCollections(collections: Record): void { this.write(() => { if (this.store.getState().readOnly) return - this.store.setState({ collections }) + this.store.setState({ collections: structuredClone(materialize(collections)) }) }) } From 71cbe07fbb0780c503d414f590d9e02d76d9328b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 11 Oct 2026 03:47:00 -0400 Subject: [PATCH 21/21] docs: scene runtime, slab roof selection and segment planner triggers Co-Authored-By: Claude Opus 5.5 --- wiki/architecture/scene-runtime.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wiki/architecture/scene-runtime.md b/wiki/architecture/scene-runtime.md index e6da19f9d1..75e6123a3e 100644 --- a/wiki/architecture/scene-runtime.md +++ b/wiki/architecture/scene-runtime.md @@ -47,7 +47,7 @@ A detached scene's `DocumentReader` records each read as a `Dependency`: | `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), and on every level an unrequested write lands on (`declareActionDependencies`): the actions read the scene directly | +| 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: @@ -65,7 +65,7 @@ Rules that keep the record precise: 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, every roof when a slab, 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. +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