@pascal-app/editor 1.0.3 → 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 (440) hide show
  1. package/package.json +11 -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 +202 -57
  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 +36 -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 +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,479 @@
1
+ import type { AnyNode, AnyNodeId, DoorNode, WallNode, WindowNode } from '@pascal-app/core'
2
+ import { getLinkedWallUpdates } from '@pascal-app/core'
3
+
4
+ /**
5
+ * Driving dimensions — WS3.
6
+ *
7
+ * A dimension label in the floor plan is editable. Committing a new value
8
+ * does NOT re-letter the annotation: it MOVES the geometry so the dimension
9
+ * becomes true, the way a CAD "driving dimension" works.
10
+ *
11
+ * Everything in this module is pure: it reads a snapshot of the scene node
12
+ * map and returns node patches. The React layer
13
+ * (`floorplan-dimension-renderer.tsx` + `floorplan-registry-layer.tsx`)
14
+ * applies them through `sceneApi.update`.
15
+ */
16
+
17
+ export type DimensionPlanPoint = [number, number]
18
+
19
+ // ── Value parsing ────────────────────────────────────────────────────
20
+
21
+ const INCHES_PER_METER = 1 / 0.0254
22
+
23
+ /**
24
+ * Parse a typed dimension value into METRES.
25
+ *
26
+ * Accepted (case-insensitive, whitespace tolerant):
27
+ * - `12'-6"`, `12' 6"`, `12'6"`, `12'`, `6"`, `12'-6 1/2"`, `6 1/2"`
28
+ * - `3810mm`, `381cm`, `3.81m`, `3810 mm`
29
+ * - bare number — `12.5` — interpreted in the caller's unit system:
30
+ * imperial → feet, metric → metres.
31
+ *
32
+ * Returns null for anything unparseable or non-positive.
33
+ */
34
+ export function parseDimensionInput(
35
+ raw: string,
36
+ unit: 'metric' | 'imperial' = 'metric',
37
+ ): number | null {
38
+ const text = raw.trim().toLowerCase().replace(/\s+/g, ' ')
39
+ if (!text) return null
40
+
41
+ // Explicit metric suffix.
42
+ const metric = /^([+-]?\d+(?:\.\d+)?)\s*(mm|cm|m)$/.exec(text)
43
+ if (metric) {
44
+ const value = Number.parseFloat(metric[1]!)
45
+ if (!Number.isFinite(value)) return null
46
+ const scale = metric[2] === 'mm' ? 0.001 : metric[2] === 'cm' ? 0.01 : 1
47
+ return positive(value * scale)
48
+ }
49
+
50
+ // Feet and/or inches. Either part may be absent; inches may carry a fraction.
51
+ const feetInches =
52
+ /^([+-]?)(?:(\d+(?:\.\d+)?)\s*(?:'|ft|feet)\s*)?(?:-?\s*(?:(\d+(?:\.\d+)?)\s*)?(?:(\d+)\/(\d+)\s*)?(?:"|in|inch|inches))?$/.exec(
53
+ text,
54
+ )
55
+ if (feetInches && (feetInches[2] || feetInches[3] || feetInches[4])) {
56
+ const sign = feetInches[1] === '-' ? -1 : 1
57
+ const feet = feetInches[2] ? Number.parseFloat(feetInches[2]) : 0
58
+ const inches = feetInches[3] ? Number.parseFloat(feetInches[3]) : 0
59
+ const numerator = feetInches[4] ? Number.parseFloat(feetInches[4]) : 0
60
+ const denominator = feetInches[5] ? Number.parseFloat(feetInches[5]) : 1
61
+ if (denominator === 0) return null
62
+ const totalInches = feet * 12 + inches + numerator / denominator
63
+ return positive((sign * totalInches) / INCHES_PER_METER)
64
+ }
65
+
66
+ // `12'-6` / `12-6` shorthand without the inch mark, feet-first.
67
+ const shorthand =
68
+ /^([+-]?\d+(?:\.\d+)?)\s*(?:'|ft)\s*-?\s*(\d+(?:\.\d+)?)(?:\s+(\d+)\/(\d+))?$/.exec(text)
69
+ if (shorthand) {
70
+ const feet = Number.parseFloat(shorthand[1]!)
71
+ const inches = Number.parseFloat(shorthand[2]!)
72
+ const numerator = shorthand[3] ? Number.parseFloat(shorthand[3]) : 0
73
+ const denominator = shorthand[4] ? Number.parseFloat(shorthand[4]) : 1
74
+ if (denominator === 0) return null
75
+ const sign = feet < 0 ? -1 : 1
76
+ return positive(
77
+ ((Math.abs(feet) * 12 + inches + numerator / denominator) * sign) / INCHES_PER_METER,
78
+ )
79
+ }
80
+
81
+ // Bare number — unit system decides.
82
+ const bare = /^([+-]?\d+(?:\.\d+)?)$/.exec(text)
83
+ if (bare) {
84
+ const value = Number.parseFloat(bare[1]!)
85
+ if (!Number.isFinite(value)) return null
86
+ return positive(unit === 'imperial' ? (value * 12) / INCHES_PER_METER : value)
87
+ }
88
+
89
+ return null
90
+ }
91
+
92
+ function positive(value: number): number | null {
93
+ return Number.isFinite(value) && value > 1e-6 ? value : null
94
+ }
95
+
96
+ // ── Drive resolution ─────────────────────────────────────────────────
97
+
98
+ export type DimensionDriveTarget =
99
+ | {
100
+ kind: 'wall-endpoint'
101
+ wallId: AnyNodeId
102
+ endpoint: 'start' | 'end'
103
+ /** Unit vector along which the moving endpoint travels when the value grows. */
104
+ direction: DimensionPlanPoint
105
+ }
106
+ | {
107
+ kind: 'opening'
108
+ openingId: AnyNodeId
109
+ wallId: AnyNodeId
110
+ /** +1 when growing the dimension increases `position[0]`, -1 otherwise. */
111
+ sign: 1 | -1
112
+ }
113
+ | {
114
+ /**
115
+ * The dimension measures the opening itself (its own width tag), so
116
+ * the value resizes the opening rather than sliding it.
117
+ */
118
+ kind: 'opening-width'
119
+ openingId: AnyNodeId
120
+ wallId: AnyNodeId
121
+ }
122
+
123
+ export type DimensionDriveResolution =
124
+ | { drivable: true; target: DimensionDriveTarget }
125
+ | { drivable: false; reason: string }
126
+
127
+ /** How close a dimension witness point must sit to a feature to bind to it. */
128
+ const FEATURE_MATCH_TOLERANCE_M = 0.28
129
+ const PARALLEL_TOLERANCE = 0.02
130
+
131
+ export type DimensionDriveQuery = {
132
+ nodes: Readonly<Record<string, AnyNode>>
133
+ /** Node the dimension geometry was emitted by (the registry entry). */
134
+ ownerNodeId: AnyNodeId
135
+ /** Witness points of the dimension segment, in level-plan metres (x, z). */
136
+ start: DimensionPlanPoint
137
+ end: DimensionPlanPoint
138
+ }
139
+
140
+ /**
141
+ * Work out what a dimension's value drives.
142
+ *
143
+ * Resolution is deliberately conservative: when nothing binds we say so and
144
+ * the caller falls back to `textOverride` (with an "override" badge) rather
145
+ * than moving something the author did not mean.
146
+ */
147
+ export function resolveDimensionDrive(query: DimensionDriveQuery): DimensionDriveResolution {
148
+ const { nodes, ownerNodeId } = query
149
+ const owner = nodes[ownerNodeId]
150
+ if (!owner) return { drivable: false, reason: 'unknown node' }
151
+ if (isNodeLocked(owner)) return { drivable: false, reason: 'node is locked' }
152
+
153
+ const dimDirection = unit(subtract(query.end, query.start))
154
+ if (!dimDirection) return { drivable: false, reason: 'degenerate dimension' }
155
+
156
+ if (owner.type === 'door' || owner.type === 'window') {
157
+ return resolveOpeningDrive(query, owner as DoorNode | WindowNode, dimDirection)
158
+ }
159
+ if (owner.type === 'wall') {
160
+ return resolveWallDrive(query, owner as WallNode, dimDirection)
161
+ }
162
+ if (owner.type === 'construction-dimension') {
163
+ return resolveConstructionDimensionDrive(query, owner, dimDirection)
164
+ }
165
+ return { drivable: false, reason: `${owner.type} dimensions are not drivable` }
166
+ }
167
+
168
+ function resolveWallDrive(
169
+ query: DimensionDriveQuery,
170
+ wall: WallNode,
171
+ dimDirection: DimensionPlanPoint,
172
+ ): DimensionDriveResolution {
173
+ const wallDirection = unit(subtract(wall.end, wall.start))
174
+ if (!wallDirection) return { drivable: false, reason: 'degenerate wall' }
175
+ if (Math.abs(cross(wallDirection, dimDirection)) > PARALLEL_TOLERANCE) {
176
+ return { drivable: false, reason: 'dimension is not along the wall' }
177
+ }
178
+ if ((wall as { curvature?: number }).curvature) {
179
+ return { drivable: false, reason: 'curved walls are not drivable yet' }
180
+ }
181
+
182
+ // Prefer moving an opening: it is the cheaper, more local edit and it is
183
+ // what the "openings" dimension tier actually measures to.
184
+ const openings = wallOpenings(query.nodes, wall)
185
+ const endOpening = nearestOpening(openings, wall, wallDirection, query.end)
186
+ if (endOpening) {
187
+ return {
188
+ drivable: true,
189
+ target: {
190
+ kind: 'opening',
191
+ openingId: endOpening.id as AnyNodeId,
192
+ wallId: wall.id as AnyNodeId,
193
+ sign: dot(wallDirection, dimDirection) >= 0 ? 1 : -1,
194
+ },
195
+ }
196
+ }
197
+
198
+ // Otherwise the wall's own length: the endpoint the dimension runs TOWARD
199
+ // moves, the one it starts from is the datum.
200
+ const endpoint = nearestWallEndpoint(wall, query.end)
201
+ if (!endpoint) return { drivable: false, reason: 'dimension does not end on this wall' }
202
+ const fixed = endpoint === 'end' ? wall.start : wall.end
203
+ const moving = endpoint === 'end' ? wall.end : wall.start
204
+ const direction = unit(subtract(moving as DimensionPlanPoint, fixed as DimensionPlanPoint))
205
+ if (!direction) return { drivable: false, reason: 'degenerate wall' }
206
+ return {
207
+ drivable: true,
208
+ target: { kind: 'wall-endpoint', wallId: wall.id as AnyNodeId, endpoint, direction },
209
+ }
210
+ }
211
+
212
+ function resolveOpeningDrive(
213
+ query: DimensionDriveQuery,
214
+ opening: DoorNode | WindowNode,
215
+ dimDirection: DimensionPlanPoint,
216
+ ): DimensionDriveResolution {
217
+ const wall = opening.wallId ? query.nodes[opening.wallId] : undefined
218
+ if (wall?.type !== 'wall') return { drivable: false, reason: 'opening has no host wall' }
219
+ if (isNodeLocked(wall)) return { drivable: false, reason: 'host wall is locked' }
220
+ const wallDirection = unit(subtract((wall as WallNode).end, (wall as WallNode).start))
221
+ if (!wallDirection) return { drivable: false, reason: 'degenerate wall' }
222
+ if (Math.abs(cross(wallDirection, dimDirection)) > PARALLEL_TOLERANCE) {
223
+ return { drivable: false, reason: 'dimension is not along the host wall' }
224
+ }
225
+ if (measuresOpeningWidth(query, opening, wall as WallNode, wallDirection)) {
226
+ return {
227
+ drivable: true,
228
+ target: {
229
+ kind: 'opening-width',
230
+ openingId: opening.id as AnyNodeId,
231
+ wallId: wall.id as AnyNodeId,
232
+ },
233
+ }
234
+ }
235
+ return {
236
+ drivable: true,
237
+ target: {
238
+ kind: 'opening',
239
+ openingId: opening.id as AnyNodeId,
240
+ wallId: wall.id as AnyNodeId,
241
+ sign: dot(wallDirection, dimDirection) >= 0 ? 1 : -1,
242
+ },
243
+ }
244
+ }
245
+
246
+ /**
247
+ * True when the dimension spans the opening edge to edge — the opening's own
248
+ * width tag. Typing into that resizes the opening; typing into a dimension
249
+ * that merely ENDS at the opening slides it.
250
+ */
251
+ function measuresOpeningWidth(
252
+ query: DimensionDriveQuery,
253
+ opening: DoorNode | WindowNode,
254
+ wall: WallNode,
255
+ wallDirection: DimensionPlanPoint,
256
+ ): boolean {
257
+ const origin = wall.start as DimensionPlanPoint
258
+ const startAlong = dot(subtract(query.start, origin), wallDirection)
259
+ const endAlong = dot(subtract(query.end, origin), wallDirection)
260
+ const span = Math.abs(endAlong - startAlong)
261
+ const centre = (startAlong + endAlong) / 2
262
+ return Math.abs(span - opening.width) <= 0.02 && Math.abs(centre - opening.position[0]) <= 0.02
263
+ }
264
+
265
+ function resolveConstructionDimensionDrive(
266
+ query: DimensionDriveQuery,
267
+ node: AnyNode,
268
+ dimDirection: DimensionPlanPoint,
269
+ ): DimensionDriveResolution {
270
+ const anchors = (node as { anchors?: unknown[] }).anchors ?? []
271
+ // The LAST anchor is the one the dimension runs toward; it is the driven end.
272
+ for (let index = anchors.length - 1; index >= 1; index--) {
273
+ const anchor = anchors[index]
274
+ if (!anchor || Array.isArray(anchor)) continue
275
+ const reference = (anchor as { reference?: { nodeId?: string } }).reference
276
+ const referenced = reference?.nodeId ? query.nodes[reference.nodeId] : undefined
277
+ if (!referenced) continue
278
+ if (isNodeLocked(referenced)) return { drivable: false, reason: 'anchored node is locked' }
279
+ if (referenced.type === 'door' || referenced.type === 'window') {
280
+ return resolveOpeningDrive(query, referenced as DoorNode | WindowNode, dimDirection)
281
+ }
282
+ if (referenced.type === 'wall') {
283
+ return resolveWallDrive(query, referenced as WallNode, dimDirection)
284
+ }
285
+ }
286
+ return { drivable: false, reason: 'dimension anchors are free points' }
287
+ }
288
+
289
+ function wallOpenings(
290
+ nodes: Readonly<Record<string, AnyNode>>,
291
+ wall: WallNode,
292
+ ): Array<DoorNode | WindowNode> {
293
+ const openings: Array<DoorNode | WindowNode> = []
294
+ for (const id of wall.children ?? []) {
295
+ const child = nodes[id]
296
+ if (child && (child.type === 'door' || child.type === 'window')) {
297
+ openings.push(child as DoorNode | WindowNode)
298
+ }
299
+ }
300
+ return openings
301
+ }
302
+
303
+ function nearestOpening(
304
+ openings: ReadonlyArray<DoorNode | WindowNode>,
305
+ wall: WallNode,
306
+ wallDirection: DimensionPlanPoint,
307
+ point: DimensionPlanPoint,
308
+ ): DoorNode | WindowNode | null {
309
+ let best: DoorNode | WindowNode | null = null
310
+ let bestDistance = FEATURE_MATCH_TOLERANCE_M
311
+ const projected = dot(subtract(point, wall.start as DimensionPlanPoint), wallDirection)
312
+ for (const opening of openings) {
313
+ const center = opening.position[0]
314
+ const half = opening.width / 2
315
+ for (const candidate of [center, center - half, center + half]) {
316
+ const distance = Math.abs(projected - candidate)
317
+ if (distance < bestDistance) {
318
+ bestDistance = distance
319
+ best = opening
320
+ }
321
+ }
322
+ }
323
+ return best
324
+ }
325
+
326
+ function nearestWallEndpoint(wall: WallNode, point: DimensionPlanPoint): 'start' | 'end' | null {
327
+ const toStart = length(subtract(point, wall.start as DimensionPlanPoint))
328
+ const toEnd = length(subtract(point, wall.end as DimensionPlanPoint))
329
+ const nearest = toEnd <= toStart ? 'end' : 'start'
330
+ const distance = Math.min(toStart, toEnd)
331
+ // Face-datum dimensions sit half a wall thickness in from the centreline
332
+ // endpoint, so the tolerance has to clear the thickest plausible wall.
333
+ return distance <= FEATURE_MATCH_TOLERANCE_M + (wall.thickness ?? 0.1) ? nearest : null
334
+ }
335
+
336
+ // ── Applying a drive ─────────────────────────────────────────────────
337
+
338
+ export type DimensionDriveUpdate = { id: AnyNodeId; data: Record<string, unknown> }
339
+
340
+ export type DimensionDrivePlan = {
341
+ updates: DimensionDriveUpdate[]
342
+ /** Signed change in metres that was applied. */
343
+ delta: number
344
+ }
345
+
346
+ /**
347
+ * Turn a resolved target plus a length delta into node patches.
348
+ *
349
+ * `currentLength` is the dimension's own measured length (whatever datum it
350
+ * uses) and `nextLength` the typed value — the drive is applied as the
351
+ * DELTA between them, so face / centreline datum offsets cancel out and the
352
+ * dimension reads exactly the typed value afterwards.
353
+ */
354
+ export function planDimensionDrive(args: {
355
+ nodes: Readonly<Record<string, AnyNode>>
356
+ target: DimensionDriveTarget
357
+ currentLength: number
358
+ nextLength: number
359
+ }): DimensionDrivePlan | null {
360
+ const delta = args.nextLength - args.currentLength
361
+ if (!Number.isFinite(delta) || Math.abs(delta) < 1e-9) return { updates: [], delta: 0 }
362
+
363
+ if (args.target.kind === 'opening-width') {
364
+ const opening = args.nodes[args.target.openingId]
365
+ if (!opening || (opening.type !== 'door' && opening.type !== 'window')) return null
366
+ const wall = args.nodes[args.target.wallId] as WallNode | undefined
367
+ const wallLength = wall
368
+ ? length(subtract(wall.end as DimensionPlanPoint, wall.start as DimensionPlanPoint))
369
+ : Number.POSITIVE_INFINITY
370
+ if (args.nextLength < 0.05 || args.nextLength > wallLength) return null
371
+ return {
372
+ updates: [{ id: args.target.openingId, data: { width: args.nextLength } }],
373
+ delta,
374
+ }
375
+ }
376
+
377
+ if (args.target.kind === 'opening') {
378
+ const opening = args.nodes[args.target.openingId]
379
+ if (!opening || (opening.type !== 'door' && opening.type !== 'window')) return null
380
+ const wall = args.nodes[args.target.wallId] as WallNode | undefined
381
+ const node = opening as DoorNode | WindowNode
382
+ const next = node.position[0] + delta * args.target.sign
383
+ const limit = wall
384
+ ? length(subtract(wall.end as DimensionPlanPoint, wall.start as DimensionPlanPoint))
385
+ : null
386
+ const clamped =
387
+ limit === null ? next : Math.min(Math.max(next, node.width / 2), limit - node.width / 2)
388
+ return {
389
+ updates: [
390
+ {
391
+ id: node.id as AnyNodeId,
392
+ data: { position: [clamped, node.position[1], node.position[2]] },
393
+ },
394
+ ],
395
+ delta,
396
+ }
397
+ }
398
+
399
+ const wall = args.nodes[args.target.wallId] as WallNode | undefined
400
+ if (!wall) return null
401
+ const originalStart = [...wall.start] as DimensionPlanPoint
402
+ const originalEnd = [...wall.end] as DimensionPlanPoint
403
+ const moving = args.target.endpoint === 'end' ? originalEnd : originalStart
404
+ const next: DimensionPlanPoint = [
405
+ moving[0] + args.target.direction[0] * delta,
406
+ moving[1] + args.target.direction[1] * delta,
407
+ ]
408
+ const nextStart = args.target.endpoint === 'start' ? next : originalStart
409
+ const nextEnd = args.target.endpoint === 'end' ? next : originalEnd
410
+ if (length(subtract(nextEnd, nextStart)) < 0.01) return null
411
+
412
+ const updates: DimensionDriveUpdate[] = [
413
+ { id: wall.id as AnyNodeId, data: { start: nextStart, end: nextEnd } },
414
+ ]
415
+
416
+ // Keep connected junctions consistent: any wall in the same level that
417
+ // shares the moved corner follows it.
418
+ const linked = Object.values(args.nodes).filter(
419
+ (node): node is WallNode =>
420
+ node?.type === 'wall' &&
421
+ node.id !== wall.id &&
422
+ (node.parentId ?? null) === (wall.parentId ?? null) &&
423
+ !isNodeLocked(node) &&
424
+ (samePlanPoint(node.start as DimensionPlanPoint, moving) ||
425
+ samePlanPoint(node.end as DimensionPlanPoint, moving)),
426
+ )
427
+ for (const update of getLinkedWallUpdates(
428
+ linked.map((node) => ({ wall: node })),
429
+ originalStart,
430
+ originalEnd,
431
+ nextStart,
432
+ nextEnd,
433
+ )) {
434
+ updates.push({ id: update.id as AnyNodeId, data: { start: update.start, end: update.end } })
435
+ }
436
+
437
+ return { updates, delta }
438
+ }
439
+
440
+ /**
441
+ * Pascal has no first-class node lock — `guideUi.locked` is UI state for
442
+ * reference guides only. `metadata.locked` is the convention used here, so a
443
+ * future lock feature can set it and driving will refuse without a change.
444
+ */
445
+ function isNodeLocked(node: AnyNode): boolean {
446
+ const metadata = (node as { metadata?: Record<string, unknown> | null }).metadata
447
+ return (
448
+ !!metadata && typeof metadata === 'object' && (metadata as { locked?: unknown }).locked === true
449
+ )
450
+ }
451
+
452
+ // ── Vector helpers ───────────────────────────────────────────────────
453
+
454
+ const SAME_POINT_EPSILON = 1e-6
455
+
456
+ function samePlanPoint(a: DimensionPlanPoint, b: DimensionPlanPoint): boolean {
457
+ return Math.abs(a[0] - b[0]) <= SAME_POINT_EPSILON && Math.abs(a[1] - b[1]) <= SAME_POINT_EPSILON
458
+ }
459
+
460
+ function subtract(a: readonly number[], b: readonly number[]): DimensionPlanPoint {
461
+ return [a[0]! - b[0]!, a[1]! - b[1]!]
462
+ }
463
+
464
+ function dot(a: DimensionPlanPoint, b: DimensionPlanPoint): number {
465
+ return a[0] * b[0] + a[1] * b[1]
466
+ }
467
+
468
+ function cross(a: DimensionPlanPoint, b: DimensionPlanPoint): number {
469
+ return a[0] * b[1] - a[1] * b[0]
470
+ }
471
+
472
+ function length(a: DimensionPlanPoint): number {
473
+ return Math.hypot(a[0], a[1])
474
+ }
475
+
476
+ function unit(a: DimensionPlanPoint): DimensionPlanPoint | null {
477
+ const magnitude = length(a)
478
+ return magnitude <= 1e-9 ? null : [a[0] / magnitude, a[1] / magnitude]
479
+ }
@@ -37,7 +37,7 @@ import {
37
37
  rotateFloorplanExportBounds,
38
38
  } from './floorplan-export'
39
39
  import { floorplanGeometryMetadata } from './floorplan-extension'
40
- import { FloorplanPdfDocument } from './floorplan-pdfkit-document'
40
+ import { FloorplanPdfDocument, loadFloorplanPdfFonts } from './floorplan-pdfkit-document'
41
41
  import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
42
42
 
43
43
  type GroupGeometry = Extract<FloorplanGeometry, { kind: 'group' }>
@@ -306,6 +306,8 @@ describe('floor plan export policy', () => {
306
306
  openingMarks: true,
307
307
  structuralGrids: false,
308
308
  roomLabels: false,
309
+ roomDetails: true,
310
+ roofPlan: true,
309
311
  stairAnnotations: true,
310
312
  }
311
313
 
@@ -328,6 +330,8 @@ describe('floor plan export policy', () => {
328
330
  openingMarks: false,
329
331
  structuralGrids: false,
330
332
  roomLabels: true,
333
+ roomDetails: false,
334
+ roofPlan: false,
331
335
  stairAnnotations: false,
332
336
  })
333
337
  })
@@ -730,7 +734,7 @@ describe('collectFloorplanGeometry', () => {
730
734
  rawPdf.on('data', (chunk: Buffer) => chunks.push(chunk))
731
735
  const completedPdf = Promise.withResolvers<string>()
732
736
  rawPdf.on('end', () => completedPdf.resolve(Buffer.concat(chunks).toString('latin1')))
733
- const pdf = new FloorplanPdfDocument(rawPdf, [200, 200])
737
+ const pdf = new FloorplanPdfDocument(rawPdf, [200, 200], await loadFloorplanPdfFonts())
734
738
  pdf.addPage()
735
739
  for (const { model } of full) {
736
740
  if (!model) continue
@@ -837,9 +841,9 @@ describe('resolveExportLevels', () => {
837
841
  selectLevel(ground.id)
838
842
 
839
843
  expect(resolveExportLevels(nodes)).toEqual([
840
- { id: ground.id, label: 'Level 0' },
841
- { id: upper.id, label: 'Level 1' },
842
- { id: attic.id, label: 'Level 3' },
844
+ { id: ground.id, label: 'Ground floor' },
845
+ { id: upper.id, label: 'Floor 1' },
846
+ { id: attic.id, label: 'Floor 3' },
843
847
  ])
844
848
  })
845
849
 
@@ -847,9 +851,9 @@ describe('resolveExportLevels', () => {
847
851
  selectLevel(roof.id)
848
852
 
849
853
  expect(resolveExportLevels(nodes)).toEqual([
850
- { id: ground.id, label: 'Level 0' },
851
- { id: upper.id, label: 'Level 1' },
852
- { id: attic.id, label: 'Level 3' },
854
+ { id: ground.id, label: 'Ground floor' },
855
+ { id: upper.id, label: 'Floor 1' },
856
+ { id: attic.id, label: 'Floor 3' },
853
857
  ])
854
858
  })
855
859
  })
@@ -7,6 +7,7 @@ import {
7
7
  type FloorplanGeometry,
8
8
  type FloorplanPalette,
9
9
  type FloorplanPoint,
10
+ getLevelDisplayName,
10
11
  isNodeKindEnabled,
11
12
  type LiveNodeOverrides,
12
13
  type NodeCategory,
@@ -169,8 +170,23 @@ export type FloorplanPageLayout = {
169
170
  planBox: { x: number; y: number; width: number; height: number }
170
171
  }
171
172
 
173
+ /**
174
+ * Install list the PDF export gates plugin kinds with. Mirrors the GLB exporter:
175
+ * a scene without explicit install state is legacy, so every loaded plugin kind
176
+ * draws (`undefined`); the store's `installedPlugins` then only holds host
177
+ * defaults and would hide plugin nodes the project has always had.
178
+ */
179
+ export function floorplanExportInstalledPlugins(scene: {
180
+ installedPlugins: readonly string[]
181
+ hasExplicitPluginInstallState: boolean
182
+ }): readonly string[] | undefined {
183
+ return scene.hasExplicitPluginInstallState ? scene.installedPlugins : undefined
184
+ }
185
+
172
186
  export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
173
- const { nodes, installedPlugins } = useScene.getState()
187
+ const sceneState = useScene.getState()
188
+ const { nodes } = sceneState
189
+ const installedPlugins = floorplanExportInstalledPlugins(sceneState)
174
190
  const viewer = useViewer.getState()
175
191
  const unit = viewer.unit
176
192
  const metricNotation = viewer.metricNotation
@@ -306,6 +322,7 @@ export function collectFloorplanSchedules(
306
322
  levelId: AnyNodeId,
307
323
  unit: 'metric' | 'imperial',
308
324
  scope: FloorplanExportScope = 'full',
325
+ { drafting = false }: { drafting?: boolean } = {},
309
326
  ): FloorplanSchedule[] {
310
327
  const siblingsByType = new Map<string, AnyNode[]>()
311
328
  const visit = (id: AnyNodeId) => {
@@ -329,7 +346,7 @@ export function collectFloorplanSchedules(
329
346
  // or other site-category contributors whose plan geometry was excluded.
330
347
  if (!isFloorplanNodeInExportScope(definition, scope)) continue
331
348
  const siblings = siblingsByType.get(kind) ?? []
332
- const schedule = scheduleContribution({ siblings, nodes, levelId, unit })
349
+ const schedule = scheduleContribution({ siblings, nodes, levelId, unit, drafting })
333
350
  if (schedule && schedule.rows.length > 0) schedules.push(schedule)
334
351
  }
335
352
  return schedules
@@ -769,7 +786,8 @@ export function collectFloorplanGeometry(
769
786
  annotationVisibility: FloorplanAnnotationVisibility,
770
787
  drawingType: ConstructionDrawingType,
771
788
  wallDimensionReference: FloorplanWallDimensionReference,
772
- installedPlugins: readonly string[],
789
+ /** `undefined` = legacy scene without install state: every loaded kind draws. */
790
+ installedPlugins: readonly string[] | undefined,
773
791
  ): ExportGeometry[] {
774
792
  const noLiveOverrides = new Map<string, LiveNodeOverrides>()
775
793
  const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
@@ -778,13 +796,17 @@ export function collectFloorplanGeometry(
778
796
  const visit = (id: AnyNodeId) => {
779
797
  const node = nodes[id]
780
798
  if (!node) return
799
+ // Same install gate as the live layer: an uninstalled plugin's kinds draw
800
+ // nothing, while their hosted children keep their own gate.
801
+ const enabled = isNodeKindEnabled(node.type, installedPlugins)
781
802
  const def = nodeRegistry.get(node.type)
782
- if (def?.computeFloorplanLevelData) {
803
+ if (enabled && def?.computeFloorplanLevelData) {
783
804
  const ids = levelNodeIdsByType.get(node.type)
784
805
  if (ids) ids.push(id)
785
806
  else levelNodeIdsByType.set(node.type, [id])
786
807
  }
787
808
  if (
809
+ enabled &&
788
810
  def?.floorplan &&
789
811
  isFloorplanNodeVisible(node) &&
790
812
  isFloorplanNodeInExportScope(def, scope)
@@ -802,7 +824,11 @@ export function collectFloorplanGeometry(
802
824
  if (activeLevelNode) {
803
825
  for (const linked of collectFloorplanLinkedLevelNodes(nodes, levelId, collectedIds)) {
804
826
  const definition = nodeRegistry.get(linked.node.type)
805
- if (isFloorplanNodeVisible(linked.node) && isFloorplanNodeInExportScope(definition, scope)) {
827
+ if (
828
+ isNodeKindEnabled(linked.node.type, installedPlugins) &&
829
+ isFloorplanNodeVisible(linked.node) &&
830
+ isFloorplanNodeInExportScope(definition, scope)
831
+ ) {
806
832
  const drawingNode = resolveNodeForDrawingType(linked.node, nodes, drawingType)
807
833
  if (drawingNode) {
808
834
  entries.push({ id: linked.id, node: drawingNode, parentOverride: activeLevelNode })
@@ -1060,9 +1086,7 @@ function levelIndexOf(node: AnyNode): number {
1060
1086
  }
1061
1087
 
1062
1088
  function levelLabelOf(node: AnyNode): string {
1063
- const name = node.name?.trim()
1064
- if (name) return name
1065
- return `Level ${levelIndexOf(node)}`
1089
+ return getLevelDisplayName({ name: node.name, level: levelIndexOf(node) })
1066
1090
  }
1067
1091
 
1068
1092
  function nextFrames(count: number): Promise<void> {