@pascal-app/editor 1.0.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -1,31 +1,54 @@
1
1
  import {
2
2
  AnyNode,
3
3
  type AnyNodeId,
4
+ collectionIdsOf,
4
5
  generateId,
5
6
  generateSceneMaterialId,
7
+ getArtifactStore,
8
+ isDerivedNode,
6
9
  type LevelNode,
7
10
  nodeRegistry,
11
+ type ParsedBuildJson,
8
12
  remapMeasurementReferences,
9
13
  SceneMaterial,
10
14
  type SceneMaterialId,
11
15
  type StairNode,
16
+ scriptedSize,
12
17
  useScene,
13
18
  } from '@pascal-app/core'
19
+ import { clampDoorToWall, clampWindowToWall } from '@pascal-app/core/building'
14
20
  import { useViewer } from '@pascal-app/viewer'
21
+ import { referencedSceneMaterialIds, remapSceneMaterialRefs } from './scene-material-refs'
15
22
 
16
23
  type ClipboardPayload = {
17
24
  copiedAt: number
18
25
  materials: SceneMaterial[]
19
26
  nodes: AnyNode[]
27
+ /** The project copied from, where the scripted nodes' artifacts live. */
28
+ projectId: string | null
20
29
  rootIds: AnyNodeId[]
21
30
  }
22
31
 
23
32
  export type PasteResult = {
24
33
  createdMaterialIds: SceneMaterialId[]
25
34
  pastedIds: AnyNodeId[]
35
+ /** Scripted nodes left out (with what they host): their artifacts could not come from the project they were copied from. */
36
+ refusedIds: AnyNodeId[]
37
+ /** Why they were left out, when any were. */
38
+ refusal: PasteRefusal | null
26
39
  skippedIds: AnyNodeId[]
27
40
  }
28
41
 
42
+ /**
43
+ * `no-access`: the source project cannot be read any more (removed from it,
44
+ * deleted, made private, another account). `no-copies`: its geometry is
45
+ * readable but this script version cannot be copied. `failed`: the copy itself failed.
46
+ */
47
+ export type PasteRefusal = 'no-access' | 'no-copies' | 'failed'
48
+
49
+ type Refused = { ids: ReadonlySet<AnyNodeId>; reason: PasteRefusal | null }
50
+ const NONE_REFUSED: Refused = { ids: new Set(), reason: null }
51
+
29
52
  const SYSTEM_CLIPBOARD_KIND = 'pascal.scene-nodes'
30
53
  const SYSTEM_CLIPBOARD_VERSION = 1
31
54
 
