@pascal-app/editor 1.0.6 → 1.1.0

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 (382) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "description": "Pascal building editor component",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/index.tsx",
9
9
  "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx",
10
+ "./locales": "./src/i18n/locales.ts",
11
+ "./locales/pseudo": "./src/i18n/pseudo-locale.ts",
10
12
  "./procedural-items": "./src/components/procedural-items/inspector.tsx",
11
13
  "./distribution": "./src/components/tools/distribution/distribution-public.ts"
12
14
  },
@@ -16,23 +18,24 @@
16
18
  "test": "bun test src"
17
19
  },
18
20
  "peerDependencies": {
19
- "@pascal-app/core": "^1.0.6",
20
- "@pascal-app/viewer": "^1.0.6",
21
+ "@pascal-app/core": "^1.1.0",
22
+ "@pascal-app/viewer": "^1.1.0",
21
23
  "@react-three/drei": "^10",
22
24
  "@react-three/fiber": "^9",
23
25
  "next": ">=15",
24
26
  "react": "^18 || ^19",
25
27
  "react-dom": "^18 || ^19",
26
- "three": "^0.186"
28
+ "three": "^0.187"
27
29
  },
28
30
  "dependencies": {
29
31
  "@dnd-kit/core": "^6.3.1",
30
32
  "@dnd-kit/sortable": "^10.0.0",
31
33
  "@dnd-kit/utilities": "^3.2.2",
32
- "@iconify/react": "^6.0.3",
34
+ "@iconify/react": "^6.0.4",
35
+ "@lingui/core": "6.12.0",
33
36
  "@number-flow/react": "^0.6.2",
34
- "@pascal-app/geometry-script": "^1.0.6",
35
- "@pascal-app/ifc-converter": "^1.0.6",
37
+ "@pascal-app/geometry-script": "^1.1.0",
38
+ "@pascal-app/ifc-converter": "^1.1.0",
36
39
  "@pascal-app/lingo": "^0.5.0",
37
40
  "@radix-ui/react-alert-dialog": "^1.2.1",
38
41
  "@radix-ui/react-context-menu": "^2.4.1",
@@ -53,31 +56,31 @@
53
56
  "cmdk": "^1.1.1",
54
57
  "fflate": "^0.8.3",
55
58
  "howler": "^2.2.4",
56
- "lucide-react": "^1.61.0",
59
+ "lucide-react": "^1.69.0",
57
60
  "manifold-3d": "3.5.4",
58
61
  "mitt": "^3.0.1",
59
- "motion": "^14.6.0",
62
+ "motion": "^14.9.1",
60
63
  "nanoid": "^6.0.2",
61
64
  "pdfkit": "^0.20.2",
62
65
  "tailwind-merge": "^3.7.0",
63
66
  "three-bvh-csg": "^0.0.18",
64
- "three-mesh-bvh": "~0.9.16",
67
+ "three-mesh-bvh": "~0.9.18",
65
68
  "zod": ">=4.6.7 <4.7",
66
69
  "zustand": "^5.0.16"
67
70
  },
68
71
  "devDependencies": {
69
- "@pascal-app/core": "^1.0.6",
70
- "@pascal-app/viewer": "^1.0.6",
72
+ "@pascal-app/core": "^1.1.0",
73
+ "@pascal-app/viewer": "^1.1.0",
71
74
  "@pascal/typescript-config": "*",
72
75
  "@react-three/test-renderer": "^9.1.1",
73
76
  "@types/blob-stream": "^0.1.33",
74
- "@types/bun": "^1.4.3",
77
+ "@types/bun": "^1.4.4",
75
78
  "@types/howler": "^2.2.13",
76
79
  "@types/pdfkit": "^0.17.7",
77
80
  "@types/react": "19.3.0",
78
81
  "@types/react-dom": "19.3.0",
79
- "@types/three": "^0.186.0",
80
- "fast-xml-parser": "^5.11.2",
82
+ "@types/three": "^0.187",
83
+ "fast-xml-parser": "^5.12.0",
81
84
  "typescript": "7.0.2"
82
85
  },
