@pascal-app/editor 1.0.3 → 1.0.5

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 (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -1,31 +1,58 @@
1
1
  import {
2
2
  AnyNode,
3
3
  type AnyNodeId,
4
+ collectionIdsOf,
4
5
  generateId,
5
6
  generateSceneMaterialId,
7
+ getArtifactStore,
8
+ isDerivedNode,
9
+ isStoreyContent,
6
10
  type LevelNode,
7
11
  nodeRegistry,
12
+ type ParsedBuildJson,
8
13
  remapMeasurementReferences,
14
+ runAsSingleSceneHistoryStep,
9
15
  SceneMaterial,
10
16
  type SceneMaterialId,
11
17
  type StairNode,
18
+ scriptedSize,
19
+ seatRoofs,
20
+ storeyOf,
12
21
  useScene,
13
22
  } from '@pascal-app/core'
23
+ import { clampDoorToWall, clampWindowToWall } from '@pascal-app/core/building'
14
24
  import { useViewer } from '@pascal-app/viewer'
25
+ import { referencedSceneMaterialIds, remapSceneMaterialRefs } from './scene-material-refs'
15
26
 
16
27
  type ClipboardPayload = {
17
28
  copiedAt: number
18
29
  materials: SceneMaterial[]
19
30
  nodes: AnyNode[]
31
+ /** The project copied from, where the scripted nodes' artifacts live. */
32
+ projectId: string | null
20
33
  rootIds: AnyNodeId[]
21
34
  }
22
35
 
23
36
  export type PasteResult = {
24
37
  createdMaterialIds: SceneMaterialId[]
25
38
  pastedIds: AnyNodeId[]
39
+ /** Scripted nodes left out (with what they host): their artifacts could not come from the project they were copied from. */
40
+ refusedIds: AnyNodeId[]
41
+ /** Why they were left out, when any were. */
42
+ refusal: PasteRefusal | null
26
43
  skippedIds: AnyNodeId[]
27
44
  }
28
45
 
46
+ /**
47
+ * `no-access`: the source project cannot be read any more (removed from it,
48
+ * deleted, made private, another account). `no-copies`: its geometry is
49
+ * readable but this script version cannot be copied. `failed`: the copy itself failed.
50
+ */
51
+ export type PasteRefusal = 'no-access' | 'no-copies' | 'failed'
52
+
53
+ type Refused = { ids: ReadonlySet<AnyNodeId>; reason: PasteRefusal | null }
54
+ const NONE_REFUSED: Refused = { ids: new Set(), reason: null }
55
+
29
56
  const SYSTEM_CLIPBOARD_KIND = 'pascal.scene-nodes'
30
57
  const SYSTEM_CLIPBOARD_VERSION = 1
31
58
 
@@ -84,7 +111,7 @@ function collectSubtreeIds(
84
111
  ) {
85
112
  if (ids.has(rootId)) return
86
113
  const node = nodes[rootId]
87
- if (!node) return
114
+ if (!node || isDerivedNode(node)) return
88
115
  ids.add(rootId)
89
116
 
90
117
  if ('children' in node && Array.isArray(node.children)) {
@@ -129,6 +156,7 @@ function isClipboardRoot(
129
156
  }
130
157
 
131
158
  function isCopyableRootType(node: AnyNode) {
159
+ if (isDerivedNode(node)) return false
132
160
  if (COPYABLE_ROOT_TYPES.has(node.type)) return true
133
161
  const definition = nodeRegistry.get(node.type)
134
162
  return !!definition && definition.capabilities?.duplicable !== false
@@ -195,10 +223,7 @@ function remapNodeReferences(
195
223
  rootIds: Set<AnyNodeId>,
196
224
  nodes: Record<AnyNodeId, AnyNode>,
197
225
  ) {
198
- const clone = remapSceneMaterialReferences(
199
- JSON.parse(JSON.stringify(node)),
200
- materialIdMap,
201
- ) as AnyNode
226
+ const clone = remapSceneMaterialRefs(JSON.parse(JSON.stringify(node)) as AnyNode, materialIdMap)
202
227
  ;(clone as Record<string, unknown>).id = idMap.get(oldId)
203
228
 
204
229
  if (rootIds.has(oldId)) {
@@ -217,6 +242,11 @@ function remapNodeReferences(
217
242
  clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId
218
243
  }
219
244
 
245
+ if (clone.type === 'zone' && node.parentId !== clone.parentId && clone.floor?.footprint) {
246
+ const { footprint: _footprint, ...floor } = clone.floor
247
+ clone.floor = floor
248
+ }
249
+
220
250
  if ('children' in clone && Array.isArray(clone.children)) {
221
251
  ;(clone as Record<string, unknown>).children = (clone.children as AnyNodeId[])
222
252
  .map((childId) => idMap.get(childId))
@@ -232,6 +262,13 @@ function remapNodeReferences(
232
262
  }
233
263
  }
234
264
 
265
+ // A corner window pasted with its partner joins the partner's copy; pasted alone, it is plain.
266
+ if (clone.type === 'window' && clone.corner) {
267
+ const partnerId = idMap.get(clone.corner.partnerId as AnyNodeId)
268
+ if (partnerId) clone.corner = { ...clone.corner, partnerId }
269
+ else delete clone.corner
270
+ }
271
+
235
272
  if (clone.type === 'stair') {
236
273
  const nextLevelId = getNextLevelId(targetLevel, nodes)
237
274
  ;(clone as StairNode).fromLevelId = targetLevel.id
@@ -253,43 +290,6 @@ function remapNodeReferences(
253
290
  return AnyNode.parse(clone)
254
291
  }
255
292
 
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
293
  function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
294
294
  const scene = useScene.getState()
295
295
  const selectedIdSet = new Set(ids)
@@ -321,8 +321,7 @@ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
321
321
  .map((id) => scene.nodes[id])
322
322
  .filter((node): node is AnyNode => !!node)
323
323
  .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode)
324
- const materialIds = new Set<SceneMaterialId>()
325
- collectReferencedSceneMaterialIds(copiedNodes, materialIds)
324
+ const materialIds = referencedSceneMaterialIds(copiedNodes)
326
325
 
327
326
  return {
328
327
  copiedAt: Date.now(),
@@ -331,6 +330,7 @@ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
331
330
  .filter((material): material is SceneMaterial => !!material)
332
331
  .map((material) => JSON.parse(JSON.stringify(material)) as SceneMaterial),
333
332
  nodes: copiedNodes,
333
+ projectId: useViewer.getState().projectId,
334
334
  rootIds,
335
335
  }
336
336
  }
@@ -363,6 +363,7 @@ function parseClipboardPayload(text: string): ClipboardPayload | null {
363
363
  copiedAt?: unknown
364
364
  materials?: unknown
365
365
  nodes?: unknown
366
+ projectId?: unknown
366
367
  rootIds?: unknown
367
368
  }
368
369
  if (
@@ -390,6 +391,7 @@ function parseClipboardPayload(text: string): ClipboardPayload | null {
390
391
  copiedAt: candidate.copiedAt,
391
392
  materials: materials.filter((result) => result.success).map((result) => result.data),
392
393
  nodes: parsedNodes,
394
+ projectId: typeof candidate.projectId === 'string' ? candidate.projectId : null,
393
395
  rootIds,
394
396
  }
395
397
  } catch {
@@ -470,23 +472,154 @@ export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteRes
470
472
  export async function pasteSystemEditorClipboardToLevel(
471
473
  targetLevelId?: AnyNodeId,
472
474
  ): Promise<PasteResult | null> {
473
- if (!(await readEditorClipboardFromSystem())) return null
474
- return pasteEditorClipboardToLevel(targetLevelId)
475
+ const projectId = useViewer.getState().projectId
476
+ const store = getArtifactStore()
477
+ if (!(await readEditorClipboardFromSystem()) || !clipboardPayload) return null
478
+ if (projectId !== useViewer.getState().projectId || store !== getArtifactStore()) return null
479
+ const payload = clipboardPayload
480
+ const targetLevel = getPasteTargetLevel(targetLevelId)
481
+ if (!targetLevel) return null
482
+ const refused =
483
+ payload.projectId && payload.projectId === projectId
484
+ ? NONE_REFUSED
485
+ : await copyArtifactsHere(payload)
486
+ // Navigation can replace the scene while the server copies its artifacts.
487
+ if (projectId !== useViewer.getState().projectId || store !== getArtifactStore()) return null
488
+ return applyClipboardPayloadToLevel(payload, targetLevel.id, refused)
489
+ }
490
+
491
+ /** The script and GLB a scripted node, a room's floor script or a wall's face scripts reference. */
492
+ function artifactHashes(node: AnyNode): string[] {
493
+ const sources =
494
+ node.type === 'zone'
495
+ ? [node.floor?.script]
496
+ : node.type === 'wall'
497
+ ? [node.finishScripts?.a, node.finishScripts?.b]
498
+ : ['source' in node ? node.source : undefined]
499
+ return sources.flatMap((source) =>
500
+ typeof source === 'object' && source?.kind === 'script' ? [source.script, source.artifact] : [],
501
+ )
502
+ }
503
+
504
+ /**
505
+ * A paste from another project first brings the artifacts its scripted nodes
506
+ * reference into this one. Returns the nodes whose artifacts could not come,
507
+ * which the paste leaves out with everything they host, and why.
508
+ */
509
+ async function copyArtifactsHere(
510
+ payload: Pick<ClipboardPayload, 'nodes' | 'projectId'>,
511
+ ): Promise<Refused> {
512
+ const store = getArtifactStore()
513
+ const hashes = [...new Set(payload.nodes.flatMap(artifactHashes))]
514
+ if (hashes.length === 0) return NONE_REFUSED
515
+
516
+ let failed = false
517
+ const missing = new Set(
518
+ store.copyFrom
519
+ ? payload.projectId
520
+ ? await store.copyFrom(payload.projectId, hashes).catch(() => {
521
+ failed = true
522
+ return hashes
523
+ })
524
+ : hashes
525
+ : hashes.filter((sha) => !store.url(sha)),
526
+ )
527
+ const ids = new Set<AnyNodeId>()
528
+ let geometryMissing = false
529
+ for (const node of payload.nodes) {
530
+ const [script, artifact] = artifactHashes(node)
531
+ if (!script || !artifact || !(missing.has(script) || missing.has(artifact))) continue
532
+ ids.add(node.id as AnyNodeId)
533
+ geometryMissing ||= missing.has(artifact)
534
+ }
535
+ if (ids.size === 0) return NONE_REFUSED
536
+ return { ids, reason: failed ? 'failed' : geometryMissing ? 'no-access' : 'no-copies' }
537
+ }
538
+
539
+ /**
540
+ * A build file loaded into another project first brings the artifacts its
541
+ * scripted nodes reference from the project it was saved in, as a paste does.
542
+ * Answers the build without the nodes whose artifacts could not come, nor
543
+ * anything they host.
544
+ */
545
+ export async function bringBuildArtifacts(build: ParsedBuildJson): Promise<{
546
+ build: ParsedBuildJson
547
+ refusedIds: AnyNodeId[]
548
+ refusal: PasteRefusal | null
549
+ }> {
550
+ const nodes = Object.values(build.nodes) as AnyNode[]
551
+ // A file's origin is untrusted, even when it names this project. Older files can
552
+ // still use artifacts already registered here, but cannot introduce missing ones.
553
+ const refused = await copyArtifactsHere({
554
+ nodes,
555
+ projectId: build.projectId ?? useViewer.getState().projectId,
556
+ })
557
+ if (refused.ids.size === 0) return { build, refusedIds: [], refusal: null }
558
+
559
+ const excluded = new Set<string>(refused.ids)
560
+ for (let grew = true; grew; ) {
561
+ grew = false
562
+ for (const node of nodes) {
563
+ if (node.parentId && excluded.has(node.parentId) && !excluded.has(node.id)) {
564
+ excluded.add(node.id)
565
+ grew = true
566
+ }
567
+ }
568
+ }
569
+ const kept: Record<string, unknown> = {}
570
+ for (const node of nodes) {
571
+ if (excluded.has(node.id)) continue
572
+ const children = (node as { children?: unknown }).children
573
+ kept[node.id] = Array.isArray(children)
574
+ ? { ...node, children: children.filter((id) => !excluded.has(id as string)) }
575
+ : node
576
+ }
577
+ const collections =
578
+ build.collections &&
579
+ Object.fromEntries(
580
+ Object.entries(build.collections).map(([id, collection]) => [
581
+ id,
582
+ { ...collection, nodeIds: collection.nodeIds.filter((nodeId) => !excluded.has(nodeId)) },
583
+ ]),
584
+ )
585
+ return {
586
+ build: {
587
+ ...build,
588
+ nodes: kept,
589
+ rootNodeIds: build.rootNodeIds.filter((id) => !excluded.has(id)),
590
+ ...(collections ? { collections } : {}),
591
+ },
592
+ refusedIds: [...refused.ids],
593
+ refusal: refused.reason,
594
+ }
475
595
  }
476
596
 
477
597
  function applyClipboardPayloadToLevel(
478
- payload: ClipboardPayload,
598
+ copied: ClipboardPayload,
479
599
  targetLevelId?: AnyNodeId,
600
+ { ids: refused, reason: refusal }: Refused = NONE_REFUSED,
480
601
  ): PasteResult | null {
481
- const targetLevel = getPasteTargetLevel(targetLevelId)
482
- if (!targetLevel) return null
602
+ const aimedLevel = getPasteTargetLevel(targetLevelId)
603
+ if (!aimedLevel) return null
604
+ const payload = refused.size > 0 ? withoutRefusedSubtrees(copied, refused) : copied
483
605
 
484
606
  const scene = useScene.getState()
607
+ // A paste lands on one level. On a roof level, one carrying anything a storey holds (walls,
608
+ // rooms, items, cabinets, …) goes to the storey it covers, whole: the row in view was the roof's,
609
+ // the plan is the storey's, and a roof in it that bears there is seated back up.
610
+ const rootIdSet = new Set(payload.rootIds)
611
+ const targetLevel = payload.nodes.some(
612
+ (node) => rootIdSet.has(node.id as AnyNodeId) && isStoreyContent(node.type, node.metadata),
613
+ )
614
+ ? ((scene.nodes[storeyOf(scene.nodes, aimedLevel.id) as AnyNodeId] as LevelNode | undefined) ??
615
+ aimedLevel)
616
+ : aimedLevel
485
617
  const idMap = new Map<AnyNodeId, AnyNodeId>()
486
618
  const materialIdMap = new Map<SceneMaterialId, SceneMaterialId>()
487
619
  const materialsToCreate: SceneMaterial[] = []
488
620
 
489
621
  for (const node of payload.nodes) {
622
+ if (isDerivedNode(node)) continue
490
623
  idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId)
491
624
  }
492
625
  for (const material of payload.materials) {
@@ -501,11 +634,14 @@ function applyClipboardPayloadToLevel(
501
634
  materialsToCreate.push({ ...material, id: nextId })
502
635
  }
503
636
 
504
- const rootIdSet = new Set(payload.rootIds)
505
637
  const pastedNodes: AnyNode[] = []
506
638
  const skippedIds: AnyNodeId[] = []
507
639
 
508
640
  for (const node of payload.nodes) {
641
+ if (isDerivedNode(node)) {
642
+ skippedIds.push(node.id as AnyNodeId)
643
+ continue
644
+ }
509
645
  try {
510
646
  pastedNodes.push(
511
647
  remapNodeReferences(
@@ -524,33 +660,108 @@ function applyClipboardPayloadToLevel(
524
660
  }
525
661
  }
526
662
 
663
+ fitOpeningsToPastedWalls(pastedNodes, scene.nodes)
664
+
527
665
  if (pastedNodes.length === 0) {
528
- return { createdMaterialIds: [], pastedIds: [], skippedIds }
666
+ return {
667
+ createdMaterialIds: [],
668
+ pastedIds: [],
669
+ refusedIds: [...refused],
670
+ refusal,
671
+ skippedIds,
672
+ }
529
673
  }
530
674
 
531
- for (const material of materialsToCreate) {
532
- scene.addSceneMaterial(material)
533
- }
534
- scene.createNodes(
535
- pastedNodes.map((node) => ({
675
+ const sourceIds = new Map([...idMap].map(([source, copy]) => [copy, source]))
676
+ // A roof pasted onto the walls that carry it goes to the level above them, in the paste itself.
677
+ const { changes } = seatRoofs(scene.nodes, {
678
+ create: pastedNodes.map((node) => ({
536
679
  node,
537
680
  parentId: (node.parentId as AnyNodeId | null) ?? undefined,
681
+ // A copy is in its source's collections; an item brings them in its own `collectionIds`.
682
+ ...(node.type !== 'item' && {
683
+ collectionIds: collectionIdsOf(scene.collections, sourceIds.get(node.id as AnyNodeId)!),
684
+ }),
538
685
  })),
539
- )
686
+ })
687
+ runAsSingleSceneHistoryStep(useScene, () => {
688
+ for (const material of materialsToCreate) scene.addSceneMaterial(material)
689
+ scene.applyNodeChanges(changes)
690
+ })
540
691
 
692
+ const nodes = useScene.getState().nodes
541
693
  const pastedNodeIds = new Set(pastedNodes.map((node) => node.id as AnyNodeId))
542
694
  const pastedRootIds = payload.rootIds
543
695
  .map((rootId) => idMap.get(rootId))
544
696
  .filter((id): id is AnyNodeId => !!id && pastedNodeIds.has(id))
697
+ const rootLevel = nodes[(nodes[pastedRootIds[0]!]?.parentId ?? '') as AnyNodeId]
545
698
 
546
699
  useViewer.getState().setSelection({
547
- levelId: targetLevel.id,
700
+ levelId: rootLevel?.type === 'level' ? rootLevel.id : targetLevel.id,
548
701
  selectedIds: pastedRootIds,
549
702
  })
550
703
 
551
704
  return {
552
705
  createdMaterialIds: materialsToCreate.map((material) => material.id as SceneMaterialId),
553
706
  pastedIds: pastedRootIds,
707
+ refusedIds: [...refused],
708
+ refusal,
554
709
  skippedIds,
555
710
  }
556
711
  }
712
+
713
+ /**
714
+ * A door or window pasted with its wall is held inside it the way the door and window tools place
715
+ * one: a wall pasted under a lower storey would otherwise leave a tall window poking above it.
716
+ */
717
+ function fitOpeningsToPastedWalls(pasted: AnyNode[], sceneNodes: Record<AnyNodeId, AnyNode>) {
718
+ const byId = Object.fromEntries(pasted.map((node) => [node.id, node])) as Record<
719
+ AnyNodeId,
720
+ AnyNode
721
+ >
722
+ let nodes: Record<AnyNodeId, AnyNode> | undefined
723
+ pasted.forEach((node, index) => {
724
+ if (node.type !== 'door' && node.type !== 'window') return
725
+ const wall = node.parentId ? byId[node.parentId as AnyNodeId] : undefined
726
+ if (wall?.type !== 'wall') return
727
+ // A scripted opening is as tall as what its script built, as the wall cuts it.
728
+ const [width, height] = node.source
729
+ ? scriptedSize(node.source.manifest)
730
+ : [node.width, node.height]
731
+ nodes ??= { ...sceneNodes, ...byId }
732
+ const [x, y, z] = node.position
733
+ const { clampedY } =
734
+ node.type === 'door'
735
+ ? clampDoorToWall(wall, x, width, height)
736
+ : clampWindowToWall(wall, x, y, width, height, nodes)
737
+ if (clampedY !== y) pasted[index] = { ...node, position: [x, clampedY, z] }
738
+ })
739
+ }
740
+
741
+ function withoutRefusedSubtrees(
742
+ payload: ClipboardPayload,
743
+ refused: ReadonlySet<AnyNodeId>,
744
+ ): ClipboardPayload {
745
+ const children = new Map<string, AnyNodeId[]>()
746
+ for (const node of payload.nodes) {
747
+ if (!node.parentId) continue
748
+ const siblings = children.get(node.parentId) ?? []
749
+ siblings.push(node.id as AnyNodeId)
750
+ children.set(node.parentId, siblings)
751
+ }
752
+ const excluded = new Set(refused)
753
+ for (const id of excluded) {
754
+ for (const childId of children.get(id) ?? []) excluded.add(childId)
755
+ }
756
+ const nodes = payload.nodes.filter((node) => !excluded.has(node.id as AnyNodeId))
757
+ const kept = new Set(nodes.map((node) => node.id))
758
+ const materialIds = referencedSceneMaterialIds(nodes)
759
+ return {
760
+ ...payload,
761
+ materials: payload.materials.filter((material) =>
762
+ materialIds.has(material.id as SceneMaterialId),
763
+ ),
764
+ nodes,
765
+ rootIds: payload.rootIds.filter((id) => kept.has(id)),
766
+ }
767
+ }
@@ -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
+ }