@pascal-app/editor 0.9.2 → 1.0.0-beta.2

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 +265 -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,237 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ normalizeCameraPose,
4
+ planCameraPoseApplication,
5
+ publishInitialCameraPose,
6
+ releaseCameraPoseEventSuppression,
7
+ stepCameraPoseInterpolation,
8
+ withCameraPoseDistance,
9
+ } from './camera-pose'
10
+
11
+ describe('camera pose', () => {
12
+ test('normalizes a finite pose without retaining input tuples', () => {
13
+ const position: [number, number, number] = [1, 2, 3]
14
+ const target: [number, number, number] = [4, 5, 6]
15
+ const pose = normalizeCameraPose({
16
+ position,
17
+ target,
18
+ projection: 'perspective',
19
+ fov: 50,
20
+ viewWidth: 12,
21
+ })
22
+
23
+ expect(pose).toEqual({
24
+ fov: 50,
25
+ position,
26
+ projection: 'perspective',
27
+ target,
28
+ viewWidth: 12,
29
+ })
30
+ expect(pose?.position).not.toBe(position)
31
+ expect(pose?.target).not.toBe(target)
32
+ })
33
+
34
+ test('rejects non-finite, malformed, and unknown camera poses', () => {
35
+ expect(normalizeCameraPose(null)).toBeNull()
36
+ expect(
37
+ normalizeCameraPose({
38
+ position: [1, 2],
39
+ target: [4, 5, 6],
40
+ projection: 'perspective',
41
+ }),
42
+ ).toBeNull()
43
+ expect(
44
+ normalizeCameraPose({
45
+ position: [1, Number.NaN, 3],
46
+ target: [4, 5, 6],
47
+ projection: 'perspective',
48
+ }),
49
+ ).toBeNull()
50
+ expect(
51
+ normalizeCameraPose({
52
+ position: [1, 2, 3],
53
+ target: [4, 5, Number.POSITIVE_INFINITY],
54
+ projection: 'perspective',
55
+ }),
56
+ ).toBeNull()
57
+ expect(
58
+ normalizeCameraPose({
59
+ position: [1, 2, 3],
60
+ target: [4, 5, 6],
61
+ projection: 'panoramic',
62
+ }),
63
+ ).toBeNull()
64
+ expect(
65
+ normalizeCameraPose({
66
+ position: [1, 2, 3],
67
+ target: [4, 5, 6],
68
+ projection: 'perspective',
69
+ fov: Number.NaN,
70
+ }),
71
+ ).toBeNull()
72
+ expect(
73
+ normalizeCameraPose({
74
+ position: [1, 2, 3],
75
+ target: [4, 5, 6],
76
+ projection: 'orthographic',
77
+ viewWidth: 0,
78
+ }),
79
+ ).toBeNull()
80
+ expect(
81
+ normalizeCameraPose({
82
+ position: [1, 2, 3],
83
+ target: [4, 5, 6],
84
+ projection: 'orthographic',
85
+ viewWidth: Number.POSITIVE_INFINITY,
86
+ }),
87
+ ).toBeNull()
88
+ })
89
+
90
+ test('plans only perspective fov application and clamps it to a safe range', () => {
91
+ expect(
92
+ planCameraPoseApplication({
93
+ position: [1, 2, 3],
94
+ target: [4, 5, 6],
95
+ projection: 'perspective',
96
+ fov: 0,
97
+ })?.perspectiveFov,
98
+ ).toBe(1)
99
+ expect(
100
+ planCameraPoseApplication({
101
+ position: [1, 2, 3],
102
+ target: [4, 5, 6],
103
+ projection: 'perspective',
104
+ fov: 200,
105
+ })?.perspectiveFov,
106
+ ).toBe(179)
107
+ expect(
108
+ planCameraPoseApplication({
109
+ position: [1, 2, 3],
110
+ target: [4, 5, 6],
111
+ projection: 'orthographic',
112
+ fov: 70,
113
+ })?.perspectiveFov,
114
+ ).toBeNull()
115
+ })
116
+
117
+ test('interpolates toward the latest pose and converges exactly', () => {
118
+ const destination = {
119
+ position: [12, 6, -4] as [number, number, number],
120
+ target: [2, 1, 3] as [number, number, number],
121
+ projection: 'perspective' as const,
122
+ }
123
+ const first = stepCameraPoseInterpolation([0, 0, 0], [0, 0, 0], destination, 0.016)
124
+ expect(first.settled).toBe(false)
125
+ expect(first.position[0]).toBeGreaterThan(0)
126
+ expect(first.position[0]).toBeLessThan(destination.position[0])
127
+
128
+ let position = first.position
129
+ let target = first.target
130
+ let settled = first.settled
131
+ for (let frame = 0; frame < 240 && !settled; frame += 1) {
132
+ const step = stepCameraPoseInterpolation(position, target, destination, 0.016)
133
+ position = step.position
134
+ target = step.target
135
+ settled = step.settled
136
+ }
137
+
138
+ expect(settled).toBe(true)
139
+ expect(position).toEqual(destination.position)
140
+ expect(target).toEqual(destination.target)
141
+ })
142
+
143
+ test('settles an already-current pose without advancing on an invalid delta', () => {
144
+ const destination = {
145
+ position: [1, 2, 3] as [number, number, number],
146
+ target: [4, 5, 6] as [number, number, number],
147
+ projection: 'orthographic' as const,
148
+ }
149
+ expect(
150
+ stepCameraPoseInterpolation(
151
+ destination.position,
152
+ destination.target,
153
+ destination,
154
+ Number.NaN,
155
+ ),
156
+ ).toEqual({
157
+ position: destination.position,
158
+ settled: true,
159
+ target: destination.target,
160
+ })
161
+ expect(stepCameraPoseInterpolation([0, 0, 0], [0, 0, 0], destination, Number.NaN)).toEqual({
162
+ position: [0, 0, 0],
163
+ settled: false,
164
+ target: [0, 0, 0],
165
+ })
166
+ })
167
+
168
+ test('publishes the initial pose before any camera control update event', () => {
169
+ let publishCount = 0
170
+
171
+ publishInitialCameraPose(() => {
172
+ publishCount += 1
173
+ })
174
+
175
+ expect(publishCount).toBe(1)
176
+ })
177
+
178
+ test('publishes the settled pose when programmatic interpolation releases suppression', () => {
179
+ const suppression = { current: true }
180
+ let publishCount = 0
181
+
182
+ releaseCameraPoseEventSuppression(suppression, () => {
183
+ publishCount += 1
184
+ })
185
+
186
+ expect(suppression.current).toBe(false)
187
+ expect(publishCount).toBe(1)
188
+ })
189
+
190
+ test('retargets the bounded interpolation owner to the latest pose', () => {
191
+ const first = stepCameraPoseInterpolation(
192
+ [0, 0, 0],
193
+ [0, 0, 0],
194
+ {
195
+ position: [10, 0, 0],
196
+ target: [5, 0, 0],
197
+ projection: 'perspective',
198
+ },
199
+ 0.016,
200
+ )
201
+ const retargeted = stepCameraPoseInterpolation(
202
+ first.position,
203
+ first.target,
204
+ {
205
+ position: [-10, 0, 0],
206
+ target: [-5, 0, 0],
207
+ projection: 'perspective',
208
+ },
209
+ 0.1,
210
+ )
211
+
212
+ expect(retargeted.position[0]).toBeLessThan(first.position[0])
213
+ expect(retargeted.target[0]).toBeLessThan(first.target[0])
214
+ })
215
+
216
+ test('preserves view direction while adapting distance to the local viewport', () => {
217
+ const pose = {
218
+ position: [4, 6, 12] as [number, number, number],
219
+ target: [1, 2, 3] as [number, number, number],
220
+ projection: 'perspective' as const,
221
+ viewWidth: 20,
222
+ }
223
+ const adapted = withCameraPoseDistance(pose, 5)
224
+ const sourceDirection = pose.position.map((value, index) => value - pose.target[index]!)
225
+ const adaptedDirection = adapted.position.map((value, index) => value - adapted.target[index]!)
226
+ const sourceLength = Math.hypot(...sourceDirection)
227
+
228
+ expect(Math.hypot(...adaptedDirection)).toBeCloseTo(5)
229
+ const expectedDirection = sourceDirection.map((component) => component * (5 / sourceLength))
230
+ expect(adaptedDirection[0]).toBeCloseTo(expectedDirection[0]!)
231
+ expect(adaptedDirection[1]).toBeCloseTo(expectedDirection[1]!)
232
+ expect(adaptedDirection[2]).toBeCloseTo(expectedDirection[2]!)
233
+ expect(adapted.target).toEqual(pose.target)
234
+ expect(adapted.target).not.toBe(pose.target)
235
+ expect(pose.position).toEqual([4, 6, 12])
236
+ })
237
+ })
@@ -0,0 +1,172 @@
1
+ import type { CameraPose } from '@pascal-app/core'
2
+
3
+ const MIN_PERSPECTIVE_FOV = 1
4
+ const MAX_PERSPECTIVE_FOV = 179
5
+ const INTERPOLATION_TIME_CONSTANT_SECONDS = 0.08
6
+ const MAX_INTERPOLATION_DELTA_SECONDS = 0.1
7
+ const INTERPOLATION_SETTLE_DISTANCE = 0.001
8
+
9
+ export type CameraPoseApplicationPlan = {
10
+ pose: CameraPose
11
+ perspectiveFov: number | null
12
+ }
13
+
14
+ export type CameraPoseInterpolationStep = {
15
+ position: [number, number, number]
16
+ settled: boolean
17
+ target: [number, number, number]
18
+ }
19
+
20
+ export function publishInitialCameraPose(publishCurrentPose: () => void): void {
21
+ publishCurrentPose()
22
+ }
23
+
24
+ export function releaseCameraPoseEventSuppression(
25
+ suppression: { current: boolean },
26
+ publishCurrentPose: () => void,
27
+ ): void {
28
+ suppression.current = false
29
+ publishCurrentPose()
30
+ }
31
+
32
+ function finiteVectorTuple(value: unknown): [number, number, number] | null {
33
+ if (
34
+ !(
35
+ Array.isArray(value) &&
36
+ value.length === 3 &&
37
+ value.every((component) => typeof component === 'number' && Number.isFinite(component))
38
+ )
39
+ ) {
40
+ return null
41
+ }
42
+
43
+ return [value[0], value[1], value[2]]
44
+ }
45
+
46
+ export function normalizeCameraPose(value: unknown): CameraPose | null {
47
+ if (!(typeof value === 'object' && value !== null)) {
48
+ return null
49
+ }
50
+
51
+ const candidate = value as Partial<CameraPose>
52
+ const position = finiteVectorTuple(candidate.position)
53
+ const target = finiteVectorTuple(candidate.target)
54
+ if (
55
+ !(
56
+ position &&
57
+ target &&
58
+ (candidate.projection === 'perspective' || candidate.projection === 'orthographic')
59
+ )
60
+ ) {
61
+ return null
62
+ }
63
+
64
+ if (
65
+ candidate.fov !== undefined &&
66
+ !(typeof candidate.fov === 'number' && Number.isFinite(candidate.fov))
67
+ ) {
68
+ return null
69
+ }
70
+
71
+ if (
72
+ candidate.viewWidth !== undefined &&
73
+ !(
74
+ typeof candidate.viewWidth === 'number' &&
75
+ Number.isFinite(candidate.viewWidth) &&
76
+ candidate.viewWidth > 0
77
+ )
78
+ ) {
79
+ return null
80
+ }
81
+
82
+ return {
83
+ ...(candidate.fov === undefined ? {} : { fov: candidate.fov }),
84
+ position,
85
+ projection: candidate.projection,
86
+ target,
87
+ ...(candidate.viewWidth === undefined ? {} : { viewWidth: candidate.viewWidth }),
88
+ }
89
+ }
90
+
91
+ export function planCameraPoseApplication(value: unknown): CameraPoseApplicationPlan | null {
92
+ const pose = normalizeCameraPose(value)
93
+ if (!pose) {
94
+ return null
95
+ }
96
+
97
+ return {
98
+ pose,
99
+ perspectiveFov:
100
+ pose.projection === 'perspective' && pose.fov !== undefined
101
+ ? Math.min(Math.max(pose.fov, MIN_PERSPECTIVE_FOV), MAX_PERSPECTIVE_FOV)
102
+ : null,
103
+ }
104
+ }
105
+
106
+ function interpolateVectorTuple(
107
+ current: [number, number, number],
108
+ destination: [number, number, number],
109
+ alpha: number,
110
+ ): { settled: boolean; value: [number, number, number] } {
111
+ const dx = destination[0] - current[0]
112
+ const dy = destination[1] - current[1]
113
+ const dz = destination[2] - current[2]
114
+ const settleDistanceSquared = INTERPOLATION_SETTLE_DISTANCE ** 2
115
+ if (dx * dx + dy * dy + dz * dz <= settleDistanceSquared) {
116
+ return { settled: true, value: [...destination] }
117
+ }
118
+
119
+ const value: [number, number, number] = [
120
+ current[0] + dx * alpha,
121
+ current[1] + dy * alpha,
122
+ current[2] + dz * alpha,
123
+ ]
124
+ const remainingX = destination[0] - value[0]
125
+ const remainingY = destination[1] - value[1]
126
+ const remainingZ = destination[2] - value[2]
127
+ const settled =
128
+ remainingX * remainingX + remainingY * remainingY + remainingZ * remainingZ <=
129
+ settleDistanceSquared
130
+
131
+ return { settled, value: settled ? [...destination] : value }
132
+ }
133
+
134
+ export function withCameraPoseDistance(pose: CameraPose, distance: number): CameraPose {
135
+ const dx = pose.position[0] - pose.target[0]
136
+ const dy = pose.position[1] - pose.target[1]
137
+ const dz = pose.position[2] - pose.target[2]
138
+ const currentDistance = Math.hypot(dx, dy, dz)
139
+ if (!(Number.isFinite(distance) && distance > 0 && currentDistance > 0)) {
140
+ return { ...pose, position: [...pose.position], target: [...pose.target] }
141
+ }
142
+
143
+ const scale = distance / currentDistance
144
+ return {
145
+ ...pose,
146
+ position: [
147
+ pose.target[0] + dx * scale,
148
+ pose.target[1] + dy * scale,
149
+ pose.target[2] + dz * scale,
150
+ ],
151
+ target: [...pose.target],
152
+ }
153
+ }
154
+
155
+ export function stepCameraPoseInterpolation(
156
+ currentPosition: [number, number, number],
157
+ currentTarget: [number, number, number],
158
+ destination: CameraPose,
159
+ deltaSeconds: number,
160
+ ): CameraPoseInterpolationStep {
161
+ const finiteDelta = Number.isFinite(deltaSeconds) ? deltaSeconds : 0
162
+ const elapsed = Math.min(Math.max(finiteDelta, 0), MAX_INTERPOLATION_DELTA_SECONDS)
163
+ const alpha = 1 - Math.exp(-elapsed / INTERPOLATION_TIME_CONSTANT_SECONDS)
164
+ const position = interpolateVectorTuple(currentPosition, destination.position, alpha)
165
+ const target = interpolateVectorTuple(currentTarget, destination.target, alpha)
166
+
167
+ return {
168
+ position: position.value,
169
+ settled: position.settled && target.settled,
170
+ target: target.value,
171
+ }
172
+ }
@@ -11,6 +11,7 @@ import {
11
11
  canDirectMoveNode,
12
12
  resolveDirectManipulationNode,
13
13
  resolveDirectRotationDragDelta,
14
+ resolveMoveActionNode,
14
15
  snapDirectRotationDelta,
15
16
  } from './direct-manipulation'
