@pascal-app/editor 1.0.1 → 1.0.4

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 (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -1,15 +1,23 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ type AnyNode,
4
5
  type AnyNodeId,
5
6
  type CameraControlEvent,
6
7
  type CameraControlFitSceneEvent,
8
+ type CameraControlFrameEvent,
7
9
  type CameraPose,
8
10
  emitter,
9
11
  sceneRegistry,
10
12
  useScene,
11
13
  } from '@pascal-app/core'
12
- import { GRID_LAYER, getLevelPresentationY, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
14
+ import {
15
+ GRID_LAYER,
16
+ getLevelPresentationY,
17
+ useSceneGroundReplacement,
18
+ useViewer,
19
+ ZONE_LAYER,
20
+ } from '@pascal-app/viewer'
13
21
  import { CameraControls, CameraControlsImpl } from '@react-three/drei'
14
22
  import { useFrame, useThree } from '@react-three/fiber'
15
23
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
@@ -31,15 +39,37 @@ import {
31
39
  withCameraPoseDistance,
32
40
  } from '../../lib/camera-pose'
33
41
  import { EDITOR_LAYER } from '../../lib/constants'
42
+ import {
43
+ acceptsKeyboardPan,
44
+ advanceKeyboardPanMotion,
45
+ clearKeyboardPanKeys,
46
+ createKeyboardPanMotion,
47
+ hasKeyboardPanInput,
48
+ isEditableKeyboardTarget,
49
+ isKeyboardPanKey,
50
+ type KeyboardPanState,
51
+ keyboardPanSpeed,
52
+ setKeyboardPanKey,
53
+ syncKeyboardPanMotion,
54
+ } from '../../lib/keyboard-pan'
55
+ import {
56
+ MAX_ORBIT_POLAR_ANGLE,
57
+ ORBIT_FLOOR_CLEARANCE,
58
+ ORBIT_TARGET_HEIGHT,
59
+ } from '../../lib/orbit-floor-clearance'
34
60
  import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority'
61
+ import { frameViewerCamera } from '../../lib/viewer-selection'
35
62
  import { publishCameraPose } from '../../store/camera-pose-store'
36
- import useEditor from '../../store/use-editor'
63
+ import useEditor, { takePreviewCameraRestore } from '../../store/use-editor'
37
64
  import {
38
65
  useActiveHandleDrag,
39
66
  useEndpointReshape,
40
67
  useMovingNode,
41
68
  } from '../../store/use-interaction-scope'
69
+ import { visibleScene } from '../../store/view-layout'
42
70
  import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle'
71
+ import { FloorAwareCameraControls } from './floor-aware-camera-controls'
72
+ import { FloorSurface } from './floor-surface'
43
73
 
44
74
  const currentTarget = new Vector3()
45
75
  const tempBox = new Box3()
@@ -51,11 +81,37 @@ const tempTarget = new Vector3()
51
81
  const transitionFreezePosition = new Vector3()
52
82
  const transitionFreezeTarget = new Vector3()
53
83
  const keyboardPanSpherical = new Spherical()
84
+ /** Mouse/touch orbit speed (camera-controls default 1): slower for precise looks. */
85
+ const ORBIT_ROTATE_SPEED = 0.6
86
+ // In 2D-only view the canvas is paused, so the floor plan drives WASD, orbit and
87
+ // top view itself (`floorplan-panel.tsx`) and the camera stands down.
88
+ const planOwnsNavigation = () => visibleScene(useEditor.getState()) === '2d'
54
89
  const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
55
- const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
56
- const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
57
- const KEYBOARD_PAN_MIN_SPEED = 2
58
- const KEYBOARD_PAN_MAX_SPEED = 55
90
+ // Focusing one of these aims at its floor (raised to the orbit's pivot
91
+ // height), not at the middle of its bounding box.
92
+ const GROUND_FOCUS_TYPES = new Set(['site', 'building', 'level', 'zone', 'slab'])
93
+
94
+ type LevelMode = ReturnType<typeof useViewer.getState>['levelMode']
95
+
96
+ /** Where the orbit pivot rests on a level: its floor plus the pivot height. */
97
+ function orbitGroundY(
98
+ levelId: string | null | undefined,
99
+ levelMode: LevelMode = useViewer.getState().levelMode,
100
+ ) {
101
+ const floorY = levelId ? getLevelPresentationY(levelId, useScene.getState().nodes, levelMode) : 0
102
+ return floorY + ORBIT_TARGET_HEIGHT
103
+ }
104
+
105
+ function levelOf(nodeId: string): string | null {
106
+ const nodes = useScene.getState().nodes
107
+ let node: AnyNode | undefined = nodes[nodeId as AnyNodeId]
108
+ while (node) {
109
+ if (node.type === 'level') return node.id
110
+ node = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined
111
+ }
112
+ return null
113
+ }
114
+
59
115
  type CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
60
116
  type CameraPoseSnapshot = {
61
117
  mode: CameraMode
@@ -114,54 +170,6 @@ function freezeCameraControlTransition(control: CameraControlsImpl) {
114
170
  )
115
171
  }
116
172
 
117
- function isEditableKeyboardTarget(target: EventTarget | null) {
118
- return (
119
- target instanceof HTMLInputElement ||
120
- target instanceof HTMLTextAreaElement ||
121
- target instanceof HTMLSelectElement ||
122
- (target instanceof HTMLElement && target.isContentEditable)
123
- )
124
- }
125
-
126
- type KeyboardPanState = {
127
- forward: boolean
128
- backward: boolean
129
- left: boolean
130
- right: boolean
131
- }
132
-
133
- function setKeyboardPanKey(state: KeyboardPanState, code: string, pressed: boolean): boolean {
134
- if (code === 'KeyW') {
135
- const changed = state.forward !== pressed
136
- state.forward = pressed
137
- return changed
138
- }
139
- if (code === 'KeyS') {
140
- const changed = state.backward !== pressed
141
- state.backward = pressed
142
- return changed
143
- }
144
- if (code === 'KeyA') {
145
- const changed = state.left !== pressed
146
- state.left = pressed
147
- return changed
148
- }
149
- if (code === 'KeyD') {
150
- const changed = state.right !== pressed
151
- state.right = pressed
152
- return changed
153
- }
154
- return false
155
- }
156
-
157
- function isKeyboardPanKey(code: string): boolean {
158
- return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
159
- }
160
-
161
- function hasKeyboardPanInput(state: KeyboardPanState): boolean {
162
- return state.forward || state.backward || state.left || state.right
163
- }
164
-
165
173
  type CameraViewportSize = {
166
174
  width: number
167
175
  height: number
@@ -348,7 +356,7 @@ function useFirstPersonCameraPoseRestore(
348
356
  }
349
357
 
350
358
  export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) => {
351
- const controls = useRef<CameraControlsImpl | null>(null)
359
+ const controls = useRef<FloorAwareCameraControls | null>(null)
352
360
  const pendingAppliedPose = useRef<CameraPoseApplicationPlan | null>(null)
353
361
  const activePoseInterpolation = useRef<{
354
362
  camera: Camera
@@ -362,6 +370,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
362
370
  left: false,
363
371
  right: false,
364
372
  })
373
+ const keyboardPanMotion = useRef(createKeyboardPanMotion())
365
374
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
366
375
  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
367
376
  const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
@@ -374,12 +383,24 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
374
383
  isFirstPersonMode,
375
384
  cameraMode,
376
385
  )
