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