@pascal-app/editor 0.9.1 → 0.9.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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,543 @@
1
+ 'use client'
2
+
3
+ import {
4
+ areMeasurementPointsCoplanar,
5
+ MEASUREMENT_PLANAR_TOLERANCE,
6
+ type MeasurementAnchor,
7
+ type MeasurementFeatureAnchor,
8
+ MeasurementNode,
9
+ type MeasurementSnapKind,
10
+ measurementNormal,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { create } from 'zustand'
15
+
16
+ export type MeasurementKind = 'distance' | 'angle' | 'area' | 'perimeter' | 'volume'
17
+ export type MeasurementDraftOwner = '2d' | '3d'
18
+ export type MeasurementDraftStage = 'collecting' | 'extruding' | 'ready'
19
+ export type MeasurementAxis = 'x' | 'y' | 'z'
20
+ export type MeasurementPoint = [number, number, number]
21
+
22
+ export type MeasurementAxisGuide = {
23
+ axis: MeasurementAxis
24
+ from: MeasurementPoint
25
+ to: MeasurementPoint
26
+ snapped: boolean
27
+ proximity?: boolean
28
+ }
29
+
30
+ export type MeasurementSurfacePoint = {
31
+ point: MeasurementPoint
32
+ normal: MeasurementPoint
33
+ targetNodeId: string | null
34
+ anchor?: MeasurementFeatureAnchor
35
+ semantic?: {
36
+ label: string
37
+ length: number | null
38
+ snapKind: MeasurementSnapKind
39
+ }
40
+ }
41
+
42
+ export type MeasurementVertexDrag = {
43
+ owner: MeasurementDraftOwner
44
+ index: number
45
+ originalPoint: MeasurementPoint
46
+ originalAnchor: MeasurementFeatureAnchor | null
47
+ inserted: boolean
48
+ }
49
+
50
+ export type MeasurementDraftPayload =
51
+ | { kind: 'distance'; points: [MeasurementAnchor, MeasurementAnchor] }
52
+ | { kind: 'angle'; points: [MeasurementAnchor, MeasurementAnchor, MeasurementAnchor] }
53
+ | { kind: 'area'; base: MeasurementAnchor[] }
54
+ | { kind: 'perimeter'; base: MeasurementAnchor[] }
55
+ | { kind: 'volume'; base: MeasurementAnchor[]; extrusion: MeasurementPoint }
56
+
57
+ type MeasurementDraftState = {
58
+ kind: MeasurementKind
59
+ owner: MeasurementDraftOwner | null
60
+ levelId: string | null
61
+ stage: MeasurementDraftStage
62
+ points: MeasurementPoint[]
63
+ anchors: Array<MeasurementFeatureAnchor | null>
64
+ hover: MeasurementSurfacePoint | null
65
+ hoverOwner: MeasurementDraftOwner | null
66
+ axisGuide: MeasurementAxisGuide | null
67
+ vertexDrag: MeasurementVertexDrag | null
68
+ collectionPlane: { point: MeasurementPoint; normal: MeasurementPoint } | null
69
+ baseNormal: MeasurementPoint | null
70
+ extrusionHeight: number
71
+ error: string | null
72
+ setKind(kind: MeasurementKind): void
73
+ setHover(
74
+ owner: MeasurementDraftOwner,
75
+ hover: MeasurementSurfacePoint | null,
76
+ axisGuide?: MeasurementAxisGuide | null,
77
+ ): void
78
+ beginVertexDrag(owner: MeasurementDraftOwner, index: number): boolean
79
+ beginMidpointVertexDrag(owner: MeasurementDraftOwner, edgeIndex: number): boolean
80
+ updateDraggedVertex(
81
+ owner: MeasurementDraftOwner,
82
+ hover: MeasurementSurfacePoint,
83
+ axisGuide?: MeasurementAxisGuide | null,
84
+ ): boolean
85
+ finishVertexDrag(owner: MeasurementDraftOwner): boolean
86
+ cancelVertexDrag(owner: MeasurementDraftOwner): boolean
87
+ addPoint(
88
+ owner: MeasurementDraftOwner,
89
+ point: MeasurementPoint,
90
+ anchor?: MeasurementFeatureAnchor,
91
+ surfaceNormal?: MeasurementPoint,
92
+ ): boolean
93
+ closeBase(owner: MeasurementDraftOwner, preferredNormal?: MeasurementPoint): boolean
94
+ setExtrusionHeight(owner: MeasurementDraftOwner, height: number): boolean
95
+ finishExtrusion(owner: MeasurementDraftOwner): boolean
96
+ removeLast(owner: MeasurementDraftOwner): boolean
97
+ getCommitPayload(owner: MeasurementDraftOwner): MeasurementDraftPayload | null
98
+ reset(): void
99
+ }
100
+
101
+ const MIN_EXTRUSION = 0.001
102
+
103
+ const clonePoint = (point: MeasurementPoint): MeasurementPoint => [...point]
104
+
105
+ function normalizePoint(point: MeasurementPoint | undefined): MeasurementPoint | null {
106
+ if (!point?.every(Number.isFinite)) return null
107
+ const length = Math.hypot(...point)
108
+ return length > 1e-9 ? [point[0] / length, point[1] / length, point[2] / length] : null
109
+ }
110
+
111
+ export function measurementPolygonMidpoints(
112
+ points: readonly MeasurementPoint[],
113
+ ): Array<{ edgeIndex: number; point: MeasurementPoint }> {
114
+ if (points.length < 3) return []
115
+ return points.map((start, edgeIndex) => {
116
+ const end = points[(edgeIndex + 1) % points.length]!
117
+ return {
118
+ edgeIndex,
119
+ point: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2, (start[2] + end[2]) / 2],
120
+ }
121
+ })
122
+ }
123
+
124
+ function idleState(kind: MeasurementKind) {
125
+ return {
126
+ kind,
127
+ owner: null,
128
+ levelId: null,
129
+ stage: 'collecting' as const,
130
+ points: [],
131
+ anchors: [],
132
+ hover: null,
133
+ hoverOwner: null,
134
+ axisGuide: null,
135
+ vertexDrag: null,
136
+ collectionPlane: null,
137
+ baseNormal: null,
138
+ extrusionHeight: 0,
139
+ error: null,
140
+ }
141
+ }
142
+
143
+ function payloadFor(state: MeasurementDraftState): MeasurementDraftPayload | null {
144
+ if (state.stage !== 'ready') return null
145
+
146
+ const anchorAt = (index: number): MeasurementAnchor =>
147
+ state.anchors[index] ?? clonePoint(state.points[index]!)
148
+
149
+ if (state.kind === 'distance') {
150
+ const [start, end] = state.points
151
+ if (!(start && end)) return null
152
+ return { kind: 'distance', points: [anchorAt(0), anchorAt(1)] }
153
+ }
154
+
155
+ if (state.kind === 'angle') {
156
+ if (state.points.length !== 3) return null
157
+ return { kind: 'angle', points: [anchorAt(0), anchorAt(1), anchorAt(2)] }
158
+ }
159
+
160
+ if (state.kind === 'area' || state.kind === 'perimeter') {
161
+ if (state.points.length < 3) return null
162
+ return { kind: state.kind, base: state.points.map((_, index) => anchorAt(index)) }
163
+ }
164
+
165
+ if (!(state.baseNormal && state.points.length >= 3)) return null
166
+ return {
167
+ kind: 'volume',
168
+ base: state.points.map((_, index) => anchorAt(index)),
169
+ extrusion: [
170
+ state.baseNormal[0] * state.extrusionHeight,
171
+ state.baseNormal[1] * state.extrusionHeight,
172
+ state.baseNormal[2] * state.extrusionHeight,
173
+ ],
174
+ }
175
+ }
176
+
177
+ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => ({
178
+ ...idleState('distance'),
179
+
180
+ setKind: (kind) => set((state) => (state.kind === kind ? state : { ...idleState(kind) })),
181
+
182
+ setHover: (owner, hover, axisGuide = null) =>
183
+ set((state) => {
184
+ if (state.owner && state.owner !== owner) return state
185
+ if (state.levelId && state.levelId !== useViewer.getState().selection.levelId) return state
186
+ if (state.vertexDrag) return state
187
+ if (!hover && !state.hover && !state.axisGuide) return state
188
+ return { hover, hoverOwner: hover ? owner : null, axisGuide }
189
+ }),
190
+
191
+ beginVertexDrag: (owner, index) => {
192
+ const state = get()
193
+ const point = state.points[index]
194
+ if (
195
+ state.owner !== owner ||
196
+ state.levelId !== useViewer.getState().selection.levelId ||
197
+ state.stage !== 'collecting' ||
198
+ state.vertexDrag ||
199
+ !Number.isInteger(index) ||
200
+ !point
201
+ ) {
202
+ return false
203
+ }
204
+
205
+ set({
206
+ vertexDrag: {
207
+ owner,
208
+ index,
209
+ originalPoint: clonePoint(point),
210
+ originalAnchor: state.anchors[index] ?? null,
211
+ inserted: false,
212
+ },
213
+ hover: null,
214
+ hoverOwner: null,
215
+ axisGuide: null,
216
+ error: null,
217
+ })
218
+ return true
219
+ },
220
+
221
+ beginMidpointVertexDrag: (owner, edgeIndex) => {
222
+ const state = get()
223
+ if (
224
+ state.owner !== owner ||
225
+ state.levelId !== useViewer.getState().selection.levelId ||
226
+ state.stage !== 'collecting' ||
227
+ state.vertexDrag ||
228
+ state.kind === 'distance' ||
229
+ state.kind === 'angle' ||
230
+ state.points.length < 3 ||
231
+ !Number.isInteger(edgeIndex) ||
232
+ edgeIndex < 0 ||
233
+ edgeIndex >= state.points.length
234
+ ) {
235
+ return false
236
+ }
237
+
238
+ const start = state.points[edgeIndex]!
239
+ const end = state.points[(edgeIndex + 1) % state.points.length]!
240
+ const midpoint: MeasurementPoint = [
241
+ (start[0] + end[0]) / 2,
242
+ (start[1] + end[1]) / 2,
243
+ (start[2] + end[2]) / 2,
244
+ ]
245
+ const index = edgeIndex + 1
246
+ const points = [...state.points.slice(0, index), midpoint, ...state.points.slice(index)]
247
+ const anchors = [...state.anchors.slice(0, index), null, ...state.anchors.slice(index)]
248
+
249
+ set({
250
+ points,
251
+ anchors,
252
+ vertexDrag: {
253
+ owner,
254
+ index,
255
+ originalPoint: clonePoint(midpoint),
256
+ originalAnchor: null,
257
+ inserted: true,
258
+ },
259
+ hover: null,
260
+ hoverOwner: null,
261
+ axisGuide: null,
262
+ error: null,
263
+ })
264
+ return true
265
+ },
266
+
267
+ updateDraggedVertex: (owner, hover, axisGuide = null) => {
268
+ const state = get()
269
+ const drag = state.vertexDrag
270
+ if (
271
+ !drag ||
272
+ drag.owner !== owner ||
273
+ state.levelId !== useViewer.getState().selection.levelId ||
274
+ state.stage !== 'collecting' ||
275
+ ![...hover.point, ...hover.normal].every(Number.isFinite)
276
+ ) {
277
+ return false
278
+ }
279
+
280
+ const points = state.points.map((point, index) =>
281
+ index === drag.index ? clonePoint(hover.point) : point,
282
+ )
283
+ const anchors = state.anchors.map((anchor, index) =>
284
+ index === drag.index ? (hover.anchor ?? null) : anchor,
285
+ )
286
+ set({
287
+ points,
288
+ anchors,
289
+ hover: {
290
+ point: clonePoint(hover.point),
291
+ normal: clonePoint(hover.normal),
292
+ targetNodeId: hover.targetNodeId,
293
+ anchor: hover.anchor,
294
+ semantic: hover.semantic,
295
+ },
296
+ hoverOwner: owner,
297
+ axisGuide: axisGuide
298
+ ? {
299
+ axis: axisGuide.axis,
300
+ from: clonePoint(axisGuide.from),
301
+ to: clonePoint(axisGuide.to),
302
+ snapped: axisGuide.snapped,
303
+ proximity: axisGuide.proximity,
304
+ }
305
+ : null,
306
+ error: null,
307
+ })
308
+ return true
309
+ },
310
+
311
+ finishVertexDrag: (owner) => {
312
+ const drag = get().vertexDrag
313
+ if (!drag || drag.owner !== owner) return false
314
+ set({ vertexDrag: null, hover: null, hoverOwner: null, axisGuide: null, error: null })
315
+ return true
316
+ },
317
+
318
+ cancelVertexDrag: (owner) => {
319
+ const state = get()
320
+ const drag = state.vertexDrag
321
+ if (!drag || drag.owner !== owner) return false
322
+ const points = drag.inserted
323
+ ? state.points.filter((_, index) => index !== drag.index)
324
+ : state.points.map((point, index) =>
325
+ index === drag.index ? clonePoint(drag.originalPoint) : point,
326
+ )
327
+ const anchors = drag.inserted
328
+ ? state.anchors.filter((_, index) => index !== drag.index)
329
+ : state.anchors.map((anchor, index) => (index === drag.index ? drag.originalAnchor : anchor))
330
+ set({
331
+ points,
332
+ anchors,
333
+ vertexDrag: null,
334
+ hover: null,
335
+ hoverOwner: null,
336
+ axisGuide: null,
337
+ error: null,
338
+ })
339
+ return true
340
+ },
341
+
342
+ addPoint: (owner, point, anchor, surfaceNormal) => {
343
+ const state = get()
344
+ const activeLevelId = useViewer.getState().selection.levelId
345
+ if (!activeLevelId) return false
346
+ if (state.stage !== 'collecting' || state.vertexDrag || (state.owner && state.owner !== owner))
347
+ return false
348
+ if (state.levelId && state.levelId !== activeLevelId) {
349
+ set({ error: 'The active level changed. Start a new measurement.' })
350
+ return false
351
+ }
352
+ if (state.kind === 'distance' && state.points.length >= 2) return false
353
+ if (state.kind === 'angle' && state.points.length >= 3) return false
354
+
355
+ const points = [...state.points, clonePoint(point)]
356
+ const anchors = [...state.anchors, anchor ?? null]
357
+ const polygon = state.kind === 'area' || state.kind === 'perimeter' || state.kind === 'volume'
358
+ const planeNormal = polygon && state.points.length === 0 ? normalizePoint(surfaceNormal) : null
359
+ const ready =
360
+ (state.kind === 'distance' && points.length === 2) ||
361
+ (state.kind === 'angle' && points.length === 3)
362
+ set({
363
+ owner,
364
+ levelId: state.levelId ?? activeLevelId,
365
+ points,
366
+ anchors,
367
+ collectionPlane: planeNormal
368
+ ? { point: clonePoint(point), normal: planeNormal }
369
+ : state.collectionPlane,
370
+ stage: ready ? 'ready' : 'collecting',
371
+ hover: null,
372
+ hoverOwner: null,
373
+ axisGuide: null,
374
+ error: null,
375
+ })
376
+ return true
377
+ },
378
+
379
+ closeBase: (owner, preferredNormal) => {
380
+ const state = get()
381
+ if (
382
+ state.owner !== owner ||
383
+ state.levelId !== useViewer.getState().selection.levelId ||
384
+ state.stage !== 'collecting' ||
385
+ state.vertexDrag ||
386
+ state.kind === 'distance' ||
387
+ state.kind === 'angle' ||
388
+ state.points.length < 3
389
+ ) {
390
+ return false
391
+ }
392
+
393
+ if (!areMeasurementPointsCoplanar(state.points, MEASUREMENT_PLANAR_TOLERANCE)) {
394
+ set({ error: 'Measurement points must be on one plane.' })
395
+ return false
396
+ }
397
+
398
+ let normal = measurementNormal(state.points)
399
+ if (!normal) {
400
+ set({ error: 'Measurement points must enclose an area.' })
401
+ return false
402
+ }
403
+ if (
404
+ preferredNormal &&
405
+ normal[0] * preferredNormal[0] +
406
+ normal[1] * preferredNormal[1] +
407
+ normal[2] * preferredNormal[2] <
408
+ 0
409
+ ) {
410
+ normal = [
411
+ normal[0] === 0 ? 0 : -normal[0],
412
+ normal[1] === 0 ? 0 : -normal[1],
413
+ normal[2] === 0 ? 0 : -normal[2],
414
+ ]
415
+ }
416
+
417
+ set({
418
+ baseNormal: clonePoint(normal),
419
+ stage: state.kind === 'volume' ? 'extruding' : 'ready',
420
+ hover: null,
421
+ hoverOwner: null,
422
+ axisGuide: null,
423
+ error: null,
424
+ })
425
+ return true
426
+ },
427
+
428
+ setExtrusionHeight: (owner, height) => {
429
+ const state = get()
430
+ if (
431
+ state.owner !== owner ||
432
+ state.levelId !== useViewer.getState().selection.levelId ||
433
+ state.stage !== 'extruding' ||
434
+ !Number.isFinite(height)
435
+ ) {
436
+ return false
437
+ }
438
+ set({ extrusionHeight: height, error: null })
439
+ return true
440
+ },
441
+
442
+ finishExtrusion: (owner) => {
443
+ const state = get()
444
+ if (
445
+ state.owner !== owner ||
446
+ state.levelId !== useViewer.getState().selection.levelId ||
447
+ state.stage !== 'extruding' ||
448
+ Math.abs(state.extrusionHeight) < MIN_EXTRUSION
449
+ ) {
450
+ return false
451
+ }
452
+ set({ stage: 'ready', hover: null, hoverOwner: null, axisGuide: null, error: null })
453
+ return true
454
+ },
455
+
456
+ removeLast: (owner) => {
457
+ const state = get()
458
+ if (
459
+ state.owner !== owner ||
460
+ state.levelId !== useViewer.getState().selection.levelId ||
461
+ state.vertexDrag ||
462
+ state.points.length === 0
463
+ ) {
464
+ return false
465
+ }
466
+
467
+ const points = state.points.slice(0, -1)
468
+ const anchors = state.anchors.slice(0, -1)
469
+ set({
470
+ owner: points.length > 0 ? owner : null,
471
+ levelId: points.length > 0 ? state.levelId : null,
472
+ stage: 'collecting',
473
+ points,
474
+ anchors,
475
+ hover: null,
476
+ hoverOwner: null,
477
+ axisGuide: null,
478
+ collectionPlane: points.length > 0 ? state.collectionPlane : null,
479
+ baseNormal: null,
480
+ extrusionHeight: 0,
481
+ error: null,
482
+ })
483
+ return true
484
+ },
485
+
486
+ getCommitPayload: (owner) => {
487
+ const state = get()
488
+ return state.owner === owner && state.levelId === useViewer.getState().selection.levelId
489
+ ? payloadFor(state)
490
+ : null
491
+ },
492
+
493
+ reset: () => set((state) => ({ ...idleState(state.kind) })),
494
+ }))
495
+
496
+ export function commitMeasurementDraft(owner: MeasurementDraftOwner): MeasurementNode['id'] | null {
497
+ const draft = useMeasurementDraft.getState()
498
+ const levelId = draft.levelId
499
+ if (!levelId || useViewer.getState().selection.levelId !== levelId) {
500
+ draft.reset()
501
+ return null
502
+ }
503
+ const measurement = draft.getCommitPayload(owner)
504
+ if (!measurement) return null
505
+
506
+ const { createNode, nodes } = useScene.getState()
507
+ const count = Object.values(nodes).filter((node) => node.type === 'measurement').length
508
+ const node = MeasurementNode.parse({ name: `Measurement ${count + 1}`, measurement })
509
+ createNode(node, levelId)
510
+ draft.reset()
511
+ return node.id
512
+ }
513
+
514
+ export function finishMeasurementDraft(
515
+ owner: MeasurementDraftOwner,
516
+ preferredNormal?: MeasurementPoint,
517
+ ): boolean {
518
+ const draft = useMeasurementDraft.getState()
519
+ if (draft.stage === 'collecting') {
520
+ if (draft.kind === 'distance' || draft.kind === 'angle') return false
521
+ if (!draft.closeBase(owner, preferredNormal)) return false
522
+ if (draft.kind === 'area' || draft.kind === 'perimeter') {
523
+ return commitMeasurementDraft(owner) !== null
524
+ }
525
+ return true
526
+ }
527
+ if (draft.stage === 'extruding') {
528
+ if (!draft.finishExtrusion(owner)) return false
529
+ }
530
+ return commitMeasurementDraft(owner) !== null
531
+ }
532
+
533
+ export function handleMeasurementDraftEscape(
534
+ owner: MeasurementDraftOwner,
535
+ preferredNormal?: MeasurementPoint,
536
+ ): boolean {
537
+ const draft = useMeasurementDraft.getState()
538
+ if (draft.owner !== owner) return false
539
+
540
+ const preserveArea = draft.kind === 'area' && draft.points.length >= 3
541
+ if (!finishMeasurementDraft(owner, preferredNormal) && !preserveArea) draft.reset()
542
+ return true
543
+ }
@@ -0,0 +1,41 @@
1
+ // Ephemeral store for the 3D opening proximity/alignment guides published by the
2
+ // door/window move + placement tools during a drag — the wall-plane counterpart
3
+ // of `useAlignmentGuides` (which only carries floor-plane XZ guides). Guides are
4
+ // already transformed into the move tool's render frame — the same building-local
5
+ // frame as the drag cursor (ToolManager's group) — so the renderer stays dumb.
6
+ // Producers clear on commit, cancel, leave, and unmount.
7
+
8
+ import { create } from 'zustand'
9
+
10
+ export type OpeningGuideVec3 = [number, number, number]
11
+
12
+ // A stable identity per guide slot (`sill`, `head`, `gap:left`, `vertical`,
13
+ // `spacing:0`, …) so the renderer can key by semantic role: as the guide set
14
+ // churns each drag tick, a slot that persists keeps its React element — and its
15
+ // drei `<Html>` portal — mounted instead of remounting when the list shape
16
+ // shifts under index keys.
17
+ export type OpeningGuide3D =
18
+ // A measured line + distance pill: sill (floor → bottom edge), head (top edge
19
+ // → wall top), or along-wall edge-to-edge proximity.
20
+ | { kind: 'dimension'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number }
21
+ // A dashed line connecting two openings that share a sill / centre / top.
22
+ | { kind: 'align-line'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3 }
23
+ // A Figma-style "=" badge marking one gap in an equal-spacing run.
24
+ | { kind: 'badge'; id: string; at: OpeningGuideVec3; value: number }
25
+
26
+ type OpeningGuidesState = {
27
+ guides: OpeningGuide3D[]
28
+ set(guides: OpeningGuide3D[]): void
29
+ clear(): void
30
+ }
31
+
32
+ const useOpeningGuides = create<OpeningGuidesState>((set) => ({
33
+ guides: [],
34
+ set: (guides) => set({ guides }),
35
+ // No-op when already empty so the common no-guide hover frame (fallback
36
+ // cursor, invalid target, roof hover) doesn't push a fresh `[]` and notify
37
+ // subscribers — the layer would re-render to the same nothing every tick.
38
+ clear: () => set((s) => (s.guides.length > 0 ? { guides: [] } : s)),
39
+ }))
40
+
41
+ export default useOpeningGuides
@@ -18,14 +18,22 @@ type PlacementPreviewState = {
18
18
  /** Transient preview node, already positioned + rotated at the (snapped,
19
19
  * aligned) cursor. `null` when no placement is active. */
20
20
  node: AnyNode | null
21
- set(node: AnyNode | null): void
21
+ /** Optional synthetic parent for the preview's `def.floorplan` context.
22
+ * Door / window glyph builders need `ctx.parent` to be a wall to draw their
23
+ * real symbol (swing arc / panes); off any real wall we hand them a
24
+ * synthetic wall segment centred at the cursor so the floating ghost shows
25
+ * the faithful blueprint symbol instead of a bare rectangle. `null` for
26
+ * self-contained kinds (column / elevator). */
27
+ parentNode: AnyNode | null
28
+ set(node: AnyNode | null, parentNode?: AnyNode | null): void
22
29
  clear(): void
23
30
  }