377
- const currentLevelId = selection.levelId
386
+ // Selecting the building or site clears the level; the camera keeps
387
+ // standing on the last level rather than dropping to the ground floor.
388
+ const lastLevelId = useRef<string | null>(null)
389
+ if (selection.levelId) lastLevelId.current = selection.levelId
390
+ const currentLevelId = lastLevelId.current
378
391
  const firstLoad = useRef(true)
392
+ const floorY = useScene((state) =>
393
+ currentLevelId ? getLevelPresentationY(currentLevelId, state.nodes, levelMode) : 0,
394
+ )
395
+ const allowUnderground = !isPreviewMode && allowUndergroundCamera
396
+ const floorMinY = allowUnderground ? null : floorY + ORBIT_FLOOR_CLEARANCE
379
397
  const maxPolarAngle =
380
- !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
398
+ allowUnderground || cameraMode === 'perspective'
399
+ ? MAX_ORBIT_POLAR_ANGLE
400
+ : DEFAULT_MAX_POLAR_ANGLE
381
401
 
382
402
  const camera = useThree((state) => state.camera)
403
+ const scene = useThree((state) => state.scene)
383
404
  const gl = useThree((state) => state.gl)
384
405
  const raycaster = useThree((state) => state.raycaster)
385
406
  const viewportSize = useThree((state) => state.size)
