Merge pull request #514 from pascalorg/feat/host-integration-primitives
Expose generic host integration primitives
This commit is contained in:
@@ -198,6 +198,15 @@ export interface CameraControlFitSceneEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export interface CameraPose {
|
||||
position: [number, number, number]
|
||||
target: [number, number, number]
|
||||
projection: 'perspective' | 'orthographic'
|
||||
/** Width, in scene units, of the visible plane through `target`. */
|
||||
viewWidth?: number
|
||||
fov?: number
|
||||
}
|
||||
|
||||
type CameraControlEvents = {
|
||||
'camera-controls:view': CameraControlEvent
|
||||
'camera-controls:focus': CameraControlEvent
|
||||
@@ -207,6 +216,10 @@ type CameraControlEvents = {
|
||||
'camera-controls:orbit-ccw': undefined
|
||||
'camera-controls:fit-scene': CameraControlFitSceneEvent
|
||||
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
|
||||
'camera-controls:pose': CameraPose
|
||||
'camera-controls:apply-pose': CameraPose
|
||||
'camera-controls:cancel-pose': undefined
|
||||
'camera-controls:interaction-start': undefined
|
||||
}
|
||||
|
||||
type ToolEvents = {
|
||||
|
||||
@@ -5,6 +5,7 @@ export type {
|
||||
CabinetModuleEvent,
|
||||
CameraControlEvent,
|
||||
CameraControlFitSceneEvent,
|
||||
CameraPose,
|
||||
CeilingEvent,
|
||||
ChimneyEvent,
|
||||
ColumnEvent,
|
||||
@@ -179,6 +180,11 @@ export {
|
||||
resetSceneHistoryPauseDepth,
|
||||
resumeSceneHistory,
|
||||
runAsSingleSceneHistoryStep,
|
||||
type SceneCommit,
|
||||
type SceneCommitListener,
|
||||
type SceneCommitOrigin,
|
||||
type SceneSnapshot,
|
||||
subscribeSceneCommits,
|
||||
} from './store/history-control'
|
||||
export {
|
||||
type ControlValue,
|
||||
@@ -199,7 +205,17 @@ export {
|
||||
type LiveNodeOverrides,
|
||||
} from './store/use-live-node-overrides'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
export {
|
||||
type ApplySceneSnapshotOptions,
|
||||
acquireSceneReadOnlyLease,
|
||||
applyScenePatch,
|
||||
applySceneSnapshot,
|
||||
clearSceneHistory,
|
||||
default as useScene,
|
||||
type SceneMaterialPatch,
|
||||
type SceneNodePatch,
|
||||
type ScenePatch,
|
||||
} from './store/use-scene'
|
||||
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
||||
export {
|
||||
type ElevatorDoorSide,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { DuctFittingNode } from './duct-fitting'
|
||||
import { DuctSegmentNode } from './duct-segment'
|
||||
import { DuctTerminalNode } from './duct-terminal'
|
||||
import { HvacEquipmentNode } from './hvac-equipment'
|
||||
import { LevelNode } from './level'
|
||||
import { LinesetNode } from './lineset'
|
||||
import { LiquidLineNode } from './liquid-line'
|
||||
import { PipeFittingNode } from './pipe-fitting'
|
||||
import { PipeSegmentNode } from './pipe-segment'
|
||||
import { PipeTrapNode } from './pipe-trap'
|
||||
|
||||
describe('LevelNode', () => {
|
||||
test('accepts every level-hosted MEP node ID', () => {
|
||||
const nodes = [
|
||||
DuctSegmentNode.parse({
|
||||
path: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
}),
|
||||
DuctFittingNode.parse({}),
|
||||
DuctTerminalNode.parse({}),
|
||||
HvacEquipmentNode.parse({}),
|
||||
LinesetNode.parse({
|
||||
path: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
}),
|
||||
LiquidLineNode.parse({
|
||||
path: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
}),
|
||||
PipeSegmentNode.parse({
|
||||
path: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
}),
|
||||
PipeFittingNode.parse({}),
|
||||
PipeTrapNode.parse({}),
|
||||
]
|
||||
|
||||
expect(LevelNode.parse({ children: nodes.map((node) => node.id) }).children).toEqual(
|
||||
nodes.map((node) => node.id),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,10 +3,19 @@ import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { CeilingNode } from './ceiling'
|
||||
import { ColumnNode } from './column'
|
||||
import { DuctFittingNode } from './duct-fitting'
|
||||
import { DuctSegmentNode } from './duct-segment'
|
||||
import { DuctTerminalNode } from './duct-terminal'
|
||||
import { FenceNode } from './fence'
|
||||
import { GuideNode } from './guide'
|
||||
import { HvacEquipmentNode } from './hvac-equipment'
|
||||
import { ItemNode } from './item'
|
||||
import { LinesetNode } from './lineset'
|
||||
import { LiquidLineNode } from './liquid-line'
|
||||
import { MeasurementNode } from './measurement'
|
||||
import { PipeFittingNode } from './pipe-fitting'
|
||||
import { PipeSegmentNode } from './pipe-segment'
|
||||
import { PipeTrapNode } from './pipe-trap'
|
||||
import { RoofNode } from './roof'
|
||||
import { ScanNode } from './scan'
|
||||
import { ShelfNode } from './shelf'
|
||||
@@ -36,6 +45,15 @@ export const LevelNode = BaseNode.extend({
|
||||
MeasurementNode.shape.id,
|
||||
SpawnNode.shape.id,
|
||||
ShelfNode.shape.id,
|
||||
DuctSegmentNode.shape.id,
|
||||
DuctFittingNode.shape.id,
|
||||
DuctTerminalNode.shape.id,
|
||||
HvacEquipmentNode.shape.id,
|
||||
LinesetNode.shape.id,
|
||||
LiquidLineNode.shape.id,
|
||||
PipeSegmentNode.shape.id,
|
||||
PipeFittingNode.shape.id,
|
||||
PipeTrapNode.shape.id,
|
||||
]),
|
||||
)
|
||||
.default([]),
|
||||
@@ -44,7 +62,7 @@ export const LevelNode = BaseNode.extend({
|
||||
}).describe(
|
||||
dedent`
|
||||
Level node - used to represent a level in the building
|
||||
- children: array of floor, wall, ceiling, roof, item nodes
|
||||
- children: array of architectural, equipment, and MEP distribution nodes
|
||||
- level: level number
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -125,12 +125,7 @@ describe('Single-undo dance', () => {
|
||||
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0)
|
||||
})
|
||||
|
||||
test('commit-returns-false (no change) does NOT consume the prior pastState', () => {
|
||||
// This is the suspected bend regression: when action.commit returns
|
||||
// false (draft.curveOffset === ctx.originalCurveOffset), session.commit
|
||||
// calls scene.restoreAll() but doesn't push to pastStates. Subsequent
|
||||
// undo pops the PRIOR action (e.g. fence creation), not the no-op
|
||||
// bend.
|
||||
test('commit-returns-false adds no step; a later standalone undo reaches the prior action', () => {
|
||||
useScene.getState().createNode(makeFence(0))
|
||||
const pastBeforeBend = useScene.temporal.getState().pastStates.length
|
||||
|
||||
@@ -143,20 +138,13 @@ describe('Single-undo dance', () => {
|
||||
scene.resumeHistory()
|
||||
|
||||
const pastAfterNoOp = useScene.temporal.getState().pastStates.length
|
||||
expect(pastAfterNoOp).toBe(pastBeforeBend) // no entries added
|
||||
expect(pastAfterNoOp).toBe(pastBeforeBend)
|
||||
|
||||
// Now undo — this should be a no-op (state unchanged), but pops the create.
|
||||
// This is an independent undo after the gesture has finished, so it
|
||||
// correctly reaches the preceding real action rather than inventing a
|
||||
// no-op history boundary for the cancelled bend.
|
||||
useScene.temporal.getState().undo()
|
||||
// ⚠️ Reproduces the bug — undo removes the fence:
|
||||
const fence = useScene.getState().nodes[FENCE_ID]
|
||||
if (fence === undefined) {
|
||||
// Bug reproduced. The "no-op bend" allowed Ctrl-Z to fall through
|
||||
// to the fence creation. Fix is in action.commit: don't return false
|
||||
// — push a no-op entry instead, or guard against the cancel path.
|
||||
expect(fence).toBeUndefined()
|
||||
} else {
|
||||
expect((fence as { curveOffset: number }).curveOffset).toBe(0)
|
||||
}
|
||||
expect(useScene.getState().nodes[FENCE_ID]).toBeUndefined()
|
||||
})
|
||||
|
||||
test('full session flow via createDragSession with real action.commit dance', async () => {
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
|
||||
let sceneHistoryPauseDepth = 0
|
||||
|
||||
export type SceneSnapshot = {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
rootNodeIds: AnyNodeId[]
|
||||
collections: Record<CollectionId, Collection>
|
||||
materials: Record<SceneMaterialId, SceneMaterial>
|
||||
installedPlugins: string[]
|
||||
}
|
||||
|
||||
export type SceneCommitOrigin = 'local' | 'load' | 'host'
|
||||
|
||||
export type SceneCommit = {
|
||||
origin: SceneCommitOrigin
|
||||
before: SceneSnapshot
|
||||
current: SceneSnapshot
|
||||
}
|
||||
|
||||
export type SceneCommitListener = (commit: SceneCommit) => void
|
||||
|
||||
type TemporalStoreLike = {
|
||||
temporal: {
|
||||
getState(): {
|
||||
@@ -18,6 +40,104 @@ type TemporalHistoryStoreLike<TPastState> = {
|
||||
}
|
||||
}
|
||||
|
||||
const sceneCommitListeners = new Set<SceneCommitListener>()
|
||||
let sceneCommitTransactionDepth = 0
|
||||
let pendingSceneCommit: SceneCommit | null = null
|
||||
|
||||
function areSemanticValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true
|
||||
if (typeof left !== typeof right || left === null || right === null) return false
|
||||
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!(Array.isArray(left) && Array.isArray(right)) || left.length !== right.length) return false
|
||||
return left.every((value, index) => areSemanticValuesEqual(value, right[index]))
|
||||
}
|
||||
|
||||
if (typeof left !== 'object' || typeof right !== 'object') return false
|
||||
|
||||
const leftRecord = left as Record<string, unknown>
|
||||
const rightRecord = right as Record<string, unknown>
|
||||
const leftKeys = Object.keys(leftRecord)
|
||||
const rightKeys = Object.keys(rightRecord)
|
||||
if (leftKeys.length !== rightKeys.length) return false
|
||||
|
||||
for (const key of leftKeys) {
|
||||
if (!(key in rightRecord) || !areSemanticValuesEqual(leftRecord[key], rightRecord[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function areSceneSnapshotsEqual(left: SceneSnapshot, right: SceneSnapshot): boolean {
|
||||
return (
|
||||
areSemanticValuesEqual(left.nodes, right.nodes) &&
|
||||
areSemanticValuesEqual(left.rootNodeIds, right.rootNodeIds) &&
|
||||
areSemanticValuesEqual(left.collections, right.collections) &&
|
||||
areSemanticValuesEqual(left.materials, right.materials) &&
|
||||
areSemanticValuesEqual(left.installedPlugins, right.installedPlugins)
|
||||
)
|
||||
}
|
||||
|
||||
export function subscribeSceneCommits(listener: SceneCommitListener): () => void {
|
||||
sceneCommitListeners.add(listener)
|
||||
return () => {
|
||||
sceneCommitListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function emitSceneCommit(commit: SceneCommit): void {
|
||||
for (const listener of [...sceneCommitListeners]) {
|
||||
try {
|
||||
listener(commit)
|
||||
} catch (error) {
|
||||
console.error('[Scene] Scene commit listener failed', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function notifySceneCommit(commit: SceneCommit): void {
|
||||
if (areSceneSnapshotsEqual(commit.before, commit.current)) return
|
||||
|
||||
if (sceneCommitTransactionDepth > 0) {
|
||||
if (pendingSceneCommit) {
|
||||
pendingSceneCommit = {
|
||||
origin: pendingSceneCommit.origin,
|
||||
before: pendingSceneCommit.before,
|
||||
current: commit.current,
|
||||
}
|
||||
} else {
|
||||
pendingSceneCommit = commit
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
emitSceneCommit(commit)
|
||||
}
|
||||
|
||||
function beginSceneCommitTransaction(): void {
|
||||
sceneCommitTransactionDepth += 1
|
||||
}
|
||||
|
||||
function pendingSceneCommitIsNoOp(): boolean {
|
||||
return Boolean(
|
||||
pendingSceneCommit &&
|
||||
areSceneSnapshotsEqual(pendingSceneCommit.before, pendingSceneCommit.current),
|
||||
)
|
||||
}
|
||||
|
||||
function endSceneCommitTransaction(): void {
|
||||
if (sceneCommitTransactionDepth === 0) return
|
||||
sceneCommitTransactionDepth -= 1
|
||||
if (sceneCommitTransactionDepth > 0) return
|
||||
|
||||
const commit = pendingSceneCommit
|
||||
pendingSceneCommit = null
|
||||
if (commit && !areSceneSnapshotsEqual(commit.before, commit.current)) {
|
||||
emitSceneCommit(commit)
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
|
||||
if (sceneHistoryPauseDepth === 0) {
|
||||
sceneStore.temporal.getState().pause()
|
||||
@@ -65,11 +185,16 @@ export function runAsSingleSceneHistoryStep<TPastState, TResult>(
|
||||
run: () => TResult,
|
||||
): TResult {
|
||||
const beforePastStates = sceneStore.temporal.getState().pastStates
|
||||
beginSceneCommitTransaction()
|
||||
try {
|
||||
const result = run()
|
||||
const afterPastStates = sceneStore.temporal.getState().pastStates
|
||||
const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates)
|
||||
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]
|
||||
if (firstAddedState !== undefined) {
|
||||
sceneStore.temporal.setState({
|
||||
@@ -78,4 +203,7 @@ export function runAsSingleSceneHistoryStep<TPastState, TResult>(
|
||||
}
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -34,7 +34,18 @@ import {
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { healSceneNodes } from '../utils/heal-scene-graph'
|
||||
import * as nodeActions from './actions/node-actions'
|
||||
import { resetSceneHistoryPauseDepth } from './history-control'
|
||||
import {
|
||||
areSceneSnapshotsEqual,
|
||||
getSceneHistoryPauseDepth,
|
||||
notifySceneCommit,
|
||||
pauseSceneHistory,
|
||||
resetSceneHistoryPauseDepth,
|
||||
resumeSceneHistory,
|
||||
type SceneCommitOrigin,
|
||||
type SceneSnapshot,
|
||||
} from './history-control'
|
||||
import useLiveNodeOverrides from './use-live-node-overrides'
|
||||
import useLiveTransforms from './use-live-transforms'
|
||||
|
||||
function getFiniteNumber(value: unknown, fallback: number) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
@@ -1015,6 +1026,16 @@ type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
||||
>
|
||||
}
|
||||
|
||||
function sceneHistorySnapshotFromState(
|
||||
state: Pick<
|
||||
SceneState,
|
||||
'nodes' | 'rootNodeIds' | 'collections' | 'materials' | 'installedPlugins'
|
||||
>,
|
||||
): SceneSnapshot {
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state
|
||||
return { nodes, rootNodeIds, collections, materials, installedPlugins }
|
||||
}
|
||||
|
||||
const useScene: UseSceneStore = create<SceneState>()(
|
||||
temporal(
|
||||
(set, get) => ({
|
||||
@@ -1311,9 +1332,14 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
},
|
||||
}),
|
||||
{
|
||||
partialize: (state) => {
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state
|
||||
return { nodes, rootNodeIds, collections, materials, installedPlugins }
|
||||
partialize: (state: SceneState) => sceneHistorySnapshotFromState(state),
|
||||
equality: (pastState, currentState) => areSceneSnapshotsEqual(pastState, currentState),
|
||||
onSave: (pastState, currentState) => {
|
||||
notifySceneCommit({
|
||||
origin: 'local',
|
||||
before: sceneHistorySnapshotFromState(pastState),
|
||||
current: sceneHistorySnapshotFromState(currentState),
|
||||
})
|
||||
},
|
||||
limit: 50, // Limit to last 50 actions
|
||||
},
|
||||
@@ -1322,6 +1348,165 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
|
||||
export default useScene
|
||||
|
||||
let sceneReadOnlyLeaseCount = 0
|
||||
let sceneReadOnlyLeaseBaseline = false
|
||||
|
||||
export function acquireSceneReadOnlyLease(): () => void {
|
||||
if (sceneReadOnlyLeaseCount === 0) {
|
||||
sceneReadOnlyLeaseBaseline = useScene.getState().readOnly
|
||||
}
|
||||
sceneReadOnlyLeaseCount += 1
|
||||
useScene.setState({ readOnly: true })
|
||||
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) return
|
||||
released = true
|
||||
sceneReadOnlyLeaseCount = Math.max(0, sceneReadOnlyLeaseCount - 1)
|
||||
if (sceneReadOnlyLeaseCount > 0) return
|
||||
useScene.setState({ readOnly: sceneReadOnlyLeaseBaseline })
|
||||
sceneReadOnlyLeaseBaseline = false
|
||||
}
|
||||
}
|
||||
|
||||
export type SceneNodePatch = {
|
||||
id: AnyNodeId
|
||||
data: Partial<AnyNode>
|
||||
removeFields: string[]
|
||||
}
|
||||
|
||||
export type SceneMaterialPatch = {
|
||||
id: SceneMaterialId
|
||||
material: SceneMaterial | null
|
||||
}
|
||||
|
||||
export type ScenePatch = {
|
||||
materialChanges: SceneMaterialPatch[]
|
||||
nodeUpdates: SceneNodePatch[]
|
||||
}
|
||||
|
||||
export function applyScenePatch(changes: ScenePatch): boolean {
|
||||
const beforeState = useScene.getState()
|
||||
const hasInvalidNodeTarget = changes.nodeUpdates.some(({ id, data, removeFields }) => {
|
||||
const node = beforeState.nodes[id]
|
||||
if (!node) return true
|
||||
if ('id' in data && data.id !== node.id) return true
|
||||
if ('type' in data && data.type !== node.type) return true
|
||||
if ('object' in data && data.object !== node.object) return true
|
||||
if (removeFields.some((field) => field === 'id' || field === 'object' || field === 'type')) {
|
||||
return true
|
||||
}
|
||||
return removeFields.some((field) => Object.hasOwn(data, field))
|
||||
})
|
||||
const hasInvalidMaterialTarget = changes.materialChanges.some(
|
||||
({ id, material }) => material !== null && material.id !== id,
|
||||
)
|
||||
if (
|
||||
(changes.nodeUpdates.length === 0 && changes.materialChanges.length === 0) ||
|
||||
hasInvalidNodeTarget ||
|
||||
hasInvalidMaterialTarget
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const temporalState = useScene.temporal.getState()
|
||||
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) return false
|
||||
|
||||
const before = sceneHistorySnapshotFromState(beforeState)
|
||||
pauseSceneHistory(useScene)
|
||||
try {
|
||||
// Host-owned fields bypass the UI lock without running local mutation cascades.
|
||||
useScene.setState((state) => {
|
||||
const nodes = changes.nodeUpdates.length > 0 ? { ...state.nodes } : state.nodes
|
||||
for (const { id, data, removeFields } of changes.nodeUpdates) {
|
||||
const node = nodes[id]
|
||||
if (!node) return {}
|
||||
const nextNode = { ...node, ...data }
|
||||
for (const field of removeFields) delete nextNode[field as keyof typeof nextNode]
|
||||
nodes[id] = nextNode as AnyNode
|
||||
}
|
||||
const materials =
|
||||
changes.materialChanges.length > 0 ? { ...state.materials } : state.materials
|
||||
for (const { id, material } of changes.materialChanges) {
|
||||
if (material === null) {
|
||||
delete materials[id]
|
||||
} else {
|
||||
materials[id] = material
|
||||
}
|
||||
}
|
||||
return { materials, nodes }
|
||||
})
|
||||
} finally {
|
||||
resumeSceneHistory(useScene)
|
||||
}
|
||||
|
||||
const currentState = useScene.getState()
|
||||
const current = sceneHistorySnapshotFromState(currentState)
|
||||
for (const { id } of changes.nodeUpdates) {
|
||||
useLiveNodeOverrides.getState().clear(id)
|
||||
useLiveTransforms.getState().clear(id)
|
||||
}
|
||||
if (areSceneSnapshotsEqual(before, current)) return false
|
||||
|
||||
for (const { id } of changes.nodeUpdates) {
|
||||
currentState.markDirty(id)
|
||||
const beforeParentId = before.nodes[id]?.parentId as AnyNodeId | null | undefined
|
||||
const currentParentId = current.nodes[id]?.parentId as AnyNodeId | null | undefined
|
||||
if (beforeParentId) currentState.markDirty(beforeParentId)
|
||||
if (currentParentId) currentState.markDirty(currentParentId)
|
||||
}
|
||||
if (changes.materialChanges.length > 0) {
|
||||
const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id)))
|
||||
for (const node of Object.values(current.nodes)) {
|
||||
const slots = 'slots' in node ? node.slots : undefined
|
||||
if (!(slots && Object.values(slots).some((ref) => materialRefs.has(ref)))) continue
|
||||
currentState.markDirty(node.id)
|
||||
if (node.parentId) currentState.markDirty(node.parentId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
notifySceneCommit({
|
||||
origin: 'host',
|
||||
before,
|
||||
current,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
export type ApplySceneSnapshotOptions = {
|
||||
origin: Extract<SceneCommitOrigin, 'load' | 'host'>
|
||||
}
|
||||
|
||||
export function applySceneSnapshot(
|
||||
snapshot: SceneSnapshot,
|
||||
options: ApplySceneSnapshotOptions,
|
||||
): boolean {
|
||||
const before = sceneHistorySnapshotFromState(useScene.getState())
|
||||
const temporalState = useScene.temporal.getState()
|
||||
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) {
|
||||
throw new Error('Cannot replace the scene snapshot during an active interaction')
|
||||
}
|
||||
pauseSceneHistory(useScene)
|
||||
try {
|
||||
useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds, {
|
||||
collections: snapshot.collections,
|
||||
installedPlugins: snapshot.installedPlugins,
|
||||
materials: snapshot.materials,
|
||||
})
|
||||
useScene.temporal.getState().clear()
|
||||
} finally {
|
||||
resumeSceneHistory(useScene)
|
||||
}
|
||||
|
||||
useLiveNodeOverrides.getState().clearAll()
|
||||
useLiveTransforms.getState().clearAll()
|
||||
|
||||
const current = sceneHistorySnapshotFromState(useScene.getState())
|
||||
if (areSceneSnapshotsEqual(before, current)) return false
|
||||
notifySceneCommit({ origin: options.origin, before, current })
|
||||
return true
|
||||
}
|
||||
|
||||
// Track previous temporal state lengths and node snapshot for diffing
|
||||
let prevPastLength = 0
|
||||
let prevFutureLength = 0
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
BuildingNode,
|
||||
ElevatorNode,
|
||||
LevelNode,
|
||||
SlabNode,
|
||||
} from '../../schema'
|
||||
import { type SceneCommit, subscribeSceneCommits } from '../../store/history-control'
|
||||
import useScene, { clearSceneHistory } from '../../store/use-scene'
|
||||
import { initializeElevatorOpeningSync } from './elevator-opening-system'
|
||||
|
||||
type RafFn = (callback: (time: number) => void) => number
|
||||
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (
|
||||
callback,
|
||||
) => {
|
||||
callback(0)
|
||||
return 0
|
||||
}
|
||||
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
|
||||
() => {}
|
||||
|
||||
const BUILDING_ID = 'building_elevator_opening_commit' as AnyNodeId
|
||||
const ELEVATOR_ID = 'elevator_opening_commit' as AnyNodeId
|
||||
const GROUND_LEVEL_ID = 'level_elevator_opening_ground' as AnyNodeId
|
||||
const UPPER_LEVEL_ID = 'level_elevator_opening_upper' as AnyNodeId
|
||||
const UPPER_SLAB_ID = 'slab_elevator_opening_upper' as AnyNodeId
|
||||
|
||||
let stopOpeningSync = () => {}
|
||||
let stopCommitSubscription = () => {}
|
||||
|
||||
function resetScene() {
|
||||
const ground = LevelNode.parse({
|
||||
id: GROUND_LEVEL_ID,
|
||||
children: [],
|
||||
level: 0,
|
||||
parentId: BUILDING_ID,
|
||||
})
|
||||
const upper = LevelNode.parse({
|
||||
id: UPPER_LEVEL_ID,
|
||||
children: [UPPER_SLAB_ID],
|
||||
level: 1,
|
||||
parentId: BUILDING_ID,
|
||||
})
|
||||
const elevator = ElevatorNode.parse({
|
||||
id: ELEVATOR_ID,
|
||||
depth: 1.6,
|
||||
fromLevelId: GROUND_LEVEL_ID,
|
||||
parentId: BUILDING_ID,
|
||||
position: [2, 0, 1.5],
|
||||
toLevelId: UPPER_LEVEL_ID,
|
||||
visible: false,
|
||||
width: 1.6,
|
||||
})
|
||||
const upperSlab = SlabNode.parse({
|
||||
id: UPPER_SLAB_ID,
|
||||
holes: [],
|
||||
parentId: UPPER_LEVEL_ID,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
})
|
||||
const building = BuildingNode.parse({
|
||||
id: BUILDING_ID,
|
||||
children: [GROUND_LEVEL_ID, UPPER_LEVEL_ID, ELEVATOR_ID],
|
||||
})
|
||||
|
||||
useScene.setState({
|
||||
collections: {},
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
materials: {},
|
||||
nodes: Object.fromEntries(
|
||||
[building, ground, upper, elevator, upperSlab].map((node) => [node.id, node]),
|
||||
) as Record<AnyNodeId, AnyNode>,
|
||||
readOnly: false,
|
||||
rootNodeIds: [BUILDING_ID],
|
||||
} as never)
|
||||
clearSceneHistory()
|
||||
}
|
||||
|
||||
function getOpeningCenter(nodes: Record<AnyNodeId, AnyNode>): [number, number] | null {
|
||||
const slab = nodes[UPPER_SLAB_ID] as { holes?: [number, number][][] }
|
||||
const opening = slab.holes?.[0]
|
||||
if (!opening || opening.length === 0) return null
|
||||
|
||||
const [x, z] = opening.reduce(([sumX, sumZ], point) => [sumX + point[0], sumZ + point[1]], [0, 0])
|
||||
return [x / opening.length, z / opening.length]
|
||||
}
|
||||
|
||||
function getElevatorPosition(nodes: Record<AnyNodeId, AnyNode>) {
|
||||
return (nodes[ELEVATOR_ID] as { position?: [number, number, number] }).position
|
||||
}
|
||||
|
||||
describe('ElevatorOpeningSystem scene commit boundary', () => {
|
||||
beforeEach(() => {
|
||||
stopOpeningSync()
|
||||
stopCommitSubscription()
|
||||
stopOpeningSync = () => {}
|
||||
stopCommitSubscription = () => {}
|
||||
resetScene()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stopOpeningSync()
|
||||
stopCommitSubscription()
|
||||
stopOpeningSync = () => {}
|
||||
stopCommitSubscription = () => {}
|
||||
})
|
||||
|
||||
test('includes the elevator edit and derived slab opening in one commit and undo step', () => {
|
||||
const commits: SceneCommit[] = []
|
||||
stopOpeningSync = initializeElevatorOpeningSync()
|
||||
stopCommitSubscription = subscribeSceneCommits((commit) => commits.push(commit))
|
||||
|
||||
useScene.getState().updateNode(ELEVATOR_ID, { visible: true } as Partial<AnyNode>)
|
||||
|
||||
expect(commits).toHaveLength(1)
|
||||
expect(commits[0]?.origin).toBe('local')
|
||||
expect(commits[0]?.before.nodes[ELEVATOR_ID]?.visible).toBe(false)
|
||||
expect(commits[0]?.current.nodes[ELEVATOR_ID]?.visible).toBe(true)
|
||||
expect((commits[0]?.before.nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toEqual([])
|
||||
expect((commits[0]?.current.nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toHaveLength(
|
||||
1,
|
||||
)
|
||||
expect(
|
||||
(commits[0]?.current.nodes[UPPER_SLAB_ID] as { holeMetadata?: unknown[] }).holeMetadata,
|
||||
).toEqual([{ elevatorId: ELEVATOR_ID, source: 'elevator' }])
|
||||
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
||||
|
||||
useScene.temporal.getState().undo()
|
||||
|
||||
expect(useScene.getState().nodes[ELEVATOR_ID]?.visible).toBe(false)
|
||||
expect((useScene.getState().nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toEqual([])
|
||||
})
|
||||
|
||||
test('processes a second relevant elevator mutation in the same turn', () => {
|
||||
const commits: SceneCommit[] = []
|
||||
stopOpeningSync = initializeElevatorOpeningSync()
|
||||
stopCommitSubscription = subscribeSceneCommits((commit) => commits.push(commit))
|
||||
|
||||
useScene.getState().updateNode(ELEVATOR_ID, { visible: true } as Partial<AnyNode>)
|
||||
useScene.getState().updateNode(ELEVATOR_ID, { position: [3, 0, 1.5] } as Partial<AnyNode>)
|
||||
|
||||
expect(commits).toHaveLength(2)
|
||||
expect(getOpeningCenter(commits[0]!.current.nodes)).toEqual([2, 1.5])
|
||||
expect(getElevatorPosition(commits[1]!.before.nodes)).toEqual([2, 0, 1.5])
|
||||
expect(getOpeningCenter(commits[1]!.before.nodes)).toEqual([2, 1.5])
|
||||
expect(getElevatorPosition(commits[1]!.current.nodes)).toEqual([3, 0, 1.5])
|
||||
expect(getOpeningCenter(commits[1]!.current.nodes)).toEqual([3, 1.5])
|
||||
expect(useScene.temporal.getState().pastStates).toHaveLength(2)
|
||||
|
||||
useScene.temporal.getState().undo()
|
||||
|
||||
expect(getElevatorPosition(useScene.getState().nodes)).toEqual([2, 0, 1.5])
|
||||
expect(getOpeningCenter(useScene.getState().nodes)).toEqual([2, 1.5])
|
||||
|
||||
useScene.temporal.getState().undo()
|
||||
|
||||
expect(useScene.getState().nodes[ELEVATOR_ID]?.visible).toBe(false)
|
||||
expect((useScene.getState().nodes[UPPER_SLAB_ID] as { holes?: unknown[] }).holes).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import type { AnyNode } from '../../schema'
|
||||
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { syncAutoElevatorOpenings } from './elevator-opening-sync'
|
||||
|
||||
@@ -32,27 +33,32 @@ function hasOpeningRelevantNodeChange(
|
||||
return false
|
||||
}
|
||||
|
||||
export const ElevatorOpeningSystem = () => {
|
||||
const syncingAutoOpeningsRef = useRef(false)
|
||||
export function initializeElevatorOpeningSync() {
|
||||
let syncingAutoOpenings = false
|
||||
|
||||
useEffect(() => {
|
||||
const applyUpdates = (updates: ReturnType<typeof syncAutoElevatorOpenings>) => {
|
||||
if (updates.length === 0) return
|
||||
syncingAutoOpeningsRef.current = true
|
||||
syncingAutoOpenings = true
|
||||
pauseSceneHistory(useScene)
|
||||
try {
|
||||
useScene.getState().updateNodes(updates)
|
||||
queueMicrotask(() => {
|
||||
syncingAutoOpeningsRef.current = false
|
||||
})
|
||||
} finally {
|
||||
resumeSceneHistory(useScene)
|
||||
syncingAutoOpenings = false
|
||||
}
|
||||
}
|
||||
|
||||
applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes))
|
||||
|
||||
return useScene.subscribe((state, prevState) => {
|
||||
if (syncingAutoOpeningsRef.current) return
|
||||
if (syncingAutoOpenings) return
|
||||
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
|
||||
applyUpdates(syncAutoElevatorOpenings(state.nodes))
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const ElevatorOpeningSystem = () => {
|
||||
useEffect(() => initializeElevatorOpeningSync(), [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -197,6 +197,10 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
|
||||
const commitFinalStateOrRevert = () => {
|
||||
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
|
||||
// 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)
|
||||
// and Phase 2's resume — but Phase 2's write is delegated, and
|
||||
// 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) {
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
||||
if (historyPaused) {
|
||||
|
||||
+44
-26
@@ -12,6 +12,48 @@ import { memo } from 'react'
|
||||
import usePlacementPreview from '../../../store/use-placement-preview'
|
||||
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
|
||||
* 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)
|
||||
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 (
|
||||
<g data-floorplan-placement-preview opacity={0.5} pointerEvents="none">
|
||||
<FloorplanGeometryRenderer geometry={geometry} />
|
||||
<g data-floorplan-placement-preview>
|
||||
<FloorplanNodePreview node={node} parentNode={parentNode} />
|
||||
</g>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type CameraControlEvent,
|
||||
type CameraControlFitSceneEvent,
|
||||
type CameraPose,
|
||||
emitter,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
@@ -20,6 +21,13 @@ import {
|
||||
Spherical,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import {
|
||||
type CameraPoseApplicationPlan,
|
||||
normalizeCameraPose,
|
||||
planCameraPoseApplication,
|
||||
stepCameraPoseInterpolation,
|
||||
withCameraPoseDistance,
|
||||
} from '../../lib/camera-pose'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
@@ -35,6 +43,8 @@ const tempDelta = new Vector3()
|
||||
const tempPosition = new Vector3()
|
||||
const tempSize = new Vector3()
|
||||
const tempTarget = new Vector3()
|
||||
const transitionFreezePosition = new Vector3()
|
||||
const transitionFreezeTarget = new Vector3()
|
||||
const syncTarget = new Vector3()
|
||||
const syncSpherical = 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) {
|
||||
return (
|
||||
target instanceof HTMLInputElement ||
|
||||
@@ -272,14 +297,18 @@ function resolveCameraViewWidthUpdate(
|
||||
return { type: 'none', viewWidth }
|
||||
}
|
||||
|
||||
function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) {
|
||||
function applyCameraViewWidth(
|
||||
control: CameraControlsImpl,
|
||||
update: CameraViewWidthUpdate,
|
||||
enableTransition = true,
|
||||
) {
|
||||
if (update.type === 'distance') {
|
||||
control.dollyTo(update.distance, true)
|
||||
control.dollyTo(update.distance, enableTransition)
|
||||
return
|
||||
}
|
||||
|
||||
if (update.type === 'zoom') {
|
||||
control.zoomTo(update.zoom, true)
|
||||
control.zoomTo(update.zoom, enableTransition)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +376,13 @@ function useFirstPersonCameraPoseRestore(
|
||||
|
||||
export const CustomCameraControls = () => {
|
||||
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>({
|
||||
forward: false,
|
||||
backward: false,
|
||||
@@ -391,6 +427,120 @@ export const CustomCameraControls = () => {
|
||||
raycaster.layers.enable(ZONE_LAYER)
|
||||
}, [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(() => {
|
||||
// Dev-only: deterministic camera poses for screenshot/automation tooling.
|
||||
// A getter, not a snapshot — drei recreates the impl when the default
|
||||
@@ -568,6 +718,36 @@ export const CustomCameraControls = () => {
|
||||
})
|
||||
}, [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(() => {
|
||||
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
|
||||
|
||||
@@ -584,6 +764,49 @@ export const CustomCameraControls = () => {
|
||||
useFrame((_, delta) => {
|
||||
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 horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
|
||||
const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
|
||||
@@ -602,7 +825,7 @@ export const CustomCameraControls = () => {
|
||||
clearPendingFloorplanNavigationPose()
|
||||
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
|
||||
if (vertical !== 0) control.forward(vertical * step, true)
|
||||
})
|
||||
}, 0)
|
||||
|
||||
// Configure mouse buttons based on control mode and camera mode
|
||||
const mouseButtons = useMemo(() => {
|
||||
@@ -750,7 +973,8 @@ export const CustomCameraControls = () => {
|
||||
!(event.metaKey || event.ctrlKey || event.altKey) &&
|
||||
!isEditableKeyboardTarget(event.target)
|
||||
) {
|
||||
setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
||||
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
||||
if (changed) beginLocalCameraInteraction()
|
||||
clearPendingFloorplanNavigationPose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -823,6 +1047,7 @@ export const CustomCameraControls = () => {
|
||||
}
|
||||
|
||||
const onWheel = () => {
|
||||
beginLocalCameraInteraction()
|
||||
clearPendingFloorplanNavigationPose()
|
||||
}
|
||||
|
||||
@@ -851,7 +1076,7 @@ export const CustomCameraControls = () => {
|
||||
window.addEventListener('pointerup', onPointerUp, true)
|
||||
window.addEventListener('pointercancel', onPointerUp, true)
|
||||
window.addEventListener('blur', onBlur)
|
||||
gl.domElement.addEventListener('wheel', onWheel, { passive: true })
|
||||
gl.domElement.addEventListener('wheel', onWheel, { capture: true, passive: true })
|
||||
updateConfig()
|
||||
|
||||
return () => {
|
||||
@@ -861,29 +1086,27 @@ export const CustomCameraControls = () => {
|
||||
window.removeEventListener('pointerup', onPointerUp, true)
|
||||
window.removeEventListener('pointercancel', onPointerUp, true)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
gl.domElement.removeEventListener('wheel', onWheel)
|
||||
gl.domElement.removeEventListener('wheel', onWheel, true)
|
||||
clearKeyboardPanKeys()
|
||||
clearNavigationCursor()
|
||||
}
|
||||
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose])
|
||||
}, [
|
||||
beginLocalCameraInteraction,
|
||||
cameraMode,
|
||||
gl,
|
||||
isPreviewMode,
|
||||
isFirstPersonMode,
|
||||
clearPendingFloorplanNavigationPose,
|
||||
])
|
||||
|
||||
// Cancel any in-progress 2D-origin navigation pose when the user starts
|
||||
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
|
||||
// fires only for user pointer interactions — not for programmatic
|
||||
// moveTo/rotateTo which emit `transitionstart` instead.
|
||||
useEffect(() => {
|
||||
if (isFirstPersonMode) return
|
||||
const control = controls.current
|
||||
if (!control) return
|
||||
|
||||
const onControlStart = () => {
|
||||
const handleControlStart = useCallback(() => {
|
||||
clearPendingFloorplanNavigationPose()
|
||||
}
|
||||
control.addEventListener('controlstart', onControlStart)
|
||||
return () => {
|
||||
control.removeEventListener('controlstart', onControlStart)
|
||||
}
|
||||
}, [isFirstPersonMode, clearPendingFloorplanNavigationPose])
|
||||
beginLocalCameraInteraction()
|
||||
}, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose])
|
||||
|
||||
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
||||
const previewTargetNodeId = isPreviewMode
|
||||
@@ -1211,7 +1434,8 @@ export const CustomCameraControls = () => {
|
||||
minDistance={minDistance}
|
||||
minPolarAngle={0}
|
||||
mouseButtons={mouseButtons}
|
||||
onUpdate={publishCurrentNavigationPose}
|
||||
onControlStart={handleControlStart}
|
||||
onUpdate={handleCameraUpdate}
|
||||
onRest={onRest}
|
||||
onSleep={onRest}
|
||||
onTransitionStart={onTransitionStart}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
type ComponentProps,
|
||||
memo,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -5195,8 +5196,10 @@ export function FloorplanPanel({
|
||||
* in 2d, 3d, and split modes, while this panel itself may be display:none.
|
||||
*/
|
||||
compassHost,
|
||||
floorplanSceneSlot,
|
||||
}: {
|
||||
compassHost?: HTMLElement | null
|
||||
floorplanSceneSlot?: ReactNode
|
||||
}) {
|
||||
const viewportHostRef = useRef<HTMLDivElement>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
@@ -5371,6 +5374,18 @@ export function FloorplanPanel({
|
||||
)
|
||||
const [ceilingDraftPoints, setCeilingDraftPoints] = 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 [siteBoundaryDraft, setSiteBoundaryDraft] = useState<SiteBoundaryDraft | null>(null)
|
||||
const [siteVertexDragState, setSiteVertexDragState] = useState<SiteVertexDragState | null>(null)
|
||||
@@ -6402,6 +6417,18 @@ export function FloorplanPanel({
|
||||
slabDraftPoints,
|
||||
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
|
||||
// `FloorplanDraftCursorLayer`, which reads the live cursor from the draft store
|
||||
// so it re-renders per move without re-rendering this panel.
|
||||
@@ -8240,9 +8267,17 @@ export function FloorplanPanel({
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
|
||||
@@ -11220,6 +11255,7 @@ export function FloorplanPanel({
|
||||
// panel, so pan/zoom is preserved across the toggle.
|
||||
<svg
|
||||
className="h-full w-full touch-none"
|
||||
data-pascal-floorplan-2d
|
||||
onClick={isMarqueeSelectionToolActive ? undefined : handleSvgClick}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onDoubleClick={isMarqueeSelectionToolActive ? undefined : handleBackgroundDoubleClick}
|
||||
@@ -11396,6 +11432,7 @@ export function FloorplanPanel({
|
||||
<FloorplanWallMoveGhostLayer />
|
||||
</g>
|
||||
<FloorplanMeasurementToolLayer />
|
||||
{floorplanSceneSlot}
|
||||
</FloorplanRenderProvider>
|
||||
{/* Cursor-driven placement ghost for movingNode when the
|
||||
active kind is registry-driven. Renders via a portal
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import {
|
||||
acquireSceneReadOnlyLease,
|
||||
getCatalogMaterialById,
|
||||
getLibraryMaterialIdFromRef,
|
||||
getSceneMaterialIdFromRef,
|
||||
@@ -153,6 +154,11 @@ export interface EditorProps {
|
||||
*/
|
||||
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
|
||||
|
||||
// Persistence — defaults to localStorage when omitted
|
||||
@@ -715,12 +721,14 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
isFirstPersonMode,
|
||||
isStudioMode,
|
||||
onThumbnailCapture,
|
||||
viewerSceneSlot,
|
||||
}: {
|
||||
isVersionPreviewMode: boolean
|
||||
isLoading: boolean
|
||||
isFirstPersonMode: boolean
|
||||
isStudioMode: boolean
|
||||
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
||||
viewerSceneSlot?: ReactNode
|
||||
}) {
|
||||
// Studio mode is a clean render/snapshot surface — no selection or editing
|
||||
// affordances. It mirrors version-preview's chrome gating on the canvas.
|
||||
@@ -759,6 +767,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
{!isFirstPersonMode && <SiteEdgeLabels />}
|
||||
<InteractiveSystem />
|
||||
{!noEditing && viewerSceneSlot}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -941,6 +950,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
sceneReadyKey,
|
||||
onSceneReadyChange,
|
||||
onThumbnailCapture,
|
||||
viewerSceneSlot,
|
||||
floorplanSceneSlot,
|
||||
}: {
|
||||
isVersionPreviewMode: boolean
|
||||
isLoading: boolean
|
||||
@@ -951,6 +962,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
sceneReadyKey: number
|
||||
onSceneReadyChange: (ready: boolean) => void
|
||||
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
||||
viewerSceneSlot?: ReactNode
|
||||
floorplanSceneSlot?: ReactNode
|
||||
}) {
|
||||
const viewMode = useEditor((s) => s.viewMode)
|
||||
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
|
||||
@@ -1027,7 +1040,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<FloorplanPanel compassHost={viewerAreaEl} />
|
||||
<FloorplanPanel compassHost={viewerAreaEl} floorplanSceneSlot={floorplanSceneSlot} />
|
||||
</div>
|
||||
{viewMode === 'split' && (
|
||||
<div
|
||||
@@ -1075,6 +1088,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
isStudioMode={isStudioMode}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
onThumbnailCapture={onThumbnailCapture}
|
||||
viewerSceneSlot={viewerSceneSlot}
|
||||
/>
|
||||
</Viewer>
|
||||
</div>
|
||||
@@ -1094,6 +1108,8 @@ export default function Editor({
|
||||
viewerToolbarRight,
|
||||
stageOverlay,
|
||||
inspectorFooter,
|
||||
viewerSceneSlot,
|
||||
floorplanSceneSlot,
|
||||
projectId,
|
||||
onLoad,
|
||||
onSave,
|
||||
@@ -1209,13 +1225,10 @@ export default function Editor({
|
||||
|
||||
// Lock scene graph and reset to select mode when entering version preview
|
||||
useEffect(() => {
|
||||
useScene.getState().setReadOnly(isVersionPreviewMode)
|
||||
if (isVersionPreviewMode) {
|
||||
if (!isVersionPreviewMode) return
|
||||
const releaseReadOnly = acquireSceneReadOnlyLease()
|
||||
useEditor.getState().setMode('select')
|
||||
}
|
||||
return () => {
|
||||
useScene.getState().setReadOnly(false)
|
||||
}
|
||||
return releaseReadOnly
|
||||
}, [isVersionPreviewMode])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1314,6 +1327,8 @@ export default function Editor({
|
||||
onThumbnailCapture={onThumbnailCapture}
|
||||
sceneReadyKey={sceneReadyKey}
|
||||
showLoader={showLoader}
|
||||
viewerSceneSlot={viewerSceneSlot}
|
||||
floorplanSceneSlot={floorplanSceneSlot}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const DEFAULT_WALL_HEIGHT = 0.5
|
||||
@@ -476,6 +477,9 @@ export const RoofTool: React.FC = () => {
|
||||
})
|
||||
|
||||
if (corner1Ref.current) {
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setRoofDraftStart([corner1Ref.current[0], corner1Ref.current[2]])
|
||||
draftPreview.setRoofDraftEnd([gridX, gridZ])
|
||||
updateOutline(corner1Ref.current, cursorPosition)
|
||||
}
|
||||
}
|
||||
@@ -497,11 +501,17 @@ export const RoofTool: React.FC = () => {
|
||||
setSelection({ selectedIds: [roofId as AnyNode['id']] })
|
||||
|
||||
corner1Ref.current = null
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setRoofDraftStart(null)
|
||||
draftPreview.setRoofDraftEnd(null)
|
||||
outlineRef.current.visible = false
|
||||
alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId)
|
||||
clearSurfacePlanSnapFeedback()
|
||||
} else {
|
||||
corner1Ref.current = [gridX, y, gridZ]
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setRoofDraftStart([gridX, gridZ])
|
||||
draftPreview.setRoofDraftEnd([gridX, gridZ])
|
||||
sfxEmitter.emit('sfx:structure-build-start')
|
||||
setPreview((prev) => ({
|
||||
...prev,
|
||||
@@ -514,6 +524,9 @@ export const RoofTool: React.FC = () => {
|
||||
if (corner1Ref.current) {
|
||||
markToolCancelConsumed()
|
||||
corner1Ref.current = null
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setRoofDraftStart(null)
|
||||
draftPreview.setRoofDraftEnd(null)
|
||||
outlineRef.current.visible = false
|
||||
setPreview((prev) => ({ ...prev, corner1: null }))
|
||||
}
|
||||
@@ -531,6 +544,9 @@ export const RoofTool: React.FC = () => {
|
||||
clearSurfacePlanSnapFeedback()
|
||||
|
||||
corner1Ref.current = null
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setRoofDraftStart(null)
|
||||
draftPreview.setRoofDraftEnd(null)
|
||||
}
|
||||
}, [currentLevelId, setSelection])
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import useEditor, {
|
||||
} from '../../../store/use-editor'
|
||||
|
||||
import useFacingPose from '../../../store/use-facing-pose'
|
||||
import { useStairBuildPreview } from '../../../store/use-stair-build-preview'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||
import {
|
||||
@@ -233,6 +234,7 @@ export const StairTool: React.FC = () => {
|
||||
|
||||
// Reset rotation when tool activates
|
||||
rotationRef.current = 0
|
||||
useStairBuildPreview.getState().reset()
|
||||
if (previewRef.current) previewRef.current.rotation.y = 0
|
||||
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)}`
|
||||
if (key === lastPreviewKey) return
|
||||
lastPreviewKey = key
|
||||
useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation)
|
||||
const preview = buildPreviewScene(position, rotation)
|
||||
const visualPosition = preview
|
||||
? getFloorStackPreviewPosition({
|
||||
@@ -510,6 +513,7 @@ export const StairTool: React.FC = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
openingPreview.clear()
|
||||
useFacingPose.getState().clear()
|
||||
useStairBuildPreview.getState().reset()
|
||||
}
|
||||
}, [currentLevelId])
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from './../../../lib/surface-plan-snap'
|
||||
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
|
||||
import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
|
||||
import { useFloorplanDraftPreview } from './../../../store/use-floorplan-draft-preview'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const Y_OFFSET = 0.02
|
||||
@@ -79,6 +80,20 @@ export const ZoneTool: React.FC = () => {
|
||||
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(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
@@ -192,6 +207,7 @@ export const ZoneTool: React.FC = () => {
|
||||
event.localPosition[2],
|
||||
])
|
||||
snappedCursorPosition = displayPoint
|
||||
useFloorplanDraftPreview.getState().setCursorPoint(displayPoint)
|
||||
|
||||
// Play snap sound when the snapped position changes during drawing — only
|
||||
// when a quantizing mode is active (off / lines move continuously).
|
||||
|
||||
@@ -2,7 +2,21 @@
|
||||
// own shells on top of `@pascal-app/editor` (community-app, embedders)
|
||||
// don't have to learn three separate package imports. The canonical
|
||||
// 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 type { EditorProps } from './components/editor'
|
||||
export { default as Editor } from './components/editor'
|
||||
@@ -66,6 +80,10 @@ export {
|
||||
type SnapshotCameraData,
|
||||
ThumbnailGenerator,
|
||||
} 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 —
|
||||
// inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved
|
||||
// stair rendering in `nodes/src/stair/floorplan.ts`.
|
||||
@@ -287,12 +305,19 @@ export {
|
||||
} from './lib/floorplan'
|
||||
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
|
||||
export { exportSceneToGlb } from './lib/glb-export'
|
||||
export {
|
||||
type HistoryCommandDelegate,
|
||||
installHistoryCommandDelegate,
|
||||
runRedo,
|
||||
runUndo,
|
||||
} from './lib/history'
|
||||
export {
|
||||
boundaryReshapeScope,
|
||||
curveReshapeScope,
|
||||
endpointReshapeScope,
|
||||
holeEditScope,
|
||||
movingNodeOf,
|
||||
scopeNodeId,
|
||||
} from './lib/interaction/scope'
|
||||
export {
|
||||
buildResetSurfaceMaterialUpdates,
|
||||
@@ -429,6 +454,7 @@ export {
|
||||
} from './store/use-editor'
|
||||
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
|
||||
export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
|
||||
export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview'
|
||||
export {
|
||||
default as useInteractionScope,
|
||||
getEditingHole,
|
||||
@@ -466,6 +492,13 @@ export {
|
||||
type PaletteViewProps,
|
||||
usePaletteViewRegistry,
|
||||
} 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 {
|
||||
activateQuickMeasurementHudSource,
|
||||
@@ -477,6 +510,10 @@ export {
|
||||
useQuickMeasurementHud,
|
||||
} from './store/use-quick-measurement-hud'
|
||||
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 { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
|
||||
export {
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
type SceneCommit,
|
||||
subscribeSceneCommits,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { z } from 'zod'
|
||||
@@ -170,12 +172,15 @@ describe('commitFreshPlacementSubtree', () => {
|
||||
})
|
||||
|
||||
test('commits a fresh draft as one undoable clean subtree', () => {
|
||||
const commits: SceneCommit[] = []
|
||||
const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const committedId = commitFreshPlacementSubtree(SHELF_ID, {
|
||||
position: [2, 0, 3],
|
||||
visible: true,
|
||||
} as Partial<AnyNode>)
|
||||
unsubscribe()
|
||||
|
||||
expect(committedId).toBeTruthy()
|
||||
expect(committedId).not.toBe(SHELF_ID)
|
||||
@@ -192,6 +197,12 @@ describe('commitFreshPlacementSubtree', () => {
|
||||
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([
|
||||
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().undo()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,19 @@
|
||||
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() {
|
||||
useLiveNodeOverrides.getState().clearAll()
|
||||
useLiveTransforms.getState().clearAll()
|
||||
@@ -11,11 +25,19 @@ function refreshSceneAfterHistoryJump() {
|
||||
}
|
||||
|
||||
export function runUndo() {
|
||||
if (historyCommandDelegate) {
|
||||
historyCommandDelegate.undo()
|
||||
return
|
||||
}
|
||||
useScene.temporal.getState().undo()
|
||||
refreshSceneAfterHistoryJump()
|
||||
}
|
||||
|
||||
export function runRedo() {
|
||||
if (historyCommandDelegate) {
|
||||
historyCommandDelegate.redo()
|
||||
return
|
||||
}
|
||||
useScene.temporal.getState().redo()
|
||||
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
|
||||
// placed. Written by the 3D spline draft tool (`@pascal-app/nodes`
|
||||
// fence/tool.tsx) and read by the contextual helper so the "finish curve" hint
|
||||
// only surfaces once the user has actually started drawing. Reset on commit,
|
||||
// cancel, and unmount — never persisted, never in undo history.
|
||||
// Ephemeral store for the in-progress curved-fence draft. Written by the 3D
|
||||
// spline tool and read by the contextual helper plus out-of-tree consumers.
|
||||
// Reset on commit, cancel, and unmount — never persisted or recorded in undo
|
||||
// history.
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type FenceCurveDraftPoint = [number, number]
|
||||
|
||||
type FenceCurveDraftState = {
|
||||
pointCount: number
|
||||
setPointCount(count: number): void
|
||||
points: FenceCurveDraftPoint[]
|
||||
cursor: FenceCurveDraftPoint | null
|
||||
setDraft(points: readonly FenceCurveDraftPoint[], cursor: FenceCurveDraftPoint | null): 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) => ({
|
||||
pointCount: 0,
|
||||
setPointCount: (count) => set({ pointCount: count }),
|
||||
reset: () => set({ pointCount: 0 }),
|
||||
points: [],
|
||||
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
|
||||
|
||||
@@ -17,6 +17,8 @@ import { create } from 'zustand'
|
||||
/** Screen-space (SVG-local px) cursor point — drives the coordinate badge. */
|
||||
type SvgPoint = { x: number; y: number }
|
||||
|
||||
export type FloorplanPolygonDraftType = 'ceiling' | 'slab' | 'zone'
|
||||
|
||||
type FloorplanDraftPreviewState = {
|
||||
/** Snapped plan-XZ point under the cursor; drives the crosshair + the
|
||||
* cursor-following polygon-draft preview. `null` when idle. */
|
||||
@@ -28,11 +30,18 @@ type FloorplanDraftPreviewState = {
|
||||
cursorPosition: SvgPoint | null
|
||||
/** 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`
|
||||
* unless that tool's draft is open. The START points stay in panel state
|
||||
* (set per click, low-frequency). */
|
||||
* unless that tool's draft is open. The START points are mirrored here (set
|
||||
* 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
|
||||
fenceDraftEnd: 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
|
||||
* subscribers don't re-render) when unchanged — `grid:move` fires far more
|
||||
* often than the snapped cell actually changes. */
|
||||
@@ -42,11 +51,28 @@ type FloorplanDraftPreviewState = {
|
||||
setWallDraftEnd(point: WallPlanPoint | null): void
|
||||
setFenceDraftEnd(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
|
||||
}
|
||||
|
||||
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(
|
||||
field: 'wallDraftEnd' | 'fenceDraftEnd' | 'roofDraftEnd',
|
||||
field:
|
||||
| 'fenceDraftEnd'
|
||||
| 'fenceDraftStart'
|
||||
| 'roofDraftEnd'
|
||||
| 'roofDraftStart'
|
||||
| 'wallDraftEnd'
|
||||
| 'wallDraftStart',
|
||||
point: WallPlanPoint | null,
|
||||
) {
|
||||
return (
|
||||
@@ -65,6 +91,11 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
|
||||
wallDraftEnd: null,
|
||||
fenceDraftEnd: null,
|
||||
roofDraftEnd: null,
|
||||
wallDraftStart: null,
|
||||
fenceDraftStart: null,
|
||||
roofDraftStart: null,
|
||||
polygonDraftType: null,
|
||||
polygonDraftPoints: [],
|
||||
setCursorPoint: (point) =>
|
||||
set((state) => {
|
||||
const prev = state.cursorPoint
|
||||
@@ -82,13 +113,27 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
|
||||
setWallDraftEnd: (point) => set(setPlanPointField('wallDraftEnd', point)),
|
||||
setFenceDraftEnd: (point) => set(setPlanPointField('fenceDraftEnd', 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: () =>
|
||||
set((state) =>
|
||||
state.cursorPoint === null &&
|
||||
state.cursorPosition === null &&
|
||||
state.wallDraftEnd === 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
|
||||
: {
|
||||
cursorPoint: null,
|
||||
@@ -96,6 +141,11 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
|
||||
wallDraftEnd: null,
|
||||
fenceDraftEnd: 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'
|
||||
|
||||
type StairPreviewPoint = [number, number]
|
||||
export type StairPreviewPoint = [number, number]
|
||||
|
||||
type StairBuildPreviewState = {
|
||||
/** 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
|
||||
* often than the snapped cell actually changes. */
|
||||
setPoint(point: StairPreviewPoint | null): void
|
||||
setPreview(point: StairPreviewPoint | null, rotation: number): void
|
||||
rotateBy(deltaRadians: number): void
|
||||
reset(): void
|
||||
}
|
||||
@@ -33,7 +34,16 @@ export const useStairBuildPreview = create<StairBuildPreviewState>((set) => ({
|
||||
const prev = state.point
|
||||
if (!point && !prev) 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 })),
|
||||
reset: () =>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolveCeilingPlanPointSnap,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
useFloorplanDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -79,6 +80,20 @@ export const CeilingTool: React.FC = () => {
|
||||
}, [points.length])
|
||||
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(
|
||||
() =>
|
||||
new BufferGeometry().setFromPoints([
|
||||
@@ -117,6 +132,7 @@ export const CeilingTool: React.FC = () => {
|
||||
fallbackPoint: orthoPoint,
|
||||
levelId: currentLevelId,
|
||||
}).point
|
||||
useFloorplanDraftPreview.getState().setCursorPoint(displayPoint)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
if (
|
||||
points.length > 0 &&
|
||||
|
||||
@@ -364,6 +364,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
preview: () => import('./renderer').then(({ ColumnPreview }) => ({ default: ColumnPreview })),
|
||||
// Registry-driven placement tool — renders a translucent `ColumnPreview`
|
||||
// ghost at the cursor (mirroring the shelf build tool) instead of the
|
||||
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePathDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -559,6 +560,35 @@ const DuctSegmentTool = () => {
|
||||
// the cursor was at key-press time.
|
||||
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(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
@@ -927,22 +957,6 @@ const DuctSegmentTool = () => {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useFenceCurveDraft,
|
||||
useFloorplanDraftPreview,
|
||||
useSegmentDraftChain,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -511,6 +512,9 @@ const StraightFenceTool: React.FC = () => {
|
||||
buildingState.current = 0
|
||||
previewRef.current.visible = false
|
||||
setDraftMeasurement(null)
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setFenceDraftStart(null)
|
||||
draftPreview.setFenceDraftEnd(null)
|
||||
useSegmentDraftChain.getState().clear('fence')
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
@@ -538,6 +542,9 @@ const StraightFenceTool: React.FC = () => {
|
||||
{ applySnap: !angleLocked },
|
||||
)
|
||||
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)
|
||||
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
|
||||
if (
|
||||
@@ -601,6 +608,9 @@ const StraightFenceTool: React.FC = () => {
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setFenceDraftStart(snappedStart)
|
||||
draftPreview.setFenceDraftEnd(snappedStart)
|
||||
triggerSFX('sfx:structure-build-start')
|
||||
previewRef.current.visible = true
|
||||
setDraftMeasurement(null)
|
||||
@@ -645,6 +655,10 @@ const StraightFenceTool: React.FC = () => {
|
||||
useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]])
|
||||
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setFenceDraftEnd(null)
|
||||
draftPreview.setFenceDraftStart(nextStart)
|
||||
draftPreview.setFenceDraftEnd(nextStart)
|
||||
cursorRef.current?.position.copy(startingPoint.current)
|
||||
previewRef.current.visible = false
|
||||
buildingState.current = 1
|
||||
@@ -669,6 +683,9 @@ const StraightFenceTool: React.FC = () => {
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useSegmentDraftChain.getState().clear('fence')
|
||||
useAlignmentGuides.getState().clear()
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setFenceDraftStart(null)
|
||||
draftPreview.setFenceDraftEnd(null)
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
@@ -725,11 +742,11 @@ const SplineFenceDraft: React.FC = () => {
|
||||
|
||||
draftRef.current = draftPoints
|
||||
|
||||
// Mirror the draft length into the HUD store so the "finish curve" hint only
|
||||
// shows once drafting has started; always clear it when the tool unmounts.
|
||||
// Mirror the full transient curve so additional preview surfaces observe
|
||||
// the same snapped control points as the local preview.
|
||||
useEffect(() => {
|
||||
useFenceCurveDraft.getState().setPointCount(draftPoints.length)
|
||||
}, [draftPoints])
|
||||
useFenceCurveDraft.getState().setDraft(draftPoints, cursor)
|
||||
}, [cursor, draftPoints])
|
||||
useEffect(() => () => useFenceCurveDraft.getState().reset(), [])
|
||||
|
||||
useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), [])
|
||||
|
||||
@@ -9,7 +9,7 @@ import { LevelNode } from './schema'
|
||||
*/
|
||||
export const levelDefinition: NodeDefinition<typeof LevelNode> = {
|
||||
kind: 'level',
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
schema: LevelNode,
|
||||
category: 'site',
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePathDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -98,6 +99,11 @@ const LinesetTool = () => {
|
||||
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
|
||||
const lastClientYRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
usePathDraftPreview.getState().setDraft('lineset', draftPoints, cursorPos)
|
||||
}, [cursorPos, draftPoints])
|
||||
useEffect(() => () => usePathDraftPreview.getState().clear('lineset'), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePathDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
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.
|
||||
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(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePathDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -162,6 +163,30 @@ const PipeSegmentTool = () => {
|
||||
const altAnchorRef = useRef<{ clientY: number; baseY: 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(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
@@ -613,26 +638,6 @@ const PipeSegmentTool = () => {
|
||||
|
||||
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
|
||||
? [
|
||||
...(['x', 'y', 'z'] as const).map((axis, i) => ({
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolveSlabPlanPointSnap,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
useFloorplanDraftPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -77,6 +78,20 @@ export const SlabTool: React.FC = () => {
|
||||
}, [points.length])
|
||||
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(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
@@ -101,6 +116,7 @@ export const SlabTool: React.FC = () => {
|
||||
fallbackPoint: orthoPoint,
|
||||
levelId: currentLevelId,
|
||||
}).point
|
||||
useFloorplanDraftPreview.getState().setCursorPoint(displayPoint)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
if (
|
||||
points.length > 0 &&
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useFloorplanDraftPreview,
|
||||
useSegmentDraftChain,
|
||||
useWallSnapIndicator,
|
||||
WALL_CONNECT_SNAP_RADIUS,
|
||||
@@ -594,6 +595,9 @@ export const WallTool: React.FC = () => {
|
||||
buildingState.current = 0
|
||||
chainFirstVertex.current = null
|
||||
chainWallIds.current = []
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setWallDraftStart(null)
|
||||
draftPreview.setWallDraftEnd(null)
|
||||
if (wallPreviewRef.current) {
|
||||
wallPreviewRef.current.visible = false
|
||||
}
|
||||
@@ -637,6 +641,9 @@ export const WallTool: React.FC = () => {
|
||||
if (buildingState.current === 1) {
|
||||
const snappedLocal = gridPosition
|
||||
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)
|
||||
setAxisGuide({
|
||||
origin: [startingPoint.current.x, startingPoint.current.z],
|
||||
@@ -706,6 +713,9 @@ export const WallTool: React.FC = () => {
|
||||
chainFirstVertex.current = startingPoint.current.clone()
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setWallDraftStart(snappedStart)
|
||||
draftPreview.setWallDraftEnd(snappedStart)
|
||||
setAxisGuide({
|
||||
origin: snappedStart,
|
||||
y: event.localPosition[1],
|
||||
@@ -783,6 +793,10 @@ export const WallTool: React.FC = () => {
|
||||
useSegmentDraftChain.getState().setChainStart('wall', [nextStart[0], nextStart[1]])
|
||||
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setWallDraftEnd(null)
|
||||
draftPreview.setWallDraftStart(nextStart)
|
||||
draftPreview.setWallDraftEnd(nextStart)
|
||||
cursorRef.current?.position.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
setAxisGuide({
|
||||
@@ -820,6 +834,9 @@ export const WallTool: React.FC = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
useSegmentDraftChain.getState().clear('wall')
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
draftPreview.setWallDraftStart(null)
|
||||
draftPreview.setWallDraftEnd(null)
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user