@pascal-app/editor 0.9.1 → 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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -38,7 +38,7 @@ export function FloatingBuildingActionMenu() {
38
38
  // Read lazily at click time — no need to subscribe to nodes for a
39
39
  // one-shot action.
40
40
  const node = useScene.getState().nodes[buildingId]
41
- if (!node || node.type !== 'building') return
41
+ if (node?.type !== 'building') return
42
42
  sfxEmitter.emit('sfx:item-pick')
43
43
  setMovingNode(node as BuildingNode)
44
44
  setSelection({ buildingId: null })
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
3
+
4
+ const baseArgs = {
5
+ canSelectElementFloorplanGeometry: true,
6
+ canSelectFloorplanZones: false,
7
+ currentSelectedIds: ['wall_1'],
8
+ getFloorplanHitIdAtPoint: () => 'door_1',
9
+ isWallBuildActive: false,
10
+ modifierKeys: { meta: false, ctrl: false, shift: false },
11
+ planPoint: [0, 0] as [number, number],
12
+ structureLayer: 'elements',
13
+ }
14
+
15
+ describe('resolveFloorplanBackgroundSelection', () => {
16
+ test('shift-click on a floorplan node toggles into the current selection', () => {
17
+ const result = resolveFloorplanBackgroundSelection({
18
+ ...baseArgs,
19
+ modifierKeys: { meta: false, ctrl: false, shift: true },
20
+ })
21
+
22
+ expect(result).toEqual({
23
+ handled: true,
24
+ kind: 'select-elements',
25
+ selectedIds: ['wall_1', 'door_1'],
26
+ })
27
+ })
28
+
29
+ test('shift-click on selected floorplan node toggles it out', () => {
30
+ const result = resolveFloorplanBackgroundSelection({
31
+ ...baseArgs,
32
+ currentSelectedIds: ['wall_1', 'door_1'],
33
+ modifierKeys: { meta: false, ctrl: false, shift: true },
34
+ })
35
+
36
+ expect(result).toEqual({
37
+ handled: true,
38
+ kind: 'select-elements',
39
+ selectedIds: ['wall_1'],
40
+ })
41
+ })
42
+
43
+ test('shift-click on empty floorplan space preserves selection', () => {
44
+ const result = resolveFloorplanBackgroundSelection({
45
+ ...baseArgs,
46
+ getFloorplanHitIdAtPoint: () => null,
47
+ modifierKeys: { meta: false, ctrl: false, shift: true },
48
+ })
49
+
50
+ expect(result).toEqual({
51
+ handled: true,
52
+ kind: 'clear-elements',
53
+ preserveSelection: true,
54
+ })
55
+ })
56
+
57
+ test('uses the registry hit result for zone selection', () => {
58
+ const result = resolveFloorplanBackgroundSelection({
59
+ ...baseArgs,
60
+ canSelectElementFloorplanGeometry: false,
61
+ canSelectFloorplanZones: true,
62
+ getFloorplanHitIdAtPoint: () => 'zone_1',
63
+ structureLayer: 'zones',
64
+ })
65
+
66
+ expect(result).toEqual({
67
+ handled: true,
68
+ kind: 'select-zone',
69
+ zoneId: 'zone_1',
70
+ })
71
+ })
72
+ })
@@ -1,19 +1,12 @@
1
1
  'use client'
2
2
 
3
- import type { Point2D, ZoneNode as ZoneNodeType } from '@pascal-app/core'
4
- import { isPointInsidePolygon } from '../../lib/floorplan'
3
+ import type { ZoneNode as ZoneNodeType } from '@pascal-app/core'
5
4
  import type { WallPlanPoint } from '../tools/wall/wall-drafting'
6
5
 
7
6
  type ModifierKeys = {
8
7
  meta: boolean
9
8
  ctrl: boolean
10
- }
11
-
12
- type ZoneHitEntry = {
13
- zone: {
14
- id: ZoneNodeType['id']
15
- }
16
- polygon: Point2D[]
9
+ shift: boolean
17
10
  }
18
11
 
19
12
  type ResolveFloorplanBackgroundSelectionArgs = {
@@ -25,8 +18,6 @@ type ResolveFloorplanBackgroundSelectionArgs = {
25
18
  modifierKeys: ModifierKeys
26
19
  planPoint: WallPlanPoint
27
20
  structureLayer: string
28
- toPoint2D: (point: WallPlanPoint) => Point2D
29
- visibleZonePolygons: ZoneHitEntry[]
30
21
  }
31
22
 
32
23
  export type FloorplanBackgroundSelectionResult =
@@ -62,18 +53,14 @@ export function resolveFloorplanBackgroundSelection({
62
53
  modifierKeys,
63
54
  planPoint,
64
55
  structureLayer,
65
- toPoint2D,
66
- visibleZonePolygons,
67
56
  }: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
68
57
  if (canSelectFloorplanZones) {
69
- const zoneHit = visibleZonePolygons.find(({ polygon }) =>
70
- isPointInsidePolygon(toPoint2D(planPoint), polygon),
71
- )
72
- if (zoneHit) {
58
+ const zoneId = getFloorplanHitIdAtPoint(planPoint)
59
+ if (zoneId) {
73
60
  return {
74
61
  handled: true,
75
62
  kind: 'select-zone',
76
- zoneId: zoneHit.zone.id,
63
+ zoneId: zoneId as ZoneNodeType['id'],
77
64
  }
78
65
  }
79
66
  }
@@ -85,7 +72,7 @@ export function resolveFloorplanBackgroundSelection({
85
72
  handled: true,
86
73
  kind: 'select-elements',
87
74
  selectedIds:
88
- modifierKeys.meta || modifierKeys.ctrl
75
+ modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
89
76
  ? currentSelectedIds.includes(hitId)
90
77
  ? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
91
78
  : [...currentSelectedIds, hitId]
@@ -105,7 +92,7 @@ export function resolveFloorplanBackgroundSelection({
105
92
  return {
106
93
  handled: true,
107
94
  kind: 'clear-elements',
108
- preserveSelection: modifierKeys.meta || modifierKeys.ctrl,
95
+ preserveSelection: modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift,
109
96
  }
110
97
  }
111
98
 
@@ -0,0 +1,249 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { CameraPose } from '@pascal-app/core'
3
+ import { publishInitialCameraPose } from '../../lib/camera-pose'
4
+ import type { NavigationSyncPose } from '../../store/use-editor'
5
+ import {
6
+ cameraPoseToFloorplanNavigationPose,
7
+ createFloorplanCameraNavigationChannel,
8
+ createFloorplanCameraSyncBridge,
9
+ floorplanNavigationPoseToCameraPose,
10
+ } from './floorplan-camera-sync'
11
+
12
+ const cameraPose: CameraPose = {
13
+ fov: 50,
14
+ position: [3, 4, 4],
15
+ projection: 'perspective',
16
+ target: [0, 1, 0],
17
+ viewWidth: 12,
18
+ }
19
+
20
+ function cameraPoseAtAzimuth(
21
+ azimuth: number,
22
+ target: [number, number, number] = [0, 1, 0],
23
+ ): CameraPose {
24
+ const horizontalDistance = 5
25
+ return {
26
+ ...cameraPose,
27
+ position: [
28
+ target[0] + Math.sin(azimuth) * horizontalDistance,
29
+ 4,
30
+ target[2] + Math.cos(azimuth) * horizontalDistance,
31
+ ],
32
+ target,
33
+ }
34
+ }
35
+
36
+ describe('floorplan camera sync', () => {
37
+ test('derives the floor-plan navigation pose from the generic camera pose', () => {
38
+ expect(
39
+ cameraPoseToFloorplanNavigationPose({
40
+ position: [10, 5, 0],
41
+ projection: 'perspective',
42
+ target: [0, 0, 0],
43
+ viewWidth: 20,
44
+ }),
45
+ ).toEqual({
46
+ source: '3d',
47
+ target: [0, 0, 0],
48
+ azimuth: Math.PI / 2,
49
+ viewWidth: 20,
50
+ })
51
+ })
52
+
53
+ test('applies a floor-plan pose while preserving the generic camera elevation and projection', () => {
54
+ expect(
55
+ floorplanNavigationPoseToCameraPose(
56
+ {
57
+ source: '2d',
58
+ revision: 4,
59
+ target: [10, 2, 20],
60
+ azimuth: Math.PI / 2,
61
+ viewWidth: 8,
62
+ },
63
+ cameraPose,
64
+ ),
65
+ ).toEqual({
66
+ fov: 50,
67
+ position: [15, 5, 20],
68
+ projection: 'perspective',
69
+ target: [10, 2, 20],
70
+ viewWidth: 8,
71
+ })
72
+ })
73
+
74
+ test('owns two-way synchronization without echoing tiny camera changes', () => {
75
+ const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
76
+ const applied: CameraPose[] = []
77
+ const bridge = createFloorplanCameraSyncBridge({
78
+ applyCameraPose: (pose) => applied.push(pose),
79
+ publishNavigationPose: (pose) => published.push(pose),
80
+ })
81
+
82
+ bridge.receiveCameraPose(cameraPose)
83
+ bridge.receiveCameraPose({
84
+ ...cameraPose,
85
+ position: [3.0001, 4, 4],
86
+ })
87
+ bridge.receiveNavigationPose({
88
+ source: '2d',
89
+ revision: 1,
90
+ target: [10, 2, 20],
91
+ azimuth: Math.PI / 2,
92
+ viewWidth: 8,
93
+ })
94
+
95
+ expect(published).toHaveLength(1)
96
+ expect(published[0]?.source).toBe('3d')
97
+ expect(applied).toHaveLength(1)
98
+ expect(applied[0]).toMatchObject({
99
+ fov: 50,
100
+ projection: 'perspective',
101
+ target: [10, 2, 20],
102
+ viewWidth: 8,
103
+ })
104
+ expect(applied[0]?.position[0]).toBeCloseTo(15)
105
+ expect(applied[0]?.position[1]).toBeCloseTo(5)
106
+ expect(applied[0]?.position[2]).toBeCloseTo(20)
107
+ })
108
+
109
+ test('publishes sub-degree camera rotation for a real-time compass', () => {
110
+ const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
111
+ const bridge = createFloorplanCameraSyncBridge({
112
+ applyCameraPose: () => {},
113
+ publishNavigationPose: (pose) => published.push(pose),
114
+ })
115
+
116
+ bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
117
+ bridge.receiveCameraPose(cameraPoseAtAzimuth((0.9 * Math.PI) / 180))
118
+ expect(published).toHaveLength(2)
119
+ expect(published[1]?.azimuth).toBeCloseTo((0.9 * Math.PI) / 180)
120
+ })
121
+
122
+ test('keeps a sub-degree camera rotation when it arrives with a pan', () => {
123
+ const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
124
+ const bridge = createFloorplanCameraSyncBridge({
125
+ applyCameraPose: () => {},
126
+ publishNavigationPose: (pose) => published.push(pose),
127
+ })
128
+
129
+ bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
130
+ bridge.receiveCameraPose(cameraPoseAtAzimuth((0.5 * Math.PI) / 180, [1, 1, 0]))
131
+
132
+ expect(published).toHaveLength(2)
133
+ expect(published[1]).toMatchObject({
134
+ target: [1, 1, 0],
135
+ })
136
+ expect(published[1]?.azimuth).toBeCloseTo((0.5 * Math.PI) / 180)
137
+ })
138
+
139
+ test('keeps streaming live camera headings in 3D-only mode', () => {
140
+ const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
141
+ const applied: CameraPose[] = []
142
+ const bridge = createFloorplanCameraSyncBridge({
143
+ active: false,
144
+ applyCameraPose: (pose) => applied.push(pose),
145
+ publishNavigationPose: (pose) => published.push(pose),
146
+ })
147
+ const latestPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
148
+
149
+ bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
150
+ bridge.receiveCameraPose(latestPose)
151
+ bridge.receiveNavigationPose({
152
+ source: '2d',
153
+ revision: 1,
154
+ target: [10, 2, 20],
155
+ azimuth: Math.PI / 2,
156
+ viewWidth: 8,
157
+ })
158
+
159
+ expect(published).toEqual([
160
+ {
161
+ source: '3d',
162
+ target: cameraPose.target,
163
+ azimuth: 0,
164
+ viewWidth: 12,
165
+ },
166
+ {
167
+ source: '3d',
168
+ target: latestPose.target,
169
+ azimuth: Math.PI / 2,
170
+ viewWidth: 12,
171
+ },
172
+ ])
173
+ expect(applied).toEqual([])
174
+
175
+ bridge.setActive(true)
176
+ expect(published).toHaveLength(2)
177
+ expect(applied).toEqual([])
178
+ })
179
+
180
+ test('applies the north-alignment pose used by the compass in 3D view', () => {
181
+ const applied: CameraPose[] = []
182
+ const bridge = createFloorplanCameraSyncBridge({
183
+ applyCameraPose: (pose) => applied.push(pose),
184
+ publishNavigationPose: () => {},
185
+ })
186
+ const currentPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
187
+
188
+ bridge.receiveCameraPose(currentPose)
189
+ bridge.receiveNavigationPose({
190
+ source: '2d',
191
+ revision: 1,
192
+ target: [...currentPose.target],
193
+ azimuth: 0,
194
+ viewWidth: currentPose.viewWidth!,
195
+ })
196
+
197
+ expect(applied).toHaveLength(1)
198
+ expect(applied[0]).toMatchObject({
199
+ target: currentPose.target,
200
+ projection: currentPose.projection,
201
+ viewWidth: currentPose.viewWidth,
202
+ })
203
+ expect(applied[0]?.position[0]).toBeCloseTo(currentPose.target[0])
204
+ expect(applied[0]?.position[1]).toBeCloseTo(currentPose.position[1])
205
+ expect(applied[0]?.position[2]).toBeCloseTo(currentPose.target[2] + 5)
206
+ })
207
+
208
+ test('applies 2D-first navigation on initial load without a 3D interaction', () => {
209
+ const applied: CameraPose[] = []
210
+ const bridge = createFloorplanCameraSyncBridge({
211
+ applyCameraPose: (pose) => applied.push(pose),
212
+ publishNavigationPose: () => {},
213
+ })
214
+
215
+ bridge.receiveNavigationPose({
216
+ source: '2d',
217
+ revision: 1,
218
+ target: [10, 2, 20],
219
+ azimuth: Math.PI / 2,
220
+ viewWidth: 8,
221
+ })
222
+ expect(applied).toEqual([])
223
+
224
+ publishInitialCameraPose(() => bridge.receiveCameraPose(cameraPose))
225
+ expect(applied).toHaveLength(1)
226
+ })
227
+
228
+ test('delivers the live camera stream through transient subscribers', () => {
229
+ const channel = createFloorplanCameraNavigationChannel()
230
+ const received: NavigationSyncPose[] = []
231
+ const unsubscribe = channel.subscribe((pose) => received.push(pose))
232
+ const input = {
233
+ source: '3d' as const,
234
+ target: [0, 1, 2] as [number, number, number],
235
+ azimuth: 0.5,
236
+ viewWidth: 12,
237
+ }
238
+
239
+ channel.publish(input)
240
+ channel.publish({ ...input, azimuth: 0.75 })
241
+ unsubscribe()
242
+ channel.publish({ ...input, azimuth: 1 })
243
+
244
+ expect(received).toEqual([
245
+ { ...input, revision: 1 },
246
+ { ...input, azimuth: 0.75, revision: 2 },
247
+ ])
248
+ })
249
+ })
@@ -0,0 +1,213 @@
1
+ 'use client'
2
+
3
+ import { type CameraPose, emitter } from '@pascal-app/core'
4
+ import { useEffect, useRef } from 'react'
5
+ import { subscribeCameraPose } from '../../store/camera-pose-store'
6
+ import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
7
+ import type { NavigationSyncPose, NavigationSyncPoseInput } from '../../store/use-editor'
8
+
9
+ const POSITION_EPSILON = 0.001
10
+ const AZIMUTH_EPSILON = 1e-4
11
+ const VIEW_WIDTH_EPSILON = 0.001
12
+
13
+ type FloorplanNavigationSnapshot = Omit<NavigationSyncPoseInput, 'source'>
14
+
15
+ function angleDeltaRadians(a: number, b: number) {
16
+ return Math.atan2(Math.sin(a - b), Math.cos(a - b))
17
+ }
18
+
19
+ function normalizeNearZero(value: number) {
20
+ return Math.abs(value) < Number.EPSILON * 10 ? 0 : value
21
+ }
22
+
23
+ function navigationSnapshot(pose: NavigationSyncPoseInput): FloorplanNavigationSnapshot {
24
+ return {
25
+ target: [...pose.target],
26
+ azimuth: pose.azimuth,
27
+ viewWidth: pose.viewWidth,
28
+ }
29
+ }
30
+
31
+ function navigationSnapshotsEqual(
32
+ previous: FloorplanNavigationSnapshot,
33
+ next: FloorplanNavigationSnapshot,
34
+ ) {
35
+ return (
36
+ Math.abs(previous.target[0] - next.target[0]) < POSITION_EPSILON &&
37
+ Math.abs(previous.target[1] - next.target[1]) < POSITION_EPSILON &&
38
+ Math.abs(previous.target[2] - next.target[2]) < POSITION_EPSILON &&
39
+ Math.abs(angleDeltaRadians(previous.azimuth, next.azimuth)) < AZIMUTH_EPSILON &&
40
+ Math.abs(previous.viewWidth - next.viewWidth) < VIEW_WIDTH_EPSILON
41
+ )
42
+ }
43
+
44
+ export function cameraPoseToFloorplanNavigationPose(
45
+ pose: CameraPose,
46
+ ): NavigationSyncPoseInput | null {
47
+ if (!(pose.viewWidth !== undefined && Number.isFinite(pose.viewWidth) && pose.viewWidth > 0)) {
48
+ return null
49
+ }
50
+
51
+ return {
52
+ source: '3d',
53
+ target: [...pose.target],
54
+ azimuth: Math.atan2(pose.position[0] - pose.target[0], pose.position[2] - pose.target[2]),
55
+ viewWidth: pose.viewWidth,
56
+ }
57
+ }
58
+
59
+ export function floorplanNavigationPoseToCameraPose(
60
+ navigationPose: NavigationSyncPose,
61
+ cameraPose: CameraPose,
62
+ ): CameraPose {
63
+ const offsetX = cameraPose.position[0] - cameraPose.target[0]
64
+ const offsetY = cameraPose.position[1] - cameraPose.target[1]
65
+ const offsetZ = cameraPose.position[2] - cameraPose.target[2]
66
+ const horizontalDistance = Math.hypot(offsetX, offsetZ)
67
+ const horizontalX = normalizeNearZero(Math.sin(navigationPose.azimuth) * horizontalDistance)
68
+ const horizontalZ = normalizeNearZero(Math.cos(navigationPose.azimuth) * horizontalDistance)
69
+ const target: [number, number, number] = [...navigationPose.target]
70
+
71
+ return {
72
+ ...(cameraPose.fov === undefined ? {} : { fov: cameraPose.fov }),
73
+ position: [target[0] + horizontalX, target[1] + offsetY, target[2] + horizontalZ],
74
+ projection: cameraPose.projection,
75
+ target,
76
+ viewWidth: navigationPose.viewWidth,
77
+ }
78
+ }
79
+
80
+ export type FloorplanCameraSyncBridge = {
81
+ receiveCameraPose: (pose: CameraPose) => void
82
+ receiveNavigationPose: (pose: NavigationSyncPose | null) => void
83
+ setActive: (active: boolean) => void
84
+ }
85
+
86
+ export type FloorplanCameraNavigationChannel = {
87
+ publish: (pose: NavigationSyncPoseInput) => void
88
+ subscribe: (listener: (pose: NavigationSyncPose) => void) => () => void
89
+ }
90
+
91
+ export function createFloorplanCameraNavigationChannel(): FloorplanCameraNavigationChannel {
92
+ const listeners = new Set<(pose: NavigationSyncPose) => void>()
93
+ let revision = 0
94
+
95
+ return {
96
+ publish: (pose) => {
97
+ revision += 1
98
+ const revisedPose = { ...pose, revision }
99
+ for (const listener of listeners) {
100
+ listener(revisedPose)
101
+ }
102
+ },
103
+ subscribe: (listener) => {
104
+ listeners.add(listener)
105
+ return () => listeners.delete(listener)
106
+ },
107
+ }
108
+ }
109
+
110
+ const liveCameraNavigation = createFloorplanCameraNavigationChannel()
111
+
112
+ export function subscribeFloorplanCameraNavigation(listener: (pose: NavigationSyncPose) => void) {
113
+ return liveCameraNavigation.subscribe(listener)
114
+ }
115
+
116
+ export function createFloorplanCameraSyncBridge({
117
+ active: initialActive = true,
118
+ applyCameraPose,
119
+ publishNavigationPose,
120
+ }: {
121
+ active?: boolean
122
+ applyCameraPose: (pose: CameraPose) => void
123
+ publishNavigationPose: (pose: NavigationSyncPoseInput) => void
124
+ }): FloorplanCameraSyncBridge {
125
+ let active = initialActive
126
+ let latestCameraPose: CameraPose | null = null
127
+ let pendingNavigationPose: NavigationSyncPose | null = null
128
+ let lastAppliedNavigationRevision = 0
129
+ let lastPublishedNavigation: FloorplanNavigationSnapshot | null = null
130
+
131
+ const applyPendingNavigationPose = () => {
132
+ if (!(latestCameraPose && pendingNavigationPose)) return false
133
+ if (pendingNavigationPose.revision === lastAppliedNavigationRevision) {
134
+ pendingNavigationPose = null
135
+ return false
136
+ }
137
+
138
+ const appliedPose = floorplanNavigationPoseToCameraPose(pendingNavigationPose, latestCameraPose)
139
+ const appliedNavigationPose = cameraPoseToFloorplanNavigationPose(appliedPose)
140
+ latestCameraPose = appliedPose
141
+ lastAppliedNavigationRevision = pendingNavigationPose.revision
142
+ pendingNavigationPose = null
143
+ if (appliedNavigationPose) {
144
+ lastPublishedNavigation = navigationSnapshot(appliedNavigationPose)
145
+ }
146
+ applyCameraPose(appliedPose)
147
+ return true
148
+ }
149
+
150
+ const publishCameraNavigationPose = (pose: CameraPose) => {
151
+ const navigationPose = cameraPoseToFloorplanNavigationPose(pose)
152
+ if (!navigationPose) return
153
+ const nextSnapshot = navigationSnapshot(navigationPose)
154
+ if (
155
+ lastPublishedNavigation &&
156
+ navigationSnapshotsEqual(lastPublishedNavigation, nextSnapshot)
157
+ ) {
158
+ return
159
+ }
160
+
161
+ lastPublishedNavigation = nextSnapshot
162
+ publishNavigationPose(navigationPose)
163
+ }
164
+
165
+ return {
166
+ receiveCameraPose: (pose) => {
167
+ latestCameraPose = pose
168
+ if (active && applyPendingNavigationPose()) return
169
+
170
+ publishCameraNavigationPose(pose)
171
+ },
172
+ receiveNavigationPose: (pose) => {
173
+ if (
174
+ !active ||
175
+ pose?.source !== '2d' ||
176
+ pose.revision === lastAppliedNavigationRevision ||
177
+ pose.revision === pendingNavigationPose?.revision
178
+ ) {
179
+ return
180
+ }
181
+
182
+ pendingNavigationPose = pose
183
+ applyPendingNavigationPose()
184
+ },
185
+ setActive: (nextActive) => {
186
+ if (active === nextActive) return
187
+ active = nextActive
188
+ if (!active) {
189
+ pendingNavigationPose = null
190
+ return
191
+ }
192
+ if (latestCameraPose) publishCameraNavigationPose(latestCameraPose)
193
+ },
194
+ }
195
+ }
196
+
197
+ export function useFloorplanCameraSyncBridge() {
198
+ // The compass is portalled into the always-visible viewer area, so its
199
+ // orientation-only 2D pose must still reach the camera while the floorplan
200
+ // itself is hidden in 3D view.
201
+ const bridgeRef = useRef<FloorplanCameraSyncBridge | null>(null)
202
+ if (!bridgeRef.current) {
203
+ bridgeRef.current = createFloorplanCameraSyncBridge({
204
+ applyCameraPose: (pose) => emitter.emit('camera-controls:apply-pose', pose),
205
+ publishNavigationPose: (pose) => liveCameraNavigation.publish(pose),
206
+ })
207
+ }
208
+ const bridge = bridgeRef.current
209
+
210
+ useEffect(() => subscribeCameraPose(bridge.receiveCameraPose), [bridge])
211
+
212
+ useEffect(() => subscribeNavigationSyncPose(bridge.receiveNavigationPose), [bridge])
213
+ }
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+
3
+ import { emitter, nodeRegistry } from '@pascal-app/core'
4
+ import { X } from 'lucide-react'
5
+ import { useEffect, useRef } from 'react'
6
+ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
7
+ import { isFloorplanToolAvailableInMode } from '../../lib/floorplan/floorplan-mode'
8
+ import useEditor from '../../store/use-editor'
9
+ import useFloorplanMode from '../../store/use-floorplan-mode'
10
+
11
+ function getToolLabel(tool: string): string {
12
+ return nodeRegistry.get(tool)?.presentation?.label ?? tool
13
+ }
14
+
15
+ export function FloorplanModeCoordinator() {
16
+ const editorMode = useEditor((state) => state.mode)
17
+ const tool = useEditor((state) => state.tool)
18
+ const floorplanMode = useFloorplanMode((state) => state.mode)
19
+ const notice = useFloorplanMode((state) => state.notice)
20
+ const dismissNotice = useFloorplanMode((state) => state.dismissNotice)
21
+ const setFloorplanMode = useFloorplanMode((state) => state.setMode)
22
+ const showExpertModeNotice = useFloorplanMode((state) => state.showExpertModeNotice)
23
+ const showNotice = useFloorplanMode((state) => state.showNotice)
24
+ const previousFloorplanMode = useRef(floorplanMode)
25
+
26
+ useEffect(() => {
27
+ const priorFloorplanMode = previousFloorplanMode.current
28
+ previousFloorplanMode.current = floorplanMode
29
+ if (floorplanMode !== 'default' || editorMode !== 'build' || !tool) return
30
+ const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
31
+ if (isFloorplanToolAvailableInMode(extension?.availableModes, floorplanMode)) return
32
+
33
+ const toolLabel = getToolLabel(tool)
34
+ emitter.emit('tool:cancel')
35
+ useEditor.getState().setMode('select')
36
+ if (priorFloorplanMode === 'expert') {
37
+ showNotice(
38
+ `Switched to Default. The unfinished ${toolLabel} draft was canceled; saved Expert annotations are hidden, not deleted.`,
39
+ )
40
+ } else {
41
+ showExpertModeNotice(toolLabel)
42
+ }
43
+ }, [editorMode, floorplanMode, showExpertModeNotice, showNotice, tool])
44
+
45
+ useEffect(() => {
46
+ if (!notice || notice.kind === 'switch-to-expert') return
47
+ const timer = window.setTimeout(dismissNotice, 6000)
48
+ return () => window.clearTimeout(timer)
49
+ }, [dismissNotice, notice])
50
+
51
+ if (!notice) return null
52
+
53
+ return (
54
+ <div
55
+ aria-live="polite"
56
+ className="fixed top-16 left-1/2 z-[100] flex max-w-md -translate-x-1/2 items-center gap-3 rounded-lg border border-border/60 bg-background/95 px-3 py-2 text-sm text-foreground shadow-elevation-3 backdrop-blur-xl"
57
+ role="status"
58
+ >
59
+ <span>{notice.message}</span>
60
+ {notice.kind === 'switch-to-expert' ? (
61
+ <button
62
+ className="shrink-0 rounded-md bg-cyan-500/15 px-2.5 py-1 font-medium text-cyan-400 hover:bg-cyan-500/25"
63
+ onClick={() => {
64
+ setFloorplanMode('expert')
65
+ dismissNotice()
66
+ }}
67
+ type="button"
68
+ >
69
+ Switch to Expert
70
+ </button>
71
+ ) : null}
72
+ <button
73
+ aria-label="Dismiss"
74
+ className="shrink-0 rounded p-1 text-muted-foreground hover:bg-white/10 hover:text-foreground"
75
+ onClick={dismissNotice}
76
+ type="button"
77
+ >
78
+ <X aria-hidden="true" className="h-3.5 w-3.5" />
79
+ </button>
80
+ </div>
81
+ )
82
+ }