@@ -418,7 +439,10 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
418
439
  const beginLocalCameraInteraction = useCallback(
419
440
  ({ dragging = true }: { dragging?: boolean } = {}) => {
420
441
  cancelPoseApplication()
421
- if (dragging) cameraDraggingLifecycle.begin()
442
+ if (dragging) {
443
+ controls.current?.releaseWrittenPose()
444
+ cameraDraggingLifecycle.begin()
445
+ }
422
446
  emitter.emit('camera-controls:interaction-start', undefined)
423
447
  },
424
448
  [cameraDraggingLifecycle, cancelPoseApplication],
@@ -506,11 +530,21 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
506
530
 
507
531
  emitter.on('camera-controls:apply-pose', handleAppliedPose)
508
532
  emitter.on('camera-controls:cancel-pose', cancelPoseApplication)
533
+ if (!isPreviewMode) {
534
+ const restore = takePreviewCameraRestore()
535
+ if (restore) handleAppliedPose(restore)
536
+ }
509
537
  return () => {
510
538
  emitter.off('camera-controls:apply-pose', handleAppliedPose)
511
539
  emitter.off('camera-controls:cancel-pose', cancelPoseApplication)
512
540
  }
513
- }, [applyPendingPose, cancelPoseApplication, freezeActivePoseInterpolation, isFirstPersonMode])
541
+ }, [
542
+ applyPendingPose,
543
+ cancelPoseApplication,
544
+ freezeActivePoseInterpolation,
545
+ isFirstPersonMode,
546
+ isPreviewMode,
547
+ ])
514
548
 
515
549
  useEffect(() => cancelPoseApplication, [cancelPoseApplication])
516
550
 
@@ -521,33 +555,60 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
521
555
  if (process.env.NODE_ENV !== 'development') return
522
556
  const w = window as typeof window & {
523
557
  __pascalCameraControls?: (() => CameraControlsImpl | null) | null
558
+ __pascalScene?: (() => unknown) | null
559
+ __pascalCapture?: (() => Promise<string>) | null
524
560
  }
525
561
  w.__pascalCameraControls = () => controls.current
562
+ // the live three.js scene, for the same tooling (a probe can walk the
563
+ // meshes for an empty geometry the WebGPU validator complains about)
564
+ w.__pascalScene = () => scene
565
+ // a fresh frame read back as a PNG data URL: the canvas is cleared after
566
+ // each presented frame, so a capture has to render and read in one task
567
+ w.__pascalCapture = async () => {
568
+ const r = gl as unknown as {
569
+ renderAsync?: (s: unknown, c: unknown) => Promise<void>
570
+ render: (s: unknown, c: unknown) => void
571
+ domElement: HTMLCanvasElement
572
+ }
573
+ if (r.renderAsync) await r.renderAsync(scene, camera)
574
+ else r.render(scene, camera)
575
+ return r.domElement.toDataURL('image/png')
576
+ }
526
577
  return () => {
527
578
  w.__pascalCameraControls = null
579
+ w.__pascalScene = null
580
+ w.__pascalCapture = null
528
581
  }