83
86
  "repository": {
@@ -1,6 +1,8 @@
1
1
  'use client'
2
2
 
3
+ import { usePascalI18n } from '@pascal-app/core'
3
4
  import type { MouseEventHandler, ReactNode } from 'react'
5
+ import { toolHintKey } from '../../i18n/node-messages'
4
6
  import { cn } from '../../lib/utils'
5
7
  import { shortcutDisplayValue } from '../ui/primitives/shortcut-token'
6
8
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
@@ -37,6 +39,7 @@ export function ActionMenuButton({
37
39
  pressed?: boolean
38
40
  children: ReactNode
39
41
  }) {
42
+ const i18n = usePascalI18n()
40
43
  return (
41
44
  <Tooltip>
42
45
  <TooltipTrigger asChild>
@@ -72,7 +75,7 @@ export function ActionMenuButton({
72
75
  className="rounded border border-background/30 px-1 font-mono text-[10px] leading-4 opacity-80"
73
76
  key={key}
74
77
  >
75
- {shortcutDisplayValue(key)}
78
+ {toolHintKey(shortcutDisplayValue(key), i18n)}
76
79
  </kbd>
77
80
  ))}
78
81
  </span>
@@ -2,13 +2,16 @@
2
2
 
3
3
  import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Html } from '@react-three/drei'
6
5
  import { useFrame } from '@react-three/fiber'
7
6
  import { memo, useMemo, useRef } from 'react'
8
- import { BoxGeometry, CircleGeometry, type Group, Vector3 } from 'three'
7
+ import { BoxGeometry, CircleGeometry, type Group, RingGeometry, Vector3 } from 'three'
9
8
  import { MeshBasicNodeMaterial } from 'three/webgpu'
10
9
  import { EDITOR_LAYER } from '../../lib/constants'
11
- import useAlignmentGuides from '../../store/use-alignment-guides'
10
+ import useAlignmentGuides, {
11
+ type DraftGuideLine,
12
+ type DraftGuides,
13
+ } from '../../store/use-alignment-guides'
14
+ import { Html } from './localized-html'
12
15
  import { formatMeasurement } from './measurement-pill'
13
16
 
14
17
  /**
@@ -22,13 +25,18 @@ import { formatMeasurement } from './measurement-pill'
22
25
  * the design reference — so they're real 3D geometry, not screen billboards.
23
26
  * Only the distance pill is screen-space (`<Html>`).
24
27
  *
25
- * Guide coordinates are XZ meters in the WORLD frame — alignment now resolves
26
- * on the world axes (via `resolveAlignmentForActiveBuilding`) so the guides
27
- * stay parallel to the visible world grid even when the active building is
28
- * rotated. This layer is mounted OUTSIDE the building-local ToolManager group
29
- * for the same reason. The whole ribbon is lifted to the active level's WORLD
30
- * Y each frame so it lies on the floor being edited — not the building base —
31
- * when floors are stacked.
28
+ * Guide coordinates are XZ meters in the active building's LOCAL frame —
29
+ * alignment resolves on the building's own axes, the axes its 3D grid is laid
30
+ * on — and the layer is mounted inside the building-local ToolManager group.
31
+ * The whole ribbon is lifted to the active level's WORLD Y each frame so it
32
+ * lies on the floor being edited — not the building base — when floors are
33
+ * stacked.
34
+ *
35
+ * A wall pulled from a wall end (and the run it continues) publishes its
36
+ * projection guides beside them (`useAlignmentGuides.draft`): the extension of
37
+ * the wall it comes from in amber, lines through other walls' ends in indigo,
38
+ * lines through the run's start in emerald, all dashed, with a ringed marker
39
+ * where two of them cross.
32
40
  */
33
41
 
34
42
  const LINE_COLOR = 0x81_8c_f8 // indigo-400 — matches the editor's selection accent (box-select / wall highlights)
@@ -59,11 +67,33 @@ const dotMaterial = new MeshBasicNodeMaterial({
59
67
  })
60
68
  const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
61
69
  const DOT_GEOMETRY = new CircleGeometry(1, 24)
70
+ const RING_GEOMETRY = new RingGeometry(0.7, 1, 28)
71
+
72
+ // Draft projection guides: one material per kind, shared like the above.
73
+ const draftGuideMaterial = (color: number) =>
74
+ new MeshBasicNodeMaterial({
75
+ color,
76
+ depthTest: false,
77
+ depthWrite: false,
78
+ toneMapped: false,
79
+ transparent: true,
80
+ opacity: 0.9,
81
+ })
82
+ const DRAFT_GUIDE_MATERIALS: Record<DraftGuideLine['kind'], MeshBasicNodeMaterial> = {
83
+ extension: draftGuideMaterial(0xf5_9e_0b), // amber-500
84
+ alignment: guideMaterial,
85
+ 'run-start': draftGuideMaterial(0x10_b9_81), // emerald-500 — the pull grip's colour
86
+ }
87
+ const DRAFT_DASH_LEN = 0.14
88
+ const DRAFT_DASH_GAP = 0.12
89
+ const DRAFT_LINE_WIDTH = 0.035
90
+ const INTERSECTION_RADIUS = 0.13
62
91
 
63
92
  type Vec3 = [number, number, number]
64
93
 
65
94
  export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
66
95
  const guides = useAlignmentGuides((s) => s.guides)
96
+ const draft = useAlignmentGuides((s) => s.draft)
67
97
  const levelId = useViewer((s) => s.selection.levelId)
68
98
  const unit = useViewer((s) => s.unit)
69
99
  const metricNotation = useViewer((s) => s.metricNotation)
@@ -82,16 +112,77 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
82
112
  group.position.y = levelMesh ? levelMesh.getWorldPosition(worldYWork).y : 0
83
113
  })
