diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index e04cee32c5..78bc2619d6 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -198,6 +198,15 @@ export interface CameraControlFitSceneEvent { } } +export interface CameraPose { + position: [number, number, number] + target: [number, number, number] + projection: 'perspective' | 'orthographic' + /** Width, in scene units, of the visible plane through `target`. */ + viewWidth?: number + fov?: number +} + type CameraControlEvents = { 'camera-controls:view': CameraControlEvent 'camera-controls:focus': CameraControlEvent @@ -207,6 +216,10 @@ type CameraControlEvents = { 'camera-controls:orbit-ccw': undefined 'camera-controls:fit-scene': CameraControlFitSceneEvent 'camera-controls:generate-thumbnail': ThumbnailGenerateEvent + 'camera-controls:pose': CameraPose + 'camera-controls:apply-pose': CameraPose + 'camera-controls:cancel-pose': undefined + 'camera-controls:interaction-start': undefined } type ToolEvents = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 16d23dfa4a..1147e04c1a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ export type { CabinetModuleEvent, CameraControlEvent, CameraControlFitSceneEvent, + CameraPose, CeilingEvent, ChimneyEvent, ColumnEvent, @@ -179,6 +180,11 @@ export { resetSceneHistoryPauseDepth, resumeSceneHistory, runAsSingleSceneHistoryStep, + type SceneCommit, + type SceneCommitListener, + type SceneCommitOrigin, + type SceneSnapshot, + subscribeSceneCommits, } from './store/history-control' export { type ControlValue, @@ -199,7 +205,17 @@ export { type LiveNodeOverrides, } from './store/use-live-node-overrides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' -export { clearSceneHistory, default as useScene } from './store/use-scene' +export { + type ApplySceneSnapshotOptions, + acquireSceneReadOnlyLease, + applyScenePatch, + applySceneSnapshot, + clearSceneHistory, + default as useScene, + type SceneMaterialPatch, + type SceneNodePatch, + type ScenePatch, +} from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { type ElevatorDoorSide, diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts new file mode 100644 index 0000000000..839be780da --- /dev/null +++ b/packages/core/src/schema/nodes/level.test.ts @@ -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), + ) + }) +}) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 2943032d24..ef4de43aeb 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -3,10 +3,19 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' import { ColumnNode } from './column' +import { DuctFittingNode } from './duct-fitting' +import { DuctSegmentNode } from './duct-segment' +import { DuctTerminalNode } from './duct-terminal' import { FenceNode } from './fence' import { GuideNode } from './guide' +import { HvacEquipmentNode } from './hvac-equipment' import { ItemNode } from './item' +import { LinesetNode } from './lineset' +import { LiquidLineNode } from './liquid-line' import { MeasurementNode } from './measurement' +import { PipeFittingNode } from './pipe-fitting' +import { PipeSegmentNode } from './pipe-segment' +import { PipeTrapNode } from './pipe-trap' import { RoofNode } from './roof' import { ScanNode } from './scan' import { ShelfNode } from './shelf' @@ -36,6 +45,15 @@ export const LevelNode = BaseNode.extend({ MeasurementNode.shape.id, SpawnNode.shape.id, ShelfNode.shape.id, + DuctSegmentNode.shape.id, + DuctFittingNode.shape.id, + DuctTerminalNode.shape.id, + HvacEquipmentNode.shape.id, + LinesetNode.shape.id, + LiquidLineNode.shape.id, + PipeSegmentNode.shape.id, + PipeFittingNode.shape.id, + PipeTrapNode.shape.id, ]), ) .default([]), @@ -44,7 +62,7 @@ export const LevelNode = BaseNode.extend({ }).describe( dedent` Level node - used to represent a level in the building - - children: array of floor, wall, ceiling, roof, item nodes + - children: array of architectural, equipment, and MEP distribution nodes - level: level number `, ) diff --git a/packages/core/src/services/single-undo-dance.test.ts b/packages/core/src/services/single-undo-dance.test.ts index 64442a38ed..8192b091a3 100644 --- a/packages/core/src/services/single-undo-dance.test.ts +++ b/packages/core/src/services/single-undo-dance.test.ts @@ -125,12 +125,7 @@ describe('Single-undo dance', () => { expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0) }) - test('commit-returns-false (no change) does NOT consume the prior pastState', () => { - // This is the suspected bend regression: when action.commit returns - // false (draft.curveOffset === ctx.originalCurveOffset), session.commit - // calls scene.restoreAll() but doesn't push to pastStates. Subsequent - // undo pops the PRIOR action (e.g. fence creation), not the no-op - // bend. + test('commit-returns-false adds no step; a later standalone undo reaches the prior action', () => { useScene.getState().createNode(makeFence(0)) const pastBeforeBend = useScene.temporal.getState().pastStates.length @@ -143,20 +138,13 @@ describe('Single-undo dance', () => { scene.resumeHistory() const pastAfterNoOp = useScene.temporal.getState().pastStates.length - expect(pastAfterNoOp).toBe(pastBeforeBend) // no entries added + expect(pastAfterNoOp).toBe(pastBeforeBend) - // Now undo — this should be a no-op (state unchanged), but pops the create. + // This is an independent undo after the gesture has finished, so it + // correctly reaches the preceding real action rather than inventing a + // no-op history boundary for the cancelled bend. useScene.temporal.getState().undo() - // ⚠️ Reproduces the bug — undo removes the fence: - const fence = useScene.getState().nodes[FENCE_ID] - if (fence === undefined) { - // Bug reproduced. The "no-op bend" allowed Ctrl-Z to fall through - // to the fence creation. Fix is in action.commit: don't return false - // — push a no-op entry instead, or guard against the cancel path. - expect(fence).toBeUndefined() - } else { - expect((fence as { curveOffset: number }).curveOffset).toBe(0) - } + expect(useScene.getState().nodes[FENCE_ID]).toBeUndefined() }) test('full session flow via createDragSession with real action.commit dance', async () => { diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts index 7e63174108..4ee927b0a4 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -1,5 +1,27 @@ +import type { Collection, CollectionId } from '../schema/collections' +import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' +import type { AnyNode, AnyNodeId } from '../schema/types' + let sceneHistoryPauseDepth = 0 +export type SceneSnapshot = { + nodes: Record + rootNodeIds: AnyNodeId[] + collections: Record + materials: Record + installedPlugins: string[] +} + +export type SceneCommitOrigin = 'local' | 'load' | 'host' + +export type SceneCommit = { + origin: SceneCommitOrigin + before: SceneSnapshot + current: SceneSnapshot +} + +export type SceneCommitListener = (commit: SceneCommit) => void + type TemporalStoreLike = { temporal: { getState(): { @@ -18,6 +40,104 @@ type TemporalHistoryStoreLike = { } } +const sceneCommitListeners = new Set() +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 + const rightRecord = right as Record + const leftKeys = Object.keys(leftRecord) + const rightKeys = Object.keys(rightRecord) + if (leftKeys.length !== rightKeys.length) return false + + for (const key of leftKeys) { + if (!(key in rightRecord) || !areSemanticValuesEqual(leftRecord[key], rightRecord[key])) { + return false + } + } + return true +} + +export function areSceneSnapshotsEqual(left: SceneSnapshot, right: SceneSnapshot): boolean { + return ( + areSemanticValuesEqual(left.nodes, right.nodes) && + areSemanticValuesEqual(left.rootNodeIds, right.rootNodeIds) && + areSemanticValuesEqual(left.collections, right.collections) && + areSemanticValuesEqual(left.materials, right.materials) && + areSemanticValuesEqual(left.installedPlugins, right.installedPlugins) + ) +} + +export function subscribeSceneCommits(listener: SceneCommitListener): () => void { + sceneCommitListeners.add(listener) + return () => { + sceneCommitListeners.delete(listener) + } +} + +function emitSceneCommit(commit: SceneCommit): void { + for (const listener of [...sceneCommitListeners]) { + try { + listener(commit) + } catch (error) { + console.error('[Scene] Scene commit listener failed', error) + } + } +} + +export function notifySceneCommit(commit: SceneCommit): void { + if (areSceneSnapshotsEqual(commit.before, commit.current)) return + + if (sceneCommitTransactionDepth > 0) { + if (pendingSceneCommit) { + pendingSceneCommit = { + origin: pendingSceneCommit.origin, + before: pendingSceneCommit.before, + current: commit.current, + } + } else { + pendingSceneCommit = commit + } + return + } + + emitSceneCommit(commit) +} + +function beginSceneCommitTransaction(): void { + sceneCommitTransactionDepth += 1 +} + +function pendingSceneCommitIsNoOp(): boolean { + return Boolean( + pendingSceneCommit && + areSceneSnapshotsEqual(pendingSceneCommit.before, pendingSceneCommit.current), + ) +} + +function endSceneCommitTransaction(): void { + if (sceneCommitTransactionDepth === 0) return + sceneCommitTransactionDepth -= 1 + if (sceneCommitTransactionDepth > 0) return + + const commit = pendingSceneCommit + pendingSceneCommit = null + if (commit && !areSceneSnapshotsEqual(commit.before, commit.current)) { + emitSceneCommit(commit) + } +} + export function pauseSceneHistory(sceneStore: TemporalStoreLike): void { if (sceneHistoryPauseDepth === 0) { sceneStore.temporal.getState().pause() @@ -65,17 +185,25 @@ export function runAsSingleSceneHistoryStep( run: () => TResult, ): TResult { const beforePastStates = sceneStore.temporal.getState().pastStates - const result = run() - const afterPastStates = sceneStore.temporal.getState().pastStates - const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates) - const addedCount = afterPastStates.length - retainedCount - if (addedCount > 1) { - const firstAddedState = afterPastStates[retainedCount] - if (firstAddedState !== undefined) { - sceneStore.temporal.setState({ - pastStates: [...afterPastStates.slice(0, retainedCount), firstAddedState], - }) + beginSceneCommitTransaction() + try { + const result = run() + const afterPastStates = sceneStore.temporal.getState().pastStates + const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates) + const addedCount = afterPastStates.length - retainedCount + + if (addedCount > 0 && pendingSceneCommitIsNoOp()) { + sceneStore.temporal.setState({ pastStates: afterPastStates.slice(0, retainedCount) }) + } else if (addedCount > 1) { + const firstAddedState = afterPastStates[retainedCount] + if (firstAddedState !== undefined) { + sceneStore.temporal.setState({ + pastStates: [...afterPastStates.slice(0, retainedCount), firstAddedState], + }) + } } + return result + } finally { + endSceneCommitTransaction() } - return result } diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts new file mode 100644 index 0000000000..dd837e8cb6 --- /dev/null +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -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(), + 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[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) + 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) + 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) + useScene.getState().updateNode(LEVEL_ID, { level: 2 } as Partial) + }) + + 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) + useScene.getState().updateNode(LEVEL_ID, { level: 0 } as Partial) + }) + + 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 }])).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 }])).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 }])).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) + 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 + 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 }, + { id: 'level_missing' as AnyNodeId, data: { level: 5 } as Partial }, + ]), + ).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, + }, + ]), + ).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 }])).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) + 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) + 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 = {} + 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) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 67c74d5e71..36848e84ec 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -34,7 +34,18 @@ import { import type { AnyNode, AnyNodeId } from '../schema/types' import { healSceneNodes } from '../utils/heal-scene-graph' import * as nodeActions from './actions/node-actions' -import { resetSceneHistoryPauseDepth } from './history-control' +import { + areSceneSnapshotsEqual, + getSceneHistoryPauseDepth, + notifySceneCommit, + pauseSceneHistory, + resetSceneHistoryPauseDepth, + resumeSceneHistory, + type SceneCommitOrigin, + type SceneSnapshot, +} from './history-control' +import useLiveNodeOverrides from './use-live-node-overrides' +import useLiveTransforms from './use-live-transforms' function getFiniteNumber(value: unknown, fallback: number) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback @@ -1015,6 +1026,16 @@ type UseSceneStore = UseBoundStore> & { > } +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()( temporal( (set, get) => ({ @@ -1311,9 +1332,14 @@ const useScene: UseSceneStore = create()( }, }), { - partialize: (state) => { - const { nodes, rootNodeIds, collections, materials, installedPlugins } = state - return { nodes, rootNodeIds, collections, materials, installedPlugins } + partialize: (state: SceneState) => sceneHistorySnapshotFromState(state), + equality: (pastState, currentState) => areSceneSnapshotsEqual(pastState, currentState), + onSave: (pastState, currentState) => { + notifySceneCommit({ + origin: 'local', + before: sceneHistorySnapshotFromState(pastState), + current: sceneHistorySnapshotFromState(currentState), + }) }, limit: 50, // Limit to last 50 actions }, @@ -1322,6 +1348,165 @@ const useScene: UseSceneStore = create()( 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 + 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 +} + +export function applySceneSnapshot( + snapshot: SceneSnapshot, + options: ApplySceneSnapshotOptions, +): boolean { + const before = sceneHistorySnapshotFromState(useScene.getState()) + const temporalState = useScene.temporal.getState() + if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) { + throw new Error('Cannot replace the scene snapshot during an active interaction') + } + pauseSceneHistory(useScene) + try { + useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds, { + collections: snapshot.collections, + installedPlugins: snapshot.installedPlugins, + materials: snapshot.materials, + }) + useScene.temporal.getState().clear() + } finally { + resumeSceneHistory(useScene) + } + + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + + const current = sceneHistorySnapshotFromState(useScene.getState()) + if (areSceneSnapshotsEqual(before, current)) return false + notifySceneCommit({ origin: options.origin, before, current }) + return true +} + // Track previous temporal state lengths and node snapshot for diffing let prevPastLength = 0 let prevFutureLength = 0 diff --git a/packages/core/src/systems/elevator/elevator-opening-system.test.ts b/packages/core/src/systems/elevator/elevator-opening-system.test.ts new file mode 100644 index 0000000000..cab6d2b17c --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-opening-system.test.ts @@ -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(), + materials: {}, + nodes: Object.fromEntries( + [building, ground, upper, elevator, upperSlab].map((node) => [node.id, node]), + ) as Record, + readOnly: false, + rootNodeIds: [BUILDING_ID], + } as never) + clearSceneHistory() +} + +function getOpeningCenter(nodes: Record): [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) { + 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) + + 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) + useScene.getState().updateNode(ELEVATOR_ID, { position: [3, 0, 1.5] } as Partial) + + 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([]) + }) +}) diff --git a/packages/core/src/systems/elevator/elevator-opening-system.tsx b/packages/core/src/systems/elevator/elevator-opening-system.tsx index 2d5b7cfd6e..d2ac128f6f 100644 --- a/packages/core/src/systems/elevator/elevator-opening-system.tsx +++ b/packages/core/src/systems/elevator/elevator-opening-system.tsx @@ -1,7 +1,8 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import type { AnyNode } from '../../schema' +import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' import useScene from '../../store/use-scene' import { syncAutoElevatorOpenings } from './elevator-opening-sync' @@ -32,27 +33,32 @@ function hasOpeningRelevantNodeChange( return false } -export const ElevatorOpeningSystem = () => { - const syncingAutoOpeningsRef = useRef(false) +export function initializeElevatorOpeningSync() { + let syncingAutoOpenings = false - useEffect(() => { - const applyUpdates = (updates: ReturnType) => { - if (updates.length === 0) return - syncingAutoOpeningsRef.current = true + const applyUpdates = (updates: ReturnType) => { + if (updates.length === 0) return + syncingAutoOpenings = true + pauseSceneHistory(useScene) + try { useScene.getState().updateNodes(updates) - queueMicrotask(() => { - syncingAutoOpeningsRef.current = false - }) + } finally { + resumeSceneHistory(useScene) + syncingAutoOpenings = false } + } - applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes)) + applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes)) - return useScene.subscribe((state, prevState) => { - if (syncingAutoOpeningsRef.current) return - if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return - applyUpdates(syncAutoElevatorOpenings(state.nodes)) - }) - }, []) + return useScene.subscribe((state, prevState) => { + if (syncingAutoOpenings) return + if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + applyUpdates(syncAutoElevatorOpenings(state.nodes)) + }) +} + +export const ElevatorOpeningSystem = () => { + useEffect(() => initializeElevatorOpeningSync(), []) return null } diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 5dc3dc1653..770f7d4629 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -197,6 +197,10 @@ export function FloorplanRegistryMoveOverlay() { const commitFinalStateOrRevert = () => { const commitValid = session.canCommit() + const freshPlacement = isFreshPlacementMetadata( + (useScene.getState().nodes[movingNode.id] as { metadata?: unknown } | undefined) + ?.metadata, + ) // Claim ownership of the drag teardown so the 3D move tool's // unmount-time cleanup skips its restore-from-snapshot — see @@ -210,6 +214,29 @@ export function FloorplanRegistryMoveOverlay() { // planner). For those we still do Phase 1 (revert to baseline) // and Phase 2's resume — but Phase 2's write is delegated, and // we skip the snapshot-diff finalUpdates path. + if (commitValid && freshPlacement) { + session.commit?.() + const stagedNode = useScene.getState().nodes[movingNode.id] + const committedId = stagedNode + ? commitFreshPlacementSubtree( + movingNode.id as AnyNodeId, + { + metadata: stripPlacementMetadataFlags(stagedNode.metadata), + visible: true, + } as Partial, + ) + : null + if (historyPaused) { + resumeSceneHistory(useScene) + historyPaused = false + } + if (committedId) { + sfxEmitter.emit('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [committedId] }) + } + return + } + if (commitValid && session.commit) { useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) if (historyPaused) { diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx index 197f93630b..1f0c364f7a 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx @@ -12,39 +12,32 @@ import { memo } from 'react' import usePlacementPreview from '../../../store/use-placement-preview' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' +export interface FloorplanNodePreviewProps { + node: AnyNode + parentNode?: AnyNode | null + opacity?: number + className?: string +} + /** - * 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 - * plan. The 3D view shows a translucent mesh preview; in 2D that mesh is - * hidden (canvas `display:none`), so without this the user only saw the grid - * cursor dot + alignment guides — no sense of the footprint they were about - * to drop. The placement tool publishes a transient, already-positioned + - * aligned node to `usePlacementPreview`; we build its `def.floorplan` - * footprint with a minimal (unselected) context and render it. - * - * Mounted inside the floor-plan scene `` so the geometry's level-local - * meters get the same world→SVG transform every other entry does. + * 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 FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() { - const node = usePlacementPreview((s) => s.node) - const parentNode = usePlacementPreview((s) => s.parentNode) - if (!node) return null - +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 - // 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, + parent: parentNode, viewState: undefined, } as unknown as GeometryContext @@ -55,8 +48,33 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr if (!geometry) return null return ( - + ) }) + +/** + * 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 + * plan. The 3D view shows a translucent mesh preview; in 2D that mesh is + * hidden (canvas `display:none`), so without this the user only saw the grid + * cursor dot + alignment guides — no sense of the footprint they were about + * to drop. The placement tool publishes a transient, already-positioned + + * aligned node to `usePlacementPreview`; we build its `def.floorplan` + * footprint with a minimal (unselected) context and render it. + * + * Mounted inside the floor-plan scene `` so the geometry's level-local + * meters get the same world→SVG transform every other entry does. + */ +export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() { + const node = usePlacementPreview((s) => s.node) + const parentNode = usePlacementPreview((s) => s.parentNode) + if (!node) return null + + return ( + + + + ) +}) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 67de8a0638..1772e19929 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -4,6 +4,7 @@ import { type AnyNodeId, type CameraControlEvent, type CameraControlFitSceneEvent, + type CameraPose, emitter, sceneRegistry, useScene, @@ -20,6 +21,13 @@ import { Spherical, Vector3, } from 'three' +import { + type CameraPoseApplicationPlan, + normalizeCameraPose, + planCameraPoseApplication, + stepCameraPoseInterpolation, + withCameraPoseDistance, +} from '../../lib/camera-pose' import { EDITOR_LAYER } from '../../lib/constants' import useEditor from '../../store/use-editor' import { @@ -35,6 +43,8 @@ const tempDelta = new Vector3() const tempPosition = new Vector3() const tempSize = new Vector3() const tempTarget = new Vector3() +const transitionFreezePosition = new Vector3() +const transitionFreezeTarget = new Vector3() const syncTarget = new Vector3() const syncSpherical = new Spherical() const keyboardPanSpherical = new Spherical() @@ -97,6 +107,21 @@ function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot ) } +function freezeCameraControlTransition(control: CameraControlsImpl) { + // `stop()` snaps to the transition endpoint, so replace it with the current pose instead. + control.getPosition(transitionFreezePosition, false) + control.getTarget(transitionFreezeTarget, false) + void control.setLookAt( + transitionFreezePosition.x, + transitionFreezePosition.y, + transitionFreezePosition.z, + transitionFreezeTarget.x, + transitionFreezeTarget.y, + transitionFreezeTarget.z, + false, + ) +} + function isEditableKeyboardTarget(target: EventTarget | null) { return ( target instanceof HTMLInputElement || @@ -272,14 +297,18 @@ function resolveCameraViewWidthUpdate( return { type: 'none', viewWidth } } -function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) { +function applyCameraViewWidth( + control: CameraControlsImpl, + update: CameraViewWidthUpdate, + enableTransition = true, +) { if (update.type === 'distance') { - control.dollyTo(update.distance, true) + control.dollyTo(update.distance, enableTransition) return } if (update.type === 'zoom') { - control.zoomTo(update.zoom, true) + control.zoomTo(update.zoom, enableTransition) } } @@ -347,6 +376,13 @@ function useFirstPersonCameraPoseRestore( export const CustomCameraControls = () => { const controls = useRef(null) + const pendingAppliedPose = useRef(null) + const activePoseInterpolation = useRef<{ + camera: Camera + control: CameraControlsImpl + plan: CameraPoseApplicationPlan + } | null>(null) + const suppressPoseEvents = useRef(false) const keyboardPanKeys = useRef({ forward: false, backward: false, @@ -391,6 +427,120 @@ export const CustomCameraControls = () => { raycaster.layers.enable(ZONE_LAYER) }, [camera, raycaster]) + const freezeActivePoseInterpolation = useCallback(() => { + const active = activePoseInterpolation.current + if (!active) return + + activePoseInterpolation.current = null + freezeCameraControlTransition(active.control) + }, []) + + const cancelPoseApplication = useCallback(() => { + pendingAppliedPose.current = null + try { + freezeActivePoseInterpolation() + } finally { + suppressPoseEvents.current = false + } + }, [freezeActivePoseInterpolation]) + + const beginLocalCameraInteraction = useCallback(() => { + cancelPoseApplication() + emitter.emit('camera-controls:interaction-start', undefined) + }, [cancelPoseApplication]) + + const applyPendingPose = useCallback(() => { + if (isFirstPersonMode) { + cancelPoseApplication() + return + } + + const plan = pendingAppliedPose.current + const control = controls.current + + const active = activePoseInterpolation.current + if (active && (active.camera !== camera || active.control !== control)) { + try { + freezeActivePoseInterpolation() + } finally { + if (!plan) suppressPoseEvents.current = false + } + } + + if (!(plan && control)) return + + const cameraMatchesProjection = + (plan.pose.projection === 'perspective' && isPerspectiveCamera(camera)) || + (plan.pose.projection === 'orthographic' && isOrthographicCamera(camera)) + if (!cameraMatchesProjection) return + + pendingAppliedPose.current = null + if (plan.perspectiveFov !== null && isPerspectiveCamera(camera)) { + camera.fov = plan.perspectiveFov + camera.updateProjectionMatrix() + } + let appliedPlan = plan + if (plan.pose.viewWidth !== undefined) { + const viewWidthUpdate = resolveCameraViewWidthUpdate( + control, + camera, + plan.pose.viewWidth, + viewportSize, + ) + if (viewWidthUpdate.type === 'distance') { + appliedPlan = { + ...plan, + pose: withCameraPoseDistance(plan.pose, viewWidthUpdate.distance), + } + } else if (viewWidthUpdate.type === 'zoom') { + applyCameraViewWidth(control, viewWidthUpdate, false) + } + } + + clearPendingFloorplanNavigationPose() + activePoseInterpolation.current = { camera, control, plan: appliedPlan } + }, [ + camera, + cancelPoseApplication, + clearPendingFloorplanNavigationPose, + freezeActivePoseInterpolation, + isFirstPersonMode, + viewportSize, + ]) + + useEffect(() => { + applyPendingPose() + }, [applyPendingPose]) + + useEffect(() => { + const handleAppliedPose = (pose: CameraPose) => { + if (isFirstPersonMode) return + + const plan = planCameraPoseApplication(pose) + if (!plan) return + + pendingAppliedPose.current = plan + suppressPoseEvents.current = true + + if (useViewer.getState().cameraMode !== plan.pose.projection) { + freezeActivePoseInterpolation() + useViewer.getState().setCameraMode(plan.pose.projection) + return + } + + applyPendingPose() + } + + emitter.on('camera-controls:apply-pose', handleAppliedPose) + emitter.on('camera-controls:cancel-pose', cancelPoseApplication) + return () => { + emitter.off('camera-controls:apply-pose', handleAppliedPose) + emitter.off('camera-controls:cancel-pose', cancelPoseApplication) + } + }, [applyPendingPose, cancelPoseApplication, freezeActivePoseInterpolation, isFirstPersonMode]) + + useEffect(() => cancelPoseApplication, [cancelPoseApplication]) + useEffect(() => { // Dev-only: deterministic camera poses for screenshot/automation tooling. // A getter, not a snapshot — drei recreates the impl when the default @@ -568,6 +718,36 @@ export const CustomCameraControls = () => { }) }, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize]) + const publishCurrentPose = useCallback(() => { + if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return + + controls.current.getPosition(tempPosition, false) + controls.current.getTarget(tempTarget, false) + const targetDistance = tempPosition.distanceTo(tempTarget) + const projection = isPerspectiveCamera(camera) + ? 'perspective' + : isOrthographicCamera(camera) + ? 'orthographic' + : null + if (!projection) return + + const pose = normalizeCameraPose({ + position: [tempPosition.x, tempPosition.y, tempPosition.z], + target: [tempTarget.x, tempTarget.y, tempTarget.z], + projection, + viewWidth: getCameraViewWidth(camera, targetDistance, viewportSize), + ...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}), + }) + if (pose) { + emitter.emit('camera-controls:pose', pose) + } + }, [camera, isFirstPersonMode, viewportSize]) + + const handleCameraUpdate = useCallback(() => { + publishCurrentNavigationPose() + publishCurrentPose() + }, [publishCurrentNavigationPose, publishCurrentPose]) + useEffect(() => { if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return @@ -584,6 +764,49 @@ export const CustomCameraControls = () => { useFrame((_, delta) => { if (isFirstPersonMode || !controls.current) return + const activePose = activePoseInterpolation.current + if (activePose) { + const control = controls.current + if (activePose.camera !== camera || activePose.control !== control) { + try { + freezeActivePoseInterpolation() + } finally { + if (!pendingAppliedPose.current) suppressPoseEvents.current = false + } + } else { + control.getPosition(tempPosition, false) + control.getTarget(tempTarget, false) + const step = stepCameraPoseInterpolation( + [tempPosition.x, tempPosition.y, tempPosition.z], + [tempTarget.x, tempTarget.y, tempTarget.z], + activePose.plan.pose, + delta, + ) + try { + // Transition-enabled calls retain one rest listener each, so this frame loop owns smoothing. + void control.setLookAt( + step.position[0], + step.position[1], + step.position[2], + step.target[0], + step.target[1], + step.target[2], + false, + ) + control.update(0) + } catch { + if (activePoseInterpolation.current === activePose) { + activePoseInterpolation.current = null + suppressPoseEvents.current = false + } + } + if (step.settled && activePoseInterpolation.current === activePose) { + activePoseInterpolation.current = null + suppressPoseEvents.current = false + } + } + } + const panKeys = keyboardPanKeys.current const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0) const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0) @@ -602,7 +825,7 @@ export const CustomCameraControls = () => { clearPendingFloorplanNavigationPose() if (horizontal !== 0) control.truck(horizontal * step, 0, true) if (vertical !== 0) control.forward(vertical * step, true) - }) + }, 0) // Configure mouse buttons based on control mode and camera mode const mouseButtons = useMemo(() => { @@ -750,7 +973,8 @@ export const CustomCameraControls = () => { !(event.metaKey || event.ctrlKey || event.altKey) && !isEditableKeyboardTarget(event.target) ) { - setKeyboardPanKey(keyboardPanKeys.current, event.code, true) + const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true) + if (changed) beginLocalCameraInteraction() clearPendingFloorplanNavigationPose() event.preventDefault() event.stopPropagation() @@ -823,6 +1047,7 @@ export const CustomCameraControls = () => { } const onWheel = () => { + beginLocalCameraInteraction() clearPendingFloorplanNavigationPose() } @@ -851,7 +1076,7 @@ export const CustomCameraControls = () => { window.addEventListener('pointerup', onPointerUp, true) window.addEventListener('pointercancel', onPointerUp, true) window.addEventListener('blur', onBlur) - gl.domElement.addEventListener('wheel', onWheel, { passive: true }) + gl.domElement.addEventListener('wheel', onWheel, { capture: true, passive: true }) updateConfig() return () => { @@ -861,29 +1086,27 @@ export const CustomCameraControls = () => { window.removeEventListener('pointerup', onPointerUp, true) window.removeEventListener('pointercancel', onPointerUp, true) window.removeEventListener('blur', onBlur) - gl.domElement.removeEventListener('wheel', onWheel) + gl.domElement.removeEventListener('wheel', onWheel, true) clearKeyboardPanKeys() clearNavigationCursor() } - }, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose]) + }, [ + beginLocalCameraInteraction, + cameraMode, + gl, + isPreviewMode, + isFirstPersonMode, + clearPendingFloorplanNavigationPose, + ]) // Cancel any in-progress 2D-origin navigation pose when the user starts // dragging (right-click orbit, middle-click pan, touch). `controlstart` // fires only for user pointer interactions — not for programmatic // moveTo/rotateTo which emit `transitionstart` instead. - useEffect(() => { - if (isFirstPersonMode) return - const control = controls.current - if (!control) return - - const onControlStart = () => { - clearPendingFloorplanNavigationPose() - } - control.addEventListener('controlstart', onControlStart) - return () => { - control.removeEventListener('controlstart', onControlStart) - } - }, [isFirstPersonMode, clearPendingFloorplanNavigationPose]) + const handleControlStart = useCallback(() => { + clearPendingFloorplanNavigationPose() + beginLocalCameraInteraction() + }, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose]) // Preview mode: auto-navigate camera to selected node (viewer behavior) const previewTargetNodeId = isPreviewMode @@ -1211,7 +1434,8 @@ export const CustomCameraControls = () => { minDistance={minDistance} minPolarAngle={0} mouseButtons={mouseButtons} - onUpdate={publishCurrentNavigationPose} + onControlStart={handleControlStart} + onUpdate={handleCameraUpdate} onRest={onRest} onSleep={onRest} onTransitionStart={onTransitionStart} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index f786de568b..9baf8848ef 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -62,6 +62,7 @@ import { type ComponentProps, memo, type MouseEvent as ReactMouseEvent, + type ReactNode, type PointerEvent as ReactPointerEvent, useCallback, useEffect, @@ -5195,8 +5196,10 @@ export function FloorplanPanel({ * in 2d, 3d, and split modes, while this panel itself may be display:none. */ compassHost, + floorplanSceneSlot, }: { compassHost?: HTMLElement | null + floorplanSceneSlot?: ReactNode }) { const viewportHostRef = useRef(null) const svgRef = useRef(null) @@ -5371,6 +5374,18 @@ export function FloorplanPanel({ ) const [ceilingDraftPoints, setCeilingDraftPoints] = useState([]) const [slabDraftPoints, setSlabDraftPoints] = useState([]) + // 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([]) const [siteBoundaryDraft, setSiteBoundaryDraft] = useState(null) const [siteVertexDragState, setSiteVertexDragState] = useState(null) @@ -6402,6 +6417,18 @@ export function FloorplanPanel({ slabDraftPoints, zoneDraftPoints, ]) + const activePolygonDraftType = isCeilingBuildActive + ? 'ceiling' + : isZoneBuildActive + ? 'zone' + : isSlabBuildActive + ? 'slab' + : null + useEffect(() => { + useFloorplanDraftPreview + .getState() + .setPolygonDraft(activePolygonDraftType, activePolygonDraftPoints) + }, [activePolygonDraftPoints, activePolygonDraftType]) // The cursor-following polygon-draft preview (slab / zone / ceiling) moved into // `FloorplanDraftCursorLayer`, which reads the live cursor from the draft store // so it re-renders per move without re-rendering this panel. @@ -8240,9 +8267,17 @@ export function FloorplanPanel({ setShiftPressed(true) } - if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) { + if ( + isStairBuildActive && + useEditor.getState().viewMode === '2d' && + (event.key === 'r' || event.key === 'R') + ) { useStairBuildPreview.getState().rotateBy(Math.PI / 4) - } else if (isStairBuildActive && (event.key === 't' || event.key === 'T')) { + } else if ( + isStairBuildActive && + useEditor.getState().viewMode === '2d' && + (event.key === 't' || event.key === 'T') + ) { useStairBuildPreview.getState().rotateBy(-Math.PI / 4) } @@ -11220,6 +11255,7 @@ export function FloorplanPanel({ // panel, so pan/zoom is preserved across the toggle. event.preventDefault()} onDoubleClick={isMarqueeSelectionToolActive ? undefined : handleBackgroundDoubleClick} @@ -11396,6 +11432,7 @@ export function FloorplanPanel({ + {floorplanSceneSlot} {/* Cursor-driven placement ghost for movingNode when the active kind is registry-driven. Renders via a portal diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d10e6c5764..531f9f82b7 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -2,6 +2,7 @@ import { Icon } from '@iconify/react' import { + acquireSceneReadOnlyLease, getCatalogMaterialById, getLibraryMaterialIdFromRef, getSceneMaterialIdFromRef, @@ -153,6 +154,11 @@ export interface EditorProps { */ inspectorFooter?: ReactNode + /** Host-owned content mounted inside the editor's React Three Fiber scene. */ + viewerSceneSlot?: ReactNode + /** Host-owned SVG content mounted in the transformed floor-plan scene. */ + floorplanSceneSlot?: ReactNode + projectId?: string | null // Persistence — defaults to localStorage when omitted @@ -715,12 +721,14 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ isFirstPersonMode, isStudioMode, onThumbnailCapture, + viewerSceneSlot, }: { isVersionPreviewMode: boolean isLoading: boolean isFirstPersonMode: boolean isStudioMode: boolean onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void + viewerSceneSlot?: ReactNode }) { // Studio mode is a clean render/snapshot surface — no selection or editing // affordances. It mirrors version-preview's chrome gating on the canvas. @@ -759,6 +767,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!isFirstPersonMode && } + {!noEditing && viewerSceneSlot} ) }) @@ -941,6 +950,8 @@ const ViewerCanvas = memo(function ViewerCanvas({ sceneReadyKey, onSceneReadyChange, onThumbnailCapture, + viewerSceneSlot, + floorplanSceneSlot, }: { isVersionPreviewMode: boolean isLoading: boolean @@ -951,6 +962,8 @@ const ViewerCanvas = memo(function ViewerCanvas({ sceneReadyKey: number onSceneReadyChange: (ready: boolean) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void + viewerSceneSlot?: ReactNode + floorplanSceneSlot?: ReactNode }) { const viewMode = useEditor((s) => s.viewMode) const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio) @@ -1027,7 +1040,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ }} >
- +
{viewMode === 'split' && (
@@ -1094,6 +1108,8 @@ export default function Editor({ viewerToolbarRight, stageOverlay, inspectorFooter, + viewerSceneSlot, + floorplanSceneSlot, projectId, onLoad, onSave, @@ -1209,13 +1225,10 @@ export default function Editor({ // Lock scene graph and reset to select mode when entering version preview useEffect(() => { - useScene.getState().setReadOnly(isVersionPreviewMode) - if (isVersionPreviewMode) { - useEditor.getState().setMode('select') - } - return () => { - useScene.getState().setReadOnly(false) - } + if (!isVersionPreviewMode) return + const releaseReadOnly = acquireSceneReadOnlyLease() + useEditor.getState().setMode('select') + return releaseReadOnly }, [isVersionPreviewMode]) useEffect(() => { @@ -1314,6 +1327,8 @@ export default function Editor({ onThumbnailCapture={onThumbnailCapture} sceneReadyKey={sceneReadyKey} showLoader={showLoader} + viewerSceneSlot={viewerSceneSlot} + floorplanSceneSlot={floorplanSceneSlot} /> ) diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index f812dc1cc1..703dde667a 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -31,6 +31,7 @@ import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' +import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview' import { CursorSphere } from '../shared/cursor-sphere' const DEFAULT_WALL_HEIGHT = 0.5 @@ -476,6 +477,9 @@ export const RoofTool: React.FC = () => { }) if (corner1Ref.current) { + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setRoofDraftStart([corner1Ref.current[0], corner1Ref.current[2]]) + draftPreview.setRoofDraftEnd([gridX, gridZ]) updateOutline(corner1Ref.current, cursorPosition) } } @@ -497,11 +501,17 @@ export const RoofTool: React.FC = () => { setSelection({ selectedIds: [roofId as AnyNode['id']] }) corner1Ref.current = null + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setRoofDraftStart(null) + draftPreview.setRoofDraftEnd(null) outlineRef.current.visible = false alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId) clearSurfacePlanSnapFeedback() } else { corner1Ref.current = [gridX, y, gridZ] + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setRoofDraftStart([gridX, gridZ]) + draftPreview.setRoofDraftEnd([gridX, gridZ]) sfxEmitter.emit('sfx:structure-build-start') setPreview((prev) => ({ ...prev, @@ -514,6 +524,9 @@ export const RoofTool: React.FC = () => { if (corner1Ref.current) { markToolCancelConsumed() corner1Ref.current = null + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setRoofDraftStart(null) + draftPreview.setRoofDraftEnd(null) outlineRef.current.visible = false setPreview((prev) => ({ ...prev, corner1: null })) } @@ -531,6 +544,9 @@ export const RoofTool: React.FC = () => { clearSurfacePlanSnapFeedback() corner1Ref.current = null + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setRoofDraftStart(null) + draftPreview.setRoofDraftEnd(null) } }, [currentLevelId, setSelection]) diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 3a34ed9341..8711909e37 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -31,6 +31,7 @@ import useEditor, { } from '../../../store/use-editor' import useFacingPose from '../../../store/use-facing-pose' +import { useStairBuildPreview } from '../../../store/use-stair-build-preview' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { @@ -233,6 +234,7 @@ export const StairTool: React.FC = () => { // Reset rotation when tool activates rotationRef.current = 0 + useStairBuildPreview.getState().reset() if (previewRef.current) previewRef.current.rotation.y = 0 lastCanonicalPositionRef.current = null @@ -285,6 +287,7 @@ export const StairTool: React.FC = () => { const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}` if (key === lastPreviewKey) return lastPreviewKey = key + useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation) const preview = buildPreviewScene(position, rotation) const visualPosition = preview ? getFloorStackPreviewPosition({ @@ -510,6 +513,7 @@ export const StairTool: React.FC = () => { useAlignmentGuides.getState().clear() openingPreview.clear() useFacingPose.getState().clear() + useStairBuildPreview.getState().reset() } }, [currentLevelId]) diff --git a/packages/editor/src/components/tools/zone/zone-tool.tsx b/packages/editor/src/components/tools/zone/zone-tool.tsx index 84364cdd3f..a72105309f 100644 --- a/packages/editor/src/components/tools/zone/zone-tool.tsx +++ b/packages/editor/src/components/tools/zone/zone-tool.tsx @@ -18,6 +18,7 @@ import { } from './../../../lib/surface-plan-snap' import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap' import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor' +import { useFloorplanDraftPreview } from './../../../store/use-floorplan-draft-preview' import { CursorSphere } from '../shared/cursor-sphere' const Y_OFFSET = 0.02 @@ -79,6 +80,20 @@ export const ZoneTool: React.FC = () => { levelY: 0, }) + useEffect(() => { + useFloorplanDraftPreview.getState().setPolygonDraft('zone', preview.points) + }, [preview.points]) + useEffect( + () => () => { + const draftPreview = useFloorplanDraftPreview.getState() + if (draftPreview.polygonDraftType === 'zone') { + draftPreview.setPolygonDraft(null, []) + } + draftPreview.setCursorPoint(null) + }, + [], + ) + useEffect(() => { if (!currentLevelId) return @@ -192,6 +207,7 @@ export const ZoneTool: React.FC = () => { event.localPosition[2], ]) snappedCursorPosition = displayPoint + useFloorplanDraftPreview.getState().setCursorPoint(displayPoint) // Play snap sound when the snapped position changes during drawing — only // when a quantizing mode is active (off / lines move continuously). diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 6a6ff85502..cc4eaaf2d5 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -2,7 +2,21 @@ // own shells on top of `@pascal-app/editor` (community-app, embedders) // don't have to learn three separate package imports. The canonical // definitions still live in `@pascal-app/core` / `@pascal-app/viewer`. -export { useScene } from '@pascal-app/core' +export { + type ApplySceneSnapshotOptions, + acquireSceneReadOnlyLease, + applyScenePatch, + applySceneSnapshot, + type SceneCommit, + type SceneCommitListener, + type SceneCommitOrigin, + type SceneMaterialPatch, + type SceneNodePatch, + type ScenePatch, + type SceneSnapshot, + subscribeSceneCommits, + useScene, +} from '@pascal-app/core' export { useViewer } from '@pascal-app/viewer' export type { EditorProps } from './components/editor' export { default as Editor } from './components/editor' @@ -66,6 +80,10 @@ export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' +export { + FloorplanNodePreview, + type FloorplanNodePreviewProps, +} from './components/editor-2d/renderers/floorplan-placement-preview-layer' // SVG path builders for arc / annular-sector / arrow-head shapes — // inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved // stair rendering in `nodes/src/stair/floorplan.ts`. @@ -287,12 +305,19 @@ export { } from './lib/floorplan' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { exportSceneToGlb } from './lib/glb-export' +export { + type HistoryCommandDelegate, + installHistoryCommandDelegate, + runRedo, + runUndo, +} from './lib/history' export { boundaryReshapeScope, curveReshapeScope, endpointReshapeScope, holeEditScope, movingNodeOf, + scopeNodeId, } from './lib/interaction/scope' export { buildResetSurfaceMaterialUpdates, @@ -429,6 +454,7 @@ export { } from './store/use-editor' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' +export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview' export { default as useInteractionScope, getEditingHole, @@ -466,6 +492,13 @@ export { type PaletteViewProps, usePaletteViewRegistry, } from './store/use-palette-view-registry' +export { + type PathDraftKind, + type PathDraftParameter, + type PathDraftParameters, + type PathDraftPoint, + usePathDraftPreview, +} from './store/use-path-draft-preview' export { default as usePlacementPreview } from './store/use-placement-preview' export { activateQuickMeasurementHudSource, @@ -477,6 +510,10 @@ export { useQuickMeasurementHud, } from './store/use-quick-measurement-hud' export { default as useSegmentDraftChain } from './store/use-segment-draft-chain' +export { + type StairPreviewPoint, + useStairBuildPreview, +} from './store/use-stair-build-preview' export { useUploadStore } from './store/use-upload' export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts' export { diff --git a/packages/editor/src/lib/camera-pose.test.ts b/packages/editor/src/lib/camera-pose.test.ts new file mode 100644 index 0000000000..946b1c4e34 --- /dev/null +++ b/packages/editor/src/lib/camera-pose.test.ts @@ -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]) + }) +}) diff --git a/packages/editor/src/lib/camera-pose.ts b/packages/editor/src/lib/camera-pose.ts new file mode 100644 index 0000000000..d4bd7158e3 --- /dev/null +++ b/packages/editor/src/lib/camera-pose.ts @@ -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 + 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, + } +} diff --git a/packages/editor/src/lib/fresh-planar-placement.test.ts b/packages/editor/src/lib/fresh-planar-placement.test.ts index f4dd30d8de..0f918a22e7 100644 --- a/packages/editor/src/lib/fresh-planar-placement.test.ts +++ b/packages/editor/src/lib/fresh-planar-placement.test.ts @@ -8,6 +8,8 @@ import { findLevelAncestorId, nodeRegistry, registerNode, + type SceneCommit, + subscribeSceneCommits, useScene, } from '@pascal-app/core' import { z } from 'zod' @@ -170,12 +172,15 @@ describe('commitFreshPlacementSubtree', () => { }) test('commits a fresh draft as one undoable clean subtree', () => { + const commits: SceneCommit[] = [] + const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit)) useScene.temporal.getState().pause() const committedId = commitFreshPlacementSubtree(SHELF_ID, { position: [2, 0, 3], visible: true, } as Partial) + unsubscribe() expect(committedId).toBeTruthy() expect(committedId).not.toBe(SHELF_ID) @@ -192,6 +197,12 @@ describe('commitFreshPlacementSubtree', () => { expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([ finalId, ]) + expect(commits).toHaveLength(1) + expect(commits[0]?.origin).toBe('local') + expect(commits[0]?.before.nodes[SHELF_ID]).toBeUndefined() + expect(commits[0]?.before.nodes[finalId]).toBeUndefined() + expect(commits[0]?.current.nodes[SHELF_ID]).toBeUndefined() + expect(commits[0]?.current.nodes[finalId]).toEqual(committed) useScene.temporal.getState().resume() useScene.temporal.getState().undo() diff --git a/packages/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts new file mode 100644 index 0000000000..6a31a7a480 --- /dev/null +++ b/packages/editor/src/lib/history.test.ts @@ -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(), + collections: {}, + materials: {}, + readOnly: false, + } as never) + clearSceneHistory() + useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial) + }) + + 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) + }) +}) diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts index 9aea631e34..0a9df448cd 100644 --- a/packages/editor/src/lib/history.ts +++ b/packages/editor/src/lib/history.ts @@ -1,5 +1,19 @@ import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core' +export type HistoryCommandDelegate = { + undo: () => void + redo: () => void +} + +let historyCommandDelegate: HistoryCommandDelegate | null = null + +export function installHistoryCommandDelegate(delegate: HistoryCommandDelegate): () => void { + historyCommandDelegate = delegate + return () => { + if (historyCommandDelegate === delegate) historyCommandDelegate = null + } +} + function refreshSceneAfterHistoryJump() { useLiveNodeOverrides.getState().clearAll() useLiveTransforms.getState().clearAll() @@ -11,11 +25,19 @@ function refreshSceneAfterHistoryJump() { } export function runUndo() { + if (historyCommandDelegate) { + historyCommandDelegate.undo() + return + } useScene.temporal.getState().undo() refreshSceneAfterHistoryJump() } export function runRedo() { + if (historyCommandDelegate) { + historyCommandDelegate.redo() + return + } useScene.temporal.getState().redo() refreshSceneAfterHistoryJump() } diff --git a/packages/editor/src/store/live-draft-preview-stores.test.ts b/packages/editor/src/store/live-draft-preview-stores.test.ts new file mode 100644 index 0000000000..5d9f4c0b08 --- /dev/null +++ b/packages/editor/src/store/live-draft-preview-stores.test.ts @@ -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() + }) +}) diff --git a/packages/editor/src/store/use-fence-curve-draft.ts b/packages/editor/src/store/use-fence-curve-draft.ts index 9b76a63b4d..42115384db 100644 --- a/packages/editor/src/store/use-fence-curve-draft.ts +++ b/packages/editor/src/store/use-fence-curve-draft.ts @@ -1,21 +1,51 @@ -// Ephemeral store: how many points the in-progress curved-fence draft has -// placed. Written by the 3D spline draft tool (`@pascal-app/nodes` -// fence/tool.tsx) and read by the contextual helper so the "finish curve" hint -// only surfaces once the user has actually started drawing. Reset on commit, -// cancel, and unmount — never persisted, never in undo history. +// Ephemeral store for the in-progress curved-fence draft. Written by the 3D +// spline tool and read by the contextual helper plus out-of-tree consumers. +// Reset on commit, cancel, and unmount — never persisted or recorded in undo +// history. import { create } from 'zustand' +export type FenceCurveDraftPoint = [number, number] + type FenceCurveDraftState = { pointCount: number - setPointCount(count: number): void + points: FenceCurveDraftPoint[] + cursor: FenceCurveDraftPoint | null + setDraft(points: readonly FenceCurveDraftPoint[], cursor: FenceCurveDraftPoint | null): void reset(): void } +function pointsEqual(a: readonly FenceCurveDraftPoint[], b: readonly FenceCurveDraftPoint[]) { + return ( + a.length === b.length && + a.every((point, index) => point[0] === b[index]?.[0] && point[1] === b[index]?.[1]) + ) +} + const useFenceCurveDraft = create((set) => ({ pointCount: 0, - setPointCount: (count) => set({ pointCount: count }), - reset: () => set({ pointCount: 0 }), + points: [], + cursor: null, + setDraft: (points, cursor) => + set((state) => { + const sameCursor = + (!cursor && !state.cursor) || + Boolean( + cursor && state.cursor && state.cursor[0] === cursor[0] && state.cursor[1] === cursor[1], + ) + if (sameCursor && pointsEqual(state.points, points)) return state + return { + pointCount: points.length, + points: points.map(([x, z]) => [x, z] as FenceCurveDraftPoint), + cursor: cursor ? [cursor[0], cursor[1]] : null, + } + }), + reset: () => + set((state) => + state.pointCount === 0 && state.points.length === 0 && state.cursor === null + ? state + : { pointCount: 0, points: [], cursor: null }, + ), })) export default useFenceCurveDraft diff --git a/packages/editor/src/store/use-floorplan-draft-preview.ts b/packages/editor/src/store/use-floorplan-draft-preview.ts index a54ce08c09..8414b82c76 100644 --- a/packages/editor/src/store/use-floorplan-draft-preview.ts +++ b/packages/editor/src/store/use-floorplan-draft-preview.ts @@ -17,6 +17,8 @@ import { create } from 'zustand' /** Screen-space (SVG-local px) cursor point — drives the coordinate badge. */ type SvgPoint = { x: number; y: number } +export type FloorplanPolygonDraftType = 'ceiling' | 'slab' | 'zone' + type FloorplanDraftPreviewState = { /** Snapped plan-XZ point under the cursor; drives the crosshair + the * cursor-following polygon-draft preview. `null` when idle. */ @@ -28,11 +30,18 @@ type FloorplanDraftPreviewState = { cursorPosition: SvgPoint | null /** Live END point of the open wall / fence / roof draft segment — the per-move * endpoint that drives the 2D draft polygon + measurement. Each is `null` - * unless that tool's draft is open. The START points stay in panel state - * (set per click, low-frequency). */ + * unless that tool's draft is open. The START points are mirrored here (set + * per click / per 3D draft move) so out-of-tree consumers — e.g. the hosted + * host-owned preview publishers — can observe the whole open + * segment; panel state remains the 2D interaction source of truth. */ wallDraftEnd: WallPlanPoint | null fenceDraftEnd: WallPlanPoint | null roofDraftEnd: WallPlanPoint | null + wallDraftStart: WallPlanPoint | null + fenceDraftStart: WallPlanPoint | null + roofDraftStart: WallPlanPoint | null + polygonDraftType: FloorplanPolygonDraftType | null + polygonDraftPoints: WallPlanPoint[] /** Set the snapped cursor point. No-ops (skips the store update, so * subscribers don't re-render) when unchanged — `grid:move` fires far more * often than the snapped cell actually changes. */ @@ -42,11 +51,28 @@ type FloorplanDraftPreviewState = { setWallDraftEnd(point: WallPlanPoint | null): void setFenceDraftEnd(point: WallPlanPoint | null): void setRoofDraftEnd(point: WallPlanPoint | null): void + setWallDraftStart(point: WallPlanPoint | null): void + setFenceDraftStart(point: WallPlanPoint | null): void + setRoofDraftStart(point: WallPlanPoint | null): void + setPolygonDraft(type: FloorplanPolygonDraftType | null, points: readonly WallPlanPoint[]): void reset(): void } +function planPointsEqual(a: readonly WallPlanPoint[], b: readonly WallPlanPoint[]) { + return ( + a.length === b.length && + a.every((point, index) => point[0] === b[index]?.[0] && point[1] === b[index]?.[1]) + ) +} + function setPlanPointField( - field: 'wallDraftEnd' | 'fenceDraftEnd' | 'roofDraftEnd', + field: + | 'fenceDraftEnd' + | 'fenceDraftStart' + | 'roofDraftEnd' + | 'roofDraftStart' + | 'wallDraftEnd' + | 'wallDraftStart', point: WallPlanPoint | null, ) { return ( @@ -65,6 +91,11 @@ export const useFloorplanDraftPreview = create((set) wallDraftEnd: null, fenceDraftEnd: null, roofDraftEnd: null, + wallDraftStart: null, + fenceDraftStart: null, + roofDraftStart: null, + polygonDraftType: null, + polygonDraftPoints: [], setCursorPoint: (point) => set((state) => { const prev = state.cursorPoint @@ -82,13 +113,27 @@ export const useFloorplanDraftPreview = create((set) setWallDraftEnd: (point) => set(setPlanPointField('wallDraftEnd', point)), setFenceDraftEnd: (point) => set(setPlanPointField('fenceDraftEnd', point)), setRoofDraftEnd: (point) => set(setPlanPointField('roofDraftEnd', point)), + setWallDraftStart: (point) => set(setPlanPointField('wallDraftStart', point)), + setFenceDraftStart: (point) => set(setPlanPointField('fenceDraftStart', point)), + setRoofDraftStart: (point) => set(setPlanPointField('roofDraftStart', point)), + setPolygonDraft: (type, points) => + set((state) => + state.polygonDraftType === type && planPointsEqual(state.polygonDraftPoints, points) + ? state + : { polygonDraftType: type, polygonDraftPoints: points.map(([x, z]) => [x, z]) }, + ), reset: () => set((state) => state.cursorPoint === null && state.cursorPosition === null && state.wallDraftEnd === null && state.fenceDraftEnd === null && - state.roofDraftEnd === null + state.roofDraftEnd === null && + state.wallDraftStart === null && + state.fenceDraftStart === null && + state.roofDraftStart === null && + state.polygonDraftType === null && + state.polygonDraftPoints.length === 0 ? state : { cursorPoint: null, @@ -96,6 +141,11 @@ export const useFloorplanDraftPreview = create((set) wallDraftEnd: null, fenceDraftEnd: null, roofDraftEnd: null, + wallDraftStart: null, + fenceDraftStart: null, + roofDraftStart: null, + polygonDraftType: null, + polygonDraftPoints: [], }, ), })) diff --git a/packages/editor/src/store/use-path-draft-preview.ts b/packages/editor/src/store/use-path-draft-preview.ts new file mode 100644 index 0000000000..b5ed650750 --- /dev/null +++ b/packages/editor/src/store/use-path-draft-preview.ts @@ -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 + +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, + 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) { + 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((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, + ), +})) diff --git a/packages/editor/src/store/use-stair-build-preview.ts b/packages/editor/src/store/use-stair-build-preview.ts index fff08e35c3..dba4f159dc 100644 --- a/packages/editor/src/store/use-stair-build-preview.ts +++ b/packages/editor/src/store/use-stair-build-preview.ts @@ -10,7 +10,7 @@ import { create } from 'zustand' -type StairPreviewPoint = [number, number] +export type StairPreviewPoint = [number, number] type StairBuildPreviewState = { /** Snapped plan-XZ point the ghost staircase sits at; `null` when idle. */ @@ -21,6 +21,7 @@ type StairBuildPreviewState = { * don't re-render) when the point is unchanged — `grid:move` fires far more * often than the snapped cell actually changes. */ setPoint(point: StairPreviewPoint | null): void + setPreview(point: StairPreviewPoint | null, rotation: number): void rotateBy(deltaRadians: number): void reset(): void } @@ -33,7 +34,16 @@ export const useStairBuildPreview = create((set) => ({ const prev = state.point if (!point && !prev) return state if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state - return { point } + return { point: point ? [point[0], point[1]] : null } + }), + setPreview: (point, rotation) => + set((state) => { + const samePoint = + (!point && !state.point) || + Boolean(point && state.point && state.point[0] === point[0] && state.point[1] === point[1]) + return samePoint && state.rotation === rotation + ? state + : { point: point ? [point[0], point[1]] : null, rotation } }), rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })), reset: () => diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index af8b9c5acd..df6f85e562 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -19,6 +19,7 @@ import { resolveCeilingPlanPointSnap, triggerSFX, useEditor, + useFloorplanDraftPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -79,6 +80,20 @@ export const CeilingTool: React.FC = () => { }, [points.length]) useEffect(() => () => useEditor.getState().setDraftVertexCount(0), []) + useEffect(() => { + useFloorplanDraftPreview.getState().setPolygonDraft('ceiling', points) + }, [points]) + useEffect( + () => () => { + const draftPreview = useFloorplanDraftPreview.getState() + if (draftPreview.polygonDraftType === 'ceiling') { + draftPreview.setPolygonDraft(null, []) + } + draftPreview.setCursorPoint(null) + }, + [], + ) + const verticalGeo = useMemo( () => new BufferGeometry().setFromPoints([ @@ -117,6 +132,7 @@ export const CeilingTool: React.FC = () => { fallbackPoint: orthoPoint, levelId: currentLevelId, }).point + useFloorplanDraftPreview.getState().setCursorPoint(displayPoint) setSnappedCursorPosition(displayPoint) if ( points.length > 0 && diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index b74a2dad13..016ef83d8b 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -364,6 +364,7 @@ export const columnDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + preview: () => import('./renderer').then(({ ColumnPreview }) => ({ default: ColumnPreview })), // Registry-driven placement tool — renders a translucent `ColumnPreview` // ghost at the cursor (mirroring the shelf build tool) instead of the // bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index d148dedbec..e176fa7e06 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -21,6 +21,7 @@ import { markToolCancelConsumed, triggerSFX, useEditor, + usePathDraftPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -559,6 +560,35 @@ const DuctSegmentTool = () => { // the cursor was at key-press time. const lastClientYRef = useRef(null) + const ghostFittings = useMemo(() => { + const last = draftPoints.at(-1) + if (!(activeLevelId && last && cursorPos) || altActive) return [] + const fittings = + planDuctDraw( + last, + cursorPos, + startPortRef.current, + startBodyRef.current, + endSnap.port, + endSnap.body, + profile, + )?.fittings ?? [] + return fittings.map( + (fitting, index): DuctFittingNode => ({ + ...fitting, + id: `duct-fitting_live-draft-${index}`, + parentId: activeLevelId, + }), + ) + }, [activeLevelId, altActive, cursorPos, draftPoints, endSnap, profile]) + + useEffect(() => { + usePathDraftPreview + .getState() + .setDraft('duct-segment', draftPoints, cursorPos, profile, ghostFittings) + }, [cursorPos, draftPoints, ghostFittings, profile]) + useEffect(() => () => usePathDraftPreview.getState().clear('duct-segment'), []) + useEffect(() => { if (!activeLevelId) return @@ -927,22 +957,6 @@ const DuctSegmentTool = () => { previewSegments.push({ a: last, b: cursorPos }) } - // Ghost the auto-inserted fittings (elbow / tee / cross) the next click - // will mint, by running the SAME planner the commit uses against the - // in-flight endpoints. Skipped in Alt-vertical mode (no XZ tap there). - const ghostFittings = - last && cursorPos && !altActive - ? (planDuctDraw( - last, - cursorPos, - startPortRef.current, - startBodyRef.current, - endSnap.port, - endSnap.body, - profile, - )?.fittings ?? []) - : [] - // Wall-style dimension pill above the cursor: absolute world coords before // the first point, signed per-axis deltas from the last placed point while // a segment is in flight. The actively-driven axis is emphasised — Y in diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index fd6ebdcd4d..c8f3d9e469 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -40,6 +40,7 @@ import { useAlignmentGuides, useEditor, useFenceCurveDraft, + useFloorplanDraftPreview, useSegmentDraftChain, } from '@pascal-app/editor' @@ -511,6 +512,9 @@ const StraightFenceTool: React.FC = () => { buildingState.current = 0 previewRef.current.visible = false setDraftMeasurement(null) + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setFenceDraftStart(null) + draftPreview.setFenceDraftEnd(null) useSegmentDraftChain.getState().clear('fence') useAlignmentGuides.getState().clear() } @@ -538,6 +542,9 @@ const StraightFenceTool: React.FC = () => { { applySnap: !angleLocked }, ) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z]) + draftPreview.setFenceDraftEnd(snappedLocal) cursorRef.current.position.copy(endingPoint.current) const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] if ( @@ -601,6 +608,9 @@ const StraightFenceTool: React.FC = () => { startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) endingPoint.current.copy(startingPoint.current) buildingState.current = 1 + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setFenceDraftStart(snappedStart) + draftPreview.setFenceDraftEnd(snappedStart) triggerSFX('sfx:structure-build-start') previewRef.current.visible = true setDraftMeasurement(null) @@ -645,6 +655,10 @@ const StraightFenceTool: React.FC = () => { useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]]) startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) endingPoint.current.copy(startingPoint.current) + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setFenceDraftEnd(null) + draftPreview.setFenceDraftStart(nextStart) + draftPreview.setFenceDraftEnd(nextStart) cursorRef.current?.position.copy(startingPoint.current) previewRef.current.visible = false buildingState.current = 1 @@ -669,6 +683,9 @@ const StraightFenceTool: React.FC = () => { emitter.off('tool:cancel', onCancel) useSegmentDraftChain.getState().clear('fence') useAlignmentGuides.getState().clear() + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setFenceDraftStart(null) + draftPreview.setFenceDraftEnd(null) } }, [unit]) @@ -725,11 +742,11 @@ const SplineFenceDraft: React.FC = () => { draftRef.current = draftPoints - // Mirror the draft length into the HUD store so the "finish curve" hint only - // shows once drafting has started; always clear it when the tool unmounts. + // Mirror the full transient curve so additional preview surfaces observe + // the same snapped control points as the local preview. useEffect(() => { - useFenceCurveDraft.getState().setPointCount(draftPoints.length) - }, [draftPoints]) + useFenceCurveDraft.getState().setDraft(draftPoints, cursor) + }, [cursor, draftPoints]) useEffect(() => () => useFenceCurveDraft.getState().reset(), []) useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), []) diff --git a/packages/nodes/src/level/definition.ts b/packages/nodes/src/level/definition.ts index 0e675dafaa..3dc516c3da 100644 --- a/packages/nodes/src/level/definition.ts +++ b/packages/nodes/src/level/definition.ts @@ -9,7 +9,7 @@ import { LevelNode } from './schema' */ export const levelDefinition: NodeDefinition = { kind: 'level', - schemaVersion: 1, + schemaVersion: 2, schema: LevelNode, category: 'site', diff --git a/packages/nodes/src/lineset/tool.tsx b/packages/nodes/src/lineset/tool.tsx index b0f4899465..4c8fe92e00 100644 --- a/packages/nodes/src/lineset/tool.tsx +++ b/packages/nodes/src/lineset/tool.tsx @@ -11,6 +11,7 @@ import { markToolCancelConsumed, triggerSFX, useEditor, + usePathDraftPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -98,6 +99,11 @@ const LinesetTool = () => { const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null) const lastClientYRef = useRef(null) + useEffect(() => { + usePathDraftPreview.getState().setDraft('lineset', draftPoints, cursorPos) + }, [cursorPos, draftPoints]) + useEffect(() => () => usePathDraftPreview.getState().clear('lineset'), []) + useEffect(() => { if (!activeLevelId) return diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx index f603705886..05f5761e0c 100644 --- a/packages/nodes/src/liquid-line/tool.tsx +++ b/packages/nodes/src/liquid-line/tool.tsx @@ -18,6 +18,7 @@ import { markToolCancelConsumed, triggerSFX, useEditor, + usePathDraftPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -285,6 +286,13 @@ const LiquidLineTool = () => { // Leaving the tool clears Follow so re-arming it starts in free-draw. useEffect(() => () => useLiquidLineToolOptions.getState().setFollow(false), []) + useEffect(() => { + usePathDraftPreview + .getState() + .setDraft('liquid-line', traceGhost ?? draftPoints, traceGhost ? null : cursorPos) + }, [cursorPos, draftPoints, traceGhost]) + useEffect(() => () => usePathDraftPreview.getState().clear('liquid-line'), []) + useEffect(() => { if (!activeLevelId) return diff --git a/packages/nodes/src/pipe-segment/tool.tsx b/packages/nodes/src/pipe-segment/tool.tsx index e6a2d7c7c4..9e49f635eb 100644 --- a/packages/nodes/src/pipe-segment/tool.tsx +++ b/packages/nodes/src/pipe-segment/tool.tsx @@ -11,6 +11,7 @@ import { markToolCancelConsumed, triggerSFX, useEditor, + usePathDraftPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -162,6 +163,30 @@ const PipeSegmentTool = () => { const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null) const lastClientYRef = useRef(null) + const displayStart = + draftStart && + cursorPos && + sloped && + system === 'waste' && + !startPortRef.current && + !startBodyRef.current && + !snapTarget && + !altActive + ? ([ + draftStart[0], + draftStart[1] + + Math.hypot(cursorPos[0] - draftStart[0], cursorPos[2] - draftStart[2]) * DRAIN_SLOPE, + draftStart[2], + ] as [number, number, number]) + : draftStart + + useEffect(() => { + usePathDraftPreview + .getState() + .setDraft('pipe-segment', displayStart ? [displayStart] : [], cursorPos, { diameter, system }) + }, [cursorPos, diameter, displayStart, system]) + useEffect(() => () => usePathDraftPreview.getState().clear('pipe-segment'), []) + useEffect(() => { if (!activeLevelId) return @@ -613,26 +638,6 @@ const PipeSegmentTool = () => { if (!activeLevelId) return null - // Free waste start lifts at commit so the run falls ONTO the grid — - // mirror that here so the preview line / pill match the placed pipe. - // A snapped end (snapTarget set) keeps the start where it is. - const displayStart = - draftStart && - cursorPos && - sloped && - system === 'waste' && - !startPortRef.current && - !startBodyRef.current && - !snapTarget && - !altActive - ? ([ - draftStart[0], - draftStart[1] + - Math.hypot(cursorPos[0] - draftStart[0], cursorPos[2] - draftStart[2]) * DRAIN_SLOPE, - draftStart[2], - ] as [number, number, number]) - : draftStart - const pillParts = cursorPos ? [ ...(['x', 'y', 'z'] as const).map((axis, i) => ({ diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index cf67ef36a7..660db4e074 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -19,6 +19,7 @@ import { resolveSlabPlanPointSnap, triggerSFX, useEditor, + useFloorplanDraftPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -77,6 +78,20 @@ export const SlabTool: React.FC = () => { }, [points.length]) useEffect(() => () => useEditor.getState().setDraftVertexCount(0), []) + useEffect(() => { + useFloorplanDraftPreview.getState().setPolygonDraft('slab', points) + }, [points]) + useEffect( + () => () => { + const draftPreview = useFloorplanDraftPreview.getState() + if (draftPreview.polygonDraftType === 'slab') { + draftPreview.setPolygonDraft(null, []) + } + draftPreview.setCursorPoint(null) + }, + [], + ) + useEffect(() => { if (!currentLevelId) return @@ -101,6 +116,7 @@ export const SlabTool: React.FC = () => { fallbackPoint: orthoPoint, levelId: currentLevelId, }).point + useFloorplanDraftPreview.getState().setCursorPoint(displayPoint) setSnappedCursorPosition(displayPoint) if ( points.length > 0 && diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index fb5ba5b6d1..f5acb490df 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -33,6 +33,7 @@ import { triggerSFX, useAlignmentGuides, useEditor, + useFloorplanDraftPreview, useSegmentDraftChain, useWallSnapIndicator, WALL_CONNECT_SNAP_RADIUS, @@ -594,6 +595,9 @@ export const WallTool: React.FC = () => { buildingState.current = 0 chainFirstVertex.current = null chainWallIds.current = [] + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setWallDraftStart(null) + draftPreview.setWallDraftEnd(null) if (wallPreviewRef.current) { wallPreviewRef.current.visible = false } @@ -637,6 +641,9 @@ export const WallTool: React.FC = () => { if (buildingState.current === 1) { const snappedLocal = gridPosition endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setWallDraftStart([startingPoint.current.x, startingPoint.current.z]) + draftPreview.setWallDraftEnd(snappedLocal) cursorRef.current.position.copy(endingPoint.current) setAxisGuide({ origin: [startingPoint.current.x, startingPoint.current.z], @@ -706,6 +713,9 @@ export const WallTool: React.FC = () => { chainFirstVertex.current = startingPoint.current.clone() endingPoint.current.copy(startingPoint.current) buildingState.current = 1 + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setWallDraftStart(snappedStart) + draftPreview.setWallDraftEnd(snappedStart) setAxisGuide({ origin: snappedStart, y: event.localPosition[1], @@ -783,6 +793,10 @@ export const WallTool: React.FC = () => { useSegmentDraftChain.getState().setChainStart('wall', [nextStart[0], nextStart[1]]) startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) endingPoint.current.copy(startingPoint.current) + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setWallDraftEnd(null) + draftPreview.setWallDraftStart(nextStart) + draftPreview.setWallDraftEnd(nextStart) cursorRef.current?.position.copy(startingPoint.current) buildingState.current = 1 setAxisGuide({ @@ -820,6 +834,9 @@ export const WallTool: React.FC = () => { useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() useSegmentDraftChain.getState().clear('wall') + const draftPreview = useFloorplanDraftPreview.getState() + draftPreview.setWallDraftStart(null) + draftPreview.setWallDraftEnd(null) } }, [unit])