feat(core): expose generic host integration primitives
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user