84
114
 
85
- if (guides.length === 0) return null
115
+ if (guides.length === 0 && !draft) return null
86
116
  return (
87
117
  <group ref={groupRef}>
88
118
  {guides.map((guide, i) => (
89
119
  <GuideLine guide={guide} key={i} metricNotation={metricNotation} unit={unit} />
90
120
  ))}
121
+ {draft ? <DraftGuidesLayer draft={draft} /> : null}
91
122
  </group>
92
123
  )
93
124
  })
94
125
 
126
+ /** A wall draft's dashed projection guides and the markers where they cross. */
127
+ function DraftGuidesLayer({ draft }: { draft: DraftGuides }) {
128
+ return (
129
+ <>
130
+ {draft.lines.map((line, i) => (
131
+ <DraftLine key={`l${i}`} line={line} />
132
+ ))}
133
+ {draft.intersections.map((point, i) => (
134
+ <mesh
135
+ geometry={RING_GEOMETRY}
136
+ key={`x${i}`}
137
+ layers={EDITOR_LAYER}
138
+ material={guideMaterial}
139
+ position={[point[0], GUIDE_Y + 0.002, point[1]]}
140
+ renderOrder={1001}
141
+ rotation={[-Math.PI / 2, 0, 0]}
142
+ scale={[INTERSECTION_RADIUS, INTERSECTION_RADIUS, INTERSECTION_RADIUS]}
143
+ />
144
+ ))}
145
+ </>
146
+ )
147
+ }
148
+
149
+ function DraftLine({ line }: { line: DraftGuideLine }) {
150
+ const [fx, fz] = line.from
151
+ const [tx, tz] = line.to
152
+ const { dashes, angleY } = useMemo(() => {
153
+ const dx = tx - fx
154
+ const dz = tz - fz
155
+ const length = Math.hypot(dx, dz)
156
+ const angle = -Math.atan2(dz, dx)
157
+ if (length < 1e-4) return { dashes: [] as Vec3[], angleY: angle }
158
+ const ux = dx / length
159
+ const uz = dz / length
160
+ const period = Math.max(DRAFT_DASH_LEN + DRAFT_DASH_GAP, length / MAX_DASHES)
161
+ const centres: Vec3[] = []
162
+ for (let d = period / 2; d - DRAFT_DASH_LEN / 2 <= length; d += period) {
163
+ centres.push([fx + ux * d, GUIDE_Y, fz + uz * d])
164
+ }
165
+ return { dashes: centres, angleY: angle }
166
+ }, [fx, fz, tx, tz])
167
+ const material = DRAFT_GUIDE_MATERIALS[line.kind]
168
+ return (
169
+ <>
170
+ {dashes.map((centre, i) => (
171
+ <mesh
172
+ geometry={DASH_GEOMETRY}
173
+ key={i}
174
+ layers={EDITOR_LAYER}
175
+ material={material}
176
+ position={centre}
177
+ renderOrder={1000}
178
+ rotation={[0, angleY, 0]}
179
+ scale={[DRAFT_DASH_LEN, 0.002, DRAFT_LINE_WIDTH]}
180
+ />
181
+ ))}
182
+ </>
183
+ )
184
+ }
185
+
95
186
  function GuideLine({
96
187
  guide,
97
188
  metricNotation,
@@ -47,8 +47,9 @@ const THUMBNAIL_BACKDROP = { inner: '#FAFAFA', outer: '#E4E4E4' }
47
47
  const THUMBNAIL_SHADOW_CATCHER_OPACITY = 0.7
48
48
  /** Soft shade hugging the ground-floor walls so the building never floats. */
49
49
  const THUMBNAIL_WALL_CONTACT = { opacity: 0.5, spreadM: 0.9 }
50
- /** Unpainted walls render as smooth plaster rather than the screw-dotted
51
- * prepared-drywall default, which reads as unfinished at card size. */
50
+ /** A roof's unpainted trim and gable bands render as smooth plaster rather
51
+ * than the screw-dotted prepared-drywall default, which reads as unfinished
52
+ * at card size. Unpainted walls already wear the interior paint. */
52
53
  const THUMBNAIL_SLOT_DEFAULT_OVERRIDES = {
53
54
  'library:concrete-drywall': 'library:concrete-plaster',
54
55
  } as const
@@ -29,6 +29,7 @@ import {
29
29
  type Camera,
30
30
  type OrthographicCamera,
31
31
  type PerspectiveCamera,
32
+ Sphere,
32
33
  Spherical,
33
34
  Vector3,
34
35
  } from 'three'
@@ -62,6 +63,7 @@ import {
62
63
  orbitFloorMinY,
63
64
  overviewEyeY,
64
65
  } from '../../lib/orbit-floor-clearance'
66
+ import { orbitNearPlane } from '../../lib/orbit-near-plane'
65
67
  import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority'
66
68
  import { frameViewerCamera } from '../../lib/viewer-selection'
67
69
  import { publishCameraPose } from '../../store/camera-pose-store'
@@ -73,6 +75,13 @@ import {
73
75
  } from '../../store/use-interaction-scope'
74
76
  import { visibleScene } from '../../store/view-layout'
75
77
  import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle'
78
+ import {
79
+ type FitSceneLookAt,
80
+ type PendingFitScene,
81
+ planFitSceneOnEvent,
82
+ planFitSceneOnOrbitResume,
83
+ planLevelFollow,
84
+ } from './fit-scene-framing'
76
85
  import { FloorAwareCameraControls } from './floor-aware-camera-controls'
77
86
  import { FloorSurface } from './floor-surface'
78
87
 
@@ -83,6 +92,7 @@ const tempDelta = new Vector3()
83
92
  const tempPosition = new Vector3()
84
93
  const tempSize = new Vector3()
85
94
  const tempTarget = new Vector3()
95
+ const fitSceneSphere = new Sphere()
86
96
  const transitionFreezePosition = new Vector3()
87
97
  const transitionFreezeTarget = new Vector3()
88
98
  const keyboardPanSpherical = new Spherical()
@@ -119,6 +129,23 @@ function keepEyeOverGround(x: number, eyeY: number, z: number, previousEyeY = ey
119
129
  return overviewEyeY(eyeY, previousEyeY, groundY, useViewer.getState().eyeUnderGround)
120
130
  }
121
131
 
132
+ function applyFitLookAt(control: CameraControlsImpl, lookAt: FitSceneLookAt) {
133
+ control.setLookAt(
134
+ lookAt.eyeX,
135
+ keepEyeOverGround(lookAt.eyeX, lookAt.eyeY, lookAt.eyeZ, lookAt.eyeHeight),
136
+ lookAt.eyeZ,
137
+ lookAt.targetX,
138
+ lookAt.targetY,
139
+ lookAt.targetZ,
140
+ true,
141
+ )
142
+ if (isOrthographicCamera(control.camera)) {
143
+ fitSceneSphere.center.set(...lookAt.fitSphere.center)
144
+ fitSceneSphere.radius = lookAt.fitSphere.radius
145
+ control.fitToSphere(fitSceneSphere, true)
146
+ }
147
+ }
148
+
122
149
  function levelOf(nodeId: string): string | null {
123
150
  const nodes = useScene.getState().nodes
124
151
  let node: AnyNode | undefined = nodes[nodeId as AnyNodeId]
@@ -406,6 +433,9 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
406
433
  if (selection.levelId) lastLevelId.current = selection.levelId
407
434
  const currentLevelId = lastLevelId.current
408
435
  const firstLoad = useRef(true)
436
+ // Survives first-person (orbit unmounted) so scene-ready fit still applies
437
+ // once CameraControls remount.
438
+ const pendingFitSceneRef = useRef<PendingFitScene | null>(null)
409
439
  const floorY = useScene((state) =>
410
440
  currentLevelId ? getLevelPresentationY(currentLevelId, state.nodes, levelMode) : 0,
411
441
  )
@@ -614,19 +644,32 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
614
644
  }
615
645
  }, [scene, gl, camera])
