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 = { type CameraControlEvents = {
'camera-controls:view': CameraControlEvent 'camera-controls:view': CameraControlEvent
'camera-controls:focus': CameraControlEvent 'camera-controls:focus': CameraControlEvent
@@ -207,6 +216,10 @@ type CameraControlEvents = {
'camera-controls:orbit-ccw': undefined 'camera-controls:orbit-ccw': undefined
'camera-controls:fit-scene': CameraControlFitSceneEvent 'camera-controls:fit-scene': CameraControlFitSceneEvent
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent '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 = { type ToolEvents = {
+17 -1
View File
@@ -5,6 +5,7 @@ export type {
CabinetModuleEvent, CabinetModuleEvent,
CameraControlEvent, CameraControlEvent,
CameraControlFitSceneEvent, CameraControlFitSceneEvent,
CameraPose,
CeilingEvent, CeilingEvent,
ChimneyEvent, ChimneyEvent,
ColumnEvent, ColumnEvent,
@@ -179,6 +180,11 @@ export {
resetSceneHistoryPauseDepth, resetSceneHistoryPauseDepth,
resumeSceneHistory, resumeSceneHistory,
runAsSingleSceneHistoryStep, runAsSingleSceneHistoryStep,
type SceneCommit,
type SceneCommitListener,
type SceneCommitOrigin,
type SceneSnapshot,
subscribeSceneCommits,
} from './store/history-control' } from './store/history-control'
export { export {
type ControlValue, type ControlValue,
@@ -199,7 +205,17 @@ export {
type LiveNodeOverrides, type LiveNodeOverrides,
} from './store/use-live-node-overrides' } from './store/use-live-node-overrides'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' 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 { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
export { export {
type ElevatorDoorSide, 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 { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling' import { CeilingNode } from './ceiling'
import { ColumnNode } from './column' import { ColumnNode } from './column'
import { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal'
import { FenceNode } from './fence' import { FenceNode } from './fence'
import { GuideNode } from './guide' import { GuideNode } from './guide'
import { HvacEquipmentNode } from './hvac-equipment'
import { ItemNode } from './item' import { ItemNode } from './item'
import { LinesetNode } from './lineset'
import { LiquidLineNode } from './liquid-line'
import { MeasurementNode } from './measurement' import { MeasurementNode } from './measurement'
import { PipeFittingNode } from './pipe-fitting'
import { PipeSegmentNode } from './pipe-segment'
import { PipeTrapNode } from './pipe-trap'
import { RoofNode } from './roof' import { RoofNode } from './roof'
import { ScanNode } from './scan' import { ScanNode } from './scan'
import { ShelfNode } from './shelf' import { ShelfNode } from './shelf'
@@ -36,6 +45,15 @@ export const LevelNode = BaseNode.extend({
MeasurementNode.shape.id, MeasurementNode.shape.id,
SpawnNode.shape.id, SpawnNode.shape.id,
ShelfNode.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([]), .default([]),
@@ -44,7 +62,7 @@ export const LevelNode = BaseNode.extend({
}).describe( }).describe(
dedent` dedent`
Level node - used to represent a level in the building 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 - level: level number
`, `,
) )
@@ -125,12 +125,7 @@ describe('Single-undo dance', () => {
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0) expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0)
}) })
test('commit-returns-false (no change) does NOT consume the prior pastState', () => { test('commit-returns-false adds no step; a later standalone undo reaches the prior action', () => {
// 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.
useScene.getState().createNode(makeFence(0)) useScene.getState().createNode(makeFence(0))
const pastBeforeBend = useScene.temporal.getState().pastStates.length const pastBeforeBend = useScene.temporal.getState().pastStates.length
@@ -143,20 +138,13 @@ describe('Single-undo dance', () => {
scene.resumeHistory() scene.resumeHistory()
const pastAfterNoOp = useScene.temporal.getState().pastStates.length 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() useScene.temporal.getState().undo()
// ⚠️ Reproduces the bug — undo removes the fence: expect(useScene.getState().nodes[FENCE_ID]).toBeUndefined()
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)
}
}) })
test('full session flow via createDragSession with real action.commit dance', async () => { test('full session flow via createDragSession with real action.commit dance', async () => {
+129 -1
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 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 = { type TemporalStoreLike = {
temporal: { temporal: {
getState(): { 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 { export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) { if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().pause() sceneStore.temporal.getState().pause()
@@ -65,11 +185,16 @@ export function runAsSingleSceneHistoryStep<TPastState, TResult>(
run: () => TResult, run: () => TResult,
): TResult { ): TResult {
const beforePastStates = sceneStore.temporal.getState().pastStates const beforePastStates = sceneStore.temporal.getState().pastStates
beginSceneCommitTransaction()
try {
const result = run() const result = run()
const afterPastStates = sceneStore.temporal.getState().pastStates const afterPastStates = sceneStore.temporal.getState().pastStates
const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates) const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates)
const addedCount = afterPastStates.length - retainedCount const addedCount = afterPastStates.length - retainedCount
if (addedCount > 1) {
if (addedCount > 0 && pendingSceneCommitIsNoOp()) {
sceneStore.temporal.setState({ pastStates: afterPastStates.slice(0, retainedCount) })
} else if (addedCount > 1) {
const firstAddedState = afterPastStates[retainedCount] const firstAddedState = afterPastStates[retainedCount]
if (firstAddedState !== undefined) { if (firstAddedState !== undefined) {
sceneStore.temporal.setState({ sceneStore.temporal.setState({
@@ -78,4 +203,7 @@ export function runAsSingleSceneHistoryStep<TPastState, TResult>(
} }
} }
return result return result
} finally {
endSceneCommitTransaction()
}
} }
@@ -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 type { AnyNode, AnyNodeId } from '../schema/types'
import { healSceneNodes } from '../utils/heal-scene-graph' import { healSceneNodes } from '../utils/heal-scene-graph'
import * as nodeActions from './actions/node-actions' 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) { function getFiniteNumber(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback 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>()( const useScene: UseSceneStore = create<SceneState>()(
temporal( temporal(
(set, get) => ({ (set, get) => ({
@@ -1311,9 +1332,14 @@ const useScene: UseSceneStore = create<SceneState>()(
}, },
}), }),
{ {
partialize: (state) => { partialize: (state: SceneState) => sceneHistorySnapshotFromState(state),
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state equality: (pastState, currentState) => areSceneSnapshotsEqual(pastState, currentState),
return { nodes, rootNodeIds, collections, materials, installedPlugins } onSave: (pastState, currentState) => {
notifySceneCommit({
origin: 'local',
before: sceneHistorySnapshotFromState(pastState),
current: sceneHistorySnapshotFromState(currentState),
})
}, },
limit: 50, // Limit to last 50 actions limit: 50, // Limit to last 50 actions
}, },
@@ -1322,6 +1348,165 @@ const useScene: UseSceneStore = create<SceneState>()(
export default useScene 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 // Track previous temporal state lengths and node snapshot for diffing
let prevPastLength = 0 let prevPastLength = 0
let prevFutureLength = 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' 'use client'
import { useEffect, useRef } from 'react' import { useEffect } from 'react'
import type { AnyNode } from '../../schema' import type { AnyNode } from '../../schema'
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { syncAutoElevatorOpenings } from './elevator-opening-sync' import { syncAutoElevatorOpenings } from './elevator-opening-sync'
@@ -32,27 +33,32 @@ function hasOpeningRelevantNodeChange(
return false return false
} }
export const ElevatorOpeningSystem = () => { export function initializeElevatorOpeningSync() {
const syncingAutoOpeningsRef = useRef(false) let syncingAutoOpenings = false
useEffect(() => {
const applyUpdates = (updates: ReturnType<typeof syncAutoElevatorOpenings>) => { const applyUpdates = (updates: ReturnType<typeof syncAutoElevatorOpenings>) => {
if (updates.length === 0) return if (updates.length === 0) return
syncingAutoOpeningsRef.current = true syncingAutoOpenings = true
pauseSceneHistory(useScene)
try {
useScene.getState().updateNodes(updates) useScene.getState().updateNodes(updates)
queueMicrotask(() => { } finally {
syncingAutoOpeningsRef.current = false resumeSceneHistory(useScene)
}) syncingAutoOpenings = false
}
} }
applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes)) applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes))
return useScene.subscribe((state, prevState) => { return useScene.subscribe((state, prevState) => {
if (syncingAutoOpeningsRef.current) return if (syncingAutoOpenings) return
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
applyUpdates(syncAutoElevatorOpenings(state.nodes)) applyUpdates(syncAutoElevatorOpenings(state.nodes))
}) })
}, []) }
export const ElevatorOpeningSystem = () => {
useEffect(() => initializeElevatorOpeningSync(), [])
return null return null
} }
@@ -197,6 +197,10 @@ export function FloorplanRegistryMoveOverlay() {
const commitFinalStateOrRevert = () => { const commitFinalStateOrRevert = () => {
const commitValid = session.canCommit() const commitValid = session.canCommit()
const freshPlacement = isFreshPlacementMetadata(
(useScene.getState().nodes[movingNode.id] as { metadata?: unknown } | undefined)
?.metadata,
)
// Claim ownership of the drag teardown so the 3D move tool's // Claim ownership of the drag teardown so the 3D move tool's
// unmount-time cleanup skips its restore-from-snapshot — see // unmount-time cleanup skips its restore-from-snapshot — see
@@ -210,6 +214,29 @@ export function FloorplanRegistryMoveOverlay() {
// planner). For those we still do Phase 1 (revert to baseline) // planner). For those we still do Phase 1 (revert to baseline)
// and Phase 2's resume — but Phase 2's write is delegated, and // and Phase 2's resume — but Phase 2's write is delegated, and
// we skip the snapshot-diff finalUpdates path. // we skip the snapshot-diff finalUpdates path.
if (commitValid && freshPlacement) {
session.commit?.()
const stagedNode = useScene.getState().nodes[movingNode.id]
const committedId = stagedNode
? commitFreshPlacementSubtree(
movingNode.id as AnyNodeId,
{
metadata: stripPlacementMetadataFlags(stagedNode.metadata),
visible: true,
} as Partial<AnyNode>,
)
: null
if (historyPaused) {
resumeSceneHistory(useScene)
historyPaused = false
}
if (committedId) {
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [committedId] })
}
return
}
if (commitValid && session.commit) { if (commitValid && session.commit) {
useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
if (historyPaused) { if (historyPaused) {
@@ -12,6 +12,48 @@ import { memo } from 'react'
import usePlacementPreview from '../../../store/use-placement-preview' import usePlacementPreview from '../../../store/use-placement-preview'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
export interface FloorplanNodePreviewProps {
node: AnyNode
parentNode?: AnyNode | null
opacity?: number
className?: string
}
/**
* Stateless floor-plan ghost for an already-positioned node. Hosts can use
* this to render host-owned placement previews without publishing transient
* state into the editor's local placement-preview store.
*/
export const FloorplanNodePreview = memo(function FloorplanNodePreview({
node,
parentNode = null,
opacity = 0.5,
className,
}: FloorplanNodePreviewProps) {
const builder = nodeRegistry.get(node.type)?.floorplan
if (!builder) return null
const ctx = {
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
children: [],
siblings: [],
parent: parentNode,
viewState: undefined,
} as unknown as GeometryContext
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
node,
ctx,
)
if (!geometry) return null
return (
<g className={className} opacity={opacity} pointerEvents="none">
<FloorplanGeometryRenderer geometry={geometry} />
</g>
)
})
/** /**
* Renders a faint, non-interactive ghost of the node being placed by a * Renders a faint, non-interactive ghost of the node being placed by a
* registry placement tool (e.g. column), following the cursor in the floor * registry placement tool (e.g. column), following the cursor in the floor
@@ -30,33 +72,9 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
const parentNode = usePlacementPreview((s) => s.parentNode) const parentNode = usePlacementPreview((s) => s.parentNode)
if (!node) return null if (!node) return null
const builder = nodeRegistry.get(node.type)?.floorplan
if (!builder) return null
// Minimal, unselected context — preview never shows selection chrome
// (move handles / resize arrows / hatch live behind `viewState.selected`).
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
// and `parent: null` short-circuits the elevator's level walk) so the layer
// never subscribes to / bulk-reads the nodes map during render.
// `parentNode` is the synthetic wall for an off-wall door/window ghost so
// its builder draws the real swing-arc / pane symbol (see use-placement-preview).
const ctx = {
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
children: [],
siblings: [],
parent: parentNode ?? null,
viewState: undefined,
} as unknown as GeometryContext
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
node,
ctx,
)
if (!geometry) return null
return ( return (
<g data-floorplan-placement-preview opacity={0.5} pointerEvents="none"> <g data-floorplan-placement-preview>
<FloorplanGeometryRenderer geometry={geometry} /> <FloorplanNodePreview node={node} parentNode={parentNode} />
</g> </g>
) )
}) })
@@ -4,6 +4,7 @@ import {
type AnyNodeId, type AnyNodeId,
type CameraControlEvent, type CameraControlEvent,
type CameraControlFitSceneEvent, type CameraControlFitSceneEvent,
type CameraPose,
emitter, emitter,
sceneRegistry, sceneRegistry,
useScene, useScene,
@@ -20,6 +21,13 @@ import {
Spherical, Spherical,
Vector3, Vector3,
} from 'three' } from 'three'
import {
type CameraPoseApplicationPlan,
normalizeCameraPose,
planCameraPoseApplication,
stepCameraPoseInterpolation,
withCameraPoseDistance,
} from '../../lib/camera-pose'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { import {
@@ -35,6 +43,8 @@ const tempDelta = new Vector3()
const tempPosition = new Vector3() const tempPosition = new Vector3()
const tempSize = new Vector3() const tempSize = new Vector3()
const tempTarget = new Vector3() const tempTarget = new Vector3()
const transitionFreezePosition = new Vector3()
const transitionFreezeTarget = new Vector3()
const syncTarget = new Vector3() const syncTarget = new Vector3()
const syncSpherical = new Spherical() const syncSpherical = new Spherical()
const keyboardPanSpherical = new Spherical() const keyboardPanSpherical = new Spherical()
@@ -97,6 +107,21 @@ function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot
) )
} }
function freezeCameraControlTransition(control: CameraControlsImpl) {
// `stop()` snaps to the transition endpoint, so replace it with the current pose instead.
control.getPosition(transitionFreezePosition, false)
control.getTarget(transitionFreezeTarget, false)
void control.setLookAt(
transitionFreezePosition.x,
transitionFreezePosition.y,
transitionFreezePosition.z,
transitionFreezeTarget.x,
transitionFreezeTarget.y,
transitionFreezeTarget.z,
false,
)
}
function isEditableKeyboardTarget(target: EventTarget | null) { function isEditableKeyboardTarget(target: EventTarget | null) {
return ( return (
target instanceof HTMLInputElement || target instanceof HTMLInputElement ||
@@ -272,14 +297,18 @@ function resolveCameraViewWidthUpdate(
return { type: 'none', viewWidth } return { type: 'none', viewWidth }
} }
function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) { function applyCameraViewWidth(
control: CameraControlsImpl,
update: CameraViewWidthUpdate,
enableTransition = true,
) {
if (update.type === 'distance') { if (update.type === 'distance') {
control.dollyTo(update.distance, true) control.dollyTo(update.distance, enableTransition)
return return
} }
if (update.type === 'zoom') { if (update.type === 'zoom') {
control.zoomTo(update.zoom, true) control.zoomTo(update.zoom, enableTransition)
} }
} }
@@ -347,6 +376,13 @@ function useFirstPersonCameraPoseRestore(
export const CustomCameraControls = () => { export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl | null>(null) const controls = useRef<CameraControlsImpl | null>(null)
const pendingAppliedPose = useRef<CameraPoseApplicationPlan | null>(null)
const activePoseInterpolation = useRef<{
camera: Camera
control: CameraControlsImpl
plan: CameraPoseApplicationPlan
} | null>(null)
const suppressPoseEvents = useRef(false)
const keyboardPanKeys = useRef<KeyboardPanState>({ const keyboardPanKeys = useRef<KeyboardPanState>({
forward: false, forward: false,
backward: false, backward: false,
@@ -391,6 +427,120 @@ export const CustomCameraControls = () => {
raycaster.layers.enable(ZONE_LAYER) raycaster.layers.enable(ZONE_LAYER)
}, [camera, raycaster]) }, [camera, raycaster])
const freezeActivePoseInterpolation = useCallback(() => {
const active = activePoseInterpolation.current
if (!active) return
activePoseInterpolation.current = null
freezeCameraControlTransition(active.control)
}, [])
const cancelPoseApplication = useCallback(() => {
pendingAppliedPose.current = null
try {
freezeActivePoseInterpolation()
} finally {
suppressPoseEvents.current = false
}
}, [freezeActivePoseInterpolation])
const beginLocalCameraInteraction = useCallback(() => {
cancelPoseApplication()
emitter.emit('camera-controls:interaction-start', undefined)
}, [cancelPoseApplication])
const applyPendingPose = useCallback(() => {
if (isFirstPersonMode) {
cancelPoseApplication()
return
}
const plan = pendingAppliedPose.current
const control = controls.current
const active = activePoseInterpolation.current
if (active && (active.camera !== camera || active.control !== control)) {
try {
freezeActivePoseInterpolation()
} finally {
if (!plan) suppressPoseEvents.current = false
}
}
if (!(plan && control)) return
const cameraMatchesProjection =
(plan.pose.projection === 'perspective' && isPerspectiveCamera(camera)) ||
(plan.pose.projection === 'orthographic' && isOrthographicCamera(camera))
if (!cameraMatchesProjection) return
pendingAppliedPose.current = null
if (plan.perspectiveFov !== null && isPerspectiveCamera(camera)) {
camera.fov = plan.perspectiveFov
camera.updateProjectionMatrix()
}
let appliedPlan = plan
if (plan.pose.viewWidth !== undefined) {
const viewWidthUpdate = resolveCameraViewWidthUpdate(
control,
camera,
plan.pose.viewWidth,
viewportSize,
)
if (viewWidthUpdate.type === 'distance') {
appliedPlan = {
...plan,
pose: withCameraPoseDistance(plan.pose, viewWidthUpdate.distance),
}
} else if (viewWidthUpdate.type === 'zoom') {
applyCameraViewWidth(control, viewWidthUpdate, false)
}
}
clearPendingFloorplanNavigationPose()
activePoseInterpolation.current = { camera, control, plan: appliedPlan }
}, [
camera,
cancelPoseApplication,
clearPendingFloorplanNavigationPose,
freezeActivePoseInterpolation,
isFirstPersonMode,
viewportSize,
])
useEffect(() => {
applyPendingPose()
}, [applyPendingPose])
useEffect(() => {
const handleAppliedPose = (pose: CameraPose) => {
if (isFirstPersonMode) return
const plan = planCameraPoseApplication(pose)
if (!plan) return
pendingAppliedPose.current = plan
suppressPoseEvents.current = true
if (useViewer.getState().cameraMode !== plan.pose.projection) {
freezeActivePoseInterpolation()
useViewer.getState().setCameraMode(plan.pose.projection)
return
}
applyPendingPose()
}
emitter.on('camera-controls:apply-pose', handleAppliedPose)
emitter.on('camera-controls:cancel-pose', cancelPoseApplication)
return () => {
emitter.off('camera-controls:apply-pose', handleAppliedPose)
emitter.off('camera-controls:cancel-pose', cancelPoseApplication)
}
}, [applyPendingPose, cancelPoseApplication, freezeActivePoseInterpolation, isFirstPersonMode])
useEffect(() => cancelPoseApplication, [cancelPoseApplication])
useEffect(() => { useEffect(() => {
// Dev-only: deterministic camera poses for screenshot/automation tooling. // Dev-only: deterministic camera poses for screenshot/automation tooling.
// A getter, not a snapshot — drei recreates the impl when the default // A getter, not a snapshot — drei recreates the impl when the default
@@ -568,6 +718,36 @@ export const CustomCameraControls = () => {
}) })
}, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize]) }, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
const publishCurrentPose = useCallback(() => {
if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return
controls.current.getPosition(tempPosition, false)
controls.current.getTarget(tempTarget, false)
const targetDistance = tempPosition.distanceTo(tempTarget)
const projection = isPerspectiveCamera(camera)
? 'perspective'
: isOrthographicCamera(camera)
? 'orthographic'
: null
if (!projection) return
const pose = normalizeCameraPose({
position: [tempPosition.x, tempPosition.y, tempPosition.z],
target: [tempTarget.x, tempTarget.y, tempTarget.z],
projection,
viewWidth: getCameraViewWidth(camera, targetDistance, viewportSize),
...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
})
if (pose) {
emitter.emit('camera-controls:pose', pose)
}
}, [camera, isFirstPersonMode, viewportSize])
const handleCameraUpdate = useCallback(() => {
publishCurrentNavigationPose()
publishCurrentPose()
}, [publishCurrentNavigationPose, publishCurrentPose])
useEffect(() => { useEffect(() => {
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
@@ -584,6 +764,49 @@ export const CustomCameraControls = () => {
useFrame((_, delta) => { useFrame((_, delta) => {
if (isFirstPersonMode || !controls.current) return if (isFirstPersonMode || !controls.current) return
const activePose = activePoseInterpolation.current
if (activePose) {
const control = controls.current
if (activePose.camera !== camera || activePose.control !== control) {
try {
freezeActivePoseInterpolation()
} finally {
if (!pendingAppliedPose.current) suppressPoseEvents.current = false
}
} else {
control.getPosition(tempPosition, false)
control.getTarget(tempTarget, false)
const step = stepCameraPoseInterpolation(
[tempPosition.x, tempPosition.y, tempPosition.z],
[tempTarget.x, tempTarget.y, tempTarget.z],
activePose.plan.pose,
delta,
)
try {
// Transition-enabled calls retain one rest listener each, so this frame loop owns smoothing.
void control.setLookAt(
step.position[0],
step.position[1],
step.position[2],
step.target[0],
step.target[1],
step.target[2],
false,
)
control.update(0)
} catch {
if (activePoseInterpolation.current === activePose) {
activePoseInterpolation.current = null
suppressPoseEvents.current = false
}
}
if (step.settled && activePoseInterpolation.current === activePose) {
activePoseInterpolation.current = null
suppressPoseEvents.current = false
}
}
}
const panKeys = keyboardPanKeys.current const panKeys = keyboardPanKeys.current
const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0) const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0) const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
@@ -602,7 +825,7 @@ export const CustomCameraControls = () => {
clearPendingFloorplanNavigationPose() clearPendingFloorplanNavigationPose()
if (horizontal !== 0) control.truck(horizontal * step, 0, true) if (horizontal !== 0) control.truck(horizontal * step, 0, true)
if (vertical !== 0) control.forward(vertical * step, true) if (vertical !== 0) control.forward(vertical * step, true)
}) }, 0)
// Configure mouse buttons based on control mode and camera mode // Configure mouse buttons based on control mode and camera mode
const mouseButtons = useMemo(() => { const mouseButtons = useMemo(() => {
@@ -750,7 +973,8 @@ export const CustomCameraControls = () => {
!(event.metaKey || event.ctrlKey || event.altKey) && !(event.metaKey || event.ctrlKey || event.altKey) &&
!isEditableKeyboardTarget(event.target) !isEditableKeyboardTarget(event.target)
) { ) {
setKeyboardPanKey(keyboardPanKeys.current, event.code, true) const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
if (changed) beginLocalCameraInteraction()
clearPendingFloorplanNavigationPose() clearPendingFloorplanNavigationPose()
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -823,6 +1047,7 @@ export const CustomCameraControls = () => {
} }
const onWheel = () => { const onWheel = () => {
beginLocalCameraInteraction()
clearPendingFloorplanNavigationPose() clearPendingFloorplanNavigationPose()
} }
@@ -851,7 +1076,7 @@ export const CustomCameraControls = () => {
window.addEventListener('pointerup', onPointerUp, true) window.addEventListener('pointerup', onPointerUp, true)
window.addEventListener('pointercancel', onPointerUp, true) window.addEventListener('pointercancel', onPointerUp, true)
window.addEventListener('blur', onBlur) window.addEventListener('blur', onBlur)
gl.domElement.addEventListener('wheel', onWheel, { passive: true }) gl.domElement.addEventListener('wheel', onWheel, { capture: true, passive: true })
updateConfig() updateConfig()
return () => { return () => {
@@ -861,29 +1086,27 @@ export const CustomCameraControls = () => {
window.removeEventListener('pointerup', onPointerUp, true) window.removeEventListener('pointerup', onPointerUp, true)
window.removeEventListener('pointercancel', onPointerUp, true) window.removeEventListener('pointercancel', onPointerUp, true)
window.removeEventListener('blur', onBlur) window.removeEventListener('blur', onBlur)
gl.domElement.removeEventListener('wheel', onWheel) gl.domElement.removeEventListener('wheel', onWheel, true)
clearKeyboardPanKeys() clearKeyboardPanKeys()
clearNavigationCursor() clearNavigationCursor()
} }
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose]) }, [
beginLocalCameraInteraction,
cameraMode,
gl,
isPreviewMode,
isFirstPersonMode,
clearPendingFloorplanNavigationPose,
])
// Cancel any in-progress 2D-origin navigation pose when the user starts // Cancel any in-progress 2D-origin navigation pose when the user starts
// dragging (right-click orbit, middle-click pan, touch). `controlstart` // dragging (right-click orbit, middle-click pan, touch). `controlstart`
// fires only for user pointer interactions — not for programmatic // fires only for user pointer interactions — not for programmatic
// moveTo/rotateTo which emit `transitionstart` instead. // moveTo/rotateTo which emit `transitionstart` instead.
useEffect(() => { const handleControlStart = useCallback(() => {
if (isFirstPersonMode) return
const control = controls.current
if (!control) return
const onControlStart = () => {
clearPendingFloorplanNavigationPose() clearPendingFloorplanNavigationPose()
} beginLocalCameraInteraction()
control.addEventListener('controlstart', onControlStart) }, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose])
return () => {
control.removeEventListener('controlstart', onControlStart)
}
}, [isFirstPersonMode, clearPendingFloorplanNavigationPose])
// Preview mode: auto-navigate camera to selected node (viewer behavior) // Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode const previewTargetNodeId = isPreviewMode
@@ -1211,7 +1434,8 @@ export const CustomCameraControls = () => {
minDistance={minDistance} minDistance={minDistance}
minPolarAngle={0} minPolarAngle={0}
mouseButtons={mouseButtons} mouseButtons={mouseButtons}
onUpdate={publishCurrentNavigationPose} onControlStart={handleControlStart}
onUpdate={handleCameraUpdate}
onRest={onRest} onRest={onRest}
onSleep={onRest} onSleep={onRest}
onTransitionStart={onTransitionStart} onTransitionStart={onTransitionStart}
@@ -62,6 +62,7 @@ import {
type ComponentProps, type ComponentProps,
memo, memo,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
type ReactNode,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
useCallback, useCallback,
useEffect, useEffect,
@@ -5195,8 +5196,10 @@ export function FloorplanPanel({
* in 2d, 3d, and split modes, while this panel itself may be display:none. * in 2d, 3d, and split modes, while this panel itself may be display:none.
*/ */
compassHost, compassHost,
floorplanSceneSlot,
}: { }: {
compassHost?: HTMLElement | null compassHost?: HTMLElement | null
floorplanSceneSlot?: ReactNode
}) { }) {
const viewportHostRef = useRef<HTMLDivElement>(null) const viewportHostRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
@@ -5371,6 +5374,18 @@ export function FloorplanPanel({
) )
const [ceilingDraftPoints, setCeilingDraftPoints] = useState<WallPlanPoint[]>([]) const [ceilingDraftPoints, setCeilingDraftPoints] = useState<WallPlanPoint[]>([])
const [slabDraftPoints, setSlabDraftPoints] = useState<WallPlanPoint[]>([]) const [slabDraftPoints, setSlabDraftPoints] = useState<WallPlanPoint[]>([])
// Mirror the per-click draft START anchors into the shared draft store so
// out-of-tree consumers (such as host-owned preview publishers) see the whole open
// segment without subscribing to this panel's state.
useEffect(() => {
useFloorplanDraftPreview.getState().setWallDraftStart(draftStart)
}, [draftStart])
useEffect(() => {
useFloorplanDraftPreview.getState().setFenceDraftStart(fenceDraftStart)
}, [fenceDraftStart])
useEffect(() => {
useFloorplanDraftPreview.getState().setRoofDraftStart(roofDraftStart)
}, [roofDraftStart])
const [zoneDraftPoints, setZoneDraftPoints] = useState<WallPlanPoint[]>([]) const [zoneDraftPoints, setZoneDraftPoints] = useState<WallPlanPoint[]>([])
const [siteBoundaryDraft, setSiteBoundaryDraft] = useState<SiteBoundaryDraft | null>(null) const [siteBoundaryDraft, setSiteBoundaryDraft] = useState<SiteBoundaryDraft | null>(null)
const [siteVertexDragState, setSiteVertexDragState] = useState<SiteVertexDragState | null>(null) const [siteVertexDragState, setSiteVertexDragState] = useState<SiteVertexDragState | null>(null)
@@ -6402,6 +6417,18 @@ export function FloorplanPanel({
slabDraftPoints, slabDraftPoints,
zoneDraftPoints, zoneDraftPoints,
]) ])
const activePolygonDraftType = isCeilingBuildActive
? 'ceiling'
: isZoneBuildActive
? 'zone'
: isSlabBuildActive
? 'slab'
: null
useEffect(() => {
useFloorplanDraftPreview
.getState()
.setPolygonDraft(activePolygonDraftType, activePolygonDraftPoints)
}, [activePolygonDraftPoints, activePolygonDraftType])
// The cursor-following polygon-draft preview (slab / zone / ceiling) moved into // The cursor-following polygon-draft preview (slab / zone / ceiling) moved into
// `FloorplanDraftCursorLayer`, which reads the live cursor from the draft store // `FloorplanDraftCursorLayer`, which reads the live cursor from the draft store
// so it re-renders per move without re-rendering this panel. // so it re-renders per move without re-rendering this panel.
@@ -8240,9 +8267,17 @@ export function FloorplanPanel({
setShiftPressed(true) setShiftPressed(true)
} }
if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) { if (
isStairBuildActive &&
useEditor.getState().viewMode === '2d' &&
(event.key === 'r' || event.key === 'R')
) {
useStairBuildPreview.getState().rotateBy(Math.PI / 4) useStairBuildPreview.getState().rotateBy(Math.PI / 4)
} else if (isStairBuildActive && (event.key === 't' || event.key === 'T')) { } else if (
isStairBuildActive &&
useEditor.getState().viewMode === '2d' &&
(event.key === 't' || event.key === 'T')
) {
useStairBuildPreview.getState().rotateBy(-Math.PI / 4) useStairBuildPreview.getState().rotateBy(-Math.PI / 4)
} }
@@ -11220,6 +11255,7 @@ export function FloorplanPanel({
// panel, so pan/zoom is preserved across the toggle. // panel, so pan/zoom is preserved across the toggle.
<svg <svg
className="h-full w-full touch-none" className="h-full w-full touch-none"
data-pascal-floorplan-2d
onClick={isMarqueeSelectionToolActive ? undefined : handleSvgClick} onClick={isMarqueeSelectionToolActive ? undefined : handleSvgClick}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
onDoubleClick={isMarqueeSelectionToolActive ? undefined : handleBackgroundDoubleClick} onDoubleClick={isMarqueeSelectionToolActive ? undefined : handleBackgroundDoubleClick}
@@ -11396,6 +11432,7 @@ export function FloorplanPanel({
<FloorplanWallMoveGhostLayer /> <FloorplanWallMoveGhostLayer />
</g> </g>
<FloorplanMeasurementToolLayer /> <FloorplanMeasurementToolLayer />
{floorplanSceneSlot}
</FloorplanRenderProvider> </FloorplanRenderProvider>
{/* Cursor-driven placement ghost for movingNode when the {/* Cursor-driven placement ghost for movingNode when the
active kind is registry-driven. Renders via a portal active kind is registry-driven. Renders via a portal
@@ -2,6 +2,7 @@
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { import {
acquireSceneReadOnlyLease,
getCatalogMaterialById, getCatalogMaterialById,
getLibraryMaterialIdFromRef, getLibraryMaterialIdFromRef,
getSceneMaterialIdFromRef, getSceneMaterialIdFromRef,
@@ -153,6 +154,11 @@ export interface EditorProps {
*/ */
inspectorFooter?: ReactNode inspectorFooter?: ReactNode
/** Host-owned content mounted inside the editor's React Three Fiber scene. */
viewerSceneSlot?: ReactNode
/** Host-owned SVG content mounted in the transformed floor-plan scene. */
floorplanSceneSlot?: ReactNode
projectId?: string | null projectId?: string | null
// Persistence — defaults to localStorage when omitted // Persistence — defaults to localStorage when omitted
@@ -715,12 +721,14 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
isFirstPersonMode, isFirstPersonMode,
isStudioMode, isStudioMode,
onThumbnailCapture, onThumbnailCapture,
viewerSceneSlot,
}: { }: {
isVersionPreviewMode: boolean isVersionPreviewMode: boolean
isLoading: boolean isLoading: boolean
isFirstPersonMode: boolean isFirstPersonMode: boolean
isStudioMode: boolean isStudioMode: boolean
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
viewerSceneSlot?: ReactNode
}) { }) {
// Studio mode is a clean render/snapshot surface — no selection or editing // Studio mode is a clean render/snapshot surface — no selection or editing
// affordances. It mirrors version-preview's chrome gating on the canvas. // affordances. It mirrors version-preview's chrome gating on the canvas.
@@ -759,6 +767,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} /> <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
{!isFirstPersonMode && <SiteEdgeLabels />} {!isFirstPersonMode && <SiteEdgeLabels />}
<InteractiveSystem /> <InteractiveSystem />
{!noEditing && viewerSceneSlot}
</> </>
) )
}) })
@@ -941,6 +950,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
sceneReadyKey, sceneReadyKey,
onSceneReadyChange, onSceneReadyChange,
onThumbnailCapture, onThumbnailCapture,
viewerSceneSlot,
floorplanSceneSlot,
}: { }: {
isVersionPreviewMode: boolean isVersionPreviewMode: boolean
isLoading: boolean isLoading: boolean
@@ -951,6 +962,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
sceneReadyKey: number sceneReadyKey: number
onSceneReadyChange: (ready: boolean) => void onSceneReadyChange: (ready: boolean) => void
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
viewerSceneSlot?: ReactNode
floorplanSceneSlot?: ReactNode
}) { }) {
const viewMode = useEditor((s) => s.viewMode) const viewMode = useEditor((s) => s.viewMode)
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio) const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
@@ -1027,7 +1040,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
}} }}
> >
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<FloorplanPanel compassHost={viewerAreaEl} /> <FloorplanPanel compassHost={viewerAreaEl} floorplanSceneSlot={floorplanSceneSlot} />
</div> </div>
{viewMode === 'split' && ( {viewMode === 'split' && (
<div <div
@@ -1075,6 +1088,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
isStudioMode={isStudioMode} isStudioMode={isStudioMode}
isVersionPreviewMode={isVersionPreviewMode} isVersionPreviewMode={isVersionPreviewMode}
onThumbnailCapture={onThumbnailCapture} onThumbnailCapture={onThumbnailCapture}
viewerSceneSlot={viewerSceneSlot}
/> />
</Viewer> </Viewer>
</div> </div>
@@ -1094,6 +1108,8 @@ export default function Editor({
viewerToolbarRight, viewerToolbarRight,
stageOverlay, stageOverlay,
inspectorFooter, inspectorFooter,
viewerSceneSlot,
floorplanSceneSlot,
projectId, projectId,
onLoad, onLoad,
onSave, onSave,
@@ -1209,13 +1225,10 @@ export default function Editor({
// Lock scene graph and reset to select mode when entering version preview // Lock scene graph and reset to select mode when entering version preview
useEffect(() => { useEffect(() => {
useScene.getState().setReadOnly(isVersionPreviewMode) if (!isVersionPreviewMode) return
if (isVersionPreviewMode) { const releaseReadOnly = acquireSceneReadOnlyLease()
useEditor.getState().setMode('select') useEditor.getState().setMode('select')
} return releaseReadOnly
return () => {
useScene.getState().setReadOnly(false)
}
}, [isVersionPreviewMode]) }, [isVersionPreviewMode])
useEffect(() => { useEffect(() => {
@@ -1314,6 +1327,8 @@ export default function Editor({
onThumbnailCapture={onThumbnailCapture} onThumbnailCapture={onThumbnailCapture}
sceneReadyKey={sceneReadyKey} sceneReadyKey={sceneReadyKey}
showLoader={showLoader} showLoader={showLoader}
viewerSceneSlot={viewerSceneSlot}
floorplanSceneSlot={floorplanSceneSlot}
/> />
) )
@@ -31,6 +31,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_WALL_HEIGHT = 0.5
@@ -476,6 +477,9 @@ export const RoofTool: React.FC = () => {
}) })
if (corner1Ref.current) { if (corner1Ref.current) {
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setRoofDraftStart([corner1Ref.current[0], corner1Ref.current[2]])
draftPreview.setRoofDraftEnd([gridX, gridZ])
updateOutline(corner1Ref.current, cursorPosition) updateOutline(corner1Ref.current, cursorPosition)
} }
} }
@@ -497,11 +501,17 @@ export const RoofTool: React.FC = () => {
setSelection({ selectedIds: [roofId as AnyNode['id']] }) setSelection({ selectedIds: [roofId as AnyNode['id']] })
corner1Ref.current = null corner1Ref.current = null
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setRoofDraftStart(null)
draftPreview.setRoofDraftEnd(null)
outlineRef.current.visible = false outlineRef.current.visible = false
alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId) alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId)
clearSurfacePlanSnapFeedback() clearSurfacePlanSnapFeedback()
} else { } else {
corner1Ref.current = [gridX, y, gridZ] corner1Ref.current = [gridX, y, gridZ]
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setRoofDraftStart([gridX, gridZ])
draftPreview.setRoofDraftEnd([gridX, gridZ])
sfxEmitter.emit('sfx:structure-build-start') sfxEmitter.emit('sfx:structure-build-start')
setPreview((prev) => ({ setPreview((prev) => ({
...prev, ...prev,
@@ -514,6 +524,9 @@ export const RoofTool: React.FC = () => {
if (corner1Ref.current) { if (corner1Ref.current) {
markToolCancelConsumed() markToolCancelConsumed()
corner1Ref.current = null corner1Ref.current = null
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setRoofDraftStart(null)
draftPreview.setRoofDraftEnd(null)
outlineRef.current.visible = false outlineRef.current.visible = false
setPreview((prev) => ({ ...prev, corner1: null })) setPreview((prev) => ({ ...prev, corner1: null }))
} }
@@ -531,6 +544,9 @@ export const RoofTool: React.FC = () => {
clearSurfacePlanSnapFeedback() clearSurfacePlanSnapFeedback()
corner1Ref.current = null corner1Ref.current = null
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setRoofDraftStart(null)
draftPreview.setRoofDraftEnd(null)
} }
}, [currentLevelId, setSelection]) }, [currentLevelId, setSelection])
@@ -31,6 +31,7 @@ import useEditor, {
} from '../../../store/use-editor' } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose' import useFacingPose from '../../../store/use-facing-pose'
import { useStairBuildPreview } from '../../../store/use-stair-build-preview'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
@@ -233,6 +234,7 @@ export const StairTool: React.FC = () => {
// Reset rotation when tool activates // Reset rotation when tool activates
rotationRef.current = 0 rotationRef.current = 0
useStairBuildPreview.getState().reset()
if (previewRef.current) previewRef.current.rotation.y = 0 if (previewRef.current) previewRef.current.rotation.y = 0
lastCanonicalPositionRef.current = null lastCanonicalPositionRef.current = null
@@ -285,6 +287,7 @@ export const StairTool: React.FC = () => {
const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}` const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}`
if (key === lastPreviewKey) return if (key === lastPreviewKey) return
lastPreviewKey = key lastPreviewKey = key
useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation)
const preview = buildPreviewScene(position, rotation) const preview = buildPreviewScene(position, rotation)
const visualPosition = preview const visualPosition = preview
? getFloorStackPreviewPosition({ ? getFloorStackPreviewPosition({
@@ -510,6 +513,7 @@ export const StairTool: React.FC = () => {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
openingPreview.clear() openingPreview.clear()
useFacingPose.getState().clear() useFacingPose.getState().clear()
useStairBuildPreview.getState().reset()
} }
}, [currentLevelId]) }, [currentLevelId])
@@ -18,6 +18,7 @@ import {
} from './../../../lib/surface-plan-snap' } from './../../../lib/surface-plan-snap'
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap' import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor' import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
import { useFloorplanDraftPreview } from './../../../store/use-floorplan-draft-preview'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
const Y_OFFSET = 0.02 const Y_OFFSET = 0.02
@@ -79,6 +80,20 @@ export const ZoneTool: React.FC = () => {
levelY: 0, levelY: 0,
}) })
useEffect(() => {
useFloorplanDraftPreview.getState().setPolygonDraft('zone', preview.points)
}, [preview.points])
useEffect(
() => () => {
const draftPreview = useFloorplanDraftPreview.getState()
if (draftPreview.polygonDraftType === 'zone') {
draftPreview.setPolygonDraft(null, [])
}
draftPreview.setCursorPoint(null)
},
[],
)
useEffect(() => { useEffect(() => {
if (!currentLevelId) return if (!currentLevelId) return
@@ -192,6 +207,7 @@ export const ZoneTool: React.FC = () => {
event.localPosition[2], event.localPosition[2],
]) ])
snappedCursorPosition = displayPoint snappedCursorPosition = displayPoint
useFloorplanDraftPreview.getState().setCursorPoint(displayPoint)
// Play snap sound when the snapped position changes during drawing — only // Play snap sound when the snapped position changes during drawing — only
// when a quantizing mode is active (off / lines move continuously). // when a quantizing mode is active (off / lines move continuously).
+38 -1
View File
@@ -2,7 +2,21 @@
// own shells on top of `@pascal-app/editor` (community-app, embedders) // own shells on top of `@pascal-app/editor` (community-app, embedders)
// don't have to learn three separate package imports. The canonical // don't have to learn three separate package imports. The canonical
// definitions still live in `@pascal-app/core` / `@pascal-app/viewer`. // definitions still live in `@pascal-app/core` / `@pascal-app/viewer`.
export { useScene } from '@pascal-app/core' export {
type ApplySceneSnapshotOptions,
acquireSceneReadOnlyLease,
applyScenePatch,
applySceneSnapshot,
type SceneCommit,
type SceneCommitListener,
type SceneCommitOrigin,
type SceneMaterialPatch,
type SceneNodePatch,
type ScenePatch,
type SceneSnapshot,
subscribeSceneCommits,
useScene,
} from '@pascal-app/core'
export { useViewer } from '@pascal-app/viewer' export { useViewer } from '@pascal-app/viewer'
export type { EditorProps } from './components/editor' export type { EditorProps } from './components/editor'
export { default as Editor } from './components/editor' export { default as Editor } from './components/editor'
@@ -66,6 +80,10 @@ export {
type SnapshotCameraData, type SnapshotCameraData,
ThumbnailGenerator, ThumbnailGenerator,
} from './components/editor/thumbnail-generator' } from './components/editor/thumbnail-generator'
export {
FloorplanNodePreview,
type FloorplanNodePreviewProps,
} from './components/editor-2d/renderers/floorplan-placement-preview-layer'
// SVG path builders for arc / annular-sector / arrow-head shapes — // SVG path builders for arc / annular-sector / arrow-head shapes —
// inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved // inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved
// stair rendering in `nodes/src/stair/floorplan.ts`. // stair rendering in `nodes/src/stair/floorplan.ts`.
@@ -287,12 +305,19 @@ export {
} from './lib/floorplan' } from './lib/floorplan'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { exportSceneToGlb } from './lib/glb-export' export { exportSceneToGlb } from './lib/glb-export'
export {
type HistoryCommandDelegate,
installHistoryCommandDelegate,
runRedo,
runUndo,
} from './lib/history'
export { export {
boundaryReshapeScope, boundaryReshapeScope,
curveReshapeScope, curveReshapeScope,
endpointReshapeScope, endpointReshapeScope,
holeEditScope, holeEditScope,
movingNodeOf, movingNodeOf,
scopeNodeId,
} from './lib/interaction/scope' } from './lib/interaction/scope'
export { export {
buildResetSurfaceMaterialUpdates, buildResetSurfaceMaterialUpdates,
@@ -429,6 +454,7 @@ export {
} from './store/use-editor' } from './store/use-editor'
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview'
export { export {
default as useInteractionScope, default as useInteractionScope,
getEditingHole, getEditingHole,
@@ -466,6 +492,13 @@ export {
type PaletteViewProps, type PaletteViewProps,
usePaletteViewRegistry, usePaletteViewRegistry,
} from './store/use-palette-view-registry' } from './store/use-palette-view-registry'
export {
type PathDraftKind,
type PathDraftParameter,
type PathDraftParameters,
type PathDraftPoint,
usePathDraftPreview,
} from './store/use-path-draft-preview'
export { default as usePlacementPreview } from './store/use-placement-preview' export { default as usePlacementPreview } from './store/use-placement-preview'
export { export {
activateQuickMeasurementHudSource, activateQuickMeasurementHudSource,
@@ -477,6 +510,10 @@ export {
useQuickMeasurementHud, useQuickMeasurementHud,
} from './store/use-quick-measurement-hud' } from './store/use-quick-measurement-hud'
export { default as useSegmentDraftChain } from './store/use-segment-draft-chain' export { default as useSegmentDraftChain } from './store/use-segment-draft-chain'
export {
type StairPreviewPoint,
useStairBuildPreview,
} from './store/use-stair-build-preview'
export { useUploadStore } from './store/use-upload' export { useUploadStore } from './store/use-upload'
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts' export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
export { export {
+213
View File
@@ -0,0 +1,213 @@
import { describe, expect, test } from 'bun:test'
import {
normalizeCameraPose,
planCameraPoseApplication,
stepCameraPoseInterpolation,
withCameraPoseDistance,
} from './camera-pose'
describe('camera pose', () => {
test('normalizes a finite pose without retaining input tuples', () => {
const position: [number, number, number] = [1, 2, 3]
const target: [number, number, number] = [4, 5, 6]
const pose = normalizeCameraPose({
position,
target,
projection: 'perspective',
fov: 50,
viewWidth: 12,
})
expect(pose).toEqual({
fov: 50,
position,
projection: 'perspective',
target,
viewWidth: 12,
})
expect(pose?.position).not.toBe(position)
expect(pose?.target).not.toBe(target)
})
test('rejects non-finite, malformed, and unknown camera poses', () => {
expect(normalizeCameraPose(null)).toBeNull()
expect(
normalizeCameraPose({
position: [1, 2],
target: [4, 5, 6],
projection: 'perspective',
}),
).toBeNull()
expect(
normalizeCameraPose({
position: [1, Number.NaN, 3],
target: [4, 5, 6],
projection: 'perspective',
}),
).toBeNull()
expect(
normalizeCameraPose({
position: [1, 2, 3],
target: [4, 5, Number.POSITIVE_INFINITY],
projection: 'perspective',
}),
).toBeNull()
expect(
normalizeCameraPose({
position: [1, 2, 3],
target: [4, 5, 6],
projection: 'panoramic',
}),
).toBeNull()
expect(
normalizeCameraPose({
position: [1, 2, 3],
target: [4, 5, 6],
projection: 'perspective',
fov: Number.NaN,
}),
).toBeNull()
expect(
normalizeCameraPose({
position: [1, 2, 3],
target: [4, 5, 6],
projection: 'orthographic',
viewWidth: 0,
}),
).toBeNull()
expect(
normalizeCameraPose({
position: [1, 2, 3],
target: [4, 5, 6],
projection: 'orthographic',
viewWidth: Number.POSITIVE_INFINITY,
}),
).toBeNull()
})
test('plans only perspective fov application and clamps it to a safe range', () => {
expect(
planCameraPoseApplication({
position: [1, 2, 3],
target: [4, 5, 6],
projection: 'perspective',
fov: 0,
})?.perspectiveFov,
).toBe(1)
expect(
planCameraPoseApplication({
position: [1, 2, 3],
target: [4, 5, 6],
projection: 'perspective',
fov: 200,
})?.perspectiveFov,
).toBe(179)
expect(
planCameraPoseApplication({
position: [1, 2, 3],
target: [4, 5, 6],
projection: 'orthographic',
fov: 70,
})?.perspectiveFov,
).toBeNull()
})
test('interpolates toward the latest pose and converges exactly', () => {
const destination = {
position: [12, 6, -4] as [number, number, number],
target: [2, 1, 3] as [number, number, number],
projection: 'perspective' as const,
}
const first = stepCameraPoseInterpolation([0, 0, 0], [0, 0, 0], destination, 0.016)
expect(first.settled).toBe(false)
expect(first.position[0]).toBeGreaterThan(0)
expect(first.position[0]).toBeLessThan(destination.position[0])
let position = first.position
let target = first.target
let settled = first.settled
for (let frame = 0; frame < 240 && !settled; frame += 1) {
const step = stepCameraPoseInterpolation(position, target, destination, 0.016)
position = step.position
target = step.target
settled = step.settled
}
expect(settled).toBe(true)
expect(position).toEqual(destination.position)
expect(target).toEqual(destination.target)
})
test('settles an already-current pose without advancing on an invalid delta', () => {
const destination = {
position: [1, 2, 3] as [number, number, number],
target: [4, 5, 6] as [number, number, number],
projection: 'orthographic' as const,
}
expect(
stepCameraPoseInterpolation(
destination.position,
destination.target,
destination,
Number.NaN,
),
).toEqual({
position: destination.position,
settled: true,
target: destination.target,
})
expect(stepCameraPoseInterpolation([0, 0, 0], [0, 0, 0], destination, Number.NaN)).toEqual({
position: [0, 0, 0],
settled: false,
target: [0, 0, 0],
})
})
test('retargets the bounded interpolation owner to the latest pose', () => {
const first = stepCameraPoseInterpolation(
[0, 0, 0],
[0, 0, 0],
{
position: [10, 0, 0],
target: [5, 0, 0],
projection: 'perspective',
},
0.016,
)
const retargeted = stepCameraPoseInterpolation(
first.position,
first.target,
{
position: [-10, 0, 0],
target: [-5, 0, 0],
projection: 'perspective',
},
0.1,
)
expect(retargeted.position[0]).toBeLessThan(first.position[0])
expect(retargeted.target[0]).toBeLessThan(first.target[0])
})
test('preserves view direction while adapting distance to the local viewport', () => {
const pose = {
position: [4, 6, 12] as [number, number, number],
target: [1, 2, 3] as [number, number, number],
projection: 'perspective' as const,
viewWidth: 20,
}
const adapted = withCameraPoseDistance(pose, 5)
const sourceDirection = pose.position.map((value, index) => value - pose.target[index]!)
const adaptedDirection = adapted.position.map((value, index) => value - adapted.target[index]!)
const sourceLength = Math.hypot(...sourceDirection)
expect(Math.hypot(...adaptedDirection)).toBeCloseTo(5)
const expectedDirection = sourceDirection.map((component) => component * (5 / sourceLength))
expect(adaptedDirection[0]).toBeCloseTo(expectedDirection[0]!)
expect(adaptedDirection[1]).toBeCloseTo(expectedDirection[1]!)
expect(adaptedDirection[2]).toBeCloseTo(expectedDirection[2]!)
expect(adapted.target).toEqual(pose.target)
expect(adapted.target).not.toBe(pose.target)
expect(pose.position).toEqual([4, 6, 12])
})
})
+160
View File
@@ -0,0 +1,160 @@
import type { CameraPose } from '@pascal-app/core'
const MIN_PERSPECTIVE_FOV = 1
const MAX_PERSPECTIVE_FOV = 179
const INTERPOLATION_TIME_CONSTANT_SECONDS = 0.08
const MAX_INTERPOLATION_DELTA_SECONDS = 0.1
const INTERPOLATION_SETTLE_DISTANCE = 0.001
export type CameraPoseApplicationPlan = {
pose: CameraPose
perspectiveFov: number | null
}
export type CameraPoseInterpolationStep = {
position: [number, number, number]
settled: boolean
target: [number, number, number]
}
function finiteVectorTuple(value: unknown): [number, number, number] | null {
if (
!(
Array.isArray(value) &&
value.length === 3 &&
value.every((component) => typeof component === 'number' && Number.isFinite(component))
)
) {
return null
}
return [value[0], value[1], value[2]]
}
export function normalizeCameraPose(value: unknown): CameraPose | null {
if (!(typeof value === 'object' && value !== null)) {
return null
}
const candidate = value as Partial<CameraPose>
const position = finiteVectorTuple(candidate.position)
const target = finiteVectorTuple(candidate.target)
if (
!(
position &&
target &&
(candidate.projection === 'perspective' || candidate.projection === 'orthographic')
)
) {
return null
}
if (
candidate.fov !== undefined &&
!(typeof candidate.fov === 'number' && Number.isFinite(candidate.fov))
) {
return null
}
if (
candidate.viewWidth !== undefined &&
!(
typeof candidate.viewWidth === 'number' &&
Number.isFinite(candidate.viewWidth) &&
candidate.viewWidth > 0
)
) {
return null
}
return {
...(candidate.fov === undefined ? {} : { fov: candidate.fov }),
position,
projection: candidate.projection,
target,
...(candidate.viewWidth === undefined ? {} : { viewWidth: candidate.viewWidth }),
}
}
export function planCameraPoseApplication(value: unknown): CameraPoseApplicationPlan | null {
const pose = normalizeCameraPose(value)
if (!pose) {
return null
}
return {
pose,
perspectiveFov:
pose.projection === 'perspective' && pose.fov !== undefined
? Math.min(Math.max(pose.fov, MIN_PERSPECTIVE_FOV), MAX_PERSPECTIVE_FOV)
: null,
}
}
function interpolateVectorTuple(
current: [number, number, number],
destination: [number, number, number],
alpha: number,
): { settled: boolean; value: [number, number, number] } {
const dx = destination[0] - current[0]
const dy = destination[1] - current[1]
const dz = destination[2] - current[2]
const settleDistanceSquared = INTERPOLATION_SETTLE_DISTANCE ** 2
if (dx * dx + dy * dy + dz * dz <= settleDistanceSquared) {
return { settled: true, value: [...destination] }
}
const value: [number, number, number] = [
current[0] + dx * alpha,
current[1] + dy * alpha,
current[2] + dz * alpha,
]
const remainingX = destination[0] - value[0]
const remainingY = destination[1] - value[1]
const remainingZ = destination[2] - value[2]
const settled =
remainingX * remainingX + remainingY * remainingY + remainingZ * remainingZ <=
settleDistanceSquared
return { settled, value: settled ? [...destination] : value }
}
export function withCameraPoseDistance(pose: CameraPose, distance: number): CameraPose {
const dx = pose.position[0] - pose.target[0]
const dy = pose.position[1] - pose.target[1]
const dz = pose.position[2] - pose.target[2]
const currentDistance = Math.hypot(dx, dy, dz)
if (!(Number.isFinite(distance) && distance > 0 && currentDistance > 0)) {
return { ...pose, position: [...pose.position], target: [...pose.target] }
}
const scale = distance / currentDistance
return {
...pose,
position: [
pose.target[0] + dx * scale,
pose.target[1] + dy * scale,
pose.target[2] + dz * scale,
],
target: [...pose.target],
}
}
export function stepCameraPoseInterpolation(
currentPosition: [number, number, number],
currentTarget: [number, number, number],
destination: CameraPose,
deltaSeconds: number,
): CameraPoseInterpolationStep {
const finiteDelta = Number.isFinite(deltaSeconds) ? deltaSeconds : 0
const elapsed = Math.min(Math.max(finiteDelta, 0), MAX_INTERPOLATION_DELTA_SECONDS)
const alpha = 1 - Math.exp(-elapsed / INTERPOLATION_TIME_CONSTANT_SECONDS)
const position = interpolateVectorTuple(currentPosition, destination.position, alpha)
const target = interpolateVectorTuple(currentTarget, destination.target, alpha)
return {
position: position.value,
settled: position.settled && target.settled,
target: target.value,
}
}
@@ -8,6 +8,8 @@ import {
findLevelAncestorId, findLevelAncestorId,
nodeRegistry, nodeRegistry,
registerNode, registerNode,
type SceneCommit,
subscribeSceneCommits,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { z } from 'zod' import { z } from 'zod'
@@ -170,12 +172,15 @@ describe('commitFreshPlacementSubtree', () => {
}) })
test('commits a fresh draft as one undoable clean subtree', () => { test('commits a fresh draft as one undoable clean subtree', () => {
const commits: SceneCommit[] = []
const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
const committedId = commitFreshPlacementSubtree(SHELF_ID, { const committedId = commitFreshPlacementSubtree(SHELF_ID, {
position: [2, 0, 3], position: [2, 0, 3],
visible: true, visible: true,
} as Partial<AnyNode>) } as Partial<AnyNode>)
unsubscribe()
expect(committedId).toBeTruthy() expect(committedId).toBeTruthy()
expect(committedId).not.toBe(SHELF_ID) expect(committedId).not.toBe(SHELF_ID)
@@ -192,6 +197,12 @@ describe('commitFreshPlacementSubtree', () => {
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([ expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([
finalId, finalId,
]) ])
expect(commits).toHaveLength(1)
expect(commits[0]?.origin).toBe('local')
expect(commits[0]?.before.nodes[SHELF_ID]).toBeUndefined()
expect(commits[0]?.before.nodes[finalId]).toBeUndefined()
expect(commits[0]?.current.nodes[SHELF_ID]).toBeUndefined()
expect(commits[0]?.current.nodes[finalId]).toEqual(committed)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.temporal.getState().undo() useScene.temporal.getState().undo()
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
BuildingNode,
clearSceneHistory,
LevelNode,
useScene,
} from '@pascal-app/core'
import { installHistoryCommandDelegate, runRedo, runUndo } from './history'
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_history_controller' as AnyNodeId
const LEVEL_ID = 'level_history_controller' as AnyNodeId
let disposeController = () => {}
function levelNumber(): number {
return (useScene.getState().nodes[LEVEL_ID] as { level: number }).level
}
describe('editor history controller', () => {
beforeEach(() => {
disposeController()
disposeController = () => {}
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()
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
})
afterEach(() => {
disposeController()
disposeController = () => {}
})
test('delegates undo and redo while a host delegate is installed', () => {
const undo = mock(() => {})
const redo = mock(() => {})
disposeController = installHistoryCommandDelegate({ undo, redo })
runUndo()
runRedo()
expect(undo).toHaveBeenCalledTimes(1)
expect(redo).toHaveBeenCalledTimes(1)
expect(levelNumber()).toBe(1)
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
})
test('falls back to standalone Zundo undo and redo when no controller is installed', () => {
runUndo()
expect(levelNumber()).toBe(0)
expect(useScene.temporal.getState().futureStates).toHaveLength(1)
runRedo()
expect(levelNumber()).toBe(1)
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
})
test('an older cleanup cannot uninstall a newer controller', () => {
const firstUndo = mock(() => {})
const stopFirst = installHistoryCommandDelegate({ undo: firstUndo, redo: () => {} })
const secondUndo = mock(() => {})
disposeController = installHistoryCommandDelegate({ undo: secondUndo, redo: () => {} })
stopFirst()
runUndo()
expect(firstUndo).toHaveBeenCalledTimes(0)
expect(secondUndo).toHaveBeenCalledTimes(1)
})
})
+22
View File
@@ -1,5 +1,19 @@
import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core' import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
export type HistoryCommandDelegate = {
undo: () => void
redo: () => void
}
let historyCommandDelegate: HistoryCommandDelegate | null = null
export function installHistoryCommandDelegate(delegate: HistoryCommandDelegate): () => void {
historyCommandDelegate = delegate
return () => {
if (historyCommandDelegate === delegate) historyCommandDelegate = null
}
}
function refreshSceneAfterHistoryJump() { function refreshSceneAfterHistoryJump() {
useLiveNodeOverrides.getState().clearAll() useLiveNodeOverrides.getState().clearAll()
useLiveTransforms.getState().clearAll() useLiveTransforms.getState().clearAll()
@@ -11,11 +25,19 @@ function refreshSceneAfterHistoryJump() {
} }
export function runUndo() { export function runUndo() {
if (historyCommandDelegate) {
historyCommandDelegate.undo()
return
}
useScene.temporal.getState().undo() useScene.temporal.getState().undo()
refreshSceneAfterHistoryJump() refreshSceneAfterHistoryJump()
} }
export function runRedo() { export function runRedo() {
if (historyCommandDelegate) {
historyCommandDelegate.redo()
return
}
useScene.temporal.getState().redo() useScene.temporal.getState().redo()
refreshSceneAfterHistoryJump() refreshSceneAfterHistoryJump()
} }
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, test } from 'bun:test'
import useFenceCurveDraft from './use-fence-curve-draft'
import { useFloorplanDraftPreview } from './use-floorplan-draft-preview'
import { usePathDraftPreview } from './use-path-draft-preview'
import { useStairBuildPreview } from './use-stair-build-preview'
afterEach(() => {
useFenceCurveDraft.getState().reset()
useFloorplanDraftPreview.getState().reset()
usePathDraftPreview.getState().reset()
useStairBuildPreview.getState().reset()
})
describe('live draft preview stores', () => {
test('dedupes polygon snapshots and owns an immutable point copy', () => {
let changes = 0
const unsubscribe = useFloorplanDraftPreview.subscribe(() => {
changes += 1
})
const points: Array<[number, number]> = [
[0, 0],
[2, 0],
]
useFloorplanDraftPreview.getState().setPolygonDraft('slab', points)
useFloorplanDraftPreview.getState().setPolygonDraft('slab', points)
points[0]![0] = 9
expect(changes).toBe(1)
expect(useFloorplanDraftPreview.getState().polygonDraftPoints).toEqual([
[0, 0],
[2, 0],
])
unsubscribe()
})
test('publishes stair point and rotation atomically', () => {
let changes = 0
const unsubscribe = useStairBuildPreview.subscribe(() => {
changes += 1
})
useStairBuildPreview.getState().setPreview([3, 4], Math.PI / 2)
useStairBuildPreview.getState().setPreview([3, 4], Math.PI / 2)
expect(changes).toBe(1)
expect(useStairBuildPreview.getState()).toMatchObject({
point: [3, 4],
rotation: Math.PI / 2,
})
unsubscribe()
})
test('dedupes curved-fence control points and cursor', () => {
let changes = 0
const unsubscribe = useFenceCurveDraft.subscribe(() => {
changes += 1
})
const points: Array<[number, number]> = [
[0, 0],
[1, 1],
]
useFenceCurveDraft.getState().setDraft(points, [2, 0])
useFenceCurveDraft.getState().setDraft(points, [2, 0])
points[0]![0] = 5
expect(changes).toBe(1)
expect(useFenceCurveDraft.getState()).toMatchObject({
cursor: [2, 0],
pointCount: 2,
points: [
[0, 0],
[1, 1],
],
})
unsubscribe()
})
test('dedupes path drafts and owns immutable point, parameter, and related-node copies', () => {
let changes = 0
const unsubscribe = usePathDraftPreview.subscribe(() => {
changes += 1
})
const points: Array<[number, number, number]> = [[0, 1, 2]]
const parameters = { diameter: 6, shape: 'round' }
const relatedNodes = [
{
angle: 90,
branchAngle: 90,
diameter: 6,
diameter2: 6,
ductMaterial: 'sheet-metal' as const,
fittingType: 'elbow' as const,
height: 8,
height2: 8,
id: 'duct-fitting_live-draft-0' as const,
metadata: {},
object: 'node' as const,
parentId: 'level_1' as const,
position: [1, 2, 3] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
shape: 'round' as const,
shape2: 'round' as const,
slots: undefined,
system: 'supply' as const,
type: 'duct-fitting' as const,
visible: true,
width: 14,
width2: 14,
},
]
usePathDraftPreview
.getState()
.setDraft('duct-segment', points, [3, 4, 5], parameters, relatedNodes)
usePathDraftPreview
.getState()
.setDraft('duct-segment', points, [3, 4, 5], parameters, relatedNodes)
points[0]![0] = 9
parameters.diameter = 12
relatedNodes[0]!.position[0] = 9
expect(changes).toBe(1)
expect(usePathDraftPreview.getState()).toMatchObject({
cursor: [3, 4, 5],
kind: 'duct-segment',
parameters: { diameter: 6, shape: 'round' },
points: [[0, 1, 2]],
relatedNodes: [
expect.objectContaining({
id: 'duct-fitting_live-draft-0',
position: [1, 2, 3],
type: 'duct-fitting',
}),
],
})
unsubscribe()
})
})
@@ -1,21 +1,51 @@
// Ephemeral store: how many points the in-progress curved-fence draft has // Ephemeral store for the in-progress curved-fence draft. Written by the 3D
// placed. Written by the 3D spline draft tool (`@pascal-app/nodes` // spline tool and read by the contextual helper plus out-of-tree consumers.
// fence/tool.tsx) and read by the contextual helper so the "finish curve" hint // Reset on commit, cancel, and unmount — never persisted or recorded in undo
// only surfaces once the user has actually started drawing. Reset on commit, // history.
// cancel, and unmount — never persisted, never in undo history.
import { create } from 'zustand' import { create } from 'zustand'
export type FenceCurveDraftPoint = [number, number]
type FenceCurveDraftState = { type FenceCurveDraftState = {
pointCount: number pointCount: number
setPointCount(count: number): void points: FenceCurveDraftPoint[]
cursor: FenceCurveDraftPoint | null
setDraft(points: readonly FenceCurveDraftPoint[], cursor: FenceCurveDraftPoint | null): void
reset(): void reset(): void
} }
function pointsEqual(a: readonly FenceCurveDraftPoint[], b: readonly FenceCurveDraftPoint[]) {
return (
a.length === b.length &&
a.every((point, index) => point[0] === b[index]?.[0] && point[1] === b[index]?.[1])
)
}
const useFenceCurveDraft = create<FenceCurveDraftState>((set) => ({ const useFenceCurveDraft = create<FenceCurveDraftState>((set) => ({
pointCount: 0, pointCount: 0,
setPointCount: (count) => set({ pointCount: count }), points: [],
reset: () => set({ pointCount: 0 }), cursor: null,
setDraft: (points, cursor) =>
set((state) => {
const sameCursor =
(!cursor && !state.cursor) ||
Boolean(
cursor && state.cursor && state.cursor[0] === cursor[0] && state.cursor[1] === cursor[1],
)
if (sameCursor && pointsEqual(state.points, points)) return state
return {
pointCount: points.length,
points: points.map(([x, z]) => [x, z] as FenceCurveDraftPoint),
cursor: cursor ? [cursor[0], cursor[1]] : null,
}
}),
reset: () =>
set((state) =>
state.pointCount === 0 && state.points.length === 0 && state.cursor === null
? state
: { pointCount: 0, points: [], cursor: null },
),
})) }))
export default useFenceCurveDraft export default useFenceCurveDraft
@@ -17,6 +17,8 @@ import { create } from 'zustand'
/** Screen-space (SVG-local px) cursor point — drives the coordinate badge. */ /** Screen-space (SVG-local px) cursor point — drives the coordinate badge. */
type SvgPoint = { x: number; y: number } type SvgPoint = { x: number; y: number }
export type FloorplanPolygonDraftType = 'ceiling' | 'slab' | 'zone'
type FloorplanDraftPreviewState = { type FloorplanDraftPreviewState = {
/** Snapped plan-XZ point under the cursor; drives the crosshair + the /** Snapped plan-XZ point under the cursor; drives the crosshair + the
* cursor-following polygon-draft preview. `null` when idle. */ * cursor-following polygon-draft preview. `null` when idle. */
@@ -28,11 +30,18 @@ type FloorplanDraftPreviewState = {
cursorPosition: SvgPoint | null cursorPosition: SvgPoint | null
/** Live END point of the open wall / fence / roof draft segment — the per-move /** Live END point of the open wall / fence / roof draft segment — the per-move
* endpoint that drives the 2D draft polygon + measurement. Each is `null` * endpoint that drives the 2D draft polygon + measurement. Each is `null`
* unless that tool's draft is open. The START points stay in panel state * unless that tool's draft is open. The START points are mirrored here (set
* (set per click, low-frequency). */ * per click / per 3D draft move) so out-of-tree consumers — e.g. the hosted
* host-owned preview publishers — can observe the whole open
* segment; panel state remains the 2D interaction source of truth. */
wallDraftEnd: WallPlanPoint | null wallDraftEnd: WallPlanPoint | null
fenceDraftEnd: WallPlanPoint | null fenceDraftEnd: WallPlanPoint | null
roofDraftEnd: WallPlanPoint | null roofDraftEnd: WallPlanPoint | null
wallDraftStart: WallPlanPoint | null
fenceDraftStart: WallPlanPoint | null
roofDraftStart: WallPlanPoint | null
polygonDraftType: FloorplanPolygonDraftType | null
polygonDraftPoints: WallPlanPoint[]
/** Set the snapped cursor point. No-ops (skips the store update, so /** Set the snapped cursor point. No-ops (skips the store update, so
* subscribers don't re-render) when unchanged — `grid:move` fires far more * subscribers don't re-render) when unchanged — `grid:move` fires far more
* often than the snapped cell actually changes. */ * often than the snapped cell actually changes. */
@@ -42,11 +51,28 @@ type FloorplanDraftPreviewState = {
setWallDraftEnd(point: WallPlanPoint | null): void setWallDraftEnd(point: WallPlanPoint | null): void
setFenceDraftEnd(point: WallPlanPoint | null): void setFenceDraftEnd(point: WallPlanPoint | null): void
setRoofDraftEnd(point: WallPlanPoint | null): void setRoofDraftEnd(point: WallPlanPoint | null): void
setWallDraftStart(point: WallPlanPoint | null): void
setFenceDraftStart(point: WallPlanPoint | null): void
setRoofDraftStart(point: WallPlanPoint | null): void
setPolygonDraft(type: FloorplanPolygonDraftType | null, points: readonly WallPlanPoint[]): void
reset(): void reset(): void
} }
function planPointsEqual(a: readonly WallPlanPoint[], b: readonly WallPlanPoint[]) {
return (
a.length === b.length &&
a.every((point, index) => point[0] === b[index]?.[0] && point[1] === b[index]?.[1])
)
}
function setPlanPointField( function setPlanPointField(
field: 'wallDraftEnd' | 'fenceDraftEnd' | 'roofDraftEnd', field:
| 'fenceDraftEnd'
| 'fenceDraftStart'
| 'roofDraftEnd'
| 'roofDraftStart'
| 'wallDraftEnd'
| 'wallDraftStart',
point: WallPlanPoint | null, point: WallPlanPoint | null,
) { ) {
return ( return (
@@ -65,6 +91,11 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
wallDraftEnd: null, wallDraftEnd: null,
fenceDraftEnd: null, fenceDraftEnd: null,
roofDraftEnd: null, roofDraftEnd: null,
wallDraftStart: null,
fenceDraftStart: null,
roofDraftStart: null,
polygonDraftType: null,
polygonDraftPoints: [],
setCursorPoint: (point) => setCursorPoint: (point) =>
set((state) => { set((state) => {
const prev = state.cursorPoint const prev = state.cursorPoint
@@ -82,13 +113,27 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
setWallDraftEnd: (point) => set(setPlanPointField('wallDraftEnd', point)), setWallDraftEnd: (point) => set(setPlanPointField('wallDraftEnd', point)),
setFenceDraftEnd: (point) => set(setPlanPointField('fenceDraftEnd', point)), setFenceDraftEnd: (point) => set(setPlanPointField('fenceDraftEnd', point)),
setRoofDraftEnd: (point) => set(setPlanPointField('roofDraftEnd', point)), setRoofDraftEnd: (point) => set(setPlanPointField('roofDraftEnd', point)),
setWallDraftStart: (point) => set(setPlanPointField('wallDraftStart', point)),
setFenceDraftStart: (point) => set(setPlanPointField('fenceDraftStart', point)),
setRoofDraftStart: (point) => set(setPlanPointField('roofDraftStart', point)),
setPolygonDraft: (type, points) =>
set((state) =>
state.polygonDraftType === type && planPointsEqual(state.polygonDraftPoints, points)
? state
: { polygonDraftType: type, polygonDraftPoints: points.map(([x, z]) => [x, z]) },
),
reset: () => reset: () =>
set((state) => set((state) =>
state.cursorPoint === null && state.cursorPoint === null &&
state.cursorPosition === null && state.cursorPosition === null &&
state.wallDraftEnd === null && state.wallDraftEnd === null &&
state.fenceDraftEnd === null && state.fenceDraftEnd === null &&
state.roofDraftEnd === null state.roofDraftEnd === null &&
state.wallDraftStart === null &&
state.fenceDraftStart === null &&
state.roofDraftStart === null &&
state.polygonDraftType === null &&
state.polygonDraftPoints.length === 0
? state ? state
: { : {
cursorPoint: null, cursorPoint: null,
@@ -96,6 +141,11 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
wallDraftEnd: null, wallDraftEnd: null,
fenceDraftEnd: null, fenceDraftEnd: null,
roofDraftEnd: null, roofDraftEnd: null,
wallDraftStart: null,
fenceDraftStart: null,
roofDraftStart: null,
polygonDraftType: null,
polygonDraftPoints: [],
}, },
), ),
})) }))
@@ -0,0 +1,90 @@
import type { AnyNode } from '@pascal-app/core'
import { create } from 'zustand'
export type PathDraftKind = 'duct-segment' | 'lineset' | 'liquid-line' | 'pipe-segment'
export type PathDraftPoint = [number, number, number]
export type PathDraftParameter = boolean | number | string
export type PathDraftParameters = Record<string, PathDraftParameter>
type PathDraftPreviewState = {
kind: PathDraftKind | null
points: PathDraftPoint[]
cursor: PathDraftPoint | null
parameters: PathDraftParameters
relatedNodes: AnyNode[]
setDraft(
kind: PathDraftKind,
points: readonly PathDraftPoint[],
cursor: PathDraftPoint | null,
parameters?: Readonly<PathDraftParameters>,
relatedNodes?: readonly AnyNode[],
): void
clear(kind: PathDraftKind): void
reset(): void
}
function pointEquals(a: PathDraftPoint | null, b: PathDraftPoint | null) {
return (!a && !b) || Boolean(a && b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2])
}
function pointsEqual(a: readonly PathDraftPoint[], b: readonly PathDraftPoint[]) {
return a.length === b.length && a.every((point, index) => pointEquals(point, b[index] ?? null))
}
function parametersEqual(a: PathDraftParameters, b: Readonly<PathDraftParameters>) {
const keys = Object.keys(a)
const nextKeys = Object.keys(b)
return keys.length === nextKeys.length && keys.every((key) => a[key] === b[key])
}
function relatedNodesEqual(a: readonly AnyNode[], b: readonly AnyNode[]) {
return (
a.length === b.length &&
a.every((node, index) => JSON.stringify(node) === JSON.stringify(b[index]))
)
}
const EMPTY_DRAFT: Pick<
PathDraftPreviewState,
'cursor' | 'kind' | 'parameters' | 'points' | 'relatedNodes'
> = {
cursor: null,
kind: null,
parameters: {},
points: [],
relatedNodes: [],
}
export const usePathDraftPreview = create<PathDraftPreviewState>((set) => ({
...EMPTY_DRAFT,
setDraft: (kind, points, cursor, parameters = {}, relatedNodes = []) =>
set((state) => {
if (
state.kind === kind &&
pointEquals(state.cursor, cursor) &&
pointsEqual(state.points, points) &&
parametersEqual(state.parameters, parameters) &&
relatedNodesEqual(state.relatedNodes, relatedNodes)
) {
return state
}
return {
cursor: cursor ? [...cursor] : null,
kind,
parameters: { ...parameters },
points: points.map((point) => [...point]),
relatedNodes: relatedNodes.map((node) => structuredClone(node)),
}
}),
clear: (kind) => set((state) => (state.kind === kind ? EMPTY_DRAFT : state)),
reset: () =>
set((state) =>
state.kind === null &&
state.points.length === 0 &&
state.cursor === null &&
Object.keys(state.parameters).length === 0 &&
state.relatedNodes.length === 0
? state
: EMPTY_DRAFT,
),
}))
@@ -10,7 +10,7 @@
import { create } from 'zustand' import { create } from 'zustand'
type StairPreviewPoint = [number, number] export type StairPreviewPoint = [number, number]
type StairBuildPreviewState = { type StairBuildPreviewState = {
/** Snapped plan-XZ point the ghost staircase sits at; `null` when idle. */ /** Snapped plan-XZ point the ghost staircase sits at; `null` when idle. */
@@ -21,6 +21,7 @@ type StairBuildPreviewState = {
* don't re-render) when the point is unchanged — `grid:move` fires far more * don't re-render) when the point is unchanged — `grid:move` fires far more
* often than the snapped cell actually changes. */ * often than the snapped cell actually changes. */
setPoint(point: StairPreviewPoint | null): void setPoint(point: StairPreviewPoint | null): void
setPreview(point: StairPreviewPoint | null, rotation: number): void
rotateBy(deltaRadians: number): void rotateBy(deltaRadians: number): void
reset(): void reset(): void
} }
@@ -33,7 +34,16 @@ export const useStairBuildPreview = create<StairBuildPreviewState>((set) => ({
const prev = state.point const prev = state.point
if (!point && !prev) return state if (!point && !prev) return state
if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
return { point } return { point: point ? [point[0], point[1]] : null }
}),
setPreview: (point, rotation) =>
set((state) => {
const samePoint =
(!point && !state.point) ||
Boolean(point && state.point && state.point[0] === point[0] && state.point[1] === point[1])
return samePoint && state.rotation === rotation
? state
: { point: point ? [point[0], point[1]] : null, rotation }
}), }),
rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })), rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })),
reset: () => reset: () =>
+16
View File
@@ -19,6 +19,7 @@ import {
resolveCeilingPlanPointSnap, resolveCeilingPlanPointSnap,
triggerSFX, triggerSFX,
useEditor, useEditor,
useFloorplanDraftPreview,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -79,6 +80,20 @@ export const CeilingTool: React.FC = () => {
}, [points.length]) }, [points.length])
useEffect(() => () => useEditor.getState().setDraftVertexCount(0), []) useEffect(() => () => useEditor.getState().setDraftVertexCount(0), [])
useEffect(() => {
useFloorplanDraftPreview.getState().setPolygonDraft('ceiling', points)
}, [points])
useEffect(
() => () => {
const draftPreview = useFloorplanDraftPreview.getState()
if (draftPreview.polygonDraftType === 'ceiling') {
draftPreview.setPolygonDraft(null, [])
}
draftPreview.setCursorPoint(null)
},
[],
)
const verticalGeo = useMemo( const verticalGeo = useMemo(
() => () =>
new BufferGeometry().setFromPoints([ new BufferGeometry().setFromPoints([
@@ -117,6 +132,7 @@ export const CeilingTool: React.FC = () => {
fallbackPoint: orthoPoint, fallbackPoint: orthoPoint,
levelId: currentLevelId, levelId: currentLevelId,
}).point }).point
useFloorplanDraftPreview.getState().setCursorPoint(displayPoint)
setSnappedCursorPosition(displayPoint) setSnappedCursorPosition(displayPoint)
if ( if (
points.length > 0 && points.length > 0 &&
+1
View File
@@ -364,6 +364,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'parametric', kind: 'parametric',
module: () => import('./renderer'), module: () => import('./renderer'),
}, },
preview: () => import('./renderer').then(({ ColumnPreview }) => ({ default: ColumnPreview })),
// Registry-driven placement tool — renders a translucent `ColumnPreview` // Registry-driven placement tool — renders a translucent `ColumnPreview`
// ghost at the cursor (mirroring the shelf build tool) instead of the // ghost at the cursor (mirroring the shelf build tool) instead of the
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s // bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s
+30 -16
View File
@@ -21,6 +21,7 @@ import {
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useEditor, useEditor,
usePathDraftPreview,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
@@ -559,6 +560,35 @@ const DuctSegmentTool = () => {
// the cursor was at key-press time. // the cursor was at key-press time.
const lastClientYRef = useRef<number | null>(null) const lastClientYRef = useRef<number | null>(null)
const ghostFittings = useMemo(() => {
const last = draftPoints.at(-1)
if (!(activeLevelId && last && cursorPos) || altActive) return []
const fittings =
planDuctDraw(
last,
cursorPos,
startPortRef.current,
startBodyRef.current,
endSnap.port,
endSnap.body,
profile,
)?.fittings ?? []
return fittings.map(
(fitting, index): DuctFittingNode => ({
...fitting,
id: `duct-fitting_live-draft-${index}`,
parentId: activeLevelId,
}),
)
}, [activeLevelId, altActive, cursorPos, draftPoints, endSnap, profile])
useEffect(() => {
usePathDraftPreview
.getState()
.setDraft('duct-segment', draftPoints, cursorPos, profile, ghostFittings)
}, [cursorPos, draftPoints, ghostFittings, profile])
useEffect(() => () => usePathDraftPreview.getState().clear('duct-segment'), [])
useEffect(() => { useEffect(() => {
if (!activeLevelId) return if (!activeLevelId) return
@@ -927,22 +957,6 @@ const DuctSegmentTool = () => {
previewSegments.push({ a: last, b: cursorPos }) previewSegments.push({ a: last, b: cursorPos })
} }
// Ghost the auto-inserted fittings (elbow / tee / cross) the next click
// will mint, by running the SAME planner the commit uses against the
// in-flight endpoints. Skipped in Alt-vertical mode (no XZ tap there).
const ghostFittings =
last && cursorPos && !altActive
? (planDuctDraw(
last,
cursorPos,
startPortRef.current,
startBodyRef.current,
endSnap.port,
endSnap.body,
profile,
)?.fittings ?? [])
: []
// Wall-style dimension pill above the cursor: absolute world coords before // Wall-style dimension pill above the cursor: absolute world coords before
// the first point, signed per-axis deltas from the last placed point while // the first point, signed per-axis deltas from the last placed point while
// a segment is in flight. The actively-driven axis is emphasised — Y in // a segment is in flight. The actively-driven axis is emphasised — Y in
+21 -4
View File
@@ -40,6 +40,7 @@ import {
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
useFenceCurveDraft, useFenceCurveDraft,
useFloorplanDraftPreview,
useSegmentDraftChain, useSegmentDraftChain,
} from '@pascal-app/editor' } from '@pascal-app/editor'
@@ -511,6 +512,9 @@ const StraightFenceTool: React.FC = () => {
buildingState.current = 0 buildingState.current = 0
previewRef.current.visible = false previewRef.current.visible = false
setDraftMeasurement(null) setDraftMeasurement(null)
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setFenceDraftStart(null)
draftPreview.setFenceDraftEnd(null)
useSegmentDraftChain.getState().clear('fence') useSegmentDraftChain.getState().clear('fence')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} }
@@ -538,6 +542,9 @@ const StraightFenceTool: React.FC = () => {
{ applySnap: !angleLocked }, { applySnap: !angleLocked },
) )
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z])
draftPreview.setFenceDraftEnd(snappedLocal)
cursorRef.current.position.copy(endingPoint.current) cursorRef.current.position.copy(endingPoint.current)
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
if ( if (
@@ -601,6 +608,9 @@ const StraightFenceTool: React.FC = () => {
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
buildingState.current = 1 buildingState.current = 1
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setFenceDraftStart(snappedStart)
draftPreview.setFenceDraftEnd(snappedStart)
triggerSFX('sfx:structure-build-start') triggerSFX('sfx:structure-build-start')
previewRef.current.visible = true previewRef.current.visible = true
setDraftMeasurement(null) setDraftMeasurement(null)
@@ -645,6 +655,10 @@ const StraightFenceTool: React.FC = () => {
useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]]) useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]])
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setFenceDraftEnd(null)
draftPreview.setFenceDraftStart(nextStart)
draftPreview.setFenceDraftEnd(nextStart)
cursorRef.current?.position.copy(startingPoint.current) cursorRef.current?.position.copy(startingPoint.current)
previewRef.current.visible = false previewRef.current.visible = false
buildingState.current = 1 buildingState.current = 1
@@ -669,6 +683,9 @@ const StraightFenceTool: React.FC = () => {
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
useSegmentDraftChain.getState().clear('fence') useSegmentDraftChain.getState().clear('fence')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setFenceDraftStart(null)
draftPreview.setFenceDraftEnd(null)
} }
}, [unit]) }, [unit])
@@ -725,11 +742,11 @@ const SplineFenceDraft: React.FC = () => {
draftRef.current = draftPoints draftRef.current = draftPoints
// Mirror the draft length into the HUD store so the "finish curve" hint only // Mirror the full transient curve so additional preview surfaces observe
// shows once drafting has started; always clear it when the tool unmounts. // the same snapped control points as the local preview.
useEffect(() => { useEffect(() => {
useFenceCurveDraft.getState().setPointCount(draftPoints.length) useFenceCurveDraft.getState().setDraft(draftPoints, cursor)
}, [draftPoints]) }, [cursor, draftPoints])
useEffect(() => () => useFenceCurveDraft.getState().reset(), []) useEffect(() => () => useFenceCurveDraft.getState().reset(), [])
useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), []) useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), [])
+1 -1
View File
@@ -9,7 +9,7 @@ import { LevelNode } from './schema'
*/ */
export const levelDefinition: NodeDefinition<typeof LevelNode> = { export const levelDefinition: NodeDefinition<typeof LevelNode> = {
kind: 'level', kind: 'level',
schemaVersion: 1, schemaVersion: 2,
schema: LevelNode, schema: LevelNode,
category: 'site', category: 'site',
+6
View File
@@ -11,6 +11,7 @@ import {
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useEditor, useEditor,
usePathDraftPreview,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
@@ -98,6 +99,11 @@ const LinesetTool = () => {
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null) const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
const lastClientYRef = useRef<number | null>(null) const lastClientYRef = useRef<number | null>(null)
useEffect(() => {
usePathDraftPreview.getState().setDraft('lineset', draftPoints, cursorPos)
}, [cursorPos, draftPoints])
useEffect(() => () => usePathDraftPreview.getState().clear('lineset'), [])
useEffect(() => { useEffect(() => {
if (!activeLevelId) return if (!activeLevelId) return
+8
View File
@@ -18,6 +18,7 @@ import {
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useEditor, useEditor,
usePathDraftPreview,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
@@ -285,6 +286,13 @@ const LiquidLineTool = () => {
// Leaving the tool clears Follow so re-arming it starts in free-draw. // Leaving the tool clears Follow so re-arming it starts in free-draw.
useEffect(() => () => useLiquidLineToolOptions.getState().setFollow(false), []) useEffect(() => () => useLiquidLineToolOptions.getState().setFollow(false), [])
useEffect(() => {
usePathDraftPreview
.getState()
.setDraft('liquid-line', traceGhost ?? draftPoints, traceGhost ? null : cursorPos)
}, [cursorPos, draftPoints, traceGhost])
useEffect(() => () => usePathDraftPreview.getState().clear('liquid-line'), [])
useEffect(() => { useEffect(() => {
if (!activeLevelId) return if (!activeLevelId) return
+25 -20
View File
@@ -11,6 +11,7 @@ import {
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useEditor, useEditor,
usePathDraftPreview,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
@@ -162,6 +163,30 @@ const PipeSegmentTool = () => {
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null) const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
const lastClientYRef = useRef<number | null>(null) const lastClientYRef = useRef<number | null>(null)
const displayStart =
draftStart &&
cursorPos &&
sloped &&
system === 'waste' &&
!startPortRef.current &&
!startBodyRef.current &&
!snapTarget &&
!altActive
? ([
draftStart[0],
draftStart[1] +
Math.hypot(cursorPos[0] - draftStart[0], cursorPos[2] - draftStart[2]) * DRAIN_SLOPE,
draftStart[2],
] as [number, number, number])
: draftStart
useEffect(() => {
usePathDraftPreview
.getState()
.setDraft('pipe-segment', displayStart ? [displayStart] : [], cursorPos, { diameter, system })
}, [cursorPos, diameter, displayStart, system])
useEffect(() => () => usePathDraftPreview.getState().clear('pipe-segment'), [])
useEffect(() => { useEffect(() => {
if (!activeLevelId) return if (!activeLevelId) return
@@ -613,26 +638,6 @@ const PipeSegmentTool = () => {
if (!activeLevelId) return null if (!activeLevelId) return null
// Free waste start lifts at commit so the run falls ONTO the grid —
// mirror that here so the preview line / pill match the placed pipe.
// A snapped end (snapTarget set) keeps the start where it is.
const displayStart =
draftStart &&
cursorPos &&
sloped &&
system === 'waste' &&
!startPortRef.current &&
!startBodyRef.current &&
!snapTarget &&
!altActive
? ([
draftStart[0],
draftStart[1] +
Math.hypot(cursorPos[0] - draftStart[0], cursorPos[2] - draftStart[2]) * DRAIN_SLOPE,
draftStart[2],
] as [number, number, number])
: draftStart
const pillParts = cursorPos const pillParts = cursorPos
? [ ? [
...(['x', 'y', 'z'] as const).map((axis, i) => ({ ...(['x', 'y', 'z'] as const).map((axis, i) => ({
+16
View File
@@ -19,6 +19,7 @@ import {
resolveSlabPlanPointSnap, resolveSlabPlanPointSnap,
triggerSFX, triggerSFX,
useEditor, useEditor,
useFloorplanDraftPreview,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -77,6 +78,20 @@ export const SlabTool: React.FC = () => {
}, [points.length]) }, [points.length])
useEffect(() => () => useEditor.getState().setDraftVertexCount(0), []) useEffect(() => () => useEditor.getState().setDraftVertexCount(0), [])
useEffect(() => {
useFloorplanDraftPreview.getState().setPolygonDraft('slab', points)
}, [points])
useEffect(
() => () => {
const draftPreview = useFloorplanDraftPreview.getState()
if (draftPreview.polygonDraftType === 'slab') {
draftPreview.setPolygonDraft(null, [])
}
draftPreview.setCursorPoint(null)
},
[],
)
useEffect(() => { useEffect(() => {
if (!currentLevelId) return if (!currentLevelId) return
@@ -101,6 +116,7 @@ export const SlabTool: React.FC = () => {
fallbackPoint: orthoPoint, fallbackPoint: orthoPoint,
levelId: currentLevelId, levelId: currentLevelId,
}).point }).point
useFloorplanDraftPreview.getState().setCursorPoint(displayPoint)
setSnappedCursorPosition(displayPoint) setSnappedCursorPosition(displayPoint)
if ( if (
points.length > 0 && points.length > 0 &&
+17
View File
@@ -33,6 +33,7 @@ import {
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
useFloorplanDraftPreview,
useSegmentDraftChain, useSegmentDraftChain,
useWallSnapIndicator, useWallSnapIndicator,
WALL_CONNECT_SNAP_RADIUS, WALL_CONNECT_SNAP_RADIUS,
@@ -594,6 +595,9 @@ export const WallTool: React.FC = () => {
buildingState.current = 0 buildingState.current = 0
chainFirstVertex.current = null chainFirstVertex.current = null
chainWallIds.current = [] chainWallIds.current = []
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setWallDraftStart(null)
draftPreview.setWallDraftEnd(null)
if (wallPreviewRef.current) { if (wallPreviewRef.current) {
wallPreviewRef.current.visible = false wallPreviewRef.current.visible = false
} }
@@ -637,6 +641,9 @@ export const WallTool: React.FC = () => {
if (buildingState.current === 1) { if (buildingState.current === 1) {
const snappedLocal = gridPosition const snappedLocal = gridPosition
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setWallDraftStart([startingPoint.current.x, startingPoint.current.z])
draftPreview.setWallDraftEnd(snappedLocal)
cursorRef.current.position.copy(endingPoint.current) cursorRef.current.position.copy(endingPoint.current)
setAxisGuide({ setAxisGuide({
origin: [startingPoint.current.x, startingPoint.current.z], origin: [startingPoint.current.x, startingPoint.current.z],
@@ -706,6 +713,9 @@ export const WallTool: React.FC = () => {
chainFirstVertex.current = startingPoint.current.clone() chainFirstVertex.current = startingPoint.current.clone()
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
buildingState.current = 1 buildingState.current = 1
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setWallDraftStart(snappedStart)
draftPreview.setWallDraftEnd(snappedStart)
setAxisGuide({ setAxisGuide({
origin: snappedStart, origin: snappedStart,
y: event.localPosition[1], y: event.localPosition[1],
@@ -783,6 +793,10 @@ export const WallTool: React.FC = () => {
useSegmentDraftChain.getState().setChainStart('wall', [nextStart[0], nextStart[1]]) useSegmentDraftChain.getState().setChainStart('wall', [nextStart[0], nextStart[1]])
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setWallDraftEnd(null)
draftPreview.setWallDraftStart(nextStart)
draftPreview.setWallDraftEnd(nextStart)
cursorRef.current?.position.copy(startingPoint.current) cursorRef.current?.position.copy(startingPoint.current)
buildingState.current = 1 buildingState.current = 1
setAxisGuide({ setAxisGuide({
@@ -820,6 +834,9 @@ export const WallTool: React.FC = () => {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear() useWallSnapIndicator.getState().clear()
useSegmentDraftChain.getState().clear('wall') useSegmentDraftChain.getState().clear('wall')
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setWallDraftStart(null)
draftPreview.setWallDraftEnd(null)
} }
}, [unit]) }, [unit])