@pascal-app/editor 0.9.2 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type CameraPose, emitter } from '@pascal-app/core'
|
|
4
|
+
import { useEffect, useRef } from 'react'
|
|
5
|
+
import { subscribeCameraPose } from '../../store/camera-pose-store'
|
|
6
|
+
import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
|
|
7
|
+
import type { NavigationSyncPose, NavigationSyncPoseInput } from '../../store/use-editor'
|
|
8
|
+
|
|
9
|
+
const POSITION_EPSILON = 0.001
|
|
10
|
+
const AZIMUTH_EPSILON = 1e-4
|
|
11
|
+
const VIEW_WIDTH_EPSILON = 0.001
|
|
12
|
+
|
|
13
|
+
type FloorplanNavigationSnapshot = Omit<NavigationSyncPoseInput, 'source'>
|
|
14
|
+
|
|
15
|
+
function angleDeltaRadians(a: number, b: number) {
|
|
16
|
+
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeNearZero(value: number) {
|
|
20
|
+
return Math.abs(value) < Number.EPSILON * 10 ? 0 : value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function navigationSnapshot(pose: NavigationSyncPoseInput): FloorplanNavigationSnapshot {
|
|
24
|
+
return {
|
|
25
|
+
target: [...pose.target],
|
|
26
|
+
azimuth: pose.azimuth,
|
|
27
|
+
viewWidth: pose.viewWidth,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function navigationSnapshotsEqual(
|
|
32
|
+
previous: FloorplanNavigationSnapshot,
|
|
33
|
+
next: FloorplanNavigationSnapshot,
|
|
34
|
+
) {
|
|
35
|
+
return (
|
|
36
|
+
Math.abs(previous.target[0] - next.target[0]) < POSITION_EPSILON &&
|
|
37
|
+
Math.abs(previous.target[1] - next.target[1]) < POSITION_EPSILON &&
|
|
38
|
+
Math.abs(previous.target[2] - next.target[2]) < POSITION_EPSILON &&
|
|
39
|
+
Math.abs(angleDeltaRadians(previous.azimuth, next.azimuth)) < AZIMUTH_EPSILON &&
|
|
40
|
+
Math.abs(previous.viewWidth - next.viewWidth) < VIEW_WIDTH_EPSILON
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function cameraPoseToFloorplanNavigationPose(
|
|
45
|
+
pose: CameraPose,
|
|
46
|
+
): NavigationSyncPoseInput | null {
|
|
47
|
+
if (!(pose.viewWidth !== undefined && Number.isFinite(pose.viewWidth) && pose.viewWidth > 0)) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
source: '3d',
|
|
53
|
+
target: [...pose.target],
|
|
54
|
+
azimuth: Math.atan2(pose.position[0] - pose.target[0], pose.position[2] - pose.target[2]),
|
|
55
|
+
viewWidth: pose.viewWidth,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function floorplanNavigationPoseToCameraPose(
|
|
60
|
+
navigationPose: NavigationSyncPose,
|
|
61
|
+
cameraPose: CameraPose,
|
|
62
|
+
): CameraPose {
|
|
63
|
+
const offsetX = cameraPose.position[0] - cameraPose.target[0]
|
|
64
|
+
const offsetY = cameraPose.position[1] - cameraPose.target[1]
|
|
65
|
+
const offsetZ = cameraPose.position[2] - cameraPose.target[2]
|
|
66
|
+
const horizontalDistance = Math.hypot(offsetX, offsetZ)
|
|
67
|
+
const horizontalX = normalizeNearZero(Math.sin(navigationPose.azimuth) * horizontalDistance)
|
|
68
|
+
const horizontalZ = normalizeNearZero(Math.cos(navigationPose.azimuth) * horizontalDistance)
|
|
69
|
+
const target: [number, number, number] = [...navigationPose.target]
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
...(cameraPose.fov === undefined ? {} : { fov: cameraPose.fov }),
|
|
73
|
+
position: [target[0] + horizontalX, target[1] + offsetY, target[2] + horizontalZ],
|
|
74
|
+
projection: cameraPose.projection,
|
|
75
|
+
target,
|
|
76
|
+
viewWidth: navigationPose.viewWidth,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type FloorplanCameraSyncBridge = {
|
|
81
|
+
receiveCameraPose: (pose: CameraPose) => void
|
|
82
|
+
receiveNavigationPose: (pose: NavigationSyncPose | null) => void
|
|
83
|
+
setActive: (active: boolean) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type FloorplanCameraNavigationChannel = {
|
|
87
|
+
publish: (pose: NavigationSyncPoseInput) => void
|
|
88
|
+
subscribe: (listener: (pose: NavigationSyncPose) => void) => () => void
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createFloorplanCameraNavigationChannel(): FloorplanCameraNavigationChannel {
|
|
92
|
+
const listeners = new Set<(pose: NavigationSyncPose) => void>()
|
|
93
|
+
let revision = 0
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
publish: (pose) => {
|
|
97
|
+
revision += 1
|
|
98
|
+
const revisedPose = { ...pose, revision }
|
|
99
|
+
for (const listener of listeners) {
|
|
100
|
+
listener(revisedPose)
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
subscribe: (listener) => {
|
|
104
|
+
listeners.add(listener)
|
|
105
|
+
return () => listeners.delete(listener)
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const liveCameraNavigation = createFloorplanCameraNavigationChannel()
|
|
111
|
+
|
|
112
|
+
export function subscribeFloorplanCameraNavigation(listener: (pose: NavigationSyncPose) => void) {
|
|
113
|
+
return liveCameraNavigation.subscribe(listener)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createFloorplanCameraSyncBridge({
|
|
117
|
+
active: initialActive = true,
|
|
118
|
+
applyCameraPose,
|
|
119
|
+
publishNavigationPose,
|
|
120
|
+
}: {
|
|
121
|
+
active?: boolean
|
|
122
|
+
applyCameraPose: (pose: CameraPose) => void
|
|
123
|
+
publishNavigationPose: (pose: NavigationSyncPoseInput) => void
|
|
124
|
+
}): FloorplanCameraSyncBridge {
|
|
125
|
+
let active = initialActive
|
|
126
|
+
let latestCameraPose: CameraPose | null = null
|
|
127
|
+
let pendingNavigationPose: NavigationSyncPose | null = null
|
|
128
|
+
let lastAppliedNavigationRevision = 0
|
|
129
|
+
let lastPublishedNavigation: FloorplanNavigationSnapshot | null = null
|
|
130
|
+
|
|
131
|
+
const applyPendingNavigationPose = () => {
|
|
132
|
+
if (!(latestCameraPose && pendingNavigationPose)) return false
|
|
133
|
+
if (pendingNavigationPose.revision === lastAppliedNavigationRevision) {
|
|
134
|
+
pendingNavigationPose = null
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const appliedPose = floorplanNavigationPoseToCameraPose(pendingNavigationPose, latestCameraPose)
|
|
139
|
+
const appliedNavigationPose = cameraPoseToFloorplanNavigationPose(appliedPose)
|
|
140
|
+
latestCameraPose = appliedPose
|
|
141
|
+
lastAppliedNavigationRevision = pendingNavigationPose.revision
|
|
142
|
+
pendingNavigationPose = null
|
|
143
|
+
if (appliedNavigationPose) {
|
|
144
|
+
lastPublishedNavigation = navigationSnapshot(appliedNavigationPose)
|
|
145
|
+
}
|
|
146
|
+
applyCameraPose(appliedPose)
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const publishCameraNavigationPose = (pose: CameraPose) => {
|
|
151
|
+
const navigationPose = cameraPoseToFloorplanNavigationPose(pose)
|
|
152
|
+
if (!navigationPose) return
|
|
153
|
+
const nextSnapshot = navigationSnapshot(navigationPose)
|
|
154
|
+
if (
|
|
155
|
+
lastPublishedNavigation &&
|
|
156
|
+
navigationSnapshotsEqual(lastPublishedNavigation, nextSnapshot)
|
|
157
|
+
) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
lastPublishedNavigation = nextSnapshot
|
|
162
|
+
publishNavigationPose(navigationPose)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
receiveCameraPose: (pose) => {
|
|
167
|
+
latestCameraPose = pose
|
|
168
|
+
if (active && applyPendingNavigationPose()) return
|
|
169
|
+
|
|
170
|
+
publishCameraNavigationPose(pose)
|
|
171
|
+
},
|
|
172
|
+
receiveNavigationPose: (pose) => {
|
|
173
|
+
if (
|
|
174
|
+
!active ||
|
|
175
|
+
pose?.source !== '2d' ||
|
|
176
|
+
pose.revision === lastAppliedNavigationRevision ||
|
|
177
|
+
pose.revision === pendingNavigationPose?.revision
|
|
178
|
+
) {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pendingNavigationPose = pose
|
|
183
|
+
applyPendingNavigationPose()
|
|
184
|
+
},
|
|
185
|
+
setActive: (nextActive) => {
|
|
186
|
+
if (active === nextActive) return
|
|
187
|
+
active = nextActive
|
|
188
|
+
if (!active) {
|
|
189
|
+
pendingNavigationPose = null
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (latestCameraPose) publishCameraNavigationPose(latestCameraPose)
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function useFloorplanCameraSyncBridge() {
|
|
198
|
+
// The compass is portalled into the always-visible viewer area, so its
|
|
199
|
+
// orientation-only 2D pose must still reach the camera while the floorplan
|
|
200
|
+
// itself is hidden in 3D view.
|
|
201
|
+
const bridgeRef = useRef<FloorplanCameraSyncBridge | null>(null)
|
|
202
|
+
if (!bridgeRef.current) {
|
|
203
|
+
bridgeRef.current = createFloorplanCameraSyncBridge({
|
|
204
|
+
applyCameraPose: (pose) => emitter.emit('camera-controls:apply-pose', pose),
|
|
205
|
+
publishNavigationPose: (pose) => liveCameraNavigation.publish(pose),
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
const bridge = bridgeRef.current
|
|
209
|
+
|
|
210
|
+
useEffect(() => subscribeCameraPose(bridge.receiveCameraPose), [bridge])
|
|
211
|
+
|
|
212
|
+
useEffect(() => subscribeNavigationSyncPose(bridge.receiveNavigationPose), [bridge])
|
|
213
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { emitter, nodeRegistry } from '@pascal-app/core'
|
|
4
|
+
import { X } from 'lucide-react'
|
|
5
|
+
import { useEffect, useRef } from 'react'
|
|
6
|
+
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
|
|
7
|
+
import { isFloorplanToolAvailableInMode } from '../../lib/floorplan/floorplan-mode'
|
|
8
|
+
import useEditor from '../../store/use-editor'
|
|
9
|
+
import useFloorplanMode from '../../store/use-floorplan-mode'
|
|
10
|
+
|
|
11
|
+
function getToolLabel(tool: string): string {
|
|
12
|
+
return nodeRegistry.get(tool)?.presentation?.label ?? tool
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function FloorplanModeCoordinator() {
|
|
16
|
+
const editorMode = useEditor((state) => state.mode)
|
|
17
|
+
const tool = useEditor((state) => state.tool)
|
|
18
|
+
const floorplanMode = useFloorplanMode((state) => state.mode)
|
|
19
|
+
const notice = useFloorplanMode((state) => state.notice)
|
|
20
|
+
const dismissNotice = useFloorplanMode((state) => state.dismissNotice)
|
|
21
|
+
const setFloorplanMode = useFloorplanMode((state) => state.setMode)
|
|
22
|
+
const showExpertModeNotice = useFloorplanMode((state) => state.showExpertModeNotice)
|
|
23
|
+
const showNotice = useFloorplanMode((state) => state.showNotice)
|
|
24
|
+
const previousFloorplanMode = useRef(floorplanMode)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const priorFloorplanMode = previousFloorplanMode.current
|
|
28
|
+
previousFloorplanMode.current = floorplanMode
|
|
29
|
+
if (floorplanMode !== 'default' || editorMode !== 'build' || !tool) return
|
|
30
|
+
const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
|
|
31
|
+
if (isFloorplanToolAvailableInMode(extension?.availableModes, floorplanMode)) return
|
|
32
|
+
|
|
33
|
+
const toolLabel = getToolLabel(tool)
|
|
34
|
+
emitter.emit('tool:cancel')
|
|
35
|
+
useEditor.getState().setMode('select')
|
|
36
|
+
if (priorFloorplanMode === 'expert') {
|
|
37
|
+
showNotice(
|
|
38
|
+
`Switched to Default. The unfinished ${toolLabel} draft was canceled; saved Expert annotations are hidden, not deleted.`,
|
|
39
|
+
)
|
|
40
|
+
} else {
|
|
41
|
+
showExpertModeNotice(toolLabel)
|
|
42
|
+
}
|
|
43
|
+
}, [editorMode, floorplanMode, showExpertModeNotice, showNotice, tool])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!notice || notice.kind === 'switch-to-expert') return
|
|
47
|
+
const timer = window.setTimeout(dismissNotice, 6000)
|
|
48
|
+
return () => window.clearTimeout(timer)
|
|
49
|
+
}, [dismissNotice, notice])
|
|
50
|
+
|
|
51
|
+
if (!notice) return null
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
aria-live="polite"
|
|
56
|
+
className="fixed top-16 left-1/2 z-[100] flex max-w-md -translate-x-1/2 items-center gap-3 rounded-lg border border-border/60 bg-background/95 px-3 py-2 text-sm text-foreground shadow-elevation-3 backdrop-blur-xl"
|
|
57
|
+
role="status"
|
|
58
|
+
>
|
|
59
|
+
<span>{notice.message}</span>
|
|
60
|
+
{notice.kind === 'switch-to-expert' ? (
|
|
61
|
+
<button
|
|
62
|
+
className="shrink-0 rounded-md bg-cyan-500/15 px-2.5 py-1 font-medium text-cyan-400 hover:bg-cyan-500/25"
|
|
63
|
+
onClick={() => {
|
|
64
|
+
setFloorplanMode('expert')
|
|
65
|
+
dismissNotice()
|
|
66
|
+
}}
|
|
67
|
+
type="button"
|
|
68
|
+
>
|
|
69
|
+
Switch to Expert
|
|
70
|
+
</button>
|
|
71
|
+
) : null}
|
|
72
|
+
<button
|
|
73
|
+
aria-label="Dismiss"
|
|
74
|
+
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-white/10 hover:text-foreground"
|
|
75
|
+
onClick={dismissNotice}
|
|
76
|
+
type="button"
|
|
77
|
+
>
|
|
78
|
+
<X aria-hidden="true" className="h-3.5 w-3.5" />
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
canApplyFloorplanNavigationSync,
|
|
4
|
+
canZoomFloorplanDuringNavigation,
|
|
5
|
+
createFloorplanNavigationSyncScheduler,
|
|
6
|
+
finalizeFloorplanNavigation,
|
|
7
|
+
flushFloorplanRotationPresentationRestore,
|
|
8
|
+
getFloorplanRotationOverscanViewBox,
|
|
9
|
+
queueFloorplanRotationPresentationRestore,
|
|
10
|
+
resolveFloorplanPresentationViewBox,
|
|
11
|
+
setFloorplanCompassRotation,
|
|
12
|
+
} from './floorplan-navigation-presentation'
|
|
13
|
+
|
|
14
|
+
describe('floorplan navigation presentation', () => {
|
|
15
|
+
test('keeps the viewport inside the painted floorplan surface throughout rotation', () => {
|
|
16
|
+
const viewport = { minX: -80, minY: -45, width: 160, height: 90 }
|
|
17
|
+
const overscan = getFloorplanRotationOverscanViewBox(viewport)
|
|
18
|
+
const viewportCorners: Array<[number, number]> = [
|
|
19
|
+
[-80, -45],
|
|
20
|
+
[80, -45],
|
|
21
|
+
[80, 45],
|
|
22
|
+
[-80, 45],
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
for (let degrees = 0; degrees < 360; degrees += 1) {
|
|
26
|
+
const radians = (-degrees * Math.PI) / 180
|
|
27
|
+
const cos = Math.cos(radians)
|
|
28
|
+
const sin = Math.sin(radians)
|
|
29
|
+
|
|
30
|
+
for (const [x, y] of viewportCorners) {
|
|
31
|
+
const localX = x * cos - y * sin
|
|
32
|
+
const localY = x * sin + y * cos
|
|
33
|
+
expect(localX).toBeGreaterThanOrEqual(overscan.minX - 1e-10)
|
|
34
|
+
expect(localX).toBeLessThanOrEqual(overscan.minX + overscan.width + 1e-10)
|
|
35
|
+
expect(localY).toBeGreaterThanOrEqual(overscan.minY - 1e-10)
|
|
36
|
+
expect(localY).toBeLessThanOrEqual(overscan.minY + overscan.height + 1e-10)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('keeps the imperative viewBox authoritative during navigation', () => {
|
|
42
|
+
const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 }
|
|
43
|
+
const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 }
|
|
44
|
+
|
|
45
|
+
expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, true)).toBe(
|
|
46
|
+
imperativeViewBox,
|
|
47
|
+
)
|
|
48
|
+
expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, false)).toBe(
|
|
49
|
+
reactViewBox,
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('does not mix wheel zoom with a compositor rotation preview', () => {
|
|
54
|
+
expect(canZoomFloorplanDuringNavigation(true)).toBe(false)
|
|
55
|
+
expect(canZoomFloorplanDuringNavigation(false)).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('does not apply synchronized camera poses over local navigation', () => {
|
|
59
|
+
expect(canApplyFloorplanNavigationSync(true)).toBe(false)
|
|
60
|
+
expect(canApplyFloorplanNavigationSync(false)).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('updates the compass presentation on every live rotation frame', () => {
|
|
64
|
+
const compass = { style: { transform: '' } }
|
|
65
|
+
|
|
66
|
+
setFloorplanCompassRotation(compass, 0.25)
|
|
67
|
+
expect(compass.style.transform).toBe('rotate(0.25deg)')
|
|
68
|
+
|
|
69
|
+
setFloorplanCompassRotation(compass, 0.5)
|
|
70
|
+
expect(compass.style.transform).toBe('rotate(0.5deg)')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('keeps the rotation preview in place until the committed state is ready to paint', () => {
|
|
74
|
+
const pending = { current: null }
|
|
75
|
+
const svg = {
|
|
76
|
+
style: {
|
|
77
|
+
transform: 'rotate(42deg)',
|
|
78
|
+
transformOrigin: 'center',
|
|
79
|
+
willChange: 'transform',
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
const presentation = {
|
|
83
|
+
svg,
|
|
84
|
+
svgStyle: {
|
|
85
|
+
transform: '',
|
|
86
|
+
transformOrigin: '',
|
|
87
|
+
willChange: '',
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
queueFloorplanRotationPresentationRestore(pending, presentation)
|
|
92
|
+
expect(svg.style.transform).toBe('rotate(42deg)')
|
|
93
|
+
|
|
94
|
+
flushFloorplanRotationPresentationRestore(pending)
|
|
95
|
+
expect(svg.style.transform).toBe('')
|
|
96
|
+
expect(pending.current).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('commits every active navigation channel before teardown', () => {
|
|
100
|
+
const calls: string[] = []
|
|
101
|
+
const rotationState = { angle: 42 }
|
|
102
|
+
|
|
103
|
+
finalizeFloorplanNavigation({
|
|
104
|
+
zoomPending: true,
|
|
105
|
+
panActive: true,
|
|
106
|
+
rotationState,
|
|
107
|
+
commitZoom: () => calls.push('zoom'),
|
|
108
|
+
commitPan: () => calls.push('pan'),
|
|
109
|
+
commitRotation: (state) => calls.push(`rotation:${state.angle}`),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
expect(calls).toEqual(['zoom', 'pan', 'rotation:42'])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('coalesces a camera pose stream into one settled React commit', () => {
|
|
116
|
+
const presented: number[] = []
|
|
117
|
+
const committed: number[] = []
|
|
118
|
+
let scheduled: (() => void) | null = null
|
|
119
|
+
const scheduler = createFloorplanNavigationSyncScheduler<number>({
|
|
120
|
+
applyPresentation: (pose) => presented.push(pose),
|
|
121
|
+
commit: (pose) => committed.push(pose),
|
|
122
|
+
schedule: (callback) => {
|
|
123
|
+
scheduled = callback
|
|
124
|
+
return 1 as unknown as ReturnType<typeof globalThis.setTimeout>
|
|
125
|
+
},
|
|
126
|
+
cancel: () => {
|
|
127
|
+
scheduled = null
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
for (let pose = 1; pose <= 30; pose += 1) {
|
|
132
|
+
scheduler.update(pose)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
expect(presented).toHaveLength(30)
|
|
136
|
+
expect(committed).toEqual([])
|
|
137
|
+
|
|
138
|
+
const settle = scheduled as (() => void) | null
|
|
139
|
+
settle?.()
|
|
140
|
+
expect(committed).toEqual([30])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export type FloorplanPresentationViewBox = {
|
|
2
|
+
minX: number
|
|
3
|
+
minY: number
|
|
4
|
+
width: number
|
|
5
|
+
height: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function setFloorplanCompassRotation(
|
|
9
|
+
compass: { style: { transform: string } } | null,
|
|
10
|
+
rotationDeg: number,
|
|
11
|
+
): void {
|
|
12
|
+
if (compass) {
|
|
13
|
+
compass.style.transform = `rotate(${rotationDeg}deg)`
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type FloorplanRotationPresentation = {
|
|
18
|
+
svg: {
|
|
19
|
+
style: {
|
|
20
|
+
transform: string
|
|
21
|
+
transformOrigin: string
|
|
22
|
+
willChange: string
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
svgStyle: {
|
|
26
|
+
transform: string
|
|
27
|
+
transformOrigin: string
|
|
28
|
+
willChange: string
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function queueFloorplanRotationPresentationRestore<
|
|
33
|
+
Presentation extends FloorplanRotationPresentation,
|
|
34
|
+
>(pending: { current: Presentation | null }, presentation: Presentation): void {
|
|
35
|
+
pending.current = presentation
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function flushFloorplanRotationPresentationRestore<
|
|
39
|
+
Presentation extends FloorplanRotationPresentation,
|
|
40
|
+
>(pending: { current: Presentation | null }): void {
|
|
41
|
+
const presentation = pending.current
|
|
42
|
+
if (!presentation) return
|
|
43
|
+
|
|
44
|
+
presentation.svg.style.transform = presentation.svgStyle.transform
|
|
45
|
+
presentation.svg.style.transformOrigin = presentation.svgStyle.transformOrigin
|
|
46
|
+
presentation.svg.style.willChange = presentation.svgStyle.willChange
|
|
47
|
+
pending.current = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getFloorplanRotationOverscanViewBox(
|
|
51
|
+
viewBox: FloorplanPresentationViewBox,
|
|
52
|
+
): FloorplanPresentationViewBox {
|
|
53
|
+
const size = Math.hypot(viewBox.width, viewBox.height)
|
|
54
|
+
const centerX = viewBox.minX + viewBox.width / 2
|
|
55
|
+
const centerY = viewBox.minY + viewBox.height / 2
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
minX: centerX - size / 2,
|
|
59
|
+
minY: centerY - size / 2,
|
|
60
|
+
width: size,
|
|
61
|
+
height: size,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveFloorplanPresentationViewBox(
|
|
66
|
+
reactViewBox: FloorplanPresentationViewBox,
|
|
67
|
+
imperativeViewBox: FloorplanPresentationViewBox | null,
|
|
68
|
+
interactionInProgress: boolean,
|
|
69
|
+
): FloorplanPresentationViewBox {
|
|
70
|
+
return interactionInProgress && imperativeViewBox ? imperativeViewBox : reactViewBox
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function canZoomFloorplanDuringNavigation(rotationInProgress: boolean): boolean {
|
|
74
|
+
return !rotationInProgress
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function canApplyFloorplanNavigationSync(interactionInProgress: boolean): boolean {
|
|
78
|
+
return !interactionInProgress
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type FloorplanNavigationSyncScheduler<Pose> = {
|
|
82
|
+
update: (pose: Pose) => void
|
|
83
|
+
flush: () => void
|
|
84
|
+
discard: () => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createFloorplanNavigationSyncScheduler<Pose>({
|
|
88
|
+
applyPresentation,
|
|
89
|
+
commit,
|
|
90
|
+
settleMs = 120,
|
|
91
|
+
schedule = globalThis.setTimeout,
|
|
92
|
+
cancel = globalThis.clearTimeout,
|
|
93
|
+
}: {
|
|
94
|
+
applyPresentation: (pose: Pose) => void
|
|
95
|
+
commit: (pose: Pose) => void
|
|
96
|
+
settleMs?: number
|
|
97
|
+
schedule?: (callback: () => void, delay: number) => ReturnType<typeof globalThis.setTimeout>
|
|
98
|
+
cancel?: (timer: ReturnType<typeof globalThis.setTimeout>) => void
|
|
99
|
+
}): FloorplanNavigationSyncScheduler<Pose> {
|
|
100
|
+
let latestPose: Pose | null = null
|
|
101
|
+
let settleTimer: ReturnType<typeof globalThis.setTimeout> | null = null
|
|
102
|
+
|
|
103
|
+
const clearSettleTimer = () => {
|
|
104
|
+
if (settleTimer === null) return
|
|
105
|
+
cancel(settleTimer)
|
|
106
|
+
settleTimer = null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const flush = () => {
|
|
110
|
+
clearSettleTimer()
|
|
111
|
+
if (latestPose === null) return
|
|
112
|
+
const pose = latestPose
|
|
113
|
+
latestPose = null
|
|
114
|
+
commit(pose)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const update = (pose: Pose) => {
|
|
118
|
+
latestPose = pose
|
|
119
|
+
applyPresentation(pose)
|
|
120
|
+
clearSettleTimer()
|
|
121
|
+
settleTimer = schedule(() => {
|
|
122
|
+
settleTimer = null
|
|
123
|
+
if (latestPose === null) return
|
|
124
|
+
const settledPose = latestPose
|
|
125
|
+
latestPose = null
|
|
126
|
+
commit(settledPose)
|
|
127
|
+
}, settleMs)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const discard = () => {
|
|
131
|
+
clearSettleTimer()
|
|
132
|
+
latestPose = null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { update, flush, discard }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function finalizeFloorplanNavigation<RotationState>({
|
|
139
|
+
zoomPending,
|
|
140
|
+
panActive,
|
|
141
|
+
rotationState,
|
|
142
|
+
commitZoom,
|
|
143
|
+
commitPan,
|
|
144
|
+
commitRotation,
|
|
145
|
+
}: {
|
|
146
|
+
zoomPending: boolean
|
|
147
|
+
panActive: boolean
|
|
148
|
+
rotationState: RotationState | null
|
|
149
|
+
commitZoom: () => void
|
|
150
|
+
commitPan: () => void
|
|
151
|
+
commitRotation: (rotationState: RotationState) => void
|
|
152
|
+
}): void {
|
|
153
|
+
if (zoomPending) commitZoom()
|
|
154
|
+
if (panActive) commitPan()
|
|
155
|
+
if (rotationState) commitRotation(rotationState)
|
|
156
|
+
}
|