16
17
 
@@ -190,3 +191,85 @@ describe('resolveDirectManipulationNode', () => {
190
191
  ).toBe(parent)
191
192
  })
192
193
  })
194
+
195
+ describe('resolveMoveActionNode', () => {
196
+ test('routes a nested same-kind child move to its host', () => {
197
+ const kind = 'move-action-nested-kind-test'
198
+ registerTestDefinition(kind, {
199
+ capabilities: {
200
+ movable: {
201
+ axes: ['x', 'z'],
202
+ parentFrame: {
203
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
204
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
205
+ parentRotationY: () => 0,
206
+ localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
207
+ local[0],
208
+ local[1],
209
+ local[2],
210
+ ],
211
+ planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
212
+ planX,
213
+ localY,
214
+ planZ,
215
+ ],
216
+ },
217
+ },
218
+ },
219
+ })
220
+ const parent = { id: 'move_action_parent', type: kind } as unknown as AnyNode
221
+ const child = {
222
+ id: 'move_action_child',
223
+ type: kind,
224
+ parentId: parent.id,
225
+ } as unknown as AnyNode
226
+
227
+ expect(
228
+ resolveMoveActionNode(child, {
229
+ [parent.id]: parent,
230
+ [child.id]: child,
231
+ }),
232
+ ).toBe(parent)
233
+ })
234
+
235
+ test('keeps a child independently movable when its parent is a different kind', () => {
236
+ const parentKind = 'move-action-parent-kind-test'
237
+ const childKind = 'move-action-child-kind-test'
238
+ registerTestDefinition(parentKind, {})
239
+ registerTestDefinition(childKind, {
240
+ capabilities: {
241
+ movable: {
242
+ axes: ['x', 'z'],
243
+ parentFrame: {
244
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
245
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
246
+ parentRotationY: () => 0,
247
+ localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
248
+ local[0],
249
+ local[1],
250
+ local[2],
251
+ ],
252
+ planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
253
+ planX,
254
+ localY,
255
+ planZ,
256
+ ],
257
+ },
258
+ },
259
+ },
260
+ })
261
+ const parent = { id: 'move_action_run', type: parentKind } as unknown as AnyNode
262
+ const child = {
263
+ id: 'move_action_module',
264
+ type: childKind,
265
+ parentId: parent.id,
266
+ } as unknown as AnyNode
267
+
268
+ expect(
269
+ resolveMoveActionNode(child, {
270
+ [parent.id]: parent,
271
+ [child.id]: child,
272
+ }),
273
+ ).toBe(child)
274
+ })
275
+ })
@@ -74,6 +74,15 @@ export function resolveDirectManipulationNode(
74
74
  return parent && canDirectRotateNode(parent) ? parent : target
75
75
  }
