@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
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNodeId,
5
5
  type CameraControlEvent,
6
6
  type CameraControlFitSceneEvent,
7
+ type CameraPose,
7
8
  emitter,
8
9
  sceneRegistry,
9
10
  useScene,
@@ -20,8 +21,25 @@ import {
20
21
  Spherical,
21
22
  Vector3,
22
23
  } from 'three'
24
+ import {
25
+ type CameraPoseApplicationPlan,
26
+ normalizeCameraPose,
27
+ planCameraPoseApplication,
28
+ publishInitialCameraPose,
29
+ releaseCameraPoseEventSuppression,
30
+ stepCameraPoseInterpolation,
31
+ withCameraPoseDistance,
32
+ } from '../../lib/camera-pose'
23
33
  import { EDITOR_LAYER } from '../../lib/constants'
34
+ import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority'
35
+ import { publishCameraPose } from '../../store/camera-pose-store'
24
36
  import useEditor from '../../store/use-editor'
37
+ import {
38
+ useActiveHandleDrag,
39
+ useEndpointReshape,
40
+ useMovingNode,
41
+ } from '../../store/use-interaction-scope'
42
+ import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle'
25
43
 
26
44
  const currentTarget = new Vector3()
27
45
  const tempBox = new Box3()
@@ -30,14 +48,11 @@ const tempDelta = new Vector3()
30
48
  const tempPosition = new Vector3()
31
49
  const tempSize = new Vector3()
32
50
  const tempTarget = new Vector3()
33
- const syncTarget = new Vector3()
34
- const syncSpherical = new Spherical()
51
+ const transitionFreezePosition = new Vector3()
52
+ const transitionFreezeTarget = new Vector3()
35
53
  const keyboardPanSpherical = new Spherical()
36
54
  const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
37
55
  const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
38
- const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
39
- const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
40
- const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001
41
56
  const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
42
57
  const KEYBOARD_PAN_MIN_SPEED = 2
43
58
  const KEYBOARD_PAN_MAX_SPEED = 55
@@ -47,14 +62,6 @@ type CameraPoseSnapshot = {
47
62
  position: [number, number, number]
48
63
  target: [number, number, number]
49
64
  }
50
- type NavigationCameraPoseSnapshot = {
51
- target: [number, number, number]
52
- azimuth: number
53
- viewWidth: number
54
- }
55
- type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & {
56
- publishOnComplete: boolean
57
- }
58
65
  type CameraViewWidthUpdate =
59
66
  | { type: 'distance'; distance: number; viewWidth: number }
60
67
  | { type: 'zoom'; viewWidth: number; zoom: number }
@@ -92,6 +99,21 @@ function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot
92
99
  )
93
100
  }
94
101
 