616
646
 
647
+ const previousLevelIdRef = useRef<string | null>(null)
648
+ const previousLevelModeRef = useRef(levelMode)
649
+ const previousCameraRef = useRef(camera)
617
650
  // `camera` too: drei builds a fresh controls instance (pivot at the origin)
618
651
  // when the default camera mounts or changes; it is rested on the floor too.
619
- // biome-ignore lint/correctness/useExhaustiveDependencies: camera re-runs this on a new instance
620
652
  useEffect(() => {
621
653
  if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
622
654
  // Analytic destination, not `sceneRegistry` mesh position: a level created
623
655
  // this frame still sits at y=0 (LevelSystem lerps it later), and a mode
624
- // switch leaves every level mid-lerp — the camera must pan to where the
656
+ // switch leaves every level mid-lerp - the camera must pan to where the
625
657
  // level will settle, in the CURRENT presentation mode.
626
658
  const targetY = orbitGroundY(currentLevelId, levelMode)
627
659
  if (!controls.current) return
628
- if (firstLoad.current) {
629
- firstLoad.current = false
660
+ const plan = planLevelFollow({
661
+ firstLoad: firstLoad.current,
662
+ sceneEmpty: Object.keys(useScene.getState().nodes).length === 0,
663
+ levelChanged: previousLevelIdRef.current !== currentLevelId,
664
+ modeChanged: previousLevelModeRef.current !== levelMode,
665
+ cameraChanged: previousCameraRef.current !== camera,
666
+ })
667
+ firstLoad.current = false
668
+ previousLevelIdRef.current = currentLevelId
669
+ previousLevelModeRef.current = levelMode
670
+ previousCameraRef.current = camera
671
+ if (plan === 'none') return
672
+ if (plan === 'default-pose') {
630
673
  controls.current.setLookAt(
631
674
  20,
632
675
  keepEyeOverGround(20, 20 + targetY, 20, 20),
@@ -636,6 +679,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
636
679
  0,
637
680
  true,
638
681
  )
682
+ return
639
683
  }
640
684
  controls.current.getOrbitTarget(currentTarget)
641
685
  // Idempotence guard: skip when already there — also swallows the thumbnail
@@ -691,6 +735,19 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
691
735
  controls.current.floorSurface = diveFloor === null ? floorSurface : null
692
736
  }, [camera, currentLevelId, diveFloor, floorMinY, floorSurface, isFirstPersonMode])
693
737
 
738
+ const flushPendingFitScene = useCallback(() => {
739
+ const plan = planFitSceneOnOrbitResume({
740
+ isPreviewMode,
741
+ isFirstPersonMode: useEditor.getState().isFirstPersonMode,
742
+ hasControls: !!controls.current,
743
+ pending: pendingFitSceneRef.current,
744
+ groundY: orbitGroundY(lastLevelId.current),
745
+ })
746
+ if (plan.action !== 'apply' || !controls.current) return
747
+ pendingFitSceneRef.current = null
748
+ applyFitLookAt(controls.current, plan.lookAt)
749
+ }, [isPreviewMode])
750
+
694
751
  const focusNode = useCallback(
695
752
  (nodeId: string) => {
696
753
  if (isPreviewMode || isFirstPersonMode || !controls.current) return
@@ -766,8 +823,16 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
766
823
  }, [camera, isFirstPersonMode, viewportSize])
767
824
 
768
825
  const handleCameraUpdate = useCallback(() => {
826
+ const control = controls.current
827
+ if (control && isPerspectiveCamera(camera)) {
828
+ const near = orbitNearPlane(control.distance)
829
+ if (Math.abs(camera.near - near) > 1e-4) {
830
+ camera.near = near
831
+ camera.updateProjectionMatrix()
832
+ }
833
+ }
769
834
  publishCurrentPose()
770
- }, [publishCurrentPose])
835
+ }, [camera, publishCurrentPose])
771
836
 
