@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,110 @@
1
+ 'use client'
2
+
3
+ import { create } from 'zustand'
4
+ import { persist } from 'zustand/middleware'
5
+ import {
6
+ DEFAULT_FLOORPLAN_MODE,
7
+ type FloorplanMode,
8
+ normalizeFloorplanModesByProject,
9
+ } from '../lib/floorplan/floorplan-mode'
10
+
11
+ export type FloorplanModeNotice = {
12
+ id: number
13
+ kind: 'info' | 'switch-to-expert'
14
+ message: string
15
+ }
16
+
17
+ type FloorplanModeState = {
18
+ mode: FloorplanMode
19
+ projectId: string | null
20
+ modesByProject: Record<string, FloorplanMode>
21
+ hasShownDefaultReassurance: boolean
22
+ notice: FloorplanModeNotice | null
23
+ dismissNotice: () => void
24
+ setMode: (mode: FloorplanMode) => void
25
+ setProjectId: (projectId: string | null) => void
26
+ showExpertModeNotice: (toolLabel: string) => void
27
+ showNotice: (message: string) => void
28
+ }
29
+
30
+ let nextNoticeId = 0
31
+
32
+ const useFloorplanMode = create<FloorplanModeState>()(
33
+ persist(
34
+ (set) => ({
35
+ mode: DEFAULT_FLOORPLAN_MODE,
36
+ projectId: null,
37
+ modesByProject: {},
38
+ hasShownDefaultReassurance: false,
39
+ notice: null,
40
+ dismissNotice: () => set({ notice: null }),
41
+ setMode: (mode) =>
42
+ set((state) => {
43
+ const shouldReassure =
44
+ state.mode === 'expert' && mode === 'default' && !state.hasShownDefaultReassurance
45
+ return {
46
+ mode,
47
+ modesByProject: state.projectId
48
+ ? { ...state.modesByProject, [state.projectId]: mode }
49
+ : state.modesByProject,
50
+ hasShownDefaultReassurance: state.hasShownDefaultReassurance || shouldReassure,
51
+ notice: shouldReassure
52
+ ? {
53
+ id: ++nextNoticeId,
54
+ kind: 'info',
55
+ message:
56
+ 'Default mode hides Expert annotations. Your saved work was not deleted.',
57
+ }
58
+ : state.notice,
59
+ }
60
+ }),
61
+ setProjectId: (projectId) =>
62
+ set((state) => ({
63
+ projectId,
64
+ mode: projectId
65
+ ? (state.modesByProject[projectId] ?? DEFAULT_FLOORPLAN_MODE)
66
+ : DEFAULT_FLOORPLAN_MODE,
67
+ notice: null,
68
+ })),
69
+ showExpertModeNotice: (toolLabel) =>
70
+ set({
71
+ notice: {
72
+ id: ++nextNoticeId,
73
+ kind: 'switch-to-expert',
74
+ message: `${toolLabel} is available in Expert mode.`,
75
+ },
76
+ }),
77
+ showNotice: (message) =>
78
+ set({
79
+ notice: {
80
+ id: ++nextNoticeId,
81
+ kind: 'info',
82
+ message,
83
+ },
84
+ }),
85
+ }),
86
+ {
87
+ name: 'pascal-floorplan-mode-by-project',
88
+ merge: (persistedState, currentState) => {
89
+ const persisted = persistedState as
90
+ | { hasShownDefaultReassurance?: unknown; modesByProject?: unknown }
91
+ | undefined
92
+ const modesByProject = normalizeFloorplanModesByProject(persisted?.modesByProject)
93
+ return {
94
+ ...currentState,
95
+ modesByProject,
96
+ hasShownDefaultReassurance: persisted?.hasShownDefaultReassurance === true,
97
+ mode: currentState.projectId
98
+ ? (modesByProject[currentState.projectId] ?? DEFAULT_FLOORPLAN_MODE)
99
+ : DEFAULT_FLOORPLAN_MODE,
100
+ }
101
+ },
102
+ partialize: (state) => ({
103
+ hasShownDefaultReassurance: state.hasShownDefaultReassurance,
104
+ modesByProject: state.modesByProject,
105
+ }),
106
+ },
107
+ ),
108
+ )
109
+
110
+ export default useFloorplanMode
@@ -2,10 +2,12 @@ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import type { AnyNode } from '@pascal-app/core'
3
3
  import {
4
4
  type ActiveInteractionScope,
5
+ curveReshapeScope,
5
6
  editingHoleInfo,
6
7
  handleDragInfo,
7
8
  isActive,
8
9
  isIdle,
10
+ isToolDrivenReshape,
9
11
  scopeNodeId,
10
12
  selectionEnabled,
11
13
  } from '../lib/interaction/scope'