529
- }, [])
582
+ }, [scene, gl, camera])
530
583
 
584
+ // `camera` too: drei builds a fresh controls instance (pivot at the origin)
585
+ // when the default camera mounts or changes; it is rested on the floor too.
586
+ // biome-ignore lint/correctness/useExhaustiveDependencies: camera re-runs this on a new instance
531
587
  useEffect(() => {
532
588
  if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
533
589
  // Analytic destination, not `sceneRegistry` mesh position: a level created
534
590
  // this frame still sits at y=0 (LevelSystem lerps it later), and a mode
535
591
  // switch leaves every level mid-lerp — the camera must pan to where the
536
592
  // level will settle, in the CURRENT presentation mode.
537
- const targetY = currentLevelId
538
- ? getLevelPresentationY(currentLevelId, useScene.getState().nodes, levelMode)
539
- : 0
593
+ const targetY = orbitGroundY(currentLevelId, levelMode)
540
594
  if (!controls.current) return
541
595
  if (firstLoad.current) {
542
596
  firstLoad.current = false
543
- controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
597
+ controls.current.setLookAt(20, 20 + targetY, 20, 0, targetY, 0, true)
544
598
  }
545
- controls.current.getTarget(currentTarget)
599
+ controls.current.getOrbitTarget(currentTarget)
546
600
  // Idempotence guard: skip when already there — also swallows the thumbnail
547
601
  // generator's synchronous stacked→restore levelMode round-trip.
548
602
  if (Math.abs(currentTarget.y - targetY) < 1e-3) return
549
603
  controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
550
- }, [currentLevelId, levelMode, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
604
+ }, [
605
+ camera,
606
+ currentLevelId,
607
+ levelMode,
608
+ isPreviewMode,
609
+ isFirstPersonMode,
610
+ isRestoringFirstPersonPose,
611
+ ])
551
612
 
552
613
  useEffect(() => {
553
614
  if (isFirstPersonMode || !controls.current) return
@@ -560,6 +621,15 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
560
621
  }
561
622
  }, [isFirstPersonMode, maxPolarAngle])
562
623
 
624
+ const floorSurface = useMemo(() => new FloorSurface(), [])
625
+ // biome-ignore lint/correctness/useExhaustiveDependencies: drei builds a new controls instance per camera
626
+ useLayoutEffect(() => {
627
+ floorSurface.levelId = currentLevelId ?? null
628
+ if (!controls.current) return
629
+ controls.current.floorMinY = floorMinY
630
+ controls.current.floorSurface = floorSurface
631
+ }, [camera, currentLevelId, floorMinY, floorSurface, isFirstPersonMode])
632
+
563
633
  const focusNode = useCallback(
564
634
  (nodeId: string) => {
565
635
  if (isPreviewMode || isFirstPersonMode || !controls.current) return
@@ -567,17 +637,34 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
567
637
  const object3D = sceneRegistry.nodes.get(nodeId)
568
638
  if (!object3D) return
569
639
 
640
+ const node = useScene.getState().nodes[nodeId as AnyNodeId]
641
+ const groundFocus = node !== undefined && GROUND_FOCUS_TYPES.has(node.type)
570
642
  tempBox.setFromObject(object3D)
571
- if (tempBox.isEmpty()) return
572
-
573
- tempBox.getCenter(tempCenter)
643
+ if (!tempBox.isEmpty()) {
644
+ tempBox.getCenter(tempCenter)
645
+ } else if (groundFocus && node.type === 'zone' && node.polygon.length > 0) {
646
+ // A room draws nothing measurable in some display modes: aim at the
647
+ // middle of its outline instead.
648
+ tempBox.makeEmpty()
649
+ for (const [x, z] of node.polygon) tempBox.expandByPoint(tempCenter.set(x, 0, z))
650
+ tempBox.getCenter(tempCenter)
651
+ object3D.updateWorldMatrix(true, false)
652
+ tempCenter.applyMatrix4(object3D.matrixWorld)
653
+ } else {
654
+ return
655
+ }
656
+ if (groundFocus) {
657
+ tempCenter.y = orbitGroundY(levelOf(nodeId) ?? lastLevelId.current)
658
+ }
574
659
  controls.current.getPosition(tempPosition)
575
660
  controls.current.getTarget(tempTarget)
576
661
  tempDelta.copy(tempCenter).sub(tempTarget)
577
662
 
663
+ const focusedY = tempPosition.y + tempDelta.y
664
+ const floorMinY = isPerspectiveCamera(camera) ? controls.current.floorLimitY : null
578
665
  controls.current.setLookAt(
579
666
  tempPosition.x + tempDelta.x,
580
- tempPosition.y + tempDelta.y,
667
+ floorMinY === null ? focusedY : Math.max(focusedY, floorMinY),
581
668
  tempPosition.z + tempDelta.z,
582
669
  tempCenter.x,
583
670
  tempCenter.y,
@@ -585,7 +672,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
585
672
  true,
586
673
  )
587
674
  },
588
- [isPreviewMode, isFirstPersonMode],
675
+ [camera, isPreviewMode, isFirstPersonMode],
589
676
  )