772
837
  useEffect(() => {
773
838
  publishInitialCameraPose(publishCurrentPose)
@@ -1054,7 +1119,10 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1054
1119
  updateNavigationCursor()
1055
1120
  }
1056
1121
 
1057
- const onWheel = () => {
1122
+ const onWheel = (event: WheelEvent) => {
1123
+ // Zooming in carries on past the closest orbit, the target with it; zooming out stops at
1124
+ // the farthest, or the eye would drift off the site. Seen before the controls' own listener.
1125
+ if (controls.current) controls.current.infinityDolly = event.deltaY < 0
1058
1126
  beginLocalCameraInteraction()
1059
1127
  cameraDraggingLifecycle.scheduleEnd()
1060
1128
  }
@@ -1376,37 +1444,22 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1376
1444
  }
1377
1445
 
1378
1446
  const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
1379
- if (isFirstPersonMode || !controls.current || isPreviewMode) return
1380
- const groundY = orbitGroundY(lastLevelId.current)
1381
- if (!bounds) {
1382
- // Restore default framing pose when no bounds were computed.
1383
- controls.current.setLookAt(
1384
- 20,
1385
- keepEyeOverGround(20, 20 + groundY, 20, 20),
1386
- 20,
1387
- 0,
1388
- groundY,
1389
- 0,
1390
- true,
1391
- )
1447
+ const plan = planFitSceneOnEvent({
1448
+ isPreviewMode,
1449
+ isFirstPersonMode,
1450
+ hasControls: !!controls.current,
1451
+ bounds: bounds ?? null,
1452
+ groundY: orbitGroundY(lastLevelId.current),
1453
+ })
1454
+ if (plan.action === 'ignore') return
1455
+ if (plan.action === 'queue') {
1456
+ pendingFitSceneRef.current = plan.pending
1457
+ // Orbit path with a not-yet-attached ref: retry next frame.
1458
+ if (!isFirstPersonMode) requestAnimationFrame(flushPendingFitScene)
1392
1459
  return
1393
1460
  }
1394
- const [cx, cz] = bounds.center
1395
- const [w, d] = bounds.size
1396
- // Use the longer horizontal extent to size the orbit radius so the whole
1397
- // footprint sits in view regardless of aspect ratio.
1398
- const maxExtent = Math.max(w, d)
1399
- const distance = Math.max(maxExtent * 1.4, 15)
1400
- const height = Math.max(maxExtent * 0.8, 10)
1401
- controls.current.setLookAt(
1402
- cx + distance * 0.7,
1403
- keepEyeOverGround(cx + distance * 0.7, height + groundY, cz + distance * 0.7, height),
1404
- cz + distance * 0.7,
1405
- cx,
1406
- groundY,
1407
- cz,
1408
- true,
1409
- )
1461
+ pendingFitSceneRef.current = null
1462
+ if (controls.current) applyFitLookAt(controls.current, plan.lookAt)
1410
1463
  }
1411
1464
 
1412
1465
  emitter.on('camera-controls:capture', handleNodeCapture)
@@ -1428,7 +1481,22 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1428
1481
  emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
1429
1482
  emitter.off('camera-controls:fit-scene', handleFitScene)
1430
1483
  }