@@ -137,7 +139,13 @@ describe('derived flag views are leak-free (no parallel flags)', () => {
137
139
 
138
140
  test('editingHoleInfo mirrors a hole reshape and clears on end', () => {
139
141
  const s = useInteractionScope.getState()
140
- s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 2 })
142
+ s.begin({
143
+ kind: 'reshaping',
144
+ nodeId: 'slab_1',
145
+ reshape: 'hole',
146
+ driver: 'tool',
147
+ holeIndex: 2,
148
+ })
141
149
  expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 2 })
142
150
  s.end()
143
151
  expect(editingHoleInfo(scope())).toBeNull()
@@ -145,16 +153,27 @@ describe('derived flag views are leak-free (no parallel flags)', () => {
145
153
 
146
154
  test('a non-hole reshape never reads as an editing hole', () => {
147
155
  const s = useInteractionScope.getState()
148
- s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve' })
156
+ s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve', driver: 'tool' })
149
157
  expect(editingHoleInfo(scope())).toBeNull()
150
158
  })
151
159
 
160
+ test('a floorplan-owned curve drag does not activate the registry reshape tool', () => {
161
+ expect(isToolDrivenReshape(curveReshapeScope('wall_1', 'floorplan'))).toBe(false)
162
+ expect(isToolDrivenReshape(curveReshapeScope('wall_1', 'tool'))).toBe(true)
163
+ })
164
+
152
165
  test('switching interactions never leaks the prior derived view', () => {
153
166
  const s = useInteractionScope.getState()
154
167
  s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
155
168
  // Single-owner replacement: the handle-drag view must vanish the instant a
156
169
  // different interaction begins — the two cannot be simultaneously active.
157
- s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 0 })
170
+ s.begin({
171
+ kind: 'reshaping',
172
+ nodeId: 'slab_1',
173
+ reshape: 'hole',
174
+ driver: 'tool',
175
+ holeIndex: 0,
176
+ })
158
177
  expect(handleDragInfo(scope())).toBeNull()
159
178
  expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 0 })
160
179
  })