590
677
 
591
678
  const publishCurrentPose = useCallback(() => {
@@ -667,23 +754,18 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
667
754
  }
668
755
  }
669
756
 
670
- const panKeys = keyboardPanKeys.current
671
- const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
672
- const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
673
- if (horizontal === 0 && vertical === 0) return
757
+ const motion = keyboardPanMotion.current
758
+ if (!advanceKeyboardPanMotion(motion, keyboardPanKeys.current, performance.now())) return
674
759
 
675
760
  const control = controls.current
676
761
 
677
762
  control.getSpherical(keyboardPanSpherical, false)
678
763
  const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize)
679
- const speed = Math.min(
680
- Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
681
- KEYBOARD_PAN_MAX_SPEED,
682
- )
683
- const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
764
+ const speed = keyboardPanSpeed(viewWidth)
684
765
 
685
- if (horizontal !== 0) control.truck(horizontal * step, 0, true)
686
- if (vertical !== 0) control.forward(vertical * step, true)
766
+ // The motion already eases; the controls' own smoothing would add a glide on top.
767
+ if (motion.stepX !== 0) control.truck(motion.stepX * speed, 0, false)
768
+ if (motion.stepY !== 0) control.forward(motion.stepY * speed, false)
687
769
  }, 0)
688
770
 
689
771
  // Configure mouse buttons based on control mode and camera mode
@@ -694,13 +776,14 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
694
776
  ? CameraControlsImpl.ACTION.ZOOM
695
777
  : CameraControlsImpl.ACTION.DOLLY
696
778
 
779
+ // Left click selects, in Preview as in the published viewer.
697
780
  return {
698
- left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE,
781
+ left: CameraControlsImpl.ACTION.NONE,
699
782
  middle: CameraControlsImpl.ACTION.SCREEN_PAN,
700
783
  right: CameraControlsImpl.ACTION.ROTATE,
701
784
  wheel: wheelAction,
702
785
  }
703
- }, [cameraMode, isPreviewMode])
786
+ }, [cameraMode])
704
787
 
705
788
  // Touch gestures (mobile / trackpad).
706
789
  // - One finger drag → rotate by default (much easier on a phone), but
@@ -766,13 +849,6 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
766
849
  let panPointerId: number | null = null
767
850
  let panPointerButton: number | null = null
768
851
 