1431
- }, [camera, cancelPoseApplication, focusNode, isPreviewMode, isFirstPersonMode])
1484
+ }, [
1485
+ camera,
1486
+ cancelPoseApplication,
1487
+ flushPendingFitScene,
1488
+ focusNode,
1489
+ isPreviewMode,
1490
+ isFirstPersonMode,
1491
+ ])
1492
+
1493
+ // Apply a fit that arrived while first-person (orbit unmounted). Wait one
1494
+ // frame so CameraControls can remount and attach its ref.
1495
+ useEffect(() => {
1496
+ if (isFirstPersonMode || isPreviewMode || !pendingFitSceneRef.current) return
1497
+ const frame = requestAnimationFrame(flushPendingFitScene)
1498
+ return () => cancelAnimationFrame(frame)
1499
+ }, [flushPendingFitScene, isFirstPersonMode, isPreviewMode])
1432
1500
 
1433
1501
  const onTransitionStart = useCallback(() => {
1434
1502
  cameraDraggingLifecycle.begin()
@@ -1452,13 +1520,12 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1452
1520
  }
1453
1521
  }, [cameraDraggingLifecycle])
1454
1522
 
1455
- // Preset capture mode frames a single subtree (often a 0.3–2m preset),
1456
- // so the default 2m minDistance prevents the user from getting close
1457
- // enough to compose a good thumbnail. Relax the clamp to 0.5m while
1458
- // capturing presets; reset on exit so general editing keeps the looser
1459
- // navigation guardrails.
1523
+ // The wheel dollies toward the cursor and, at the closest orbit, carries the
1524
+ // target on (infinity dolly), so the eye goes right up to a window's trim
1525
+ // or through a doorway rather than stopping short of the orbit's pivot.
1526
+ // Preset capture frames a single small subtree and orbits it closer still.
1460
1527
  const isPresetCapture = captureMode.mode === 'preset'