102
+ function freezeCameraControlTransition(control: CameraControlsImpl) {
103
+ // `stop()` snaps to the transition endpoint, so replace it with the current pose instead.
104
+ control.getPosition(transitionFreezePosition, false)
105
+ control.getTarget(transitionFreezeTarget, false)
106
+ void control.setLookAt(
107
+ transitionFreezePosition.x,
108
+ transitionFreezePosition.y,
109
+ transitionFreezePosition.z,
110
+ transitionFreezeTarget.x,
111
+ transitionFreezeTarget.y,
112
+ transitionFreezeTarget.z,
113
+ false,
114
+ )
115
+ }
116
+
95
117
  function isEditableKeyboardTarget(target: EventTarget | null) {
96
118
  return (
97
119
  target instanceof HTMLInputElement ||
@@ -136,6 +158,10 @@ function isKeyboardPanKey(code: string): boolean {
136
158
  return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
137
159
  }
138
160
 
161
+ function hasKeyboardPanInput(state: KeyboardPanState): boolean {
162
+ return state.forward || state.backward || state.left || state.right
163
+ }
164
+
139
165
  type CameraViewportSize = {
140
166
  width: number
141
167
  height: number
@@ -166,14 +192,6 @@ function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewpo
166
192
  return Math.max(0.001, distance)
167
193
  }
168
194
 
169
- function getAngleDeltaRadians(a: number, b: number) {
170
- return Math.atan2(Math.sin(a - b), Math.cos(a - b))
171
- }
172
-
173
- function nearestEquivalentRadians(angle: number, reference: number) {
174
- return reference + getAngleDeltaRadians(angle, reference)
175
- }
176
-
177
195
  function clampFinite(value: number, min: number, max: number) {
178
196
  const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
179
197
  const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY
@@ -198,21 +216,6 @@ function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) {
198
216
  )
199
217
  }
200
218
 
201
- function isCameraAtNavigationPose(
202
- pose: NavigationCameraPoseSnapshot,
203
- target: Vector3,
204
- azimuth: number,
205
- viewWidth: number,
206
- ) {
207
- return (
208
- Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
209
- Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
210
- Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
211
- Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
212
- Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
213
- )
214
- }
215
-
216
219
  function getCameraDistanceForViewWidth(
217
220
  camera: Camera,
218
221
  viewWidth: number,
@@ -267,14 +270,18 @@ function resolveCameraViewWidthUpdate(
267
270
  return { type: 'none', viewWidth }
268
271
  }
269
272
 
270
- function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) {
273
+ function applyCameraViewWidth(
274
+ control: CameraControlsImpl,
275
+ update: CameraViewWidthUpdate,
276
+ enableTransition = true,
277
+ ) {
271
278
  if (update.type === 'distance') {
272
- control.dollyTo(update.distance, true)
279
+ control.dollyTo(update.distance, enableTransition)
273
280
  return
274
281
  }
275
282
 
276
283
  if (update.type === 'zoom') {
277
- control.zoomTo(update.zoom, true)
284
+ control.zoomTo(update.zoom, enableTransition)
278
285
  }
279
286
  }
280
287
 
@@ -342,6 +349,13 @@ function useFirstPersonCameraPoseRestore(
342
349
 
343
350
  export const CustomCameraControls = () => {
344
351
  const controls = useRef<CameraControlsImpl | null>(null)
352
+ const pendingAppliedPose = useRef<CameraPoseApplicationPlan | null>(null)
353
+ const activePoseInterpolation = useRef<{
354
+ camera: Camera
355
+ control: CameraControlsImpl
356
+ plan: CameraPoseApplicationPlan
357
+ } | null>(null)
358
+ const suppressPoseEvents = useRef(false)
345
359
  const keyboardPanKeys = useRef<KeyboardPanState>({
346
360
  forward: false,
347
361
  backward: false,
@@ -351,7 +365,6 @@ export const CustomCameraControls = () => {
351
365
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
352
366
  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
353
367
  const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
354
- const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
355
368
  const selection = useViewer((s) => s.selection)
356
369
  const cameraMode = useViewer((state) => state.cameraMode)
357
370
  const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
@@ -361,19 +374,21 @@ export const CustomCameraControls = () => {
361
374
  )
362
375
  const currentLevelId = selection.levelId
363
376
  const firstLoad = useRef(true)
364
- const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
365
- const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
366
- const lastApplied2dNavigationRevision = useRef(0)
367
377
  const maxPolarAngle =
368
378
  !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
369
- const clearPendingFloorplanNavigationPose = useCallback(() => {
370
- pendingFloorplanNavigationPose.current = null
371
- }, [])
372
379
 
373
380
  const camera = useThree((state) => state.camera)
374
381
  const gl = useThree((state) => state.gl)
375
382
  const raycaster = useThree((state) => state.raycaster)
376
383
  const viewportSize = useThree((state) => state.size)
384
+ const cameraDraggingLifecycle = useMemo(
385
+ () =>
386
+ createCameraDraggingLifecycle({
387
+ setDragging: (dragging) => useViewer.getState().setCameraDragging(dragging),
388
+ }),
389
+ [],
390
+ )
391
+ useEffect(() => () => cameraDraggingLifecycle.end(), [cameraDraggingLifecycle])
377
392
  useEffect(() => {
378
393
  camera.layers.enable(EDITOR_LAYER)
379
394
  camera.layers.enable(GRID_LAYER)
@@ -381,6 +396,136 @@ export const CustomCameraControls = () => {
381
396
  raycaster.layers.enable(ZONE_LAYER)
382
397
  }, [camera, raycaster])
383
398
 
399
+ const freezeActivePoseInterpolation = useCallback(() => {
400
+ const active = activePoseInterpolation.current
401
+ if (!active) return
402
+
403
+ activePoseInterpolation.current = null
404
+ freezeCameraControlTransition(active.control)
405
+ }, [])
406
+
407
+ const cancelPoseApplication = useCallback(() => {
408
+ pendingAppliedPose.current = null
409
+ try {
410
+ freezeActivePoseInterpolation()
411
+ } finally {
412
+ suppressPoseEvents.current = false
413
+ }
414
+ }, [freezeActivePoseInterpolation])
415
+
416
+ const beginLocalCameraInteraction = useCallback(
417
+ ({ dragging = true }: { dragging?: boolean } = {}) => {
418
+ cancelPoseApplication()
419
+ if (dragging) cameraDraggingLifecycle.begin()
420
+ emitter.emit('camera-controls:interaction-start', undefined)
421
+ },
422
+ [cameraDraggingLifecycle, cancelPoseApplication],
423
+ )
424
+
425
+ const applyPendingPose = useCallback(() => {
426
+ if (isFirstPersonMode) {
427
+ cancelPoseApplication()
428
+ return
429
+ }
430
+
431
+ const plan = pendingAppliedPose.current
432
+ const control = controls.current
433
+
434
+ const active = activePoseInterpolation.current
435
+ if (active && (active.camera !== camera || active.control !== control)) {
436
+ try {
437
+ freezeActivePoseInterpolation()
438
+ } finally {
439
+ if (!plan) suppressPoseEvents.current = false
440
+ }
441
+ }
442
+
443
+ if (!(plan && control)) return
444
+
445
+ const cameraMatchesProjection =
446
+ (plan.pose.projection === 'perspective' && isPerspectiveCamera(camera)) ||
447
+ (plan.pose.projection === 'orthographic' && isOrthographicCamera(camera))
448
+ if (!cameraMatchesProjection) return
449
+
450
+ pendingAppliedPose.current = null
451
+ if (plan.perspectiveFov !== null && isPerspectiveCamera(camera)) {
452
+ camera.fov = plan.perspectiveFov
453
+ camera.updateProjectionMatrix()
454
+ }
455
+ let appliedPlan = plan
456
+ if (plan.pose.viewWidth !== undefined) {
457
+ const viewWidthUpdate = resolveCameraViewWidthUpdate(
458
+ control,
459
+ camera,
460
+ plan.pose.viewWidth,
461
+ viewportSize,
462
+ )
463
+ if (viewWidthUpdate.type === 'distance') {
464
+ appliedPlan = {
465
+ ...plan,
466
+ pose: withCameraPoseDistance(plan.pose, viewWidthUpdate.distance),
467
+ }
468
+ } else if (viewWidthUpdate.type === 'zoom') {
469
+ applyCameraViewWidth(control, viewWidthUpdate, false)
470
+ }
471
+ }
472
+
473
+ activePoseInterpolation.current = { camera, control, plan: appliedPlan }
474
+ }, [
475
+ camera,
476
+ cancelPoseApplication,
477
+ freezeActivePoseInterpolation,
478
+ isFirstPersonMode,
479
+ viewportSize,
480
+ ])
481
+
482
+ useEffect(() => {
483
+ applyPendingPose()
484
+ }, [applyPendingPose])
485
+
486
+ useEffect(() => {
487
+ const handleAppliedPose = (pose: CameraPose) => {
488
+ if (isFirstPersonMode) return
489
+
490
+ const plan = planCameraPoseApplication(pose)
491
+ if (!plan) return
492
+
493
+ pendingAppliedPose.current = plan
494
+ suppressPoseEvents.current = true
495
+
496
+ if (useViewer.getState().cameraMode !== plan.pose.projection) {
497
+ freezeActivePoseInterpolation()
498
+ useViewer.getState().setCameraMode(plan.pose.projection)
499
+ return
500
+ }
501
+
502
+ applyPendingPose()
503
+ }
504
+
505
+ emitter.on('camera-controls:apply-pose', handleAppliedPose)
506
+ emitter.on('camera-controls:cancel-pose', cancelPoseApplication)
507
+ return () => {
508
+ emitter.off('camera-controls:apply-pose', handleAppliedPose)
509
+ emitter.off('camera-controls:cancel-pose', cancelPoseApplication)
510
+ }
511
+ }, [applyPendingPose, cancelPoseApplication, freezeActivePoseInterpolation, isFirstPersonMode])
512
+
513
+ useEffect(() => cancelPoseApplication, [cancelPoseApplication])
514
+
515
+ useEffect(() => {
516
+ // Dev-only: deterministic camera poses for screenshot/automation tooling.
517
+ // A getter, not a snapshot — drei recreates the impl when the default
518
+ // camera changes, so a captured instance goes stale.
519
+ if (process.env.NODE_ENV !== 'development') return
520
+ const w = window as typeof window & {
521
+ __pascalCameraControls?: (() => CameraControlsImpl | null) | null
522
+ }
523
+ w.__pascalCameraControls = () => controls.current
524
+ return () => {
525
+ w.__pascalCameraControls = null
526
+ }
527
+ }, [])
528
+
384
529
  useEffect(() => {
385
530
  if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
386
531
  let targetY = 0
@@ -393,19 +538,11 @@ export const CustomCameraControls = () => {
393
538
  if (!controls.current) return
394
539
  if (firstLoad.current) {
395
540
  firstLoad.current = false
396
- clearPendingFloorplanNavigationPose()
397
541
  controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
398
542
  }
399
543
  controls.current.getTarget(currentTarget)
400
- clearPendingFloorplanNavigationPose()
401
544
  controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
402
- }, [
403
- clearPendingFloorplanNavigationPose,
404
- currentLevelId,
405
- isPreviewMode,
406
- isFirstPersonMode,
407
- isRestoringFirstPersonPose,
408
- ])
545
+ }, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
409
546
 
410
547
  useEffect(() => {
411
548
  if (isFirstPersonMode || !controls.current) return
@@ -433,7 +570,6 @@ export const CustomCameraControls = () => {
433
570
  controls.current.getTarget(tempTarget)
434
571
  tempDelta.copy(tempCenter).sub(tempTarget)
435
572
 
436
- clearPendingFloorplanNavigationPose()
437
573
  controls.current.setLookAt(
438
574
  tempPosition.x + tempDelta.x,
439
575
  tempPosition.y + tempDelta.y,
@@ -444,120 +580,88 @@ export const CustomCameraControls = () => {
444
580
  true,
445
581
  )
446
582
  },
447
- [clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
583
+ [isPreviewMode, isFirstPersonMode],
448
584
  )
449
585
 
450
- useEffect(() => {
451
- if (isFirstPersonMode) return
452
-
453
- return useEditor.subscribe((state) => {
454
- const pose = state.navigationSyncPose
455
- if (
456
- !pose ||
457
- pose.source !== '2d' ||
458
- pose.revision === lastApplied2dNavigationRevision.current
459
- )
460
- return
461
-
462
- const control = controls.current
463
- if (!control) return
464
-
465
- lastApplied2dNavigationRevision.current = pose.revision
466
- const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle)
467
- const viewWidthUpdate = resolveCameraViewWidthUpdate(
468
- control,
469
- camera,
470
- pose.viewWidth,
471
- viewportSize,
472
- )
473
- pendingFloorplanNavigationPose.current = {
474
- target: [...pose.target],
475
- azimuth: targetAzimuth,
476
- viewWidth: viewWidthUpdate.viewWidth,
477
- publishOnComplete:
478
- Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
479
- NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
480
- }
481
- control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
482
- control.rotateTo(targetAzimuth, control.polarAngle, true)
483
- applyCameraViewWidth(control, viewWidthUpdate)
586
+ const publishCurrentPose = useCallback(() => {
587
+ if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return
588
+
589
+ controls.current.getPosition(tempPosition, false)
590
+ controls.current.getTarget(tempTarget, false)
591
+ const targetDistance = tempPosition.distanceTo(tempTarget)
592
+ const projection = isPerspectiveCamera(camera)
593
+ ? 'perspective'
594
+ : isOrthographicCamera(camera)
595
+ ? 'orthographic'
596
+ : null
597
+ if (!projection) return
598
+
599
+ const pose = normalizeCameraPose({
600
+ position: [tempPosition.x, tempPosition.y, tempPosition.z],
601
+ target: [tempTarget.x, tempTarget.y, tempTarget.z],
602
+ projection,
603
+ viewWidth: getCameraViewWidth(camera, targetDistance, viewportSize),
604
+ ...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
484
605
  })
485
- }, [camera, isFirstPersonMode, viewportSize])
486
-
487
- const publishCurrentNavigationPose = useCallback(() => {
488
- if (isFirstPersonMode || !controls.current) return
489
-
490
- controls.current.getTarget(syncTarget, false)
491
- controls.current.getSpherical(syncSpherical, false)
492
- const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize)
493
-
494
- const pendingFloorplanPose = pendingFloorplanNavigationPose.current
495
- if (pendingFloorplanPose) {
496
- // The camera is still damping toward a 2D-originated pose; do not echo
497
- // intermediate 3D poses back into the floorplan.
498
- if (
499
- isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
500
- ) {
501
- lastPublishedNavigationSync.current = pendingFloorplanPose
502
- pendingFloorplanNavigationPose.current = null
503
- if (pendingFloorplanPose.publishOnComplete) {
504
- useEditor.getState().publishNavigationSyncPose({
505
- source: '3d',
506
- target: [
507
- pendingFloorplanPose.target[0],
508
- pendingFloorplanPose.target[1],
509
- pendingFloorplanPose.target[2],
510
- ],
511
- azimuth: pendingFloorplanPose.azimuth,
512
- viewWidth: pendingFloorplanPose.viewWidth,
513
- })
514
- }
515
- }
516
- return
606
+ if (pose) {
607
+ publishCameraPose(pose)
517
608
  }
518
-
519
- const previous = lastPublishedNavigationSync.current
520
- if (
521
- previous &&
522
- Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
523
- Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
524
- Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
525
- Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) <
526
- NAVIGATION_SYNC_AZIMUTH_EPSILON &&
527
- Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
528
- ) {
529
- return
530
- }
531
-
532
- lastPublishedNavigationSync.current = {
533
- target: [syncTarget.x, syncTarget.y, syncTarget.z],
534
- azimuth: syncSpherical.theta,
535
- viewWidth,
536
- }
537
- useEditor.getState().publishNavigationSyncPose({
538
- source: '3d',
539
- target: [syncTarget.x, syncTarget.y, syncTarget.z],
540
- azimuth: syncSpherical.theta,
541
- viewWidth,
542
- })
543
609
  }, [camera, isFirstPersonMode, viewportSize])
544
610
 
545
- useEffect(() => {
546
- if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
547
-
548
- const frame = requestAnimationFrame(() => {
549
- lastPublishedNavigationSync.current = null
550
- publishCurrentNavigationPose()
551
- })
611
+ const handleCameraUpdate = useCallback(() => {
612
+ publishCurrentPose()
613
+ }, [publishCurrentPose])
552
614
 
553
- return () => {
554
- cancelAnimationFrame(frame)
555
- }
556
- }, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
615
+ useEffect(() => {
616
+ publishInitialCameraPose(publishCurrentPose)
617
+ }, [publishCurrentPose])
557
618
 
558
619
  useFrame((_, delta) => {
559
620
  if (isFirstPersonMode || !controls.current) return
560
621
 
622
+ const activePose = activePoseInterpolation.current
623
+ if (activePose) {
624
+ const control = controls.current
625
+ if (activePose.camera !== camera || activePose.control !== control) {
626
+ try {
627
+ freezeActivePoseInterpolation()
628
+ } finally {
629
+ if (!pendingAppliedPose.current) suppressPoseEvents.current = false
630
+ }
631
+ } else {
632
+ control.getPosition(tempPosition, false)
633
+ control.getTarget(tempTarget, false)
634
+ const step = stepCameraPoseInterpolation(
635
+ [tempPosition.x, tempPosition.y, tempPosition.z],
636
+ [tempTarget.x, tempTarget.y, tempTarget.z],
637
+ activePose.plan.pose,
638
+ delta,
639
+ )
640
+ try {
641
+ // Transition-enabled calls retain one rest listener each, so this frame loop owns smoothing.
642
+ void control.setLookAt(
643
+ step.position[0],
644
+ step.position[1],
645
+ step.position[2],
646
+ step.target[0],
647
+ step.target[1],
648
+ step.target[2],
649
+ false,
650
+ )
651
+ control.update(0)
652
+ } catch {
653
+ if (activePoseInterpolation.current === activePose) {
654
+ activePoseInterpolation.current = null
655
+ releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose)
656
+ }
657
+ }
658
+ if (step.settled && activePoseInterpolation.current === activePose) {
659
+ activePoseInterpolation.current = null
660
+ releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose)
661
+ }
662
+ }
663
+ }
664
+
561
665
  const panKeys = keyboardPanKeys.current
562
666
  const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
563
667
  const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
@@ -573,10 +677,9 @@ export const CustomCameraControls = () => {
573
677
  )
574
678
  const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
575
679
 
576
- pendingFloorplanNavigationPose.current = null
577
680
  if (horizontal !== 0) control.truck(horizontal * step, 0, true)
578
681
  if (vertical !== 0) control.forward(vertical * step, true)
579
- })
682
+ }, 0)
580
683
 
581
684
  // Configure mouse buttons based on control mode and camera mode
582
685
  const mouseButtons = useMemo(() => {
@@ -596,11 +699,11 @@ export const CustomCameraControls = () => {
596
699
 
597
700
  // Touch gestures (mobile / trackpad).
598
701
  // - One finger drag → rotate by default (much easier on a phone), but
599
- // falls back to NONE while the user is actively
600
- // placing/moving something OR in box-select mode,
601
- // so the editor's pointer handlers (place tool,
602
- // drag-to-move endpoint, marquee selection drag)
603
- // keep priority over the camera.
702
+ // falls back to NONE while the editor owns the drag —
703
+ // placing/moving something, box-select, or a brush mode
704
+ // so the editor's pointer handlers (place tool,
705
+ // drag-to-move endpoint, marquee selection drag, paint
706
+ // and sculpt strokes) keep priority over the camera.
604
707
  // In preview mode it's TOUCH_TRUCK (pan), matching
605
708
  // preview's left = SCREEN_PAN.
606
709
  // - Two finger pinch → zoom + pan together (TOUCH_DOLLY_TRUCK for
@@ -611,19 +714,20 @@ export const CustomCameraControls = () => {
611
714
  const tool = useEditor((s) => s.tool)
612
715
  const mode = useEditor((s) => s.mode)
613
716
  const selectionTool = useEditor((s) => s.floorplanSelectionTool)
614
- const movingNode = useEditor((s) => s.movingNode)
615
- const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
616
- const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
617
- const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
717
+ const movingNode = useMovingNode()
718
+ const endpointReshape = useEndpointReshape()
719
+ const activeHandleDrag = useActiveHandleDrag()
618
720
  const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
619
- const isInteracting = Boolean(
620
- tool ||
621
- movingNode ||
622
- movingWallEndpoint ||
623
- movingFenceEndpoint ||
624
- activeHandleDrag ||
625
- isBoxSelectActive,
626
- )
721
+ // The mode term lives in `editorOwnsOneFingerDrag` rather than in this OR: a
722
+ // brush mode owns the drag the way an armed tool does, but none of the
723
+ // transient terms notice it (entering paint or sculpt *clears* `tool`, and its
724
+ // scope is `painting`/`sculpting`, not `handle-drag`).
725
+ const isInteracting = editorOwnsOneFingerDrag({
726
+ mode,
727
+ activeGesture: Boolean(
728
+ tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive,
729
+ ),
730
+ })
627
731
  const touches = useMemo(() => {
628
732
  const twoFingerAction =
629
733
  cameraMode === 'orthographic'
@@ -730,8 +834,8 @@ export const CustomCameraControls = () => {
730
834
  !(event.metaKey || event.ctrlKey || event.altKey) &&
731
835
  !isEditableKeyboardTarget(event.target)
732
836
  ) {
733
- setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
734
- pendingFloorplanNavigationPose.current = null
837
+ const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
838
+ if (changed) beginLocalCameraInteraction()
735
839
  event.preventDefault()
736
840
  event.stopPropagation()
737
841
  }
@@ -763,6 +867,9 @@ export const CustomCameraControls = () => {
763
867
  if (isKeyboardPanKey(event.code)) {
764
868
  const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false)
765
869
  if (changed) {
870
+ if (!hasKeyboardPanInput(keyboardPanKeys.current)) {
871
+ cameraDraggingLifecycle.end()
872
+ }
766
873
  event.preventDefault()
767
874
  event.stopPropagation()
768
875
  }
@@ -794,7 +901,6 @@ export const CustomCameraControls = () => {
794
901
 
795
902
  const onPointerDown = (event: PointerEvent) => {
796
903
  if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
797
- pendingFloorplanNavigationPose.current = null
798
904
  if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
799
905
 
800
906
  panPointerId = event.pointerId
@@ -803,7 +909,8 @@ export const CustomCameraControls = () => {
803
909
  }
804
910
 
805
911
  const onWheel = () => {
806
- pendingFloorplanNavigationPose.current = null
912
+ beginLocalCameraInteraction()
913
+ cameraDraggingLifecycle.scheduleEnd()
807
914
  }
808
915
 
809
916
  const onPointerUp = (event: PointerEvent) => {
@@ -822,6 +929,7 @@ export const CustomCameraControls = () => {
822
929
  panPointerId = null
823
930
  panPointerButton = null
824
931
  clearNavigationCursor()
932
+ cameraDraggingLifecycle.end()
825
933
  updateConfig()
826
934
  }
827
935
 
@@ -831,7 +939,7 @@ export const CustomCameraControls = () => {
831
939
  window.addEventListener('pointerup', onPointerUp, true)
832
940
  window.addEventListener('pointercancel', onPointerUp, true)
833
941
  window.addEventListener('blur', onBlur)
834
- gl.domElement.addEventListener('wheel', onWheel, { passive: true })
942
+ gl.domElement.addEventListener('wheel', onWheel, { capture: true, passive: true })
835
943
  updateConfig()
836
944
 
837
945
  return () => {
@@ -841,11 +949,30 @@ export const CustomCameraControls = () => {
841
949
  window.removeEventListener('pointerup', onPointerUp, true)
842
950
  window.removeEventListener('pointercancel', onPointerUp, true)
843
951
  window.removeEventListener('blur', onBlur)
844
- gl.domElement.removeEventListener('wheel', onWheel)
952
+ gl.domElement.removeEventListener('wheel', onWheel, true)
845
953
  clearKeyboardPanKeys()
846
954
  clearNavigationCursor()
955
+ cameraDraggingLifecycle.end()
847
956
  }
848
- }, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
957
+ }, [
958
+ beginLocalCameraInteraction,
959
+ cameraDraggingLifecycle,
960
+ cameraMode,
961
+ gl,
962
+ isPreviewMode,
963
+ isFirstPersonMode,
964
+ ])
965
+
966
+ // `controlstart` fires only for user pointer interactions. Pointerdowns
967
+ // mapped to ACTION.NONE must not flag the camera as dragging because no
968
+ // rest/sleep event follows to clear the flag.
969
+ const handleControlStart = useCallback(() => {
970
+ beginLocalCameraInteraction({
971
+ dragging: controls.current
972
+ ? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE
973
+ : false,
974
+ })
975
+ }, [beginLocalCameraInteraction])
849
976
 
850
977
  // Preview mode: auto-navigate camera to selected node (viewer behavior)
851
978
  const previewTargetNodeId = isPreviewMode
@@ -1052,7 +1179,6 @@ export const CustomCameraControls = () => {
1052
1179
  if (!node?.camera) return
1053
1180
  const { position, target } = node.camera
1054
1181
 
1055
- clearPendingFloorplanNavigationPose()
1056
1182
  controls.current.setLookAt(
1057
1183
  position[0],
1058
1184
  position[1],
@@ -1073,7 +1199,6 @@ export const CustomCameraControls = () => {
1073
1199
  // Otherwise, go to top view (0°)
1074
1200
  const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
1075
1201
 
1076
- clearPendingFloorplanNavigationPose()
1077
1202
  controls.current.rotatePolarTo(targetAngle, true)
1078
1203
  }
1079
1204
 
@@ -1086,7 +1211,6 @@ export const CustomCameraControls = () => {
1086
1211
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
1087
1212
  const target = rounded - Math.PI / 2
1088
1213
 
1089
- clearPendingFloorplanNavigationPose()
1090
1214
  controls.current.rotateTo(target, currentPolar, true)
1091
1215
  }
1092
1216
 
@@ -1099,7 +1223,6 @@ export const CustomCameraControls = () => {
1099
1223
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
1100
1224
  const target = rounded + Math.PI / 2
1101
1225
 
1102
- clearPendingFloorplanNavigationPose()
1103
1226
  controls.current.rotateTo(target, currentPolar, true)
1104
1227
  }
1105
1228
 
@@ -1111,7 +1234,6 @@ export const CustomCameraControls = () => {
1111
1234
  if (isFirstPersonMode || !controls.current || isPreviewMode) return
1112
1235
  if (!bounds) {
1113
1236
  // Restore default framing pose when no bounds were computed.
1114
- clearPendingFloorplanNavigationPose()
1115
1237
  controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
1116
1238
  return
1117
1239
  }
@@ -1122,7 +1244,6 @@ export const CustomCameraControls = () => {
1122
1244
  const maxExtent = Math.max(w, d)
1123
1245
  const distance = Math.max(maxExtent * 1.4, 15)
1124
1246
  const height = Math.max(maxExtent * 0.8, 10)
1125
- clearPendingFloorplanNavigationPose()
1126
1247
  controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
1127
1248
  }
1128
1249
 
@@ -1143,23 +1264,33 @@ export const CustomCameraControls = () => {
1143
1264
  emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
1144
1265
  emitter.off('camera-controls:fit-scene', handleFitScene)
1145
1266
  }
1146
- }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
1267
+ }, [focusNode, isPreviewMode, isFirstPersonMode])
1147
1268
 
1148
1269
  const onTransitionStart = useCallback(() => {
1149
- useViewer.getState().setCameraDragging(true)
1150
- }, [])
1270
+ cameraDraggingLifecycle.begin()
1271
+ }, [cameraDraggingLifecycle])
1151
1272
 
1152
1273
  const onRest = useCallback(() => {
1153
- useViewer.getState().setCameraDragging(false)
1154
- }, [])
1274
+ cameraDraggingLifecycle.end()
1275
+ }, [cameraDraggingLifecycle])
1276
+
1277
+ const onControlEnd = useCallback(() => {
1278
+ // A mapped-button tap with zero camera movement never wakes the
1279
+ // controls, so no rest/sleep follows — clear the dragging flag on
1280
+ // release. While damping is still settling (`active`), rest/sleep
1281
+ // clears it instead.
1282
+ if (!controls.current?.active) {
1283
+ cameraDraggingLifecycle.end()
1284
+ }
1285
+ }, [cameraDraggingLifecycle])
1155
1286
 
1156
1287
  // Preset capture mode frames a single subtree (often a 0.3–2m preset),
1157
- // so the default 6m minDistance prevents the user from getting close
1288
+ // so the default 2m minDistance prevents the user from getting close
1158
1289
  // enough to compose a good thumbnail. Relax the clamp to 0.5m while
1159
1290
  // capturing presets; reset on exit so general editing keeps the looser
1160
1291
  // navigation guardrails.
1161
1292
  const isPresetCapture = captureMode.mode === 'preset'
1162
- const minDistance = isPresetCapture ? 0.5 : 6
1293
+ const minDistance = isPresetCapture ? 0.5 : 2
1163
1294
 
1164
1295
  if (isFirstPersonMode) {
1165
1296
  return null
@@ -1173,7 +1304,9 @@ export const CustomCameraControls = () => {
1173
1304
  minDistance={minDistance}
1174
1305
  minPolarAngle={0}
1175
1306
  mouseButtons={mouseButtons}
1176
- onUpdate={publishCurrentNavigationPose}
1307
+ onControlEnd={onControlEnd}
1308
+ onControlStart={handleControlStart}
1309
+ onUpdate={handleCameraUpdate}
1177
1310
  onRest={onRest}
1178
1311
  onSleep={onRest}
1179
1312
  onTransitionStart={onTransitionStart}