@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.
Files changed (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. 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
+ }