@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,136 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import useFenceCurveDraft from './use-fence-curve-draft'
3
+ import { useFloorplanDraftPreview } from './use-floorplan-draft-preview'
4
+ import { usePathDraftPreview } from './use-path-draft-preview'
5
+ import { useStairBuildPreview } from './use-stair-build-preview'
6
+
7
+ afterEach(() => {
8
+ useFenceCurveDraft.getState().reset()
9
+ useFloorplanDraftPreview.getState().reset()
10
+ usePathDraftPreview.getState().reset()
11
+ useStairBuildPreview.getState().reset()
12
+ })
13
+
14
+ describe('live draft preview stores', () => {
15
+ test('dedupes polygon snapshots and owns an immutable point copy', () => {
16
+ let changes = 0
17
+ const unsubscribe = useFloorplanDraftPreview.subscribe(() => {
18
+ changes += 1
19
+ })
20
+ const points: Array<[number, number]> = [
21
+ [0, 0],
22
+ [2, 0],
23
+ ]
24
+ useFloorplanDraftPreview.getState().setPolygonDraft('slab', points)
25
+ useFloorplanDraftPreview.getState().setPolygonDraft('slab', points)
26
+ points[0]![0] = 9
27
+
28
+ expect(changes).toBe(1)
29
+ expect(useFloorplanDraftPreview.getState().polygonDraftPoints).toEqual([
30
+ [0, 0],
31
+ [2, 0],
32
+ ])
33
+ unsubscribe()
34
+ })
35
+
36
+ test('publishes stair point and rotation atomically', () => {
37
+ let changes = 0
38
+ const unsubscribe = useStairBuildPreview.subscribe(() => {
39
+ changes += 1
40
+ })
41
+ useStairBuildPreview.getState().setPreview([3, 4], Math.PI / 2)
42
+ useStairBuildPreview.getState().setPreview([3, 4], Math.PI / 2)
43
+
44
+ expect(changes).toBe(1)
45
+ expect(useStairBuildPreview.getState()).toMatchObject({
46
+ point: [3, 4],
47
+ rotation: Math.PI / 2,
48
+ })
49
+ unsubscribe()
50
+ })
51
+
52
+ test('dedupes curved-fence control points and cursor', () => {
53
+ let changes = 0
54
+ const unsubscribe = useFenceCurveDraft.subscribe(() => {
55
+ changes += 1
56
+ })
57
+ const points: Array<[number, number]> = [
58
+ [0, 0],
59
+ [1, 1],
60
+ ]
61
+ useFenceCurveDraft.getState().setDraft(points, [2, 0])
62
+ useFenceCurveDraft.getState().setDraft(points, [2, 0])
63
+ points[0]![0] = 5
64
+
65
+ expect(changes).toBe(1)
66
+ expect(useFenceCurveDraft.getState()).toMatchObject({
67
+ cursor: [2, 0],
68
+ pointCount: 2,
69
+ points: [
70
+ [0, 0],
71
+ [1, 1],
72
+ ],
73
+ })
74
+ unsubscribe()
75
+ })
76
+
77
+ test('dedupes path drafts and owns immutable point, parameter, and related-node copies', () => {
78
+ let changes = 0
79
+ const unsubscribe = usePathDraftPreview.subscribe(() => {
80
+ changes += 1
81
+ })
82
+ const points: Array<[number, number, number]> = [[0, 1, 2]]
83
+ const parameters = { diameter: 6, shape: 'round' }
84
+ const relatedNodes = [
85
+ {
86
+ angle: 90,
87
+ branchAngle: 90,
88
+ diameter: 6,
89
+ diameter2: 6,
90
+ ductMaterial: 'sheet-metal' as const,
91
+ fittingType: 'elbow' as const,
92
+ height: 8,
93
+ height2: 8,
94
+ id: 'duct-fitting_live-draft-0' as const,
95
+ metadata: {},
96
+ object: 'node' as const,
97
+ parentId: 'level_1' as const,
98
+ position: [1, 2, 3] as [number, number, number],
99
+ rotation: [0, 0, 0] as [number, number, number],
100
+ shape: 'round' as const,
101
+ shape2: 'round' as const,
102
+ slots: undefined,
103
+ system: 'supply' as const,
104
+ type: 'duct-fitting' as const,
105
+ visible: true,
106
+ width: 14,
107
+ width2: 14,
108
+ },
109
+ ]
110
+ usePathDraftPreview
111
+ .getState()
112
+ .setDraft('duct-segment', points, [3, 4, 5], parameters, relatedNodes)
113
+ usePathDraftPreview
114
+ .getState()
115
+ .setDraft('duct-segment', points, [3, 4, 5], parameters, relatedNodes)
116
+ points[0]![0] = 9
117
+ parameters.diameter = 12
118
+ relatedNodes[0]!.position[0] = 9
119
+
120
+ expect(changes).toBe(1)
121
+ expect(usePathDraftPreview.getState()).toMatchObject({
122
+ cursor: [3, 4, 5],
123
+ kind: 'duct-segment',
124
+ parameters: { diameter: 6, shape: 'round' },
125
+ points: [[0, 1, 2]],
126
+ relatedNodes: [
127
+ expect.objectContaining({
128
+ id: 'duct-fitting_live-draft-0',
129
+ position: [1, 2, 3],
130
+ type: 'duct-fitting',
131
+ }),
132
+ ],
133
+ })
134
+ unsubscribe()
135
+ })
136
+ })
@@ -0,0 +1,34 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ navigationSyncPoseStore,
4
+ publishNavigationSyncPoseToStore,
5
+ subscribeNavigationSyncPose,
6
+ } from './navigation-sync-pose-store'
7
+ import type { NavigationSyncPose } from './use-editor'
8
+
9
+ const pose = {
10
+ source: '2d' as const,
11
+ revision: 1,
12
+ target: [1, 2, 3] as [number, number, number],
13
+ azimuth: 0.5,
14
+ viewWidth: 12,
15
+ }
16
+
17
+ afterEach(() => {
18
+ navigationSyncPoseStore.setState({ pose: null })
19
+ })
20
+
21
+ describe('navigation sync pose store', () => {
22
+ test('replays the latest pose and streams only later pose writes', () => {
23
+ publishNavigationSyncPoseToStore(pose)
24
+ const received: NavigationSyncPose[] = []
25
+ const unsubscribe = subscribeNavigationSyncPose((nextPose) => received.push(nextPose))
26
+ const nextPose = { ...pose, revision: 2, viewWidth: 8 }
27
+
28
+ publishNavigationSyncPoseToStore(nextPose)
29
+ unsubscribe()
30
+ publishNavigationSyncPoseToStore({ ...pose, revision: 3 })
31
+
32
+ expect(received).toEqual([pose, nextPose])
33
+ })
34
+ })
@@ -0,0 +1,27 @@
1
+ import { subscribeWithSelector } from 'zustand/middleware'
2
+ import { createStore } from 'zustand/vanilla'
3
+ import type { NavigationSyncPose } from './use-editor'
4
+
5
+ type NavigationSyncPoseState = {
6
+ pose: NavigationSyncPose | null
7
+ }
8
+
9
+ export const navigationSyncPoseStore = createStore<NavigationSyncPoseState>()(
10
+ subscribeWithSelector<NavigationSyncPoseState>(() => ({ pose: null })),
11
+ )
12
+
13
+ export function publishNavigationSyncPoseToStore(pose: NavigationSyncPose) {
14
+ navigationSyncPoseStore.setState({ pose })
15
+ }
16
+
17
+ export function subscribeNavigationSyncPose(listener: (pose: NavigationSyncPose) => void) {
18
+ const currentPose = navigationSyncPoseStore.getState().pose
19
+ if (currentPose) listener(currentPose)
20
+
21
+ return navigationSyncPoseStore.subscribe(
22
+ (state) => state.pose,
23
+ (pose) => {
24
+ if (pose) listener(pose)
25
+ },
26
+ )
27
+ }
@@ -0,0 +1,223 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import useEditor, { isBrushMode, type Mode, normalizePersistedEditorUiState } from './use-editor'
3
+ import useInteractionScope from './use-interaction-scope'
4
+
5
+ /**
6
+ * The mode-lifecycle contract for terrain sculpting.
7
+ *
8
+ * These are not tests of the brush — that lives in `core`'s terrain-brush tests.
9
+ * They cover the part the user experiences as "does the tool fight the rest of
10
+ * the editor": entering sculpt mode must not leave conflicting affordances
11
+ * armed, and leaving it must not leak sculpt-only state into select mode.
12
+ */
13
+
14
+ function reset() {
15
+ // These come first: each one forces `mode: 'select'` on entry, so leaving one
16
+ // latched would make the next test's `setMode('terrain-sculpt')` a no-op.
17
+ useEditor.getState().setPreviewMode(false)
18
+ useEditor.getState().setFirstPersonMode(false)
19
+ useEditor.getState().setWorkspaceMode('edit')
20
+ // Before the mode: `setViewMode('2d')` disarms sculpt, and leaving a stale view
21
+ // behind would silently change what the next test's `setMode` does.
22
+ useEditor.getState().setViewMode('3d')
23
+ useEditor.getState().setMode('select')
24
+ useEditor.getState().setTerrainVerb('flatten')
25
+ useEditor.getState().setTerrainSampling(false)
26
+ useInteractionScope.getState().end()
27
+ }
28
+ afterEach(reset)
29
+
30
+ describe('terrain-sculpt mode lifecycle', () => {
31
+ test('entering claims the sculpting scope for the whole mode', () => {
32
+ useEditor.getState().setMode('terrain-sculpt')
33
+ // Held by the mode, not by a pointer gesture: an active scope is what makes
34
+ // every other node's handles and the floating action menu step back.
35
+ expect(useInteractionScope.getState().scope.kind).toBe('sculpting')
36
+ })
37
+
38
+ test('leaving releases it, so selection works again', () => {
39
+ useEditor.getState().setMode('terrain-sculpt')
40
+ useEditor.getState().setMode('select')
41
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
42
+ })
43
+
44
+ test('arming sculpt from another phase moves to the site phase', () => {
45
+ useEditor.getState().setPhase('structure')
46
+ useEditor.getState().setMode('terrain-sculpt')
47
+ // Otherwise `normalizeModeForPhase` rejects the mode on the next rehydrate
48
+ // and the UI shows a mode the store does not hold.
49
+ expect(useEditor.getState().phase).toBe('site')
50
+ expect(useEditor.getState().mode).toBe('terrain-sculpt')
51
+ })
52
+
53
+ test('a phase switch out of site drops the scope', () => {
54
+ useEditor.getState().setMode('terrain-sculpt')
55
+ // This path rewrites `mode` without going through `setMode`, which is
56
+ // exactly why the scope sync is its own function.
57
+ useEditor.getState().setPhase('structure')
58
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
59
+ })
60
+ })
61
+
62
+ describe('the eyedropper arm is not allowed to outlive sculpt mode', () => {
63
+ test('leaving via setMode disarms it', () => {
64
+ useEditor.getState().setMode('terrain-sculpt')
65
+ useEditor.getState().setTerrainSampling(true)
66
+ useEditor.getState().setMode('select')
67
+ // A one-shot arm that survived would swallow the user's first click back in
68
+ // select mode, with the panel that showed it as armed already unmounted.
69
+ expect(useEditor.getState().terrainSampling).toBe(false)
70
+ })
71
+
72
+ test('leaving via a phase switch disarms it too', () => {
73
+ useEditor.getState().setMode('terrain-sculpt')
74
+ useEditor.getState().setTerrainSampling(true)
75
+ useEditor.getState().setPhase('furnish')
76
+ expect(useEditor.getState().terrainSampling).toBe(false)
77
+ })
78
+
79
+ test('switching away from flatten disarms it', () => {
80
+ useEditor.getState().setMode('terrain-sculpt')
81
+ useEditor.getState().setTerrainSampling(true)
82
+ useEditor.getState().setTerrainVerb('raise')
83
+ expect(useEditor.getState().terrainSampling).toBe(false)
84
+ })
85
+
86
+ test('staying on flatten keeps it armed', () => {
87
+ useEditor.getState().setMode('terrain-sculpt')
88
+ useEditor.getState().setTerrainSampling(true)
89
+ useEditor.getState().setTerrainVerb('flatten')
90
+ expect(useEditor.getState().terrainSampling).toBe(true)
91
+ })
92
+
93
+ test('picking a target consumes the arm', () => {
94
+ useEditor.getState().setMode('terrain-sculpt')
95
+ useEditor.getState().setTerrainSampling(true)
96
+ useEditor.getState().setTerrainFlattenTarget(2.5)
97
+ expect(useEditor.getState().terrainFlattenTarget).toBe(2.5)
98
+ expect(useEditor.getState().terrainSampling).toBe(false)
99
+ })
100
+ })
101
+
102
+ describe('the brush and the 3D canvas travel together', () => {
103
+ // In `2d` the 3D pane is still mounted but `display: none`, so nothing about an
104
+ // armed brush is observable and no pointer event can reach it. Every path that
105
+ // could pair the two has to resolve it, and they resolve it in opposite
106
+ // directions on purpose: arming the mode asks for the ground (so the view
107
+ // moves), while choosing the floorplan asks for the floorplan (so the mode
108
+ // yields).
109
+ test('arming from the 2D floorplan opens a 3D pane beside it', () => {
110
+ useEditor.getState().setViewMode('2d')
111
+ useEditor.getState().setMode('terrain-sculpt')
112
+ // `split`, not `3d`: the floorplan was a deliberate choice, so keep it.
113
+ expect(useEditor.getState().viewMode).toBe('split')
114
+ expect(useEditor.getState().isFloorplanOpen).toBe(true)
115
+ expect(useEditor.getState().mode).toBe('terrain-sculpt')
116
+ })
117
+
118
+ test('switching to the 2D floorplan disarms the brush', () => {
119
+ useEditor.getState().setMode('terrain-sculpt')
120
+ useEditor.getState().setViewMode('2d')
121
+ expect(useEditor.getState().mode).toBe('select')
122
+ // And the scope goes with it — otherwise selection stays suppressed in the
123
+ // floorplan the user just asked for, with no visible way out.
124
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
125
+ })
126
+
127
+ test('split keeps it armed — the canvas is right there', () => {
128
+ useEditor.getState().setMode('terrain-sculpt')
129
+ useEditor.getState().setViewMode('split')
130
+ expect(useEditor.getState().mode).toBe('terrain-sculpt')
131
+ })
132
+
133
+ test('rehydrating the pair resolves it to select', () => {
134
+ // Mode and view persist independently and rehydrate runs neither setter, so
135
+ // this is the one reconciliation the setters cannot cover.
136
+ const state = normalizePersistedEditorUiState({
137
+ phase: 'site',
138
+ mode: 'terrain-sculpt',
139
+ viewMode: '2d',
140
+ })
141
+ expect(state.mode).toBe('select')
142
+ })
143
+
144
+ test('rehydrating sculpt beside a visible canvas restores the mode', () => {
145
+ const state = normalizePersistedEditorUiState({
146
+ phase: 'site',
147
+ mode: 'terrain-sculpt',
148
+ viewMode: 'split',
149
+ })
150
+ expect(state.mode).toBe('terrain-sculpt')
151
+ })
152
+ })
153
+
154
+ describe('the modes that hide the editing canvas release the brush', () => {
155
+ // Preview / walkthrough / studio all unmount `ToolManager` (via `noEditing`),
156
+ // so the sculpt tool that would release the scope on unmount is gone. Each one
157
+ // resets `mode` with a raw `set` rather than `setMode`, which is exactly the
158
+ // bug class `setPhase` had: a `sculpting` scope leaked here disables selection
159
+ // across the whole editor with nothing left mounted to clear it.
160
+ test('entering preview releases it', () => {
161
+ useEditor.getState().setMode('terrain-sculpt')
162
+ useEditor.getState().setPreviewMode(true)
163
+ expect(useEditor.getState().mode).toBe('select')
164
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
165
+ })
166
+
167
+ test('entering the first-person walkthrough releases it', () => {
168
+ useEditor.getState().setMode('terrain-sculpt')
169
+ useEditor.getState().setFirstPersonMode(true)
170
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
171
+ })
172
+
173
+ test('entering the studio workspace releases it', () => {
174
+ useEditor.getState().setMode('terrain-sculpt')
175
+ useEditor.getState().setWorkspaceMode('studio')
176
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
177
+ })
178
+
179
+ test('a structure-layer switch releases it', () => {
180
+ useEditor.getState().setMode('terrain-sculpt')
181
+ // Reachable from the layer toggle while sculpt is armed, and it takes the
182
+ // same raw-`set` path — the mode resets, so the scope has to follow.
183
+ useEditor.getState().setStructureLayer('zones')
184
+ expect(useEditor.getState().mode).toBe('select')
185
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
186
+ })
187
+ })
188
+
189
+ describe('entering sculpt mode stands other affordances down', () => {
190
+ test('the armed build tool is cleared', () => {
191
+ useEditor.getState().setPhase('structure')
192
+ useEditor.getState().setMode('build')
193
+ useEditor.getState().setTool('wall')
194
+ useEditor.getState().setMode('terrain-sculpt')
195
+ // A ghost still armed under the brush would place a wall on the first dab.
196
+ expect(useEditor.getState().tool).toBeNull()
197
+ })
198
+ })
199
+
200
+ describe('isBrushMode', () => {
201
+ /**
202
+ * The set of modes that hold their interaction scope for the whole mode rather
203
+ * than for a pointer gesture. It matters outside the store because a scope is
204
+ * single-owner: anything calling `begin()` while a brush mode holds its scope
205
+ * evicts it silently, leaving the brush armed and painting while the editor
206
+ * believes a drag is running. Nearly every producer needs a selection — which
207
+ * entering a brush mode clears — but the clipboard paths read the clipboard, so
208
+ * `pasteSelectionAndPickUp` asks this before picking clones up.
209
+ */
210
+ test('is exactly the two modes that hold a scope for their duration', () => {
211
+ const modes: Mode[] = ['select', 'edit', 'delete', 'build', 'material-paint', 'terrain-sculpt']
212
+ const held: Mode[] = []
213
+ for (const mode of modes) {
214
+ useEditor.getState().setMode(mode)
215
+ const kind = useInteractionScope.getState().scope.kind
216
+ if (kind === 'painting' || kind === 'sculpting') held.push(mode)
217
+ }
218
+ // Derived from observed scope behaviour rather than restating the literal, so
219
+ // a third brush mode that forgets `isBrushMode` fails here.
220
+ expect(held.filter(isBrushMode)).toEqual(held)
221
+ expect(modes.filter(isBrushMode)).toEqual(held)
222
+ })
223
+ })
@@ -0,0 +1,27 @@
1
+ import { create } from 'zustand'
2
+
3
+ type DeleteConfirmationRequest = {
4
+ count: number
5
+ onConfirm: () => void
6
+ }
7
+
8
+ type DeleteConfirmationStore = {
9
+ request: DeleteConfirmationRequest | null
10
+ cancel: () => void
11
+ confirm: () => void
12
+ requestConfirmation: (request: DeleteConfirmationRequest) => void
13
+ }
14
+
15
+ const useDeleteConfirmation = create<DeleteConfirmationStore>((set, get) => ({
16
+ request: null,
17
+ cancel: () => set({ request: null }),
18
+ confirm: () => {
19
+ const request = get().request
20
+ if (!request) return
21
+ set({ request: null })
22
+ request.onConfirm()
23
+ },
24
+ requestConfirmation: (request) => set({ request }),
25
+ }))
26
+
27
+ export default useDeleteConfirmation
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import useDrawingView, { normalizeAnnotationLayoutOverrides } from './use-drawing-view'
3
+
4
+ describe('drawing type', () => {
5
+ test('keeps the workspace on the floor plan', () => {
6
+ expect(useDrawingView.getState().drawingType).toBe('floor-plan')
7
+ expect('setDrawingType' in useDrawingView.getState()).toBe(false)
8
+ })
9
+ })
10
+
11
+ describe('normalizeAnnotationLayoutOverrides', () => {
12
+ test('keeps finite pinned drawing-view annotation offsets', () => {
13
+ expect(
14
+ normalizeAnnotationLayoutOverrides({
15
+ a: { dx: 1.25, dy: -0.5, pinned: true },
16
+ stale: { dx: Number.NaN, dy: 0, pinned: true },
17
+ unpinned: { dx: 1, dy: 2, pinned: false },
18
+ }),
19
+ ).toEqual({
20
+ a: { dx: 1.25, dy: -0.5, pinned: true },
21
+ })
22
+ })
23
+ })
@@ -0,0 +1,84 @@
1
+ 'use client'
2
+
3
+ import type { ConstructionDrawingType } from '@pascal-app/core'
4
+ import { create } from 'zustand'
5
+ import { persist } from 'zustand/middleware'
6
+
7
+ export const DRAWING_TYPE_OPTIONS = [
8
+ { id: 'floor-plan', label: 'Floor plan' },
9
+ { id: 'foundation-plan', label: 'Foundation plan' },
10
+ { id: 'reflected-ceiling-plan', label: 'Reflected ceiling plan' },
11
+ { id: 'roof-plan', label: 'Roof plan' },
12
+ { id: 'site-plan', label: 'Site plan' },
13
+ ] as const satisfies readonly { id: ConstructionDrawingType; label: string }[]
14
+
15
+ export type DrawingAnnotationLayoutOverride = {
16
+ dx: number
17
+ dy: number
18
+ pinned: true
19
+ }
20
+
21
+ export type DrawingAnnotationLayoutOverrides = Record<string, DrawingAnnotationLayoutOverride>
22
+
23
+ type DrawingViewState = {
24
+ drawingType: Extract<ConstructionDrawingType, 'floor-plan'>
25
+ annotationLayoutOverrides: DrawingAnnotationLayoutOverrides
26
+ setAnnotationLayoutOverride: (
27
+ id: string,
28
+ override: DrawingAnnotationLayoutOverride | null,
29
+ ) => void
30
+ }
31
+
32
+ export function normalizeAnnotationLayoutOverrides(
33
+ value: unknown,
34
+ ): DrawingAnnotationLayoutOverrides {
35
+ if (!value || typeof value !== 'object') return {}
36
+ const out: DrawingAnnotationLayoutOverrides = {}
37
+ for (const [id, raw] of Object.entries(value)) {
38
+ if (!id || !raw || typeof raw !== 'object') continue
39
+ const dx = (raw as { dx?: unknown }).dx
40
+ const dy = (raw as { dy?: unknown }).dy
41
+ const pinned = (raw as { pinned?: unknown }).pinned
42
+ if (
43
+ typeof dx === 'number' &&
44
+ Number.isFinite(dx) &&
45
+ typeof dy === 'number' &&
46
+ Number.isFinite(dy) &&
47
+ pinned === true
48
+ ) {
49
+ out[id] = { dx, dy, pinned: true }
50
+ }
51
+ }
52
+ return out
53
+ }
54
+
55
+ const useDrawingView = create<DrawingViewState>()(
56
+ persist(
57
+ (set) => ({
58
+ drawingType: 'floor-plan',
59
+ annotationLayoutOverrides: {},
60
+ setAnnotationLayoutOverride: (id, override) =>
61
+ set((state) => {
62
+ const next = { ...state.annotationLayoutOverrides }
63
+ if (override) next[id] = override
64
+ else delete next[id]
65
+ return { annotationLayoutOverrides: next }
66
+ }),
67
+ }),
68
+ {
69
+ name: 'pascal-floorplan-drawing-view',
70
+ merge: (persistedState, currentState) => ({
71
+ ...currentState,
72
+ annotationLayoutOverrides: normalizeAnnotationLayoutOverrides(
73
+ (persistedState as { annotationLayoutOverrides?: unknown } | undefined)
74
+ ?.annotationLayoutOverrides,
75
+ ),
76
+ }),
77
+ partialize: (state) => ({
78
+ annotationLayoutOverrides: state.annotationLayoutOverrides,
79
+ }),
80
+ },
81
+ ),
82
+ )
83
+
84
+ export default useDrawingView