Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/core/src/events/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type {
CabinetModuleEvent,
CameraControlEvent,
CameraControlFitSceneEvent,
CameraPose,
CeilingEvent,
ChimneyEvent,
ColumnEvent,
Expand Down Expand Up @@ -179,6 +180,11 @@ export {
resetSceneHistoryPauseDepth,
resumeSceneHistory,
runAsSingleSceneHistoryStep,
type SceneCommit,
type SceneCommitListener,
type SceneCommitOrigin,
type SceneSnapshot,
subscribeSceneCommits,
} from './store/history-control'
export {
type ControlValue,
Expand All @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/schema/nodes/level.test.ts
Original file line number Diff line number Diff line change
@@ -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),
)
})
})
20 changes: 19 additions & 1 deletion packages/core/src/schema/nodes/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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([]),
Expand All @@ -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
`,
)
Expand Down
24 changes: 6 additions & 18 deletions packages/core/src/services/single-undo-dance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 () => {
Expand Down
150 changes: 139 additions & 11 deletions packages/core/src/store/history-control.ts
Original file line number Diff line number Diff line change
@@ -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(): {
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
Loading
Loading