76
76
 
77
+ export function resolveMoveActionNode(
78
+ node: AnyNode,
79
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
80
+ ): AnyNode {
81
+ const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame
82
+ const parent = parentFrame?.resolveParent(node, nodes as Readonly<Record<string, AnyNode>>)
83
+ return parent?.type === node.type ? parent : node
84
+ }
85
+
77
86
  export function snapDirectRotationDelta(delta: number, free: boolean): number {
78
87
  return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
79
88
  }
@@ -15,7 +15,7 @@ type DoorOpenAnimationOptions = {
15
15
  persist?: boolean
16
16
  }
17
17
 
18
- function getDisplayedDoorValue(
18
+ export function getDisplayedDoorValue(
19
19
  doorId: AnyNodeId,
20
20
  field: keyof DoorInteractiveState,
21
21
  nodeValue: number | undefined,
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core'
3
+ import useElevationGuides from '../store/use-elevation-guides'
4
+ import {
5
+ clearStructuralElevationGuide,
6
+ collectElevationSnapTargets,
7
+ publishStructuralElevationGuide,
8
+ resolveElevationSnapMatch,
9
+ resolveStructuralElevationSnap,
10
+ } from './elevation-guides'
11
+
12
+ function structuralScene() {
13
+ const rawLevel = LevelNode.parse({ level: 0, height: 3 })
14
+ const slab = SlabNode.parse({
15
+ parentId: rawLevel.id,
16
+ polygon: [
17
+ [2, -1],
18
+ [4, -1],
19
+ [4, 1],
20
+ [2, 1],
21
+ ],
22
+ elevation: 0.6,
23
+ thickness: 0.2,
24
+ })
25
+ const ceiling = CeilingNode.parse({
26
+ parentId: rawLevel.id,
27
+ polygon: [
28
+ [-4, -1],
29
+ [-2, -1],
30
+ [-2, 1],
31
+ [-4, 1],
32
+ ],
33
+ height: 2.4,
34
+ })
35
+ const level = { ...rawLevel, children: [slab.id, ceiling.id] }
36
+ const nodes: Record<string, AnyNode> = {
37
+ [level.id]: level,
38
+ [slab.id]: slab,
39
+ [ceiling.id]: ceiling,
40
+ }
41
+ return { ceiling, level, nodes, slab }
42
+ }
43
+
44
+ describe('elevation guides', () => {
45
+ test('collects the level, slab faces, and ceiling plane on the source level', () => {
46
+ const { level, nodes, slab } = structuralScene()
47
+ const targets = collectElevationSnapTargets(
48
+ { nodeId: 'wall_moving', levelId: level.id, anchor: [0, 0] },
49
+ nodes,
50
+ )
51
+
52
+ expect(targets.map((target) => [target.label, target.elevation])).toEqual([
53
+ ['Level', 0],
54
+ ['Slab top', 0.6],
55
+ ['Slab underside', 0.39999999999999997],
56
+ ['Ceiling', 2.4],
57
+ ])
58
+
59
+ const withoutSelf = collectElevationSnapTargets(
60
+ { nodeId: slab.id, levelId: level.id, anchor: [0, 0] },
61
+ nodes,
62
+ )
63
+ expect(withoutSelf.some((target) => target.id.startsWith(slab.id))).toBe(false)
64
+ })
65
+
66
+ test('snaps inside the Y tolerance and leaves values outside it alone', () => {
67
+ const { level, nodes } = structuralScene()
68
+ const source = { nodeId: 'wall_moving', levelId: level.id, anchor: [0, 0] } as const
69
+
70
+ expect(resolveStructuralElevationSnap(source, 0.54, nodes)).toBe(0.6)
71
+ expect(resolveStructuralElevationSnap(source, 0.49, nodes)).toBe(0.49)
72
+ expect(resolveStructuralElevationSnap(source, 2.34, nodes)).toBe(2.4)
73
+ })
74
+
75
+ test('uses plan distance to disambiguate datums at the same elevation', () => {
76
+ const match = resolveElevationSnapMatch(
77
+ 1,
78
+ [0, 0],
79
+ [
80
+ { id: 'far', elevation: 1, anchor: [10, 0], label: 'Far' },
81
+ { id: 'near', elevation: 1, anchor: [2, 0], label: 'Near' },
82
+ ],
83
+ )
84
+
85
+ expect(match?.target.id).toBe('near')
86
+ })
87
+
88
+ test('publishes one owner-scoped guide only while exactly aligned', () => {
89
+ const { level, nodes } = structuralScene()
90
+ const source = { nodeId: 'wall_moving', levelId: level.id, anchor: [0, 0] } as const
91
+ useElevationGuides.setState({ guide: null })
92
+
93
+ publishStructuralElevationGuide(source, 0.6, nodes)
94
+ expect(useElevationGuides.getState().guide).toMatchObject({
95
+ ownerId: 'wall_moving',
96
+ elevation: 0.6,
97
+ label: 'Slab top',
98
+ direction: [1, 0],
99
+ })
100
+
101
+ clearStructuralElevationGuide('someone_else')
102
+ expect(useElevationGuides.getState().guide).not.toBeNull()
103
+
104
+ publishStructuralElevationGuide(source, 0.7, nodes)
105
+ expect(useElevationGuides.getState().guide).toBeNull()
106
+ })
107
+ })