@pascal-app/editor 1.0.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,189 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
3
+ import { doorSchedule, formatScheduleLength, roomSchedule, windowSchedule } from './schedules'
4
+
5
+ function fixture(): Record<string, AnyNode> {
6
+ const nodes: Record<string, AnyNode> = {}
7
+ const node = (value: Record<string, unknown>) => {
8
+ nodes[value.id as string] = {
9
+ object: 'node',
10
+ parentId: null,
11
+ visible: true,
12
+ metadata: {},
13
+ children: [],
14
+ ...value,
15
+ } as unknown as AnyNode
16
+ }
17
+
18
+ node({ id: 'level_1', type: 'level', level: 0, children: ['w_n', 'w_s', 'zone_1'] })
19
+ node({
20
+ id: 'w_n',
21
+ type: 'wall',
22
+ parentId: 'level_1',
23
+ start: [0, 0],
24
+ end: [6, 0],
25
+ thickness: 0.15,
26
+ frontSide: 'exterior',
27
+ backSide: 'interior',
28
+ children: ['d_1', 'win_1'],
29
+ })
30
+ node({
31
+ id: 'w_s',
32
+ type: 'wall',
33
+ parentId: 'level_1',
34
+ start: [6, 4],
35
+ end: [0, 4],
36
+ thickness: 0.1,
37
+ frontSide: 'interior',
38
+ backSide: 'interior',
39
+ children: ['d_2'],
40
+ })
41
+ node({
42
+ id: 'd_1',
43
+ type: 'door',
44
+ parentId: 'w_n',
45
+ wallId: 'w_n',
46
+ doorType: 'hinged',
47
+ openingKind: 'door',
48
+ width: 0.9144,
49
+ height: 2.0574,
50
+ frameThickness: 0.05,
51
+ frameDepth: 0.07,
52
+ threshold: true,
53
+ doorCloser: false,
54
+ panicBar: false,
55
+ roughOpeningWidth: 0.9652,
56
+ roughOpeningHeight: 2.0828,
57
+ position: [1, 1.05, 0],
58
+ })
59
+ node({
60
+ id: 'd_2',
61
+ type: 'door',
62
+ parentId: 'w_s',
63
+ wallId: 'w_s',
64
+ doorType: 'pocket',
65
+ openingKind: 'door',
66
+ width: 0.762,
67
+ height: 2.0574,
68
+ frameThickness: 0.05,
69
+ frameDepth: 0.07,
70
+ threshold: false,
71
+ doorCloser: false,
72
+ panicBar: false,
73
+ position: [2, 1.05, 0],
74
+ })
75
+ node({
76
+ id: 'win_1',
77
+ type: 'window',
78
+ parentId: 'w_n',
79
+ wallId: 'w_n',
80
+ windowType: 'casement',
81
+ openingKind: 'window',
82
+ width: 1.2192,
83
+ height: 1.2192,
84
+ position: [4, 1.1, 0],
85
+ })
86
+ node({
87
+ id: 'zone_1',
88
+ type: 'zone',
89
+ parentId: 'level_1',
90
+ spaceRole: 'room',
91
+ name: 'Living',
92
+ roomNumber: '101',
93
+ floorFinish: 'Oak',
94
+ wallFinish: '',
95
+ ceilingFinish: '',
96
+ ceilingHeight: 2.44,
97
+ occupancy: 'Residential',
98
+ enclosureStatus: 'unknown',
99
+ polygon: [
100
+ [0, 0],
101
+ [6, 0],
102
+ [6, 4],
103
+ [0, 4],
104
+ ],
105
+ })
106
+ return nodes
107
+ }
108
+
109
+ describe('formatScheduleLength', () => {
110
+ test('imperial to sixteenths', () => {
111
+ expect(formatScheduleLength(0.9144, 'imperial')).toBe(`3'-0"`)
112
+ expect(formatScheduleLength(2.0574, 'imperial')).toBe(`6'-9"`)
113
+ expect(formatScheduleLength(0.1524, 'imperial')).toBe(`6"`)
114
+ })
115
+ test('metric is millimetres', () => {
116
+ expect(formatScheduleLength(0.9144, 'metric')).toBe('914')
117
+ })
118
+ })
119
+
120
+ describe('doorSchedule', () => {
121
+ test('rows carry mark, type, size, rough opening, frame, hardware, remarks', () => {
122
+ const { rows, issues } = doorSchedule(fixture(), 'level_1' as AnyNodeId)
123
+ expect(issues).toEqual([])
124
+ expect(rows).toHaveLength(2)
125
+ expect(rows[0]).toMatchObject({
126
+ mark: 'D101',
127
+ type: 'Hinged',
128
+ sizeText: `3'-0" x 6'-9"`,
129
+ roughOpening: `3'-2" x 6'-10"`,
130
+ frame: `1 15/16" / 2 3/4"`,
131
+ hardware: 'Threshold',
132
+ count: 1,
133
+ remarks: 'Exterior',
134
+ })
135
+ expect(rows[1]).toMatchObject({
136
+ mark: 'D102',
137
+ type: 'Pocket',
138
+ roughOpening: null,
139
+ remarks: 'VERIFY R.O.',
140
+ })
141
+ })
142
+
143
+ test('grouping collapses identical openings and counts them', () => {
144
+ const nodes = fixture()
145
+ // A second identical exterior door on the same wall.
146
+ ;(nodes.w_n as unknown as { children: string[] }).children = ['d_1', 'd_1b', 'win_1']
147
+ nodes.d_1b = {
148
+ ...(nodes.d_1 as unknown as Record<string, unknown>),
149
+ id: 'd_1b',
150
+ position: [3, 1.05, 0],
151
+ } as unknown as AnyNode
152
+ const { rows } = doorSchedule(nodes, 'level_1' as AnyNodeId, { group: true })
153
+ const hinged = rows.find((row) => row.type === 'Hinged')
154
+ expect(hinged?.count).toBe(2)
155
+ })
156
+ })
157
+
158
+ describe('windowSchedule', () => {
159
+ test('windows number from W101', () => {
160
+ const { rows } = windowSchedule(fixture(), 'level_1' as AnyNodeId)
161
+ expect(rows).toHaveLength(1)
162
+ expect(rows[0]).toMatchObject({
163
+ mark: 'W101',
164
+ type: 'Casement',
165
+ sizeText: `4'-0" x 4'-0"`,
166
+ hardware: null,
167
+ })
168
+ })
169
+ })
170
+
171
+ describe('roomSchedule', () => {
172
+ test('one row per room zone with area and floor finish', () => {
173
+ const { rows, issues } = roomSchedule(fixture(), 'level_1' as AnyNodeId)
174
+ expect(issues).toEqual([])
175
+ expect(rows).toHaveLength(1)
176
+ expect(rows[0]?.number).toBe('101')
177
+ expect(rows[0]?.name).toBe('Living')
178
+ expect(rows[0]?.floorFinish).toBe('Oak')
179
+ expect(rows[0]?.area).toBeCloseTo(24, 6)
180
+ expect(rows[0]?.areaText).toBe('258.3 ft²')
181
+ })
182
+
183
+ test('a room without a number is reported', () => {
184
+ const nodes = fixture()
185
+ ;(nodes.zone_1 as unknown as { roomNumber: string }).roomNumber = ''
186
+ const { issues } = roomSchedule(nodes, 'level_1' as AnyNodeId)
187
+ expect(issues).toEqual(['Room Living has no room number'])
188
+ })
189
+ })
@@ -0,0 +1,339 @@
1
+ import type { AnyNode, AnyNodeId, DoorNode, WallNode, WindowNode, ZoneNode } from '@pascal-app/core'
2
+ import { deriveZoneQuantityReport } from '@pascal-app/core'
3
+ import { collectFloorplanSchedules } from './floorplan-export'
4
+ import type { FloorplanSchedule } from './floorplan-extension'
5
+ import { resolveMarkDetail } from './marks'
6
+
7
+ /**
8
+ * Construction-document schedule DATA — WS3.
9
+ *
10
+ * The PDF export already renders schedules through the node registry
11
+ * (`collectFloorplanSchedules` in `floorplan-export.tsx`, backed by each
12
+ * kind's `def.extensions['pascal:editor/floorplan'].schedule`). That path is
13
+ * untouched and keeps working; `floorplanSchedules()` below re-exports it.
14
+ *
15
+ * What this module adds is a TYPED, renderer-agnostic row model that the
16
+ * sheets workstream can lay out itself (column widths, grouping, sorting)
17
+ * instead of re-parsing formatted cells. Marks come from `marks.ts`, so a
18
+ * schedule row and the tag drawn on the plan always agree.
19
+ */
20
+
21
+ const SQUARE_FEET_PER_SQUARE_METER = 10.76391041671
22
+ const INCHES_PER_METER = 1 / 0.0254
23
+
24
+ export type ScheduleUnit = 'metric' | 'imperial'
25
+
26
+ export type OpeningScheduleRow = {
27
+ id: string
28
+ mark: string
29
+ type: string
30
+ /** Metres. */
31
+ width: number
32
+ /** Metres. */
33
+ height: number
34
+ widthText: string
35
+ heightText: string
36
+ sizeText: string
37
+ /** Rough opening as "W x H", or `null` when it was never entered. */
38
+ roughOpening: string | null
39
+ /** Frame material / thickness-depth where the model knows it. */
40
+ material: string | null
41
+ frame: string | null
42
+ hardware: string | null
43
+ /** How many identical openings this row stands for (grouped rows only). */
44
+ count: number
45
+ remarks: string
46
+ }
47
+
48
+ export type RoomScheduleRow = {
49
+ id: string
50
+ number: string
51
+ name: string
52
+ /** Square metres. */
53
+ area: number
54
+ areaText: string
55
+ floorFinish: string | null
56
+ ceilingHeight: number
57
+ remarks: string
58
+ }
59
+
60
+ export type ScheduleResult<TRow> = {
61
+ rows: TRow[]
62
+ issues: string[]
63
+ }
64
+
65
+ export type ScheduleSceneInput =
66
+ | { nodes: Readonly<Record<string, AnyNode>> }
67
+ | Readonly<Record<string, AnyNode>>
68
+
69
+ export type ScheduleOptions = {
70
+ unit?: ScheduleUnit
71
+ /** Collapse identical openings into one row carrying `count`. Default false. */
72
+ group?: boolean
73
+ }
74
+
75
+ // ── Door schedule ────────────────────────────────────────────────────
76
+
77
+ export function doorSchedule(
78
+ scene: ScheduleSceneInput,
79
+ levelId?: AnyNodeId,
80
+ options: ScheduleOptions = {},
81
+ ): ScheduleResult<OpeningScheduleRow> {
82
+ return openingSchedule(scene, 'door', levelId, options)
83
+ }
84
+
85
+ export function windowSchedule(
86
+ scene: ScheduleSceneInput,
87
+ levelId?: AnyNodeId,
88
+ options: ScheduleOptions = {},
89
+ ): ScheduleResult<OpeningScheduleRow> {
90
+ return openingSchedule(scene, 'window', levelId, options)
91
+ }
92
+
93
+ function openingSchedule(
94
+ scene: ScheduleSceneInput,
95
+ kind: 'door' | 'window',
96
+ levelId: AnyNodeId | undefined,
97
+ options: ScheduleOptions,
98
+ ): ScheduleResult<OpeningScheduleRow> {
99
+ const nodes = normalizeNodes(scene)
100
+ const unit = options.unit ?? 'imperial'
101
+ const levelIds = levelId ? [levelId] : allLevelIds(nodes)
102
+
103
+ const rows: OpeningScheduleRow[] = []
104
+ const issues: string[] = []
105
+
106
+ for (const id of levelIds) {
107
+ const resolution = resolveMarkDetail(nodes, id as AnyNodeId)
108
+ issues.push(...resolution.issues)
109
+ for (const node of collectSubtree(nodes, id)) {
110
+ if (node.type !== kind) continue
111
+ const opening = node as DoorNode | WindowNode
112
+ rows.push(openingRow(opening, nodes, resolution.marks.get(opening.id) ?? '—', unit))
113
+ }
114
+ }
115
+
116
+ rows.sort((left, right) => left.mark.localeCompare(right.mark, 'en', { numeric: true }))
117
+ return { rows: options.group ? groupOpeningRows(rows) : rows, issues }
118
+ }
119
+
120
+ function openingRow(
121
+ opening: DoorNode | WindowNode,
122
+ nodes: Readonly<Record<string, AnyNode>>,
123
+ mark: string,
124
+ unit: ScheduleUnit,
125
+ ): OpeningScheduleRow {
126
+ const remarks: string[] = []
127
+ if (opening.roughOpeningWidth === undefined || opening.roughOpeningHeight === undefined) {
128
+ remarks.push('VERIFY R.O.')
129
+ }
130
+ const wall = opening.wallId ? (nodes[opening.wallId] as WallNode | undefined) : undefined
131
+ if (
132
+ wall &&
133
+ wall.type === 'wall' &&
134
+ (wall.frontSide === 'exterior' || wall.backSide === 'exterior')
135
+ ) {
136
+ remarks.push('Exterior')
137
+ }
138
+
139
+ return {
140
+ id: opening.id,
141
+ mark,
142
+ type: openingTypeLabel(opening),
143
+ width: opening.width,
144
+ height: opening.height,
145
+ widthText: formatScheduleLength(opening.width, unit),
146
+ heightText: formatScheduleLength(opening.height, unit),
147
+ sizeText: `${formatScheduleLength(opening.width, unit)} x ${formatScheduleLength(opening.height, unit)}`,
148
+ roughOpening:
149
+ opening.roughOpeningWidth !== undefined && opening.roughOpeningHeight !== undefined
150
+ ? `${formatScheduleLength(opening.roughOpeningWidth, unit)} x ${formatScheduleLength(opening.roughOpeningHeight, unit)}`
151
+ : null,
152
+ material: openingMaterial(opening),
153
+ frame: openingFrame(opening, unit),
154
+ hardware: opening.type === 'door' ? doorHardware(opening as DoorNode) : null,
155
+ count: 1,
156
+ remarks: remarks.join('; '),
157
+ }
158
+ }
159
+
160
+ function groupOpeningRows(rows: readonly OpeningScheduleRow[]): OpeningScheduleRow[] {
161
+ const grouped = new Map<string, OpeningScheduleRow>()
162
+ for (const row of rows) {
163
+ const key = [
164
+ row.type,
165
+ row.sizeText,
166
+ row.roughOpening,
167
+ row.material,
168
+ row.frame,
169
+ row.hardware,
170
+ ].join('|')
171
+ const existing = grouped.get(key)
172
+ if (existing) existing.count += row.count
173
+ else grouped.set(key, { ...row })
174
+ }
175
+ return [...grouped.values()]
176
+ }
177
+
178
+ function openingTypeLabel(opening: DoorNode | WindowNode): string {
179
+ if (opening.openingKind === 'opening') return 'Opening'
180
+ return titleCase(
181
+ opening.type === 'door' ? (opening as DoorNode).doorType : (opening as WindowNode).windowType,
182
+ )
183
+ }
184
+
185
+ function openingMaterial(opening: DoorNode | WindowNode): string | null {
186
+ // `slots.panel` / `material` hold a MaterialRef (`library:<id>` /
187
+ // `scene:<id>`). Only the ref is knowable here — no invented values.
188
+ const ref = opening.slots?.panel ?? opening.slots?.frame ?? undefined
189
+ if (typeof ref === 'string' && ref.includes(':')) return titleCase(ref.split(':')[1] ?? ref)
190
+ return null
191
+ }
192
+
193
+ function openingFrame(opening: DoorNode | WindowNode, unit: ScheduleUnit): string | null {
194
+ const thickness = (opening as DoorNode).frameThickness
195
+ const depth = (opening as DoorNode).frameDepth
196
+ if (typeof thickness !== 'number' || typeof depth !== 'number') return null
197
+ return `${formatScheduleLength(thickness, unit)} / ${formatScheduleLength(depth, unit)}`
198
+ }
199
+
200
+ function doorHardware(door: DoorNode): string {
201
+ if (door.openingKind === 'opening') return 'None'
202
+ const hardware: string[] = []
203
+ if (door.doorCloser) hardware.push('Closer')
204
+ if (door.panicBar) hardware.push('Panic bar')
205
+ if (door.threshold) hardware.push('Threshold')
206
+ return hardware.length > 0 ? hardware.join(', ') : 'Standard'
207
+ }
208
+
209
+ // ── Room schedule ────────────────────────────────────────────────────
210
+
211
+ export function roomSchedule(
212
+ scene: ScheduleSceneInput,
213
+ levelId?: AnyNodeId,
214
+ options: ScheduleOptions = {},
215
+ ): ScheduleResult<RoomScheduleRow> {
216
+ const nodes = normalizeNodes(scene)
217
+ const unit = options.unit ?? 'imperial'
218
+ const levelIds = levelId ? [levelId] : allLevelIds(nodes)
219
+
220
+ const rows: RoomScheduleRow[] = []
221
+ const issues: string[] = []
222
+ const seenNumbers = new Map<string, string[]>()
223
+
224
+ for (const id of levelIds) {
225
+ for (const node of collectSubtree(nodes, id)) {
226
+ if (node.type !== 'zone') continue
227
+ const zone = node as ZoneNode
228
+ if (zone.spaceRole !== 'room') continue
229
+ const report = deriveZoneQuantityReport(zone, nodes as Record<string, AnyNode>)
230
+ const number = zone.roomNumber.trim()
231
+ if (!number) issues.push(`Room ${zone.name.trim() || zone.id} has no room number`)
232
+ else {
233
+ const owners = seenNumbers.get(number.toLocaleUpperCase())
234
+ if (owners) owners.push(zone.id)
235
+ else seenNumbers.set(number.toLocaleUpperCase(), [zone.id])
236
+ }
237
+ rows.push({
238
+ id: zone.id,
239
+ number: number || '—',
240
+ name: zone.name.trim() || '—',
241
+ area: report.footprintArea,
242
+ areaText: formatArea(report.footprintArea, unit),
243
+ floorFinish: zone.floorFinish.trim() || null,
244
+ ceilingHeight: zone.ceilingHeight,
245
+ remarks: zone.occupancy.trim(),
246
+ })
247
+ }
248
+ }
249
+
250
+ for (const [number, owners] of seenNumbers) {
251
+ if (owners.length > 1) issues.push(`Duplicate room number ${number} (${owners.length} rooms)`)
252
+ }
253
+
254
+ rows.sort((left, right) => left.number.localeCompare(right.number, 'en', { numeric: true }))
255
+ return { rows, issues }
256
+ }
257
+
258
+ // ── PDF-compatible schedules (unchanged registry path) ───────────────
259
+
260
+ /**
261
+ * The registry-driven schedules the PDF export renders. Kept as the single
262
+ * source for the PDF so extending this module never forks that output.
263
+ */
264
+ export function floorplanSchedules(
265
+ nodes: Record<string, AnyNode>,
266
+ levelId: AnyNodeId,
267
+ unit: ScheduleUnit,
268
+ ): FloorplanSchedule[] {
269
+ return collectFloorplanSchedules(nodes, levelId, unit)
270
+ }
271
+
272
+ // ── Helpers ──────────────────────────────────────────────────────────
273
+
274
+ export function formatScheduleLength(metres: number, unit: ScheduleUnit): string {
275
+ if (!Number.isFinite(metres)) return '—'
276
+ if (unit === 'metric') return `${Math.round(metres * 1000)}`
277
+ const denominator = 16
278
+ const sign = metres < 0 ? '-' : ''
279
+ const totalUnits = Math.round(Math.abs(metres) * INCHES_PER_METER * denominator)
280
+ const unitsPerFoot = 12 * denominator
281
+ const feet = Math.floor(totalUnits / unitsPerFoot)
282
+ const remainder = totalUnits - feet * unitsPerFoot
283
+ const inches = Math.floor(remainder / denominator)
284
+ const numerator = remainder - inches * denominator
285
+ const divisor = greatestCommonDivisor(numerator, denominator)
286
+ const fraction = numerator === 0 ? '' : `${numerator / divisor}/${denominator / divisor}`
287
+ const inchText = fraction ? `${inches} ${fraction}` : `${inches}`
288
+ return feet === 0 ? `${sign}${inchText}"` : `${sign}${feet}'-${inchText}"`
289
+ }
290
+
291
+ function formatArea(squareMetres: number, unit: ScheduleUnit): string {
292
+ if (!Number.isFinite(squareMetres)) return '—'
293
+ return unit === 'metric'
294
+ ? `${squareMetres.toFixed(2)} m²`
295
+ : `${(squareMetres * SQUARE_FEET_PER_SQUARE_METER).toFixed(1)} ft²`
296
+ }
297
+
298
+ function greatestCommonDivisor(a: number, b: number): number {
299
+ let left = Math.abs(a)
300
+ let right = Math.abs(b)
301
+ while (right !== 0) {
302
+ const next = left % right
303
+ left = right
304
+ right = next
305
+ }
306
+ return left || 1
307
+ }
308
+
309
+ function titleCase(value: string): string {
310
+ return value
311
+ .split(/[-_]/)
312
+ .map((part) => part.charAt(0).toLocaleUpperCase() + part.slice(1))
313
+ .join(' ')
314
+ }
315
+
316
+ function collectSubtree(nodes: Readonly<Record<string, AnyNode>>, rootId: string): AnyNode[] {
317
+ const result: AnyNode[] = []
318
+ const visit = (id: string) => {
319
+ const node = nodes[id]
320
+ if (!node || node.visible === false) return
321
+ result.push(node)
322
+ for (const childId of (node as { children?: string[] }).children ?? []) visit(childId)
323
+ }
324
+ visit(rootId)
325
+ return result
326
+ }
327
+
328
+ function allLevelIds(nodes: Readonly<Record<string, AnyNode>>): string[] {
329
+ return Object.values(nodes)
330
+ .filter((node) => node?.type === 'level')
331
+ .map((node) => node.id)
332
+ }
333
+
334
+ function normalizeNodes(scene: ScheduleSceneInput): Readonly<Record<string, AnyNode>> {
335
+ const candidate = scene as { nodes?: Readonly<Record<string, AnyNode>> }
336
+ return candidate && typeof candidate === 'object' && candidate.nodes
337
+ ? candidate.nodes
338
+ : (scene as Readonly<Record<string, AnyNode>>)
339
+ }
@@ -0,0 +1,100 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { exportSheetsToPdf, type SheetPdfPage } from './sheet-export'
3
+
4
+ const sheet = (number: string, title: string): SheetPdfPage => ({
5
+ number,
6
+ title,
7
+ widthIn: 36,
8
+ heightIn: 24,
9
+ plate: [],
10
+ windows: [],
11
+ overlay: [],
12
+ })
13
+
14
+ const globals = globalThis as unknown as Record<string, unknown>
15
+ const originalDocument = globals.document
16
+ const originalCreateObjectURL = URL.createObjectURL
17
+
18
+ afterEach(() => {
19
+ globals.document = originalDocument
20
+ URL.createObjectURL = originalCreateObjectURL
21
+ })
22
+
23
+ /** Runs the export with the browser download stubbed, returning the file and its name. */
24
+ async function exportToBytes(pages: SheetPdfPage[], documentTitle: string) {
25
+ let blob: Blob | null = null
26
+ let filename = ''
27
+ URL.createObjectURL = (object: Blob | MediaSource) => {
28
+ blob = object as Blob
29
+ return 'blob:sheet-export-test'
30
+ }
31
+ globals.document = {
32
+ createElement: () => {
33
+ const anchor = {
34
+ href: '',
35
+ download: '',
36
+ click: () => {
37
+ filename = anchor.download
38
+ },
39
+ }
40
+ return anchor
41
+ },
42
+ }
43
+ await exportSheetsToPdf(pages, 'qa-hip-ranch_2026-09-23.pdf', documentTitle)
44
+ if (!blob) throw new Error('the export produced no file')
45
+ const pdf = Buffer.from(await (blob as Blob).arrayBuffer()).toString('latin1')
46
+ return { pdf, filename }
47
+ }
48
+
49
+ /** The PDF literal string opening at `start` ("(…)"), decoded — UTF-16BE when it carries the BOM. */
50
+ function readLiteral(pdf: string, start: number): string {
51
+ const escaped: Record<string, string> = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f' }
52
+ let bytes = ''
53
+ for (let i = start + 1; pdf[i] !== ')'; i += 1) {
54
+ if (pdf[i] === '\\') {
55
+ i += 1
56
+ bytes += escaped[pdf[i]!] ?? pdf[i]
57
+ } else bytes += pdf[i]
58
+ }
59
+ if (!bytes.startsWith('\xfe\xff')) return bytes
60
+ let text = ''
61
+ for (let k = 2; k < bytes.length; k += 2) {
62
+ text += String.fromCharCode((bytes.charCodeAt(k) << 8) | bytes.charCodeAt(k + 1))
63
+ }
64
+ return text
65
+ }
66
+
67
+ /** Every `/Title` in the file — outline entries inline, the document info's by reference. */
68
+ function titles(pdf: string): string[] {
69
+ return [...pdf.matchAll(/\/Title (?:\(|(\d+) 0 R)/g)].map((match) =>
70
+ match[1]
71
+ ? readLiteral(pdf, pdf.indexOf(`\n${match[1]} 0 obj\n(`) + `\n${match[1]} 0 obj\n`.length)
72
+ : readLiteral(pdf, match.index + '/Title '.length),
73
+ )
74
+ }
75
+
76
+ describe('exportSheetsToPdf', () => {
77
+ test('bookmarks every sheet by its number and title and titles the document', async () => {
78
+ const { pdf, filename } = await exportToBytes(
79
+ [
80
+ sheet('A0.0', 'Cover sheet'),
81
+ sheet('A2.0', 'Ground floor floor plan'),
82
+ sheet('S2.0', 'Floor framing plan — Ground floor'),
83
+ ],
84
+ 'QA Hip Ranch',
85
+ )
86
+
87
+ expect(filename).toBe('qa-hip-ranch_2026-09-23.pdf')
88
+ expect(titles(pdf).sort()).toEqual([
89
+ 'A0.0 Cover sheet',
90
+ 'A2.0 Ground floor floor plan',
91
+ 'QA Hip Ranch',
92
+ 'S2.0 Floor framing plan — Ground floor',
93
+ ])
94
+ // the bookmarks pane opens with the file, each entry fits its whole sheet
95
+ expect(pdf).toContain('/PageMode /UseOutlines')
96
+ expect(pdf.match(/\/Dest \[\d+ 0 R \/Fit\]/g)).toHaveLength(3)
97
+ // the viewer's title bar shows the project, not the file name
98
+ expect(pdf).toContain('/DisplayDocTitle true')
99
+ })
100
+ })