@@ -84,7 +107,7 @@ function collectSubtreeIds(
84
107
  ) {
85
108
  if (ids.has(rootId)) return
86
109
  const node = nodes[rootId]
87
- if (!node) return
110
+ if (!node || isDerivedNode(node)) return
88
111
  ids.add(rootId)
89
112
 
90
113
  if ('children' in node && Array.isArray(node.children)) {
@@ -129,6 +152,7 @@ function isClipboardRoot(
129
152
  }
130
153
 
131
154
  function isCopyableRootType(node: AnyNode) {
155
+ if (isDerivedNode(node)) return false
132
156
  if (COPYABLE_ROOT_TYPES.has(node.type)) return true
133
157
  const definition = nodeRegistry.get(node.type)
134
158
  return !!definition && definition.capabilities?.duplicable !== false
@@ -195,10 +219,7 @@ function remapNodeReferences(
195
219
  rootIds: Set<AnyNodeId>,
196
220
  nodes: Record<AnyNodeId, AnyNode>,
197
221
  ) {
198
- const clone = remapSceneMaterialReferences(
199
- JSON.parse(JSON.stringify(node)),
200
- materialIdMap,
201
- ) as AnyNode
222
+ const clone = remapSceneMaterialRefs(JSON.parse(JSON.stringify(node)) as AnyNode, materialIdMap)
202
223
  ;(clone as Record<string, unknown>).id = idMap.get(oldId)
203
224
 
204
225
  if (rootIds.has(oldId)) {
@@ -217,6 +238,11 @@ function remapNodeReferences(
217
238
  clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId
218
239
  }
219
240
 
241
+ if (clone.type === 'zone' && node.parentId !== clone.parentId && clone.floor?.footprint) {
242
+ const { footprint: _footprint, ...floor } = clone.floor
243
+ clone.floor = floor
244
+ }
245
+
220
246
  if ('children' in clone && Array.isArray(clone.children)) {
221
247
  ;(clone as Record<string, unknown>).children = (clone.children as AnyNodeId[])
222
248
  .map((childId) => idMap.get(childId))
@@ -253,43 +279,6 @@ function remapNodeReferences(
253
279
  return AnyNode.parse(clone)
254
280
  }
255
281
 
256
- function remapSceneMaterialReferences(
257
- value: unknown,
258
- materialIdMap: Map<SceneMaterialId, SceneMaterialId>,
259
- ): unknown {
260
- if (typeof value === 'string' && value.startsWith('scene:')) {
261
- const oldId = value.slice('scene:'.length) as SceneMaterialId
262
- const nextId = materialIdMap.get(oldId)
263
- return nextId ? `scene:${nextId}` : value
264
- }
265
- if (Array.isArray(value)) {
266
- return value.map((entry) => remapSceneMaterialReferences(entry, materialIdMap))
267
- }
268
- if (value && typeof value === 'object') {
269
- return Object.fromEntries(
270
- Object.entries(value).map(([key, entry]) => [
271
- key,
272
- remapSceneMaterialReferences(entry, materialIdMap),
273
- ]),
274
- )
275
- }
276
- return value
277
- }
278
-
279
- function collectReferencedSceneMaterialIds(value: unknown, ids: Set<SceneMaterialId>) {
280
- if (typeof value === 'string' && value.startsWith('scene:')) {
281
- ids.add(value.slice('scene:'.length) as SceneMaterialId)
282
- return
283
- }
284
- if (Array.isArray(value)) {
285
- for (const entry of value) collectReferencedSceneMaterialIds(entry, ids)
286
- return
287
- }
288
- if (value && typeof value === 'object') {
289
- for (const entry of Object.values(value)) collectReferencedSceneMaterialIds(entry, ids)
290
- }
291
- }
292
-
293
282
  function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
294
283
  const scene = useScene.getState()
295
284
  const selectedIdSet = new Set(ids)
@@ -321,8 +310,7 @@ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
321
310
  .map((id) => scene.nodes[id])
322
311
  .filter((node): node is AnyNode => !!node)
323
312
  .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode)
324
- const materialIds = new Set<SceneMaterialId>()
325
- collectReferencedSceneMaterialIds(copiedNodes, materialIds)
313
+ const materialIds = referencedSceneMaterialIds(copiedNodes)
326
314
 
327
315
  return {
328
316
  copiedAt: Date.now(),
@@ -331,6 +319,7 @@ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
331
319
  .filter((material): material is SceneMaterial => !!material)
332
320
  .map((material) => JSON.parse(JSON.stringify(material)) as SceneMaterial),
333
321
  nodes: copiedNodes,
322
+ projectId: useViewer.getState().projectId,
334
323
  rootIds,
335
324
  }
336
325
  }
@@ -363,6 +352,7 @@ function parseClipboardPayload(text: string): ClipboardPayload | null {
363
352
  copiedAt?: unknown
364
353
  materials?: unknown
365
354
  nodes?: unknown
355
+ projectId?: unknown
366
356
  rootIds?: unknown
367
357
  }
368
358
  if (
@@ -390,6 +380,7 @@ function parseClipboardPayload(text: string): ClipboardPayload | null {
390
380
  copiedAt: candidate.copiedAt,
391
381
  materials: materials.filter((result) => result.success).map((result) => result.data),
392
382
  nodes: parsedNodes,
383
+ projectId: typeof candidate.projectId === 'string' ? candidate.projectId : null,
393
384
  rootIds,
394
385
  }
395
386
  } catch {
@@ -470,16 +461,130 @@ export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteRes
470
461
  export async function pasteSystemEditorClipboardToLevel(
471
462
  targetLevelId?: AnyNodeId,
472
463
  ): Promise<PasteResult | null> {
473
- if (!(await readEditorClipboardFromSystem())) return null
474
- return pasteEditorClipboardToLevel(targetLevelId)
464
+ const projectId = useViewer.getState().projectId
465
+ const store = getArtifactStore()
466
+ if (!(await readEditorClipboardFromSystem()) || !clipboardPayload) return null
467
+ if (projectId !== useViewer.getState().projectId || store !== getArtifactStore()) return null
468
+ const payload = clipboardPayload
469
+ const targetLevel = getPasteTargetLevel(targetLevelId)
470
+ if (!targetLevel) return null
471
+ const refused =
472
+ payload.projectId && payload.projectId === projectId
473
+ ? NONE_REFUSED
474
+ : await copyArtifactsHere(payload)
475
+ // Navigation can replace the scene while the server copies its artifacts.
476
+ if (projectId !== useViewer.getState().projectId || store !== getArtifactStore()) return null
477
+ return applyClipboardPayloadToLevel(payload, targetLevel.id, refused)
478
+ }
479
+
480
+ function artifactHashes(node: AnyNode): string[] {
481
+ const source = 'source' in node ? node.source : undefined
482
+ return typeof source === 'object' && source.kind === 'script'
483
+ ? [source.script, source.artifact]
484
+ : []
485
+ }
486
+
487
+ /**
488
+ * A paste from another project first brings the artifacts its scripted nodes
489
+ * reference into this one. Returns the nodes whose artifacts could not come,
490
+ * which the paste leaves out with everything they host, and why.
491
+ */
492
+ async function copyArtifactsHere(
493
+ payload: Pick<ClipboardPayload, 'nodes' | 'projectId'>,
494
+ ): Promise<Refused> {
495
+ const store = getArtifactStore()
496
+ const hashes = [...new Set(payload.nodes.flatMap(artifactHashes))]
497
+ if (hashes.length === 0) return NONE_REFUSED
498
+
499
+ let failed = false
500
+ const missing = new Set(
501
+ store.copyFrom
502
+ ? payload.projectId
503
+ ? await store.copyFrom(payload.projectId, hashes).catch(() => {
504
+ failed = true
505
+ return hashes
506
+ })
507
+ : hashes
508
+ : hashes.filter((sha) => !store.url(sha)),
509
+ )
510
+ const ids = new Set<AnyNodeId>()
511
+ let geometryMissing = false
512
+ for (const node of payload.nodes) {
513
+ const [script, artifact] = artifactHashes(node)
514
+ if (!script || !artifact || !(missing.has(script) || missing.has(artifact))) continue
515
+ ids.add(node.id as AnyNodeId)
516
+ geometryMissing ||= missing.has(artifact)
517
+ }
518
+ if (ids.size === 0) return NONE_REFUSED
519
+ return { ids, reason: failed ? 'failed' : geometryMissing ? 'no-access' : 'no-copies' }
520
+ }
521
+
522
+ /**
523
+ * A build file loaded into another project first brings the artifacts its
524
+ * scripted nodes reference from the project it was saved in, as a paste does.
525
+ * Answers the build without the nodes whose artifacts could not come, nor
526
+ * anything they host.
527
+ */
528
+ export async function bringBuildArtifacts(build: ParsedBuildJson): Promise<{
529
+ build: ParsedBuildJson
530
+ refusedIds: AnyNodeId[]
531
+ refusal: PasteRefusal | null
532
+ }> {
533
+ const nodes = Object.values(build.nodes) as AnyNode[]
534
+ // A file's origin is untrusted, even when it names this project. Older files can
535
+ // still use artifacts already registered here, but cannot introduce missing ones.
536
+ const refused = await copyArtifactsHere({
537
+ nodes,
538
+ projectId: build.projectId ?? useViewer.getState().projectId,
539
+ })
540
+ if (refused.ids.size === 0) return { build, refusedIds: [], refusal: null }
541
+
542
+ const excluded = new Set<string>(refused.ids)
543
+ for (let grew = true; grew; ) {
544
+ grew = false
545
+ for (const node of nodes) {
546
+ if (node.parentId && excluded.has(node.parentId) && !excluded.has(node.id)) {
547
+ excluded.add(node.id)
548
+ grew = true
549
+ }
550
+ }
551
+ }
552
+ const kept: Record<string, unknown> = {}
553
+ for (const node of nodes) {
554
+ if (excluded.has(node.id)) continue
555
+ const children = (node as { children?: unknown }).children
556
+ kept[node.id] = Array.isArray(children)
557
+ ? { ...node, children: children.filter((id) => !excluded.has(id as string)) }
558
+ : node
559
+ }
560
+ const collections =
561
+ build.collections &&
562
+ Object.fromEntries(
563
+ Object.entries(build.collections).map(([id, collection]) => [
564
+ id,
565
+ { ...collection, nodeIds: collection.nodeIds.filter((nodeId) => !excluded.has(nodeId)) },
566
+ ]),
567
+ )
568
+ return {
569
+ build: {
570
+ ...build,
571
+ nodes: kept,
572
+ rootNodeIds: build.rootNodeIds.filter((id) => !excluded.has(id)),
573
+ ...(collections ? { collections } : {}),
574
+ },
575
+ refusedIds: [...refused.ids],
576
+ refusal: refused.reason,
577
+ }
475
578
  }
476
579
 
477
580
  function applyClipboardPayloadToLevel(
478
- payload: ClipboardPayload,
581
+ copied: ClipboardPayload,
479
582
  targetLevelId?: AnyNodeId,
583
+ { ids: refused, reason: refusal }: Refused = NONE_REFUSED,
480
584
  ): PasteResult | null {
481
585
  const targetLevel = getPasteTargetLevel(targetLevelId)
482
586
  if (!targetLevel) return null
587
+ const payload = refused.size > 0 ? withoutRefusedSubtrees(copied, refused) : copied
483
588
 
484
589
  const scene = useScene.getState()
485
590
  const idMap = new Map<AnyNodeId, AnyNodeId>()
@@ -487,6 +592,7 @@ function applyClipboardPayloadToLevel(
487
592
  const materialsToCreate: SceneMaterial[] = []
488
593
 
489
594
  for (const node of payload.nodes) {
595
+ if (isDerivedNode(node)) continue
490
596
  idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId)
491
597
  }
492
598
  for (const material of payload.materials) {
@@ -506,6 +612,10 @@ function applyClipboardPayloadToLevel(
506
612
  const skippedIds: AnyNodeId[] = []
507
613
 
508
614
  for (const node of payload.nodes) {
615
+ if (isDerivedNode(node)) {
616
+ skippedIds.push(node.id as AnyNodeId)
617
+ continue
618
+ }
509
619
  try {
510
620
  pastedNodes.push(
511
621
  remapNodeReferences(
@@ -524,17 +634,30 @@ function applyClipboardPayloadToLevel(
524
634
  }
525
635
  }
526
636
 
637
+ fitOpeningsToPastedWalls(pastedNodes, scene.nodes)
638
+
527
639
  if (pastedNodes.length === 0) {
528
- return { createdMaterialIds: [], pastedIds: [], skippedIds }
640
+ return {
641
+ createdMaterialIds: [],
642
+ pastedIds: [],
643
+ refusedIds: [...refused],
644
+ refusal,
645
+ skippedIds,
646
+ }
529
647
  }
530
648
 
531
649
  for (const material of materialsToCreate) {
532
650
  scene.addSceneMaterial(material)
533
651
  }
652
+ const sourceIds = new Map([...idMap].map(([source, copy]) => [copy, source]))
534
653
  scene.createNodes(
535
654
  pastedNodes.map((node) => ({
536
655
  node,
537
656
  parentId: (node.parentId as AnyNodeId | null) ?? undefined,
657
+ // A copy is in its source's collections; an item brings them in its own `collectionIds`.
658
+ ...(node.type !== 'item' && {
659
+ collectionIds: collectionIdsOf(scene.collections, sourceIds.get(node.id as AnyNodeId)!),
660
+ }),
538
661
  })),
539
662
  )
540
663
 
@@ -551,6 +674,64 @@ function applyClipboardPayloadToLevel(
551
674
  return {
552
675
  createdMaterialIds: materialsToCreate.map((material) => material.id as SceneMaterialId),
553
676
  pastedIds: pastedRootIds,
677
+ refusedIds: [...refused],
678
+ refusal,
554
679
  skippedIds,
555
680
  }
556
681
  }
682
+
683
+ /**
684
+ * A door or window pasted with its wall is held inside it the way the door and window tools place
685
+ * one: a wall pasted under a lower storey would otherwise leave a tall window poking above it.
686
+ */
687
+ function fitOpeningsToPastedWalls(pasted: AnyNode[], sceneNodes: Record<AnyNodeId, AnyNode>) {
688
+ const byId = Object.fromEntries(pasted.map((node) => [node.id, node])) as Record<
689
+ AnyNodeId,
690
+ AnyNode
691
+ >
692
+ let nodes: Record<AnyNodeId, AnyNode> | undefined
693
+ pasted.forEach((node, index) => {
694
+ if (node.type !== 'door' && node.type !== 'window') return
695
+ const wall = node.parentId ? byId[node.parentId as AnyNodeId] : undefined
696
+ if (wall?.type !== 'wall') return
697
+ // A scripted opening is as tall as what its script built, as the wall cuts it.
698
+ const [width, height] = node.source
699
+ ? scriptedSize(node.source.manifest)
700
+ : [node.width, node.height]
701
+ nodes ??= { ...sceneNodes, ...byId }
702
+ const [x, y, z] = node.position
703
+ const { clampedY } =
704
+ node.type === 'door'
705
+ ? clampDoorToWall(wall, x, width, height)
706
+ : clampWindowToWall(wall, x, y, width, height, nodes)
707
+ if (clampedY !== y) pasted[index] = { ...node, position: [x, clampedY, z] }
708
+ })
709
+ }
710
+
711
+ function withoutRefusedSubtrees(
712
+ payload: ClipboardPayload,
713
+ refused: ReadonlySet<AnyNodeId>,
714
+ ): ClipboardPayload {
715
+ const children = new Map<string, AnyNodeId[]>()
716
+ for (const node of payload.nodes) {
717
+ if (!node.parentId) continue
718
+ const siblings = children.get(node.parentId) ?? []
719
+ siblings.push(node.id as AnyNodeId)
720
+ children.set(node.parentId, siblings)
721
+ }
722
+ const excluded = new Set(refused)
723
+ for (const id of excluded) {
724
+ for (const childId of children.get(id) ?? []) excluded.add(childId)
725
+ }
726
+ const nodes = payload.nodes.filter((node) => !excluded.has(node.id as AnyNodeId))
727
+ const kept = new Set(nodes.map((node) => node.id))
728
+ const materialIds = referencedSceneMaterialIds(nodes)
729
+ return {
730
+ ...payload,
731
+ materials: payload.materials.filter((material) =>
732
+ materialIds.has(material.id as SceneMaterialId),
733
+ ),
734
+ nodes,
735
+ rootIds: payload.rootIds.filter((id) => kept.has(id)),
736
+ }
737
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode } from '@pascal-app/core'
3
+ import {
4
+ referencedSceneMaterialIds,
5
+ remapSceneMaterialRefs,
6
+ sceneMaterialUsageCounts,
7
+ } from './scene-material-refs'
8
+
9
+ const ref = (id: string) => `scene:${id}`
10
+
11
+ // One node per place a finish can live.
12
+ const nodes = {
13
+ zone_room: {
14
+ id: 'zone_room',
15
+ type: 'zone',
16
+ floor: {
17
+ finish: ref('floor'),
18
+ regions: [{ id: 'r1', polygon: [], finish: ref('rug') }],
19
+ },
20
+ wallMaterial: ref('walls'),
21
+ wallOverrides: [{ wallId: 'wall_1', face: 'a', finish: ref('accent') }],
22
+ floorStepFinish: ref('step'),
23
+ floorStepOverrides: [{ key: 'door_1', finish: ref('doorstep') }],
24
+ floorEdgeFinish: ref('edge'),
25
+ },
26
+ wall_1: {
27
+ id: 'wall_1',
28
+ type: 'wall',
29
+ slots: { a: ref('walls') },
30
+ faceRegions: [{ id: 'w1', face: 'a', finish: ref('wainscot') }],
31
+ },
32
+ slab_base: {
33
+ id: 'slab_base',
34
+ type: 'slab',
35
+ plateRole: 'base',
36
+ slots: { edge: ref('band') },
37
+ foundation: { type: 'solid', material: ref('foundation') },
38
+ },
39
+ item_1: { id: 'item_1', type: 'item', slots: { seat: 'library:oak' } },
40
+ } as unknown as Record<string, AnyNode>
41
+
42
+ describe('scene material refs', () => {
43
+ test('counts every finish location, one per use', () => {
44
+ const counts = sceneMaterialUsageCounts(nodes)
45
+ expect(Object.fromEntries(counts)).toEqual({
46
+ floor: 1,
47
+ rug: 1,
48
+ walls: 2,
49
+ accent: 1,
50
+ step: 1,
51
+ doorstep: 1,
52
+ edge: 1,
53
+ wainscot: 1,
54
+ band: 1,
55
+ foundation: 1,
56
+ })
57
+ })
58
+
59
+ test('collects the materials a set of nodes carries, ignoring library refs', () => {
60
+ expect(
61
+ [...referencedSceneMaterialIds([nodes.zone_room, nodes.item_1])].map(String).sort(),
62
+ ).toEqual(['accent', 'doorstep', 'edge', 'floor', 'rug', 'step', 'walls'])
63
+ })
64
+
65
+ test('remaps refs wherever they are and keeps untouched nodes as they were', () => {
66
+ const map = new Map([
67
+ ['floor', 'floor2'],
68
+ ['foundation', 'stone'],
69
+ ['doorstep', 'tile'],
70
+ ])
71
+ const zone = remapSceneMaterialRefs(nodes.zone_room, map) as Record<string, any>
72
+ expect(zone.floor.finish).toBe(ref('floor2'))
73
+ expect(zone.floor.regions).toBe((nodes.zone_room as Record<string, any>).floor.regions)
74
+ expect(zone.floorStepOverrides).toEqual([{ key: 'door_1', finish: ref('tile') }])
75
+ const slab = remapSceneMaterialRefs(nodes.slab_base, map) as Record<string, any>
76
+ expect(slab.foundation).toEqual({ type: 'solid', material: ref('stone') })
77
+ expect(remapSceneMaterialRefs(nodes.item_1, map)).toBe(nodes.item_1)
78
+ })
79
+ })
@@ -0,0 +1,79 @@
1
+ import {
2
+ type AnyNode,
3
+ getSceneMaterialIdFromRef,
4
+ type SceneMaterialId,
5
+ toSceneMaterialRef,
6
+ } from '@pascal-app/core'
7
+
8
+ // Where a scene material (`scene:<id>`) is used. A node carries refs in more
9
+ // places than its `slots`: a room's floor finish and floor regions, its wall
10
+ // material and per-wall overrides, its floor step finishes (room-wide and per
11
+ // doorway) and edge finish, a wall's face paint regions, a footprint's edge
12
+ // band (`slots.edge`) and foundation.
13
+ // Every string ref anywhere in a node counts, so a new finish location is
14
+ // covered without touching this walker. The usage count, preset/room saving
15
+ // and pasting all walk the same way, so they always agree.
16
+
17
+ /** Calls `visit` with the material id of every `scene:` ref inside `value`, once per use. */
18
+ export function forEachSceneMaterialRef(value: unknown, visit: (id: SceneMaterialId) => void) {
19
+ if (typeof value === 'string') {
20
+ const id = getSceneMaterialIdFromRef(value)
21
+ if (id) visit(id as SceneMaterialId)
22
+ return
23
+ }
24
+ if (Array.isArray(value)) {
25
+ for (const entry of value) forEachSceneMaterialRef(entry, visit)
26
+ return
27
+ }
28
+ if (value && typeof value === 'object')
29
+ for (const entry of Object.values(value)) forEachSceneMaterialRef(entry, visit)
30
+ }
31
+
32
+ /** The scene materials `value` references (a node, a list of nodes, …). */
33
+ export function referencedSceneMaterialIds(value: unknown): Set<SceneMaterialId> {
34
+ const ids = new Set<SceneMaterialId>()
35
+ forEachSceneMaterialRef(value, (id) => ids.add(id))
36
+ return ids
37
+ }
38
+
39
+ /** How many parts use each scene material across `nodes` (one per finish or slot). */
40
+ export function sceneMaterialUsageCounts(
41
+ nodes: Record<string, AnyNode>,
42
+ ): Map<SceneMaterialId, number> {
43
+ const counts = new Map<SceneMaterialId, number>()
44
+ for (const node of Object.values(nodes))
45
+ forEachSceneMaterialRef(node, (id) => counts.set(id, (counts.get(id) ?? 0) + 1))
46
+ return counts
47
+ }
48
+
49
+ /**
50
+ * `value` with every `scene:<old>` ref the map knows rewritten to `scene:<new>`.
51
+ * Unchanged branches keep their identity, so an untouched node comes back as-is.
52
+ */
53
+ export function remapSceneMaterialRefs<T>(value: T, idMap: ReadonlyMap<string, string>): T {
54
+ if (typeof value === 'string') {
55
+ const id = getSceneMaterialIdFromRef(value)
56
+ const next = id ? idMap.get(id) : undefined
57
+ return (next && next !== id ? toSceneMaterialRef(next) : value) as T
58
+ }
59
+ if (Array.isArray(value)) {
60
+ let changed = false
61
+ const next = value.map((entry) => {
62
+ const mapped = remapSceneMaterialRefs(entry, idMap)
63
+ if (mapped !== entry) changed = true
64
+ return mapped
65
+ })
66
+ return (changed ? next : value) as T
67
+ }
68
+ if (value && typeof value === 'object') {
69
+ let changed = false
70
+ const next: Record<string, unknown> = {}
71
+ for (const [key, entry] of Object.entries(value)) {
72
+ const mapped = remapSceneMaterialRefs(entry, idMap)
73
+ if (mapped !== entry) changed = true
74
+ next[key] = mapped
75
+ }
76
+ return (changed ? next : value) as T
77
+ }
78
+ return value
79
+ }
@@ -0,0 +1,87 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import { useScene } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import useEditor from '../store/use-editor'
5
+ import { syncEditorSelectionFromCurrentScene } from './scene'
6
+
7
+ const building = {
8
+ children: ['level_scene-root'],
9
+ id: 'building_scene-root',
10
+ object: 'node',
11
+ parentId: null,
12
+ position: [0, 0, 0],
13
+ rotation: [0, 0, 0],
14
+ type: 'building',
15
+ visible: true,
16
+ }
17
+
18
+ const level = {
19
+ children: ['wall_scene-root'],
20
+ id: 'level_scene-root',
21
+ level: 0,
22
+ object: 'node',
23
+ parentId: building.id,
24
+ type: 'level',
25
+ visible: true,
26
+ }
27
+
28
+ const wall = {
29
+ children: [],
30
+ end: [4, 0],
31
+ id: 'wall_scene-root',
32
+ object: 'node',
33
+ parentId: level.id,
34
+ start: [0, 0],
35
+ type: 'wall',
36
+ visible: true,
37
+ }
38
+
39
+ describe('scene selection synchronization', () => {
40
+ beforeEach(() => {
41
+ useViewer.getState().resetSelection()
42
+ useEditor.setState({ mode: 'select', phase: 'site', tool: null })
43
+ })
44
+
45
+ test('enters the first level when a scene graph is rooted at a building', () => {
46
+ useScene.setState({
47
+ nodes: {
48
+ [building.id]: building,
49
+ [level.id]: level,
50
+ [wall.id]: wall,
51
+ },
52
+ rootNodeIds: [building.id],
53
+ } as never)
54
+
55
+ syncEditorSelectionFromCurrentScene()
56
+
57
+ expect(useViewer.getState().selection).toMatchObject({
58
+ buildingId: building.id,
59
+ levelId: level.id,
60
+ })
61
+ expect(useEditor.getState().phase).toBe('building')
62
+ })
63
+
64
+ test('opening another project disarms the tool the last one left armed', () => {
65
+ // This runs on every scene load, so a mid-session project switch reads the
66
+ // live state of the project the user just left. A project always opens in
67
+ // select mode — the first click on the new scene selects, it does not build.
68
+ useScene.setState({
69
+ nodes: {
70
+ [building.id]: building,
71
+ [level.id]: level,
72
+ [wall.id]: wall,
73
+ },
74
+ rootNodeIds: [building.id],
75
+ } as never)
76
+ useEditor.getState().setPhase('building')
77
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
78
+
79
+ syncEditorSelectionFromCurrentScene()
80
+
81
+ expect(useEditor.getState().toolMode).toEqual({ mode: 'select' })
82
+ expect(useEditor.getState().mode).toBe('select')
83
+ expect(useEditor.getState().tool).toBeNull()
84
+ // The phase is a preference and stays put.
85
+ expect(useEditor.getState().phase).toBe('building')
86
+ })
87
+ })