24
31
 
25
32
  const usePlacementPreview = create<PlacementPreviewState>((set) => ({
26
33
  node: null,
27
- set: (node) => set({ node }),
28
- clear: () => set({ node: null }),
34
+ parentNode: null,
35
+ set: (node, parentNode = null) => set({ node, parentNode }),
36
+ clear: () => set({ node: null, parentNode: null }),
29
37
  }))
30
38
 
31
39
  export default usePlacementPreview
@@ -0,0 +1,53 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import type { QuickMeasurementReport } from '@pascal-app/core'
3
+ import { selectQuickMeasurementHudEntry, useQuickMeasurementHud } from './use-quick-measurement-hud'
4
+
5
+ const report = (title: string): QuickMeasurementReport => ({
6
+ title,
7
+ kindLabel: 'Wall',
8
+ anchor: [0, 0, 0],
9
+ metrics: [],
10
+ })
11
+
12
+ afterEach(() => {
13
+ useQuickMeasurementHud.setState({
14
+ activeSource: null,
15
+ sources: { '2d': null, '3d': null },
16
+ })
17
+ })
18
+
19
+ describe('quick measurement HUD ownership', () => {
20
+ test('uses one active source across split view while single views stay pinned to their pane', () => {
21
+ const twoDimensional = { lensState: 'live' as const, report: report('2D wall') }
22
+ const threeDimensional = { lensState: 'pinned' as const, report: report('3D wall') }
23
+ const store = useQuickMeasurementHud.getState()
24
+ store.publish('2d', twoDimensional)
25
+ store.publish('3d', threeDimensional)
26
+
27
+ expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), 'split')).toBe(
28
+ twoDimensional,
29
+ )
30
+ store.activate('3d')
31
+ expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), 'split')).toBe(
32
+ threeDimensional,
33
+ )
34
+ expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), '2d')).toBe(
35
+ twoDimensional,
36
+ )
37
+ expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), '3d')).toBe(
38
+ threeDimensional,
39
+ )
40
+ })
41
+
42
+ test('falls back to the sibling only when the active source unmounts', () => {
43
+ const store = useQuickMeasurementHud.getState()
44
+ store.publish('2d', { lensState: 'pinned', report: report('2D wall') })
45
+ store.publish('3d', { lensState: 'live', report: report('3D wall') })
46
+ store.activate('3d')
47
+ store.clear('3d')
48
+
49
+ const state = useQuickMeasurementHud.getState()
50
+ expect(state.activeSource).toBe('2d')
51
+ expect(selectQuickMeasurementHudEntry(state, 'split')?.report.title).toBe('2D wall')
52
+ })
53
+ })