feat(core): expose generic host integration primitives

This commit is contained in:
Aymeric Rabot
2026-07-19 17:11:43 +02:00
parent 2084892383
commit 8f1c834e56
39 changed files with 2471 additions and 166 deletions
+13
View File
@@ -198,6 +198,15 @@ export interface CameraControlFitSceneEvent {
}
}
export interface CameraPose {
position: [number, number, number]
target: [number, number, number]
projection: 'perspective' | 'orthographic'
/** Width, in scene units, of the visible plane through `target`. */
viewWidth?: number
fov?: number
}
type CameraControlEvents = {
'camera-controls:view': CameraControlEvent
'camera-controls:focus': CameraControlEvent
@@ -207,6 +216,10 @@ type CameraControlEvents = {
'camera-controls:orbit-ccw': undefined
'camera-controls:fit-scene': CameraControlFitSceneEvent
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
'camera-controls:pose': CameraPose
'camera-controls:apply-pose': CameraPose
'camera-controls:cancel-pose': undefined
'camera-controls:interaction-start': undefined
}
type ToolEvents = {
+17 -1
View File
@@ -5,6 +5,7 @@ export type {
CabinetModuleEvent,
CameraControlEvent,
CameraControlFitSceneEvent,
CameraPose,
CeilingEvent,
ChimneyEvent,
ColumnEvent,
@@ -179,6 +180,11 @@ export {
resetSceneHistoryPauseDepth,
resumeSceneHistory,
runAsSingleSceneHistoryStep,
type SceneCommit,
type SceneCommitListener,
type SceneCommitOrigin,
type SceneSnapshot,
subscribeSceneCommits,
} from './store/history-control'
export {
type ControlValue,
@@ -199,7 +205,17 @@ export {
type LiveNodeOverrides,
} from './store/use-live-node-overrides'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene'
export {
type ApplySceneSnapshotOptions,
acquireSceneReadOnlyLease,
applyScenePatch,
applySceneSnapshot,
clearSceneHistory,
default as useScene,
type SceneMaterialPatch,
type SceneNodePatch,
type ScenePatch,
} from './store/use-scene'
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
export {
type ElevatorDoorSide,
@@ -0,0 +1,51 @@
import { describe, expect, test } from 'bun:test'
import { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal'
import { HvacEquipmentNode } from './hvac-equipment'
import { LevelNode } from './level'
import { LinesetNode } from './lineset'
import { LiquidLineNode } from './liquid-line'
import { PipeFittingNode } from './pipe-fitting'
import { PipeSegmentNode } from './pipe-segment'
import { PipeTrapNode } from './pipe-trap'
describe('LevelNode', () => {
test('accepts every level-hosted MEP node ID', () => {
const nodes = [
DuctSegmentNode.parse({
path: [
[0, 0, 0],
[1, 0, 0],
],
}),
DuctFittingNode.parse({}),
DuctTerminalNode.parse({}),
HvacEquipmentNode.parse({}),
LinesetNode.parse({
path: [
[0, 0, 0],
[1, 0, 0],
],
}),
LiquidLineNode.parse({
path: [
[0, 0, 0],
[1, 0, 0],
],
}),
PipeSegmentNode.parse({
path: [
[0, 0, 0],
[1, 0, 0],
],
}),
PipeFittingNode.parse({}),
PipeTrapNode.parse({}),
]
expect(LevelNode.parse({ children: nodes.map((node) => node.id) }).children).toEqual(
nodes.map((node) => node.id),
)
})
})
+19 -1
View File
@@ -3,10 +3,19 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling'
import { ColumnNode } from './column'
import { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal'
import { FenceNode } from './fence'
import { GuideNode } from './guide'
import { HvacEquipmentNode } from './hvac-equipment'
import { ItemNode } from './item'
import { LinesetNode } from './lineset'
import { LiquidLineNode } from './liquid-line'
import { MeasurementNode } from './measurement'
import { PipeFittingNode } from './pipe-fitting'
import { PipeSegmentNode } from './pipe-segment'
import { PipeTrapNode } from './pipe-trap'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
import { ShelfNode } from './shelf'
@@ -36,6 +45,15 @@ export const LevelNode = BaseNode.extend({
MeasurementNode.shape.id,
SpawnNode.shape.id,
ShelfNode.shape.id,
DuctSegmentNode.shape.id,
DuctFittingNode.shape.id,
DuctTerminalNode.shape.id,
HvacEquipmentNode.shape.id,
LinesetNode.shape.id,
LiquidLineNode.shape.id,
PipeSegmentNode.shape.id,
PipeFittingNode.shape.id,
PipeTrapNode.shape.id,
]),
)
.default([]),
@@ -44,7 +62,7 @@ export const LevelNode = BaseNode.extend({
}).describe(
dedent`
Level node - used to represent a level in the building
- children: array of floor, wall, ceiling, roof, item nodes
- children: array of architectural, equipment, and MEP distribution nodes
- level: level number
`,
)
@@ -125,12 +125,7 @@ describe('Single-undo dance', () => {
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0)
})
test('commit-returns-false (no change) does NOT consume the prior pastState', () => {
// This is the suspected bend regression: when action.commit returns
// false (draft.curveOffset === ctx.originalCurveOffset), session.commit
// calls scene.restoreAll() but doesn't push to pastStates. Subsequent
// undo pops the PRIOR action (e.g. fence creation), not the no-op
// bend.
test('commit-returns-false adds no step; a later standalone undo reaches the prior action', () => {
useScene.getState().createNode(makeFence(0))
const pastBeforeBend = useScene.temporal.getState().pastStates.length
@@ -143,20 +138,13 @@ describe('Single-undo dance', () => {
scene.resumeHistory()
const pastAfterNoOp = useScene.temporal.getState().pastStates.length
expect(pastAfterNoOp).toBe(pastBeforeBend) // no entries added
expect(pastAfterNoOp).toBe(pastBeforeBend)
// Now undo — this should be a no-op (state unchanged), but pops the create.
// This is an independent undo after the gesture has finished, so it
// correctly reaches the preceding real action rather than inventing a
// no-op history boundary for the cancelled bend.
useScene.temporal.getState().undo()
// ⚠️ Reproduces the bug — undo removes the fence:
const fence = useScene.getState().nodes[FENCE_ID]
if (fence === undefined) {
// Bug reproduced. The "no-op bend" allowed Ctrl-Z to fall through
// to the fence creation. Fix is in action.commit: don't return false
// — push a no-op entry instead, or guard against the cancel path.
expect(fence).toBeUndefined()
} else {
expect((fence as { curveOffset: number }).curveOffset).toBe(0)
}
expect(useScene.getState().nodes[FENCE_ID]).toBeUndefined()
})
test('full session flow via createDragSession with real action.commit dance', async () => {
+139 -11
View File
@@ -1,5 +1,27 @@
import type { Collection, CollectionId } from '../schema/collections'
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
let sceneHistoryPauseDepth = 0
export type SceneSnapshot = {
nodes: Record<AnyNodeId, AnyNode>
rootNodeIds: AnyNodeId[]
collections: Record<CollectionId, Collection>
materials: Record<SceneMaterialId, SceneMaterial>
installedPlugins: string[]
}
export type SceneCommitOrigin = 'local' | 'load' | 'host'
export type SceneCommit = {
origin: SceneCommitOrigin
before: SceneSnapshot
current: SceneSnapshot
}
export type SceneCommitListener = (commit: SceneCommit) => void
type TemporalStoreLike = {
temporal: {
getState(): {
@@ -18,6 +40,104 @@ type TemporalHistoryStoreLike<TPastState> = {
}
}
const sceneCommitListeners = new Set<SceneCommitListener>()
let sceneCommitTransactionDepth = 0
let pendingSceneCommit: SceneCommit | null = null
function areSemanticValuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
if (typeof left !== typeof right || left === null || right === null) return false
if (Array.isArray(left) || Array.isArray(right)) {
if (!(Array.isArray(left) && Array.isArray(right)) || left.length !== right.length) return false
return left.every((value, index) => areSemanticValuesEqual(value, right[index]))
}
if (typeof left !== 'object' || typeof right !== 'object') return false
const leftRecord = left as Record<string, unknown>
const rightRecord = right as Record<string, unknown>
const leftKeys = Object.keys(leftRecord)
const rightKeys = Object.keys(rightRecord)
if (leftKeys.length !== rightKeys.length) return false
for (const key of leftKeys) {
if (!(key in rightRecord) || !areSemanticValuesEqual(leftRecord[key], rightRecord[key])) {
return false
}
}
return true
}
export function areSceneSnapshotsEqual(left: SceneSnapshot, right: SceneSnapshot): boolean {
return (
areSemanticValuesEqual(left.nodes, right.nodes) &&
areSemanticValuesEqual(left.rootNodeIds, right.rootNodeIds) &&
areSemanticValuesEqual(left.collections, right.collections) &&
areSemanticValuesEqual(left.materials, right.materials) &&
areSemanticValuesEqual(left.installedPlugins, right.installedPlugins)
)
}
export function subscribeSceneCommits(listener: SceneCommitListener): () => void {
sceneCommitListeners.add(listener)
return () => {
sceneCommitListeners.delete(listener)
}
}
function emitSceneCommit(commit: SceneCommit): void {
for (const listener of [...sceneCommitListeners]) {
try {
listener(commit)
} catch (error) {
console.error('[Scene] Scene commit listener failed', error)
}
}
}
export function notifySceneCommit(commit: SceneCommit): void {
if (areSceneSnapshotsEqual(commit.before, commit.current)) return
if (sceneCommitTransactionDepth > 0) {
if (pendingSceneCommit) {
pendingSceneCommit = {
origin: pendingSceneCommit.origin,
before: pendingSceneCommit.before,
current: commit.current,
}
} else {
pendingSceneCommit = commit
}
return
}
emitSceneCommit(commit)
}
function beginSceneCommitTransaction(): void {
sceneCommitTransactionDepth += 1
}
function pendingSceneCommitIsNoOp(): boolean {
return Boolean(
pendingSceneCommit &&
areSceneSnapshotsEqual(pendingSceneCommit.before, pendingSceneCommit.current),
)
}
function endSceneCommitTransaction(): void {
if (sceneCommitTransactionDepth === 0) return
sceneCommitTransactionDepth -= 1
if (sceneCommitTransactionDepth > 0) return
const commit = pendingSceneCommit
pendingSceneCommit = null
if (commit && !areSceneSnapshotsEqual(commit.before, commit.current)) {
emitSceneCommit(commit)
}
}
export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().pause()
@@ -65,17 +185,25 @@ export function runAsSingleSceneHistoryStep<TPastState, TResult>(
run: () => TResult,
): TResult {
const beforePastStates = sceneStore.temporal.getState().pastStates
const result = run()
const afterPastStates = sceneStore.temporal.getState().pastStates
const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates)
const addedCount = afterPastStates.length - retainedCount
if (addedCount > 1) {
const firstAddedState = afterPastStates[retainedCount]
if (firstAddedState !== undefined) {
sceneStore.temporal.setState({
pastStates: [...afterPastStates.slice(0, retainedCount), firstAddedState],
})
beginSceneCommitTransaction()
try {
const result = run()
const afterPastStates = sceneStore.temporal.getState().pastStates
const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates)
const addedCount = afterPastStates.length - retainedCount
if (addedCount > 0 && pendingSceneCommitIsNoOp()) {
sceneStore.temporal.setState({ pastStates: afterPastStates.slice(0, retainedCount) })
} else if (addedCount > 1) {
const firstAddedState = afterPastStates[retainedCount]
if (firstAddedState !== undefined) {
sceneStore.temporal.setState({
pastStates: [...afterPastStates.slice(0, retainedCount), firstAddedState],
})
}
}
return result
} finally {
endSceneCommitTransaction()
}
return result
}
@@ -0,0 +1,422 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { BuildingNode } from '../schema/nodes/building'
import { LevelNode } from '../schema/nodes/level'
import { SceneMaterial, type SceneMaterialId } from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import {
areSceneSnapshotsEqual,
pauseSceneHistory,
resumeSceneHistory,
runAsSingleSceneHistoryStep,
type SceneCommit,
type SceneSnapshot,
subscribeSceneCommits,
} from './history-control'
import useLiveNodeOverrides from './use-live-node-overrides'
import useLiveTransforms from './use-live-transforms'
import useScene, {
acquireSceneReadOnlyLease,
applyScenePatch,
applySceneSnapshot,
clearSceneHistory,
} from './use-scene'
type RafFn = (cb: (time: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
cb(0)
return 0
}
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
() => {}
const BUILDING_ID = 'building_commit' as AnyNodeId
const LEVEL_ID = 'level_commit' as AnyNodeId
let unsubscribe = () => {}
function resetScene(): void {
const level = LevelNode.parse({
id: LEVEL_ID,
parentId: BUILDING_ID,
children: [],
level: 0,
})
const building = BuildingNode.parse({
id: BUILDING_ID,
parentId: null,
children: [LEVEL_ID],
})
useScene.setState({
nodes: { [BUILDING_ID]: building, [LEVEL_ID]: level },
rootNodeIds: [BUILDING_ID],
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
materials: {},
readOnly: false,
} as never)
clearSceneHistory()
useLiveNodeOverrides.getState().clearAll()
useLiveTransforms.getState().clearAll()
}
function levelNumber(): number {
return (useScene.getState().nodes[LEVEL_ID] as { level: number }).level
}
function currentSnapshot(): SceneSnapshot {
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
return { nodes, rootNodeIds, collections, materials, installedPlugins }
}
function applyHostNodePatches(
nodeUpdates: Array<
Omit<Parameters<typeof applyScenePatch>[0]['nodeUpdates'][number], 'removeFields'>
>,
) {
return applyScenePatch({
materialChanges: [],
nodeUpdates: nodeUpdates.map((update) => ({ ...update, removeFields: [] })),
})
}
describe('scene commit boundary', () => {
beforeEach(() => {
unsubscribe()
unsubscribe = () => {}
resetScene()
})
afterEach(() => {
unsubscribe()
unsubscribe = () => {}
})
test('emits one local commit with before/current snapshots and skips semantic no-ops', () => {
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
expect(commits).toHaveLength(1)
expect(commits[0]?.origin).toBe('local')
expect((commits[0]?.before.nodes[LEVEL_ID] as { level: number }).level).toBe(0)
expect((commits[0]?.current.nodes[LEVEL_ID] as { level: number }).level).toBe(1)
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
expect(commits).toHaveLength(1)
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
})
test('coalesces a compound transaction into one commit and one undo step', () => {
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
runAsSingleSceneHistoryStep(useScene, () => {
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
useScene.getState().updateNode(LEVEL_ID, { level: 2 } as Partial<AnyNode>)
})
expect(commits).toHaveLength(1)
expect((commits[0]?.before.nodes[LEVEL_ID] as { level: number }).level).toBe(0)
expect((commits[0]?.current.nodes[LEVEL_ID] as { level: number }).level).toBe(2)
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
useScene.temporal.getState().undo()
expect(levelNumber()).toBe(0)
})
test('drops a compound transaction that returns to its semantic baseline', () => {
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
runAsSingleSceneHistoryStep(useScene, () => {
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
useScene.getState().updateNode(LEVEL_ID, { level: 0 } as Partial<AnyNode>)
})
expect(commits).toHaveLength(0)
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
})
test('applies host patches without local history and marks node and parent dirty', () => {
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
useLiveNodeOverrides.getState().set(LEVEL_ID, { level: 99 })
useLiveTransforms.getState().set(LEVEL_ID, { position: [1, 0, 1], rotation: 0 })
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 3 } as Partial<AnyNode> }])).toBe(
true,
)
expect(levelNumber()).toBe(3)
expect(commits.map((commit) => commit.origin)).toEqual(['host'])
expect(commits.filter((commit) => commit.origin === 'local')).toHaveLength(0)
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(true)
expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(true)
expect(useLiveNodeOverrides.getState().get(LEVEL_ID)).toBeUndefined()
expect(useLiveTransforms.getState().get(LEVEL_ID)).toBeUndefined()
})
test('applies material patches atomically and dirties nodes that reference them', () => {
const materialId = 'mat_host' as SceneMaterialId
const material = SceneMaterial.parse({
id: materialId,
name: 'Host red',
material: { properties: { color: '#ff0000' } },
})
useScene.setState((state) => ({
nodes: {
...state.nodes,
[LEVEL_ID]: {
...state.nodes[LEVEL_ID],
slots: { surface: `scene:${materialId}` },
} as AnyNode,
},
}))
clearSceneHistory()
useScene.getState().dirtyNodes.clear()
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
expect(
applyScenePatch({
materialChanges: [{ id: materialId, material }],
nodeUpdates: [],
}),
).toBe(true)
expect(useScene.getState().materials[materialId]).toEqual(material)
expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(true)
expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(true)
expect(commits.map((commit) => commit.origin)).toEqual(['host'])
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
expect(
applyScenePatch({
materialChanges: [{ id: materialId, material: null }],
nodeUpdates: [{ id: LEVEL_ID, data: {}, removeFields: ['slots'] }],
}),
).toBe(true)
expect(useScene.getState().materials[materialId]).toBeUndefined()
expect(useScene.getState().nodes[LEVEL_ID]).not.toHaveProperty('slots')
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
})
test('keeps host patches untracked across nested history pauses', () => {
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
const unsubscribeNestedPause = useScene.subscribe((state, previousState) => {
if (state.nodes === previousState.nodes) return
pauseSceneHistory(useScene)
resumeSceneHistory(useScene)
})
try {
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 3 } as Partial<AnyNode> }])).toBe(
true,
)
} finally {
unsubscribeNestedPause()
}
expect(levelNumber()).toBe(3)
expect(commits.map((commit) => commit.origin)).toEqual(['host'])
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
})
test('applies host patches through read-only and restores the UI lock', () => {
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
useScene.getState().setReadOnly(true)
const beforeState = useScene.getState()
const levelBefore = beforeState.nodes[LEVEL_ID]
const buildingBefore = beforeState.nodes[BUILDING_ID]
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 3 } as Partial<AnyNode> }])).toBe(
true,
)
expect(levelNumber()).toBe(3)
expect(useScene.getState().readOnly).toBe(true)
expect(commits.map((commit) => commit.origin)).toEqual(['host'])
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
expect(useScene.getState().nodes[LEVEL_ID]).toEqual({
...levelBefore,
level: 3,
} as AnyNode)
expect(useScene.getState().nodes[BUILDING_ID]).toBe(buildingBefore)
expect(useScene.getState().rootNodeIds).toBe(beforeState.rootNodeIds)
expect(useScene.getState().collections).toBe(beforeState.collections)
expect(useScene.getState().materials).toBe(beforeState.materials)
useScene.getState().updateNode(LEVEL_ID, { level: 4 } as Partial<AnyNode>)
expect(levelNumber()).toBe(3)
})
test('keeps read-only active until every owner releases its lease', () => {
const releaseHost = acquireSceneReadOnlyLease()
const releasePreview = acquireSceneReadOnlyLease()
expect(useScene.getState().readOnly).toBe(true)
releaseHost()
expect(useScene.getState().readOnly).toBe(true)
releaseHost()
expect(useScene.getState().readOnly).toBe(true)
releasePreview()
expect(useScene.getState().readOnly).toBe(false)
})
test('restores read-only and history tracking when a host patch throws', () => {
const throwingData = {} as Partial<AnyNode>
Object.defineProperty(throwingData, 'level', {
enumerable: true,
get: () => {
throw new Error('update failed')
},
})
useScene.getState().setReadOnly(true)
expect(() => applyHostNodePatches([{ id: LEVEL_ID, data: throwingData }])).toThrow(
'update failed',
)
expect(levelNumber()).toBe(0)
expect(useScene.getState().readOnly).toBe(true)
expect(useScene.temporal.getState().isTracking).toBe(true)
})
test('rejects a host patch atomically when any target is missing', () => {
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
expect(
applyHostNodePatches([
{ id: LEVEL_ID, data: { level: 4 } as Partial<AnyNode> },
{ id: 'level_missing' as AnyNodeId, data: { level: 5 } as Partial<AnyNode> },
]),
).toBe(false)
expect(levelNumber()).toBe(0)
expect(commits).toHaveLength(0)
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
})
test('rejects patches that would change a node identity', () => {
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
expect(
applyHostNodePatches([
{
id: LEVEL_ID,
data: { id: 'level_rekeyed', level: 6 } as Partial<AnyNode>,
},
]),
).toBe(false)
expect(levelNumber()).toBe(0)
expect(useScene.getState().nodes[LEVEL_ID]?.id).toBe(LEVEL_ID)
expect(commits).toHaveLength(0)
})
test('defers host patches while a local interaction has history paused', () => {
pauseSceneHistory(useScene)
try {
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial<AnyNode> }])).toBe(
false,
)
expect(levelNumber()).toBe(0)
expect(useScene.temporal.getState().isTracking).toBe(false)
} finally {
resumeSceneHistory(useScene)
}
})
test('applies a host snapshot as a history floor and clears live state', () => {
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
useLiveNodeOverrides.getState().set(LEVEL_ID, { level: 99 })
useLiveTransforms.getState().set(LEVEL_ID, { position: [1, 0, 1], rotation: 0 })
const snapshot = currentSnapshot()
snapshot.nodes = {
...snapshot.nodes,
[LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], level: 8 } as AnyNode,
}
snapshot.installedPlugins = ['pascal:trees']
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
useScene.getState().dirtyNodes.clear()
expect(applySceneSnapshot(snapshot, { origin: 'host' })).toBe(true)
expect(levelNumber()).toBe(8)
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(commits.map((commit) => commit.origin)).toEqual(['host'])
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
expect(useScene.temporal.getState().futureStates).toHaveLength(0)
expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(true)
expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(true)
expect(useLiveNodeOverrides.getState().get(LEVEL_ID)).toBeUndefined()
expect(useLiveTransforms.getState().get(LEVEL_ID)).toBeUndefined()
})
test('rejects snapshot replacement during a paused interaction', () => {
const snapshot = currentSnapshot()
snapshot.nodes = {
...snapshot.nodes,
[LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], level: 9 } as AnyNode,
}
pauseSceneHistory(useScene)
try {
expect(() => applySceneSnapshot(snapshot, { origin: 'host' })).toThrow('active interaction')
expect(levelNumber()).toBe(0)
expect(useScene.temporal.getState().isTracking).toBe(false)
} finally {
resumeSceneHistory(useScene)
}
})
test('isolates a throwing listener so later listeners and Zundo still run', () => {
const originalConsoleError = console.error
const errorLog = mock(() => {})
console.error = errorLog
const stopThrowing = subscribeSceneCommits(() => {
throw new Error('listener failed')
})
const healthyListener = mock(() => {})
unsubscribe = subscribeSceneCommits(healthyListener)
try {
useScene.getState().updateNode(LEVEL_ID, { level: 2 } as Partial<AnyNode>)
expect(healthyListener).toHaveBeenCalledTimes(1)
expect(errorLog).toHaveBeenCalledTimes(1)
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
} finally {
stopThrowing()
console.error = originalConsoleError
}
})
test('semantic equality short-circuits shared nodes in a large scene', () => {
const nodes: Record<AnyNodeId, AnyNode> = {}
for (let index = 0; index < 1_000; index += 1) {
const id = `level_${index}` as AnyNodeId
nodes[id] = { id, type: 'level', level: index, children: [] } as unknown as AnyNode
}
const left: SceneSnapshot = {
nodes,
rootNodeIds: [],
collections: {},
installedPlugins: [],
materials: {},
}
const right: SceneSnapshot = {
...left,
nodes: { ...nodes, level_999: { ...nodes.level_999 } as AnyNode },
}
expect(areSceneSnapshotsEqual(left, right)).toBe(true)
})
})
+189 -4
View File
@@ -34,7 +34,18 @@ import {
import type { AnyNode, AnyNodeId } from '../schema/types'
import { healSceneNodes } from '../utils/heal-scene-graph'
import * as nodeActions from './actions/node-actions'
import { resetSceneHistoryPauseDepth } from './history-control'
import {
areSceneSnapshotsEqual,
getSceneHistoryPauseDepth,
notifySceneCommit,
pauseSceneHistory,
resetSceneHistoryPauseDepth,
resumeSceneHistory,
type SceneCommitOrigin,
type SceneSnapshot,
} from './history-control'
import useLiveNodeOverrides from './use-live-node-overrides'
import useLiveTransforms from './use-live-transforms'
function getFiniteNumber(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
@@ -1015,6 +1026,16 @@ type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
>
}
function sceneHistorySnapshotFromState(
state: Pick<
SceneState,
'nodes' | 'rootNodeIds' | 'collections' | 'materials' | 'installedPlugins'
>,
): SceneSnapshot {
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state
return { nodes, rootNodeIds, collections, materials, installedPlugins }
}
const useScene: UseSceneStore = create<SceneState>()(
temporal(
(set, get) => ({
@@ -1311,9 +1332,14 @@ const useScene: UseSceneStore = create<SceneState>()(
},
}),
{
partialize: (state) => {
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state
return { nodes, rootNodeIds, collections, materials, installedPlugins }
partialize: (state: SceneState) => sceneHistorySnapshotFromState(state),
equality: (pastState, currentState) => areSceneSnapshotsEqual(pastState, currentState),
onSave: (pastState, currentState) => {
notifySceneCommit({
origin: 'local',
before: sceneHistorySnapshotFromState(pastState),
current: sceneHistorySnapshotFromState(currentState),
})
},
limit: 50, // Limit to last 50 actions
},
@@ -1322,6 +1348,165 @@ const useScene: UseSceneStore = create<SceneState>()(
export default useScene
let sceneReadOnlyLeaseCount = 0
let sceneReadOnlyLeaseBaseline = false
export function acquireSceneReadOnlyLease(): () => void {
if (sceneReadOnlyLeaseCount === 0) {
sceneReadOnlyLeaseBaseline = useScene.getState().readOnly
}
sceneReadOnlyLeaseCount += 1
useScene.setState({ readOnly: true })
let released = false
return () => {
if (released) return
released = true
sceneReadOnlyLeaseCount = Math.max(0, sceneReadOnlyLeaseCount - 1)
if (sceneReadOnlyLeaseCount > 0) return
useScene.setState({ readOnly: sceneReadOnlyLeaseBaseline })
sceneReadOnlyLeaseBaseline = false
}
}
export type SceneNodePatch = {
id: AnyNodeId
data: Partial<AnyNode>
removeFields: string[]
}
export type SceneMaterialPatch = {
id: SceneMaterialId
material: SceneMaterial | null
}
export type ScenePatch = {
materialChanges: SceneMaterialPatch[]
nodeUpdates: SceneNodePatch[]
}
export function applyScenePatch(changes: ScenePatch): boolean {
const beforeState = useScene.getState()
const hasInvalidNodeTarget = changes.nodeUpdates.some(({ id, data, removeFields }) => {
const node = beforeState.nodes[id]
if (!node) return true
if ('id' in data && data.id !== node.id) return true
if ('type' in data && data.type !== node.type) return true
if ('object' in data && data.object !== node.object) return true
if (removeFields.some((field) => field === 'id' || field === 'object' || field === 'type')) {
return true
}
return removeFields.some((field) => Object.hasOwn(data, field))
})
const hasInvalidMaterialTarget = changes.materialChanges.some(
({ id, material }) => material !== null && material.id !== id,
)
if (
(changes.nodeUpdates.length === 0 && changes.materialChanges.length === 0) ||
hasInvalidNodeTarget ||
hasInvalidMaterialTarget
) {
return false
}
const temporalState = useScene.temporal.getState()
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) return false
const before = sceneHistorySnapshotFromState(beforeState)
pauseSceneHistory(useScene)
try {
// Host-owned fields bypass the UI lock without running local mutation cascades.
useScene.setState((state) => {
const nodes = changes.nodeUpdates.length > 0 ? { ...state.nodes } : state.nodes
for (const { id, data, removeFields } of changes.nodeUpdates) {
const node = nodes[id]
if (!node) return {}
const nextNode = { ...node, ...data }
for (const field of removeFields) delete nextNode[field as keyof typeof nextNode]
nodes[id] = nextNode as AnyNode
}
const materials =
changes.materialChanges.length > 0 ? { ...state.materials } : state.materials
for (const { id, material } of changes.materialChanges) {
if (material === null) {
delete materials[id]
} else {
materials[id] = material
}
}
return { materials, nodes }
})
} finally {
resumeSceneHistory(useScene)
}
const currentState = useScene.getState()
const current = sceneHistorySnapshotFromState(currentState)
for (const { id } of changes.nodeUpdates) {
useLiveNodeOverrides.getState().clear(id)
useLiveTransforms.getState().clear(id)
}
if (areSceneSnapshotsEqual(before, current)) return false
for (const { id } of changes.nodeUpdates) {
currentState.markDirty(id)
const beforeParentId = before.nodes[id]?.parentId as AnyNodeId | null | undefined
const currentParentId = current.nodes[id]?.parentId as AnyNodeId | null | undefined
if (beforeParentId) currentState.markDirty(beforeParentId)
if (currentParentId) currentState.markDirty(currentParentId)
}
if (changes.materialChanges.length > 0) {
const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id)))
for (const node of Object.values(current.nodes)) {
const slots = 'slots' in node ? node.slots : undefined
if (!(slots && Object.values(slots).some((ref) => materialRefs.has(ref)))) continue
currentState.markDirty(node.id)
if (node.parentId) currentState.markDirty(node.parentId as AnyNodeId)
}
}
notifySceneCommit({
origin: 'host',
before,
current,
})
return true
}
export type ApplySceneSnapshotOptions = {
origin: Extract<SceneCommitOrigin, 'load' | 'host'>
}
export function applySceneSnapshot(
snapshot: SceneSnapshot,
options: ApplySceneSnapshotOptions,
): boolean {
const before = sceneHistorySnapshotFromState(useScene.getState())
const temporalState = useScene.temporal.getState()
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) {
throw new Error('Cannot replace the scene snapshot during an active interaction')
}
pauseSceneHistory(useScene)
try {
useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds, {
collections: snapshot.collections,
installedPlugins: snapshot.installedPlugins,
materials: snapshot.materials,
})
useScene.temporal.getState().clear()
} finally {
resumeSceneHistory(useScene)
}
useLiveNodeOverrides.getState().clearAll()
useLiveTransforms.getState().clearAll()
const current = sceneHistorySnapshotFromState(useScene.getState())
if (areSceneSnapshotsEqual(before, current)) return false
notifySceneCommit({ origin: options.origin, before, current })
return true
}
// Track previous temporal state lengths and node snapshot for diffing
let prevPastLength = 0
let prevFutureLength = 0
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
BuildingNode,
ElevatorNode,
LevelNode,
SlabNode,
} from '../../schema'
import { type SceneCommit, subscribeSceneCommits } from '../../store/history-control'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { initializeElevatorOpeningSync } from './elevator-opening-system'
type RafFn = (callback: (time: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (
callback,
) => {
callback(0)
return 0
}
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
() => {}
const BUILDING_ID = 'building_elevator_opening_commit' as AnyNodeId
const ELEVATOR_ID = 'elevator_opening_commit' as AnyNodeId
const GROUND_LEVEL_ID = 'level_elevator_opening_ground' as AnyNodeId
const UPPER_LEVEL_ID = 'level_elevator_opening_upper' as AnyNodeId
const UPPER_SLAB_ID = 'slab_elevator_opening_upper' as AnyNodeId
let stopOpeningSync = () => {}
let stopCommitSubscription = () => {}
function resetScene() {
const ground = LevelNode.parse({
id: GROUND_LEVEL_ID,
children: [],
level: 0,
parentId: BUILDING_ID,
})
const upper = LevelNode.parse({
id: UPPER_LEVEL_ID,
children: [UPPER_SLAB_ID],
level: 1,
parentId: BUILDING_ID,
})
const elevator = ElevatorNode.parse({
id: ELEVATOR_ID,
depth: 1.6,
fromLevelId: GROUND_LEVEL_ID,
parentId: BUILDING_ID,
position: [2, 0, 1.5],
toLevelId: UPPER_LEVEL_ID,
visible: false,
width: 1.6,
})
const upperSlab = SlabNode.parse({
id: UPPER_SLAB_ID,
holes: [],
parentId: UPPER_LEVEL_ID,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const building = BuildingNode.parse({
id: BUILDING_ID,
children: [GROUND_LEVEL_ID, UPPER_LEVEL_ID, ELEVATOR_ID],
})
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
materials: {},
nodes: Object.fromEntries(
[building, ground, upper, elevator, upperSlab].map((node) => [node.id, node]),
) as Record<AnyNodeId, AnyNode>,
readOnly: false,
rootNodeIds: [BUILDING_ID],
} as never)
clearSceneHistory()
}
function getOpeningCenter(nodes: Record<AnyNodeId, AnyNode>): [number, number] | null {
const slab = nodes[UPPER_SLAB_ID] as { holes?: [number, number][][] }
const opening = slab.holes?.[0]
if (!opening || opening.length === 0) return null
const [x, z] = opening.reduce(([sumX, sumZ], point) => [sumX + point[0], sumZ + point[1]], [0, 0])
return [x / opening.length, z / opening.length]
}
function getElevatorPosition(nodes: Record<AnyNodeId, AnyNode>) {
return (nodes[ELEVATOR_ID] as { position?: [number, number, number] }).position
}
describe('ElevatorOpeningSystem scene commit boundary', () => {
beforeEach(() => {
stopOpeningSync()
stopCommitSubscription()
stopOpeningSync = () => {}
stopCommitSubscription = () => {}
resetScene()
})
afterEach(() => {
stopOpeningSync()
stopCommitSubscription()
stopOpeningSync = () => {}
stopCommitSubscription = () => {}
})
test('includes the elevator edit and derived slab opening in one commit and undo step', () => {
const commits: SceneCommit[] = []
stopOpeningSync = initializeElevatorOpeningSync()
stopCommitSubscription = subscribeSceneCommits((commit) => commits.push(commit))
useScene.getState().updateNode(ELEVATOR_ID, { visible: true } as Partial<AnyNode>)
expect(commits).toHaveLength(1)
expect(commits[0]?.origin).toBe('local')
expect(commits[0]?.before.nodes[ELEVATOR_ID]?.visible).toBe(false)
expect(commits[0]?.current.nodes[ELEVATOR_ID]?.visible).toBe(true)
expect((commits[0]?.before.nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toEqual([])
expect((commits[0]?.current.nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toHaveLength(
1,
)
expect(
(commits[0]?.current.nodes[UPPER_SLAB_ID] as { holeMetadata?: unknown[] }).holeMetadata,
).toEqual([{ elevatorId: ELEVATOR_ID, source: 'elevator' }])
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
useScene.temporal.getState().undo()
expect(useScene.getState().nodes[ELEVATOR_ID]?.visible).toBe(false)
expect((useScene.getState().nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toEqual([])
})
test('processes a second relevant elevator mutation in the same turn', () => {
const commits: SceneCommit[] = []
stopOpeningSync = initializeElevatorOpeningSync()
stopCommitSubscription = subscribeSceneCommits((commit) => commits.push(commit))
useScene.getState().updateNode(ELEVATOR_ID, { visible: true } as Partial<AnyNode>)
useScene.getState().updateNode(ELEVATOR_ID, { position: [3, 0, 1.5] } as Partial<AnyNode>)
expect(commits).toHaveLength(2)
expect(getOpeningCenter(commits[0]!.current.nodes)).toEqual([2, 1.5])
expect(getElevatorPosition(commits[1]!.before.nodes)).toEqual([2, 0, 1.5])
expect(getOpeningCenter(commits[1]!.before.nodes)).toEqual([2, 1.5])
expect(getElevatorPosition(commits[1]!.current.nodes)).toEqual([3, 0, 1.5])
expect(getOpeningCenter(commits[1]!.current.nodes)).toEqual([3, 1.5])
expect(useScene.temporal.getState().pastStates).toHaveLength(2)
useScene.temporal.getState().undo()
expect(getElevatorPosition(useScene.getState().nodes)).toEqual([2, 0, 1.5])
expect(getOpeningCenter(useScene.getState().nodes)).toEqual([2, 1.5])
useScene.temporal.getState().undo()
expect(useScene.getState().nodes[ELEVATOR_ID]?.visible).toBe(false)
expect((useScene.getState().nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toEqual([])
})
})
@@ -1,7 +1,8 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect } from 'react'
import type { AnyNode } from '../../schema'
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
import useScene from '../../store/use-scene'
import { syncAutoElevatorOpenings } from './elevator-opening-sync'
@@ -32,27 +33,32 @@ function hasOpeningRelevantNodeChange(
return false
}
export const ElevatorOpeningSystem = () => {
const syncingAutoOpeningsRef = useRef(false)
export function initializeElevatorOpeningSync() {
let syncingAutoOpenings = false
useEffect(() => {
const applyUpdates = (updates: ReturnType<typeof syncAutoElevatorOpenings>) => {
if (updates.length === 0) return
syncingAutoOpeningsRef.current = true
const applyUpdates = (updates: ReturnType<typeof syncAutoElevatorOpenings>) => {
if (updates.length === 0) return
syncingAutoOpenings = true
pauseSceneHistory(useScene)
try {
useScene.getState().updateNodes(updates)
queueMicrotask(() => {
syncingAutoOpeningsRef.current = false
})
} finally {
resumeSceneHistory(useScene)
syncingAutoOpenings = false
}
}
applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes))
applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes))
return useScene.subscribe((state, prevState) => {
if (syncingAutoOpeningsRef.current) return
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
applyUpdates(syncAutoElevatorOpenings(state.nodes))
})
}, [])
return useScene.subscribe((state, prevState) => {
if (syncingAutoOpenings) return
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
applyUpdates(syncAutoElevatorOpenings(state.nodes))
})
}
export const ElevatorOpeningSystem = () => {
useEffect(() => initializeElevatorOpeningSync(), [])
return null
}