769
- const clearKeyboardPanKeys = () => {
770
- keyboardPanKeys.current.forward = false
771
- keyboardPanKeys.current.backward = false
772
- keyboardPanKeys.current.left = false
773
- keyboardPanKeys.current.right = false
774
- }
775
-
776
852
  const setNavigationCursor = (cursor: 'grab' | 'grabbing') => {
777
853
  document.body.style.cursor = cursor
778
854
  gl.domElement.style.cursor = cursor
@@ -823,10 +899,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
823
899
  controls.current.mouseButtons.wheel = wheelAction
824
900
  controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
825
901
  controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
826
- if (isPreviewMode) {
827
- // In preview mode, left-click is always pan (viewer-style)
828
- controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
829
- } else if (space) {
902
+ if (space) {
830
903
  controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
831
904
  } else {
832
905
  controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
@@ -835,10 +908,12 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
835
908
 
836
909
  const onKeyDown = (event: KeyboardEvent) => {
837
910
  if (isKeyboardPanKey(event.code)) {
838
- if (
839
- !(event.metaKey || event.ctrlKey || event.altKey) &&
840
- !isEditableKeyboardTarget(event.target)
841
- ) {
911
+ if (acceptsKeyboardPan(event) && !planOwnsNavigation()) {
912
+ syncKeyboardPanMotion(
913
+ keyboardPanMotion.current,
914
+ keyboardPanKeys.current,
915
+ performance.now(),
916
+ )
842
917
  const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
843
918
  if (changed) beginLocalCameraInteraction()
844
919
  event.preventDefault()
@@ -870,6 +945,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
870
945
 
871
946
  const onKeyUp = (event: KeyboardEvent) => {
872
947
  if (isKeyboardPanKey(event.code)) {
948
+ syncKeyboardPanMotion(keyboardPanMotion.current, keyboardPanKeys.current, performance.now())
873
949
  const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false)
874
950
  if (changed) {
875
951
  if (!hasKeyboardPanInput(keyboardPanKeys.current)) {
@@ -930,7 +1006,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
930
1006
 
931
1007
  const onBlur = () => {
932
1008
  keyState.space = false
933
- clearKeyboardPanKeys()
1009
+ clearKeyboardPanKeys(keyboardPanKeys.current)
934
1010
  panPointerId = null
935
1011
  panPointerButton = null
936
1012
  clearNavigationCursor()
@@ -955,18 +1031,11 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
955
1031
  window.removeEventListener('pointercancel', onPointerUp, true)
956
1032
  window.removeEventListener('blur', onBlur)
957
1033
  gl.domElement.removeEventListener('wheel', onWheel, true)
958
- clearKeyboardPanKeys()
1034
+ clearKeyboardPanKeys(keyboardPanKeys.current)
959
1035
  clearNavigationCursor()
960
1036
  cameraDraggingLifecycle.end()
961
1037
  }
962
- }, [
963
- beginLocalCameraInteraction,
964
- cameraDraggingLifecycle,
965
- cameraMode,
966
- gl,
967
- isPreviewMode,
968
- isFirstPersonMode,
969
- ])
1038
+ }, [beginLocalCameraInteraction, cameraDraggingLifecycle, cameraMode, gl, isFirstPersonMode])
970
1039
 
971
1040
  // `controlstart` fires only for user pointer interactions. Pointerdowns
972
1041
  // mapped to ACTION.NONE must not flag the camera as dragging because no
@@ -979,18 +1048,18 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
979
1048
  })
980
1049
  }, [beginLocalCameraInteraction])
981
1050
 
982
- // Preview mode: auto-navigate camera to selected node (viewer behavior)
983
- const previewTargetNodeId = isPreviewMode
984
- ? (selection.zoneId ?? selection.levelId ?? selection.buildingId)
985
- : null
986
-
1051
+ // Preview opens on the editor's floor (or building, or site) framed the
1052
+ // viewer's way; afterwards only the viewer navigation moves the camera
1053
+ // (`camera-controls:frame`), never a canvas selection.
987
1054
  useEffect(() => {
988
1055
  if (!(isPreviewMode && controls.current) || isFirstPersonMode) return
989
1056
 
990
1057
  const nodes = useScene.getState().nodes
991
- let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
1058
+ const { selection: opening } = useViewer.getState()
1059
+ const openingNodeId = opening.levelId ?? opening.buildingId
1060
+ let node = openingNodeId ? nodes[openingNodeId as AnyNodeId] : null
992
1061
 
993
- if (!previewTargetNodeId) {
1062
+ if (!openingNodeId) {
994
1063
  const site = Object.values(nodes).find((n) => n.type === 'site')
995
1064
  node = site || null
996
1065
  }
@@ -1023,10 +1092,10 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1023
1092
  return
1024
1093
  }
1025
1094
 
1026
- if (!previewTargetNodeId) return
1095
+ if (!openingNodeId) return
1027
1096
 
1028
1097
  // Calculate camera position from bounding box
1029
- const object3D = sceneRegistry.nodes.get(previewTargetNodeId)
1098
+ const object3D = sceneRegistry.nodes.get(openingNodeId)
1030
1099
  if (!object3D) return
1031
1100
 
1032
1101
  tempBox.setFromObject(object3D)
@@ -1045,7 +1114,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1045
1114
  tempCenter.z,
1046
1115
  true,
1047
1116
  )
1048
- }, [isPreviewMode, isFirstPersonMode, previewTargetNodeId])
1117
+ }, [isPreviewMode, isFirstPersonMode])
1049
1118
 