1461
- const minDistance = isPresetCapture ? 0.5 : 2
1528
+ const minDistance = isPresetCapture ? 0.25 : 0.5
1462
1529
  // Over modeled or mapped surroundings (a neighbourhood instead of the
1463
1530
  // horizon disc) the camera may pull back far enough to take it all in.
1464
1531
  const groundReplaced = useSceneGroundReplacement()
@@ -1471,6 +1538,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1471
1538
  return (
1472
1539
  <CameraControls
1473
1540
  azimuthRotateSpeed={ORBIT_ROTATE_SPEED}
1541
+ dollyToCursor
1474
1542
  makeDefault
1475
1543
  maxDistance={maxDistance}
1476
1544
  impl={FloorAwareCameraControls}
@@ -9,6 +9,7 @@ import {
9
9
  FenceNode,
10
10
  generateId,
11
11
  ItemNode,
12
+ isNodeDuplicable,
12
13
  nodeRegistry,
13
14
  RoofSegmentNode,
14
15
  runSceneHistoryDraftWrite,
@@ -48,7 +49,7 @@ export function copyCollectionIds(copy: AnyNode): CollectionId[] {
48
49
  * every inspector's Duplicate. The copy is in the collections its source is in.
49
50
  */
50
51
  export function duplicateNodeAndPickUp(node: AnyNode) {
51
- if (!node.parentId) return
52
+ if (!(node.parentId && isNodeDuplicable(node))) return
52
53
  if (startZoneRoomTransform(node, 'duplicate')) return
53
54
  sfxEmitter.emit('sfx:item-pick')
54
55
  const { setMovingNode } = useEditor.getState()
@@ -2,13 +2,13 @@
2
2
 
3
3
  import { sceneRegistry } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Html } from '@react-three/drei'
6
5
  import { useFrame } from '@react-three/fiber'
7
6
  import { memo, useMemo, useRef } from 'react'
8
7
  import { BoxGeometry, CircleGeometry, type Group } from 'three'
9
8
  import { MeshBasicNodeMaterial } from 'three/webgpu'
10
9
  import { EDITOR_LAYER } from '../../lib/constants'
11
10
  import useElevationGuides from '../../store/use-elevation-guides'
11
+ import { Html } from './localized-html'
12
12
  import { formatMeasurement } from './measurement-pill'
13
13
 
14
14
  const LINE_COLOR = 0x81_8c_f8