@@ -174,6 +193,7 @@ describe('derived flag views are leak-free (no parallel flags)', () => {
174
193
  { kind: 'drafting', tool: 'wall' },
175
194
  { kind: 'box-select' },
176
195
  { kind: 'painting' },
196
+ { kind: 'sculpting' },
177
197
  ]
178
198
  for (const k of kinds) {
179
199
  s.begin(k)
@@ -13,6 +13,8 @@ import {
13
13
  IDLE_SCOPE,
14
14
  type InteractionScope,
15
15
  isCurveReshape,
16
+ isFloorplanDrivenReshape,
17
+ isToolDrivenReshape,
16
18
  movingNodeOf,
17
19
  reshapingNodeId,
18
20
  tangentReshapeInfo,
@@ -84,6 +86,12 @@ export const getIsCurveReshape = (): boolean => isCurveReshape(useInteractionSco
84
86
  // recovered by reading the reshaped node's type from `useReshapingNode`.
85
87
  export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCurveReshape(s.scope))
86
88
 
89
+ export const useIsToolDrivenReshape = (): boolean =>
90
+ useInteractionScope((s) => isToolDrivenReshape(s.scope))
91
+
92
+ export const useIsFloorplanDrivenReshape = (): boolean =>
93
+ useInteractionScope((s) => isFloorplanDrivenReshape(s.scope))
94
+
87
95
  // Replaces the legacy `movingWallEndpoint` / `movingFenceEndpoint` payloads,
88
96
  // minus the node (fetch it from `useReshapingNode`).
89
97
  export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null =>
@@ -195,6 +195,7 @@ describe('measurement draft transitions', () => {
195
195
  }),
196
196
  ).toBe(true)
197
197
  expect(draft.finishVertexDrag('3d')).toBe(true)
198
+ expect(useMeasurementDraft.getState().points[0]).toEqual(point(0, 0, 0))
198
199
  expect(useMeasurementDraft.getState().collectionPlane).toEqual({
199
200
  point: point(0, 0, 0),
200
201
  normal: point(0, 1, 0),
@@ -208,6 +209,34 @@ describe('measurement draft transitions', () => {
208
209
  expect(useMeasurementDraft.getState().collectionPlane).toBeNull()
209
210
  })
210
211
 
212
+ test('projects every later polygon point and feature fallback onto the first surface plane', () => {
213
+ const draft = useMeasurementDraft.getState()
214
+ const anchor = {
215
+ kind: 'feature' as const,
216
+ reference: {
217
+ nodeId: 'slab_host',
218
+ featureId: 'slab:boundary',
219
+ parameters: { t: 0.25 },
220
+ },
221
+ fallback: point(2, 0.4, 0),
222
+ }
223
+ draft.setKind('area')
224
+ draft.addPoint('3d', point(0, 0, 0), undefined, point(0, 1, 0))
225
+ draft.addPoint('3d', anchor.fallback, anchor, point(1, 0, 0))
226
+ draft.addPoint('3d', point(2, -0.7, 2), undefined, point(0, -1, 0))
227
+
228
+ expect(useMeasurementDraft.getState().points).toEqual([
229
+ point(0, 0, 0),
230
+ point(2, 0, 0),
231
+ point(2, 0, 2),
232
+ ])
233
+ expect(useMeasurementDraft.getState().anchors[1]).toMatchObject({
234
+ fallback: point(2, 0, 0),
235
+ })
236
+ expect(draft.closeBase('3d')).toBe(true)
237
+ expect(useMeasurementDraft.getState().error).toBeNull()
238
+ })
239
+
211
240
  test('closes a volume base before accepting explicit extrusion', () => {
212
241
  const draft = useMeasurementDraft.getState()
213
242
  draft.setKind('volume')
@@ -108,6 +108,29 @@ function normalizePoint(point: MeasurementPoint | undefined): MeasurementPoint |
108
108
  return length > 1e-9 ? [point[0] / length, point[1] / length, point[2] / length] : null
109
109
  }
110
110
 
111
+ function projectPointToPlane(
112
+ point: MeasurementPoint,
113
+ plane: { point: MeasurementPoint; normal: MeasurementPoint } | null,
114
+ ): MeasurementPoint {
115
+ if (!plane) return clonePoint(point)
116
+ const distance =
117
+ (point[0] - plane.point[0]) * plane.normal[0] +
118
+ (point[1] - plane.point[1]) * plane.normal[1] +
119
+ (point[2] - plane.point[2]) * plane.normal[2]
120
+ return [
121
+ point[0] - plane.normal[0] * distance,
122
+ point[1] - plane.normal[1] * distance,
123
+ point[2] - plane.normal[2] * distance,
124
+ ]
125
+ }
126
+
127
+ function anchorWithFallback(
128
+ anchor: MeasurementFeatureAnchor | undefined,
129
+ fallback: MeasurementPoint,
130
+ ): MeasurementFeatureAnchor | null {
131
+ return anchor ? { ...anchor, fallback: clonePoint(fallback) } : null
132
+ }
133
+
111
134
  export function measurementPolygonMidpoints(
112
135
  points: readonly MeasurementPoint[],
113
136
  ): Array<{ edgeIndex: number; point: MeasurementPoint }> {
@@ -277,17 +300,18 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
277
300
  return false
278
301
  }
279
302
 
303
+ const projectedPoint = projectPointToPlane(hover.point, state.collectionPlane)
280
304
  const points = state.points.map((point, index) =>
281
- index === drag.index ? clonePoint(hover.point) : point,
305
+ index === drag.index ? projectedPoint : point,
282
306
  )
283
307
  const anchors = state.anchors.map((anchor, index) =>
284
- index === drag.index ? (hover.anchor ?? null) : anchor,
308
+ index === drag.index ? anchorWithFallback(hover.anchor, projectedPoint) : anchor,
285
309
  )
286
310
  set({
287
311
  points,
288
312
  anchors,
289
313
  hover: {
290
- point: clonePoint(hover.point),
314
+ point: clonePoint(projectedPoint),
291
315
  normal: clonePoint(hover.normal),
292
316
  targetNodeId: hover.targetNodeId,
293
317
  anchor: hover.anchor,
@@ -352,9 +376,12 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
352
376
  if (state.kind === 'distance' && state.points.length >= 2) return false
353
377
  if (state.kind === 'angle' && state.points.length >= 3) return false
354
378
 
355
- const points = [...state.points, clonePoint(point)]
356
- const anchors = [...state.anchors, anchor ?? null]
357
379
  const polygon = state.kind === 'area' || state.kind === 'perimeter' || state.kind === 'volume'
380
+ const projectedPoint = polygon
381
+ ? projectPointToPlane(point, state.collectionPlane)
382
+ : clonePoint(point)
383
+ const points = [...state.points, projectedPoint]
384
+ const anchors = [...state.anchors, anchorWithFallback(anchor, projectedPoint)]
358
385
  const planeNormal = polygon && state.points.length === 0 ? normalizePoint(surfaceNormal) : null
359
386
  const ready =
360
387
  (state.kind === 'distance' && points.length === 2) ||
@@ -365,7 +392,7 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
365
392
  points,
366
393
  anchors,
367
394
  collectionPlane: planeNormal
368
- ? { point: clonePoint(point), normal: planeNormal }
395
+ ? { point: clonePoint(projectedPoint), normal: planeNormal }
369
396
  : state.collectionPlane,
370
397
  stage: ready ? 'ready' : 'collecting',
371
398
  hover: null,
@@ -0,0 +1,90 @@
1
+ import type { AnyNode } from '@pascal-app/core'
2
+ import { create } from 'zustand'
3
+
4
+ export type PathDraftKind = 'duct-segment' | 'lineset' | 'liquid-line' | 'pipe-segment'
5
+ export type PathDraftPoint = [number, number, number]
6
+ export type PathDraftParameter = boolean | number | string
7
+ export type PathDraftParameters = Record<string, PathDraftParameter>
8
+
9
+ type PathDraftPreviewState = {
10
+ kind: PathDraftKind | null
11
+ points: PathDraftPoint[]
12
+ cursor: PathDraftPoint | null
13
+ parameters: PathDraftParameters
14
+ relatedNodes: AnyNode[]
15
+ setDraft(
16
+ kind: PathDraftKind,
17
+ points: readonly PathDraftPoint[],
18
+ cursor: PathDraftPoint | null,
19
+ parameters?: Readonly<PathDraftParameters>,
20
+ relatedNodes?: readonly AnyNode[],
21
+ ): void
22
+ clear(kind: PathDraftKind): void
23
+ reset(): void
24
+ }
25
+
26
+ function pointEquals(a: PathDraftPoint | null, b: PathDraftPoint | null) {
27
+ return (!a && !b) || Boolean(a && b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2])
28
+ }
29
+
30
+ function pointsEqual(a: readonly PathDraftPoint[], b: readonly PathDraftPoint[]) {
31
+ return a.length === b.length && a.every((point, index) => pointEquals(point, b[index] ?? null))
32
+ }
33
+
34
+ function parametersEqual(a: PathDraftParameters, b: Readonly<PathDraftParameters>) {
35
+ const keys = Object.keys(a)
36
+ const nextKeys = Object.keys(b)
37
+ return keys.length === nextKeys.length && keys.every((key) => a[key] === b[key])
38
+ }
39
+
40
+ function relatedNodesEqual(a: readonly AnyNode[], b: readonly AnyNode[]) {
41
+ return (
42
+ a.length === b.length &&
43
+ a.every((node, index) => JSON.stringify(node) === JSON.stringify(b[index]))
44
+ )
45
+ }
46
+
47
+ const EMPTY_DRAFT: Pick<
48
+ PathDraftPreviewState,
49
+ 'cursor' | 'kind' | 'parameters' | 'points' | 'relatedNodes'
50
+ > = {
51
+ cursor: null,
52
+ kind: null,
53
+ parameters: {},
54
+ points: [],
55
+ relatedNodes: [],
56
+ }
57
+
58
+ export const usePathDraftPreview = create<PathDraftPreviewState>((set) => ({
59
+ ...EMPTY_DRAFT,
60
+ setDraft: (kind, points, cursor, parameters = {}, relatedNodes = []) =>
61
+ set((state) => {
62
+ if (
63
+ state.kind === kind &&
64
+ pointEquals(state.cursor, cursor) &&
65
+ pointsEqual(state.points, points) &&
66
+ parametersEqual(state.parameters, parameters) &&
67
+ relatedNodesEqual(state.relatedNodes, relatedNodes)
68
+ ) {
69
+ return state
70
+ }
71
+ return {
72
+ cursor: cursor ? [...cursor] : null,
73
+ kind,
74
+ parameters: { ...parameters },
75
+ points: points.map((point) => [...point]),
76
+ relatedNodes: relatedNodes.map((node) => structuredClone(node)),
77
+ }
78
+ }),
79
+ clear: (kind) => set((state) => (state.kind === kind ? EMPTY_DRAFT : state)),
80
+ reset: () =>
81
+ set((state) =>
82
+ state.kind === null &&
83
+ state.points.length === 0 &&
84
+ state.cursor === null &&
85
+ Object.keys(state.parameters).length === 0 &&
86
+ state.relatedNodes.length === 0
87
+ ? state
88
+ : EMPTY_DRAFT,
89
+ ),
90
+ }))
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { create } from 'zustand'
12
12
 
13
- type StairPreviewPoint = [number, number]
13
+ export type StairPreviewPoint = [number, number]
14
14
 
15
15
  type StairBuildPreviewState = {
16
16
  /** Snapped plan-XZ point the ghost staircase sits at; `null` when idle. */
@@ -21,6 +21,7 @@ type StairBuildPreviewState = {
21
21
  * don't re-render) when the point is unchanged — `grid:move` fires far more
22
22
  * often than the snapped cell actually changes. */
23
23
  setPoint(point: StairPreviewPoint | null): void
24
+ setPreview(point: StairPreviewPoint | null, rotation: number): void
24
25
  rotateBy(deltaRadians: number): void
25
26
  reset(): void
26
27
  }
@@ -33,7 +34,16 @@ export const useStairBuildPreview = create<StairBuildPreviewState>((set) => ({
33
34
  const prev = state.point
34
35
  if (!point && !prev) return state
35
36
  if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
36
- return { point }
37
+ return { point: point ? [point[0], point[1]] : null }
38
+ }),
39
+ setPreview: (point, rotation) =>
40
+ set((state) => {
41
+ const samePoint =
42
+ (!point && !state.point) ||
43
+ Boolean(point && state.point && state.point[0] === point[0] && state.point[1] === point[1])
44
+ return samePoint && state.rotation === rotation
45
+ ? state
46
+ : { point: point ? [point[0], point[1]] : null, rotation }
37
47
  }),
38
48
  rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })),
39
49
  reset: () =>