1050
1119
  // Preset capture auto-framing — when `setCaptureMode({ mode: 'preset',
1051
1120
  // isolated })` fires, fly the camera to a pose that fits the union
@@ -1196,7 +1265,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1196
1265
  }
1197
1266
 
1198
1267
  const handleTopView = () => {
1199
- if (isFirstPersonMode || !controls.current) return
1268
+ if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return
1200
1269
 
1201
1270
  const currentPolarAngle = controls.current.polarAngle
1202
1271
 
@@ -1208,10 +1277,10 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1208
1277
  }
1209
1278
 
1210
1279
  const handleOrbitCW = () => {
1211
- if (isFirstPersonMode || !controls.current) return
1280
+ if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return
1212
1281
 
1213
1282
  const currentAzimuth = controls.current.azimuthAngle
1214
- const currentPolar = controls.current.polarAngle
1283
+ const currentPolar = controls.current.orbitPolarAngle
1215
1284
  // Round to nearest 90° increment, then rotate 90° clockwise
1216
1285
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
1217
1286
  const target = rounded - Math.PI / 2
@@ -1220,10 +1289,10 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1220
1289
  }
1221
1290
 
1222
1291
  const handleOrbitCCW = () => {
1223
- if (isFirstPersonMode || !controls.current) return
1292
+ if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return
1224
1293
 
1225
1294
  const currentAzimuth = controls.current.azimuthAngle
1226
- const currentPolar = controls.current.polarAngle
1295
+ const currentPolar = controls.current.orbitPolarAngle
1227
1296
  // Round to nearest 90° increment, then rotate 90° counter-clockwise
1228
1297
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
1229
1298
  const target = rounded + Math.PI / 2
@@ -1235,11 +1304,18 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1235
1304
  focusNode(nodeId)
1236
1305
  }
1237
1306
 
1307
+ const handleFrame = (event: CameraControlFrameEvent) => {
1308
+ if (isFirstPersonMode || !controls.current) return
1309
+ cancelPoseApplication()
1310
+ frameViewerCamera(controls.current, camera, event)
1311
+ }
1312
+
1238
1313
  const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
1239
1314
  if (isFirstPersonMode || !controls.current || isPreviewMode) return
1315
+ const groundY = orbitGroundY(lastLevelId.current)
1240
1316
  if (!bounds) {
1241
1317
  // Restore default framing pose when no bounds were computed.
1242
- controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
1318
+ controls.current.setLookAt(20, 20 + groundY, 20, 0, groundY, 0, true)
1243
1319
  return
1244
1320
  }
1245
1321
  const [cx, cz] = bounds.center
@@ -1249,11 +1325,20 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1249
1325
  const maxExtent = Math.max(w, d)
1250
1326
  const distance = Math.max(maxExtent * 1.4, 15)
1251
1327
  const height = Math.max(maxExtent * 0.8, 10)
1252
- controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
1328
+ controls.current.setLookAt(
1329
+ cx + distance * 0.7,
1330
+ height + groundY,
1331
+ cz + distance * 0.7,
1332
+ cx,
1333
+ groundY,
1334
+ cz,
1335
+ true,
1336
+ )
1253
1337
  }
1254
1338
 
1255
1339
  emitter.on('camera-controls:capture', handleNodeCapture)
1256
1340
  emitter.on('camera-controls:focus', handleNodeFocus)
1341
+ emitter.on('camera-controls:frame', handleFrame)
1257
1342
  emitter.on('camera-controls:view', handleNodeView)
1258
1343
  emitter.on('camera-controls:top-view', handleTopView)
1259
1344
  emitter.on('camera-controls:orbit-cw', handleOrbitCW)
@@ -1263,13 +1348,14 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1263
1348
  return () => {
1264
1349
  emitter.off('camera-controls:capture', handleNodeCapture)
1265
1350
  emitter.off('camera-controls:focus', handleNodeFocus)
1351
+ emitter.off('camera-controls:frame', handleFrame)
1266
1352
  emitter.off('camera-controls:view', handleNodeView)
1267
1353
  emitter.off('camera-controls:top-view', handleTopView)
1268
1354
  emitter.off('camera-controls:orbit-cw', handleOrbitCW)
1269
1355
  emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
1270
1356
  emitter.off('camera-controls:fit-scene', handleFitScene)
1271
1357
  }
1272
- }, [focusNode, isPreviewMode, isFirstPersonMode])
1358
+ }, [camera, cancelPoseApplication, focusNode, isPreviewMode, isFirstPersonMode])
1273
1359
 
1274
1360
  const onTransitionStart = useCallback(() => {
1275
1361
  cameraDraggingLifecycle.begin()
@@ -1300,6 +1386,10 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1300
1386
  // navigation guardrails.
1301
1387
  const isPresetCapture = captureMode.mode === 'preset'
1302
1388
  const minDistance = isPresetCapture ? 0.5 : 2
1389
+ // Over modeled or mapped surroundings (a neighbourhood instead of the
1390
+ // horizon disc) the camera may pull back far enough to take it all in.
1391
+ const groundReplaced = useSceneGroundReplacement()
1392
+ const maxDistance = groundReplaced ? 1500 : 100
1303
1393
 
1304
1394
  if (isFirstPersonMode) {
1305
1395
  return null
@@ -1307,8 +1397,10 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1307
1397
 
1308
1398
  return (
1309
1399
  <CameraControls
1400
+ azimuthRotateSpeed={ORBIT_ROTATE_SPEED}
1310
1401
  makeDefault
1311
- maxDistance={100}
1402
+ maxDistance={maxDistance}
1403
+ impl={FloorAwareCameraControls}
1312
1404
  maxPolarAngle={maxPolarAngle}
1313
1405
  minDistance={minDistance}
1314
1406
  minPolarAngle={0}
@@ -1317,6 +1409,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1317
1409
  onControlStart={handleControlStart}
1318
1410
  onUpdate={handleCameraUpdate}
1319
1411
  onRest={onRest}
1412
+ polarRotateSpeed={ORBIT_ROTATE_SPEED}
1320
1413
  onSleep={onRest}
1321
1414
  onTransitionStart={onTransitionStart}
1322
1415
  ref={controls}