@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
@@ -2,15 +2,21 @@ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeDefinition,
5
+ BaseNode,
5
6
  DoorNode,
6
7
  type GeometryContext,
8
+ LevelNode,
7
9
  loadPlugin,
10
+ type NodeDefinition,
8
11
  nodeRegistry,
12
+ nodeType,
13
+ objectId,
9
14
  registerNode,
10
15
  SiteNode,
11
16
  sceneRegistry,
12
17
  useScene,
13
18
  } from '@pascal-app/core'
19
+ import { evaluateRecipe, ProceduralItemNode, parseRecipe } from '@pascal-app/core/procedural-items'
14
20
  import {
15
21
  buildDoorPreviewMesh,
16
22
  markViewerPresentationTextureBorrowed,
@@ -19,9 +25,14 @@ import {
19
25
  } from '@pascal-app/viewer'
20
26
  import * as THREE from 'three'
21
27
  import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js'
28
+ import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
22
29
  import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
23
30
  import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
31
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
24
32
  import { MeshStandardNodeMaterial } from 'three/webgpu'
33
+ import cabinetJson from '../../../core/src/procedural-items/__fixtures__/cabinet_two_doors_drawer.json'
34
+ import ceilingFanJson from '../../../core/src/procedural-items/__fixtures__/ceiling_fan.json'
35
+ import pendantJson from '../../../core/src/procedural-items/__fixtures__/pendant_lamp.json'
25
36
  import {
26
37
  prepareSceneForExport,
27
38
  prepareSceneForExportAsync,
@@ -84,6 +95,249 @@ function sceneWithVisibleAndHiddenBoxes(): {
84
95
  }
85
96
 
86
97
  describe('prepareSceneForExport', () => {
98
+ test.each([
99
+ true,
100
+ false,
101
+ ])('omits registered draft geometry absent from the saved document (onlyVisible=%s)', (onlyVisible) => {
102
+ const { root, nodes } = sceneWithVisibleAndHiddenBoxes()
103
+ const draft = sceneRegistry.nodes.get('item_hidden')!
104
+ draft.name = 'Unplaced preview'
105
+ nodes.item_hidden!.visible = true
106
+ delete nodes.item_hidden
107
+
108
+ const result = prepareSceneForExport(root, nodes, { onlyVisible })
109
+ expect(result.scene.getObjectByName('Unplaced preview')).toBeUndefined()
110
+ expect(result.scene.children).toHaveLength(1)
111
+ expect(root.getObjectByName('Unplaced preview')).toBe(draft)
112
+ })
113
+
114
+ test('skips a refused subtree and reports its warning while exporting other objects', () => {
115
+ const root = new THREE.Group()
116
+ const refused = new THREE.Group()
117
+ refused.name = 'Oversized stair'
118
+ refused.userData.pascalExportRefusal = 'Detailed stair geometry exceeds the computation budget.'
119
+ refused.add(meshWithNodeMaterial(nodeMaterial()))
120
+ const accepted = meshWithNodeMaterial(nodeMaterial())
121
+ accepted.name = 'Retained object'
122
+ root.add(refused, accepted)
123
+ const result = prepareSceneForExport(root, {})
124
+ expect(result.scene.getObjectByName(refused.name)).toBeUndefined()
125
+ expect(result.scene.getObjectByName(accepted.name)).toBeDefined()
126
+ expect(
127
+ result.warnings.some(
128
+ (warning) => warning.includes(refused.name) && warning.includes('computation budget'),
129
+ ),
130
+ ).toBe(true)
131
+ expect(root.children).toContain(refused)
132
+ result.dispose()
133
+ })
134
+
135
+ test('exports procedural bulb emission on detached materials regardless of live state', async () => {
136
+ const definitionModule = '../../../nodes/src/procedural-item/definition'
137
+ const { proceduralItemDefinition } = await import(definitionModule)
138
+ registerNode(proceduralItemDefinition as AnyNodeDefinition)
139
+ const node = ProceduralItemNode.parse({
140
+ id: 'procedural-item_pendant_export',
141
+ recipe: parseRecipe(pendantJson),
142
+ })
143
+ const root = new THREE.Group()
144
+ const group = new THREE.Group()
145
+ const source = new THREE.MeshStandardMaterial({ color: '#3377aa' })
146
+ source.emissiveIntensity = 0
147
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.1), source)
148
+ mesh.userData.slotId = 'bulb'
149
+ group.add(mesh)
150
+ root.add(group)
151
+ sceneRegistry.nodes.set(node.id, group)
152
+
153
+ const result = prepareSceneForExport(root, { [node.id]: node as AnyNode })
154
+ const bakedMesh = result.scene.getObjectByName(node.id)?.children[0] as THREE.Mesh
155
+ const material = bakedMesh.material as THREE.MeshStandardMaterial
156
+ const light = evaluateRecipe(node.recipe).lights[0]!
157
+ expect(material).not.toBe(source)
158
+ expect(material.emissive.getHexString()).toBe(new THREE.Color(light.color).getHexString())
159
+ expect(material.emissiveIntensity).toBe(1)
160
+ expect(source.emissiveIntensity).toBe(0)
161
+ expect(result.scene.getObjectByProperty('type', 'PointLight')).toBeUndefined()
162
+ result.dispose()
163
+ })
164
+ test('exports procedural door, drawer, and fan clips from rest-pose motion groups', async () => {
165
+ const definitionModule = '../../../nodes/src/procedural-item/definition'
166
+ const { proceduralItemDefinition } = await import(definitionModule)
167
+ registerNode(proceduralItemDefinition as AnyNodeDefinition)
168
+ const root = new THREE.Group()
169
+ const nodes: Record<string, AnyNode> = {}
170
+ for (const [id, source] of [
171
+ ['procedural-item_cabinet:odd', cabinetJson],
172
+ ['procedural-item_fan:odd', ceilingFanJson],
173
+ ] as const) {
174
+ const node = ProceduralItemNode.parse({ id, recipe: parseRecipe(source) })
175
+ nodes[id] = node as AnyNode
176
+ const evaluation = evaluateRecipe(node.recipe)
177
+ const object = new THREE.Group()
178
+ const groups = new Map<string, THREE.Group>()
179
+ for (const motion of evaluation.motions) {
180
+ const group = new THREE.Group()
181
+ group.name = `${id}__motion__${motion.id}`
182
+ group.position.set(...motion.pivot)
183
+ group.userData.proceduralMotion = {
184
+ nodeId: id,
185
+ partId: motion.partId,
186
+ groupId: motion.id,
187
+ kind: motion.kind,
188
+ }
189
+ groups.set(motion.id, group)
190
+ object.add(group)
191
+ }
192
+ for (const shape of evaluation.shapes) {
193
+ const mesh = new THREE.Mesh(
194
+ new THREE.BoxGeometry(0.02, 0.02, 0.02),
195
+ new THREE.MeshStandardMaterial(),
196
+ )
197
+ mesh.userData.slotId = shape.slot
198
+ ;(shape.motionGroup ? groups.get(shape.motionGroup)! : object).add(mesh)
199
+ }
200
+ for (const group of groups.values()) group.rotation.y = 0.4
201
+ groups.get('drawer')?.position.set(0, 0, 0.25)
202
+ root.add(object)
203
+ sceneRegistry.nodes.set(id, object)
204
+ }
205
+
206
+ const { scene, animations } = prepareSceneForExport(root, nodes, { animations: 'keep' })
207
+ expect(animations.map((clip) => clip.name).sort()).toEqual([
208
+ 'procedural-item_cabinet:odd:doors: open',
209
+ 'procedural-item_cabinet:odd:drawer: open',
210
+ 'procedural-item_fan:odd:rotor: loop',
211
+ ])
212
+ const doors = animations.find((clip) => clip.name.endsWith('doors: open'))!
213
+ expect(doors.tracks).toHaveLength(2)
214
+ expect(doors.duration).toBeCloseTo(0.65)
215
+ expect(doors.userData.loop).toBe(false)
216
+ const drawer = animations.find((clip) => clip.name.endsWith('drawer: open'))!
217
+ expect(drawer.tracks[0]).toBeInstanceOf(THREE.VectorKeyframeTrack)
218
+ for (const clip of [doors, drawer])
219
+ for (const track of clip.tracks) {
220
+ expect(track.times.length).toBeGreaterThanOrEqual(33)
221
+ expect(track.times[0]).toBe(0)
222
+ expect(track.times.at(-1)).toBeCloseTo(clip.duration)
223
+ for (let index = 1; index < track.times.length; index++)
224
+ expect(track.times[index]).toBeGreaterThan(track.times[index - 1]!)
225
+ }
226
+ expect(drawer.tracks[0]!.times.some((time) => Math.abs(time - 0.3) < 1e-6)).toBe(true)
227
+ expect(doors.tracks[0]!.times.some((time) => Math.abs(time - 0.55) < 1e-6)).toBe(true)
228
+ const drawerTrack = drawer.tracks[0]!
229
+ expect(Array.from(drawerTrack.values.slice(0, 3))).toEqual(
230
+ Array.from(drawerTrack.values.slice(3, 6)),
231
+ )
232
+ const doorTrack = doors.tracks[0]!
233
+ const last = doorTrack.values.length
234
+ expect(Array.from(doorTrack.values.slice(last - 8, last - 4))).toEqual(
235
+ Array.from(doorTrack.values.slice(last - 4)),
236
+ )
237
+ const fan = animations.find((clip) => clip.name.endsWith('rotor: loop'))!
238
+ expect(fan.tracks[0]!.times).toHaveLength(5)
239
+ expect(fan.userData.loop).toBe(true)
240
+ for (const clip of animations)
241
+ for (const track of clip.tracks) {
242
+ const target = scene.getObjectByProperty(
243
+ 'uuid',
244
+ track.name.slice(0, track.name.lastIndexOf('.')),
245
+ )
246
+ expect(target).toBeDefined()
247
+ expect(target!.quaternion.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
248
+ const marker = target!.userData.proceduralMotion
249
+ expect(marker.clip).toBe(clip.name)
250
+ expect(marker.groupId).toBeDefined()
251
+ if (clip !== fan) expect(marker.activeWindow).toBeDefined()
252
+ expect(target!.name).toContain('__motion__')
253
+ }
254
+ expect(scene.getObjectByName('procedural-item_cabinet:odd')?.userData).toMatchObject({
255
+ openable: true,
256
+ clips: [doors.name, drawer.name],
257
+ })
258
+ expect(scene.getObjectByName('procedural-item_fan:odd')?.userData).toMatchObject({
259
+ clips: [fan.name],
260
+ })
261
+ expect(scene.getObjectByName('procedural-item_fan:odd')?.userData.openable).toBeUndefined()
262
+ expect(
263
+ scene.getObjectByName('procedural-item_cabinet:odd__motion__doors')?.userData.proceduralMotion
264
+ .activeWindow,
265
+ ).toEqual([0, 0.63])
266
+ expect(
267
+ scene.getObjectByName('procedural-item_cabinet:odd__motion__drawer')?.userData
268
+ .proceduralMotion.activeWindow[0],
269
+ ).toBeCloseTo(0.3)
270
+ const exportedSlots: string[] = []
271
+ scene.traverse((object) => {
272
+ if (object.userData.slotId) exportedSlots.push(object.userData.slotId)
273
+ })
274
+ expect(exportedSlots).toContain('paint')
275
+ const originalFileReader = globalThis.FileReader
276
+ class ExportFileReader {
277
+ result: ArrayBuffer | null = null
278
+ onloadend: (() => void) | null = null
279
+ readAsArrayBuffer(blob: Blob) {
280
+ void blob.arrayBuffer().then((buffer) => {
281
+ this.result = buffer
282
+ this.onloadend?.()
283
+ })
284
+ }
285
+ }
286
+ globalThis.FileReader = ExportFileReader as unknown as typeof FileReader
287
+ let binary: ArrayBuffer
288
+ try {
289
+ binary = (await new GLTFExporter().parseAsync(scene, {
290
+ animations,
291
+ binary: true,
292
+ })) as ArrayBuffer
293
+ } finally {
294
+ globalThis.FileReader = originalFileReader
295
+ }
296
+ const loaded = await new GLTFLoader().parseAsync(binary, '')
297
+ expect(loaded.animations.map((clip) => clip.name).sort()).toEqual(
298
+ animations.map((clip) => clip.name).sort(),
299
+ )
300
+ for (const clip of loaded.animations) {
301
+ for (const track of clip.tracks) {
302
+ const target = loaded.scene.getObjectByName(
303
+ track.name.slice(0, track.name.lastIndexOf('.')),
304
+ )
305
+ expect(target?.userData.proceduralMotion.clip).toBe(clip.name)
306
+ expect(target?.userData.proceduralMotion.groupId).toBeDefined()
307
+ }
308
+ }
309
+ const loadedById = new Map<string, THREE.Object3D>()
310
+ loaded.scene.traverse((object) => {
311
+ if (typeof object.userData.pascalId === 'string')
312
+ loadedById.set(object.userData.pascalId, object)
313
+ })
314
+ expect(loadedById.get('procedural-item_cabinet:odd')?.userData).toMatchObject({
315
+ kind: 'procedural-item',
316
+ clips: [doors.name, drawer.name],
317
+ })
318
+ expect(loadedById.get('procedural-item_fan:odd')?.userData).toMatchObject({
319
+ kind: 'procedural-item',
320
+ clips: [fan.name],
321
+ })
322
+ const loadedSlots: string[] = []
323
+ loaded.scene.traverse((object) => {
324
+ if (object.userData.slotId) loadedSlots.push(object.userData.slotId)
325
+ })
326
+ expect(loadedSlots).toContain('paint')
327
+ expect(
328
+ scene.getObjectByName('procedural-item_cabinet:odd__motion__drawer')?.position.toArray(),
329
+ ).toEqual([0, 0, 0])
330
+
331
+ const withoutClips = prepareSceneForExport(root, nodes, { animations: 'none' })
332
+ expect(withoutClips.animations).toEqual([])
333
+ for (const motion of ['doors', 'doors~1', 'drawer', 'rotor']) {
334
+ const id = motion === 'rotor' ? 'procedural-item_fan:odd' : 'procedural-item_cabinet:odd'
335
+ const target = withoutClips.scene.getObjectByName(`${id}__motion__${motion}`)!
336
+ expect(target.quaternion.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
337
+ if (motion === 'drawer') expect(target.position.toArray()).toEqual([0, 0, 0])
338
+ expect(target.userData.proceduralMotion.clip).toBeUndefined()
339
+ }
340
+ })
87
341
  test('converts NodeMaterials to classic glTF-standard materials', () => {
88
342
  const root = new THREE.Group()
89
343
  root.name = 'scene-renderer'
@@ -590,19 +844,64 @@ describe('prepareSceneForExport', () => {
590
844
  expect(animations).toHaveLength(0)
591
845
  })
592
846
 
593
- test('inherits hidden Site visibility for detached declared children and their descendants', async () => {
847
+ test('keeps the buildings on a hidden Site and drops only the Site ground', () => {
848
+ // A layout authored outside the editor can hide the root Site (the
849
+ // renderer ignores that flag) while every node on it stays visible. The
850
+ // Site's own flag must stop at the Site: it is the parcel reference, not
851
+ // a container the building inherits visibility from.
852
+ const root = new THREE.Group()
853
+ const siteGroup = new THREE.Group()
854
+ const siteGround = meshWithNodeMaterial(nodeMaterial())
855
+ const buildingGroup = new THREE.Group()
856
+ const levelGroup = new THREE.Group()
857
+ const itemGroup = new THREE.Group()
858
+ itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
859
+ levelGroup.add(itemGroup)
860
+ buildingGroup.add(levelGroup)
861
+ siteGroup.add(buildingGroup, siteGround)
862
+ root.add(siteGroup)
863
+
864
+ const siteId = 'site_hidden'
865
+ const buildingId = 'building_on_hidden_site'
866
+ const levelId = 'level_on_hidden_site'
867
+ const itemId = 'item_on_hidden_site'
868
+ sceneRegistry.nodes.set(siteId, siteGroup)
869
+ sceneRegistry.nodes.set(buildingId, buildingGroup)
870
+ sceneRegistry.nodes.set(levelId, levelGroup)
871
+ sceneRegistry.nodes.set(itemId, itemGroup)
872
+ const node = (id: string, type: string, parentId: string | null, visible: boolean) =>
873
+ ({ object: 'node', id, type, parentId, visible }) as unknown as AnyNode
874
+ const nodes: Record<string, AnyNode> = {
875
+ [siteId]: node(siteId, 'site', null, false),
876
+ [buildingId]: node(buildingId, 'building', siteId, true),
877
+ [levelId]: node(levelId, 'level', buildingId, true),
878
+ [itemId]: node(itemId, 'item', levelId, true),
879
+ }
880
+
881
+ const { scene } = prepareSceneForExport(root, nodes)
882
+
883
+ const meshes: THREE.Mesh[] = []
884
+ scene.traverse((object) => {
885
+ if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
886
+ })
887
+ expect(scene.getObjectByName(buildingId)).toBeDefined()
888
+ expect(scene.getObjectByName(itemId)).toBeDefined()
889
+ expect(meshes).toHaveLength(1)
890
+ expect(meshes[0]?.parent?.name).toBe(itemId)
891
+ })
892
+
893
+ test('keeps the nodes a hidden Site hosts, declared or detached, unless hidden themselves', async () => {
594
894
  const restoreRegistry = nodeRegistry._snapshot()
595
895
  try {
596
- const kind = 'test:detached-site-visibility'
896
+ const kind = 'test:hidden-site-host'
597
897
  const childId = 'detached_site_child'
598
898
  const descendantId = 'detached_site_descendant'
599
899
  const explicitId = 'explicit_site_child'
600
- const unownedId = 'unowned_site_child'
900
+ const hiddenChildId = 'hidden_site_child'
601
901
  const hiddenSite = SiteNode.parse({
602
902
  visible: false,
603
- children: [childId, explicitId],
903
+ children: [childId, explicitId, hiddenChildId],
604
904
  })
605
- const visibleSite = SiteNode.parse({ visible: true })
606
905
  registerNode({
607
906
  kind,
608
907
  schemaVersion: 1,
@@ -615,17 +914,19 @@ describe('prepareSceneForExport', () => {
615
914
  } as AnyNodeDefinition)
616
915
  const nodes = {
617
916
  [hiddenSite.id]: hiddenSite,
618
- [visibleSite.id]: visibleSite,
619
917
  [childId]: { id: childId, type: kind, parentId: null, visible: true },
620
918
  [descendantId]: { id: descendantId, type: kind, parentId: childId, visible: true },
621
- [explicitId]: { id: explicitId, type: kind, parentId: visibleSite.id, visible: true },
622
- [unownedId]: { id: unownedId, type: kind, parentId: null, visible: true },
919
+ [explicitId]: { id: explicitId, type: kind, parentId: hiddenSite.id, visible: true },
920
+ [hiddenChildId]: { id: hiddenChildId, type: kind, parentId: hiddenSite.id, visible: false },
623
921
  } as unknown as Record<string, AnyNode>
624
- const allIds = [childId, descendantId, explicitId, unownedId]
922
+ const allIds = [childId, descendantId, explicitId, hiddenChildId]
625
923
  const root = new THREE.Group()
626
- for (const id of [hiddenSite.id, visibleSite.id, ...allIds]) {
924
+ const siteObject = new THREE.Group()
925
+ root.add(siteObject)
926
+ sceneRegistry.nodes.set(hiddenSite.id, siteObject)
927
+ for (const id of allIds) {
627
928
  const object = new THREE.Group()
628
- root.add(object)
929
+ siteObject.add(object)
629
930
  sceneRegistry.nodes.set(id, object)
630
931
  }
631
932
  const exportedIds = async (onlyVisible?: boolean) => {
@@ -637,12 +938,8 @@ describe('prepareSceneForExport', () => {
637
938
  }
638
939
  }
639
940
 
640
- expect(await exportedIds()).toEqual([explicitId, unownedId])
941
+ expect(await exportedIds()).toEqual([childId, descendantId, explicitId])
641
942
  expect(await exportedIds(false)).toEqual(allIds)
642
- nodes[hiddenSite.id] = { ...hiddenSite, visible: true }
643
- expect(await exportedIds()).toEqual(allIds)
644
- nodes[hiddenSite.id] = hiddenSite
645
- expect(await exportedIds()).toEqual([explicitId, unownedId])
646
943
  } finally {
647
944
  restoreRegistry()
648
945
  }
@@ -955,6 +1252,54 @@ describe('prepareSceneForExport', () => {
955
1252
  })
956
1253
  })
957
1254
 
1255
+ test("lists any kind's registry loop clip in extras.clips, without claiming it opens", () => {
1256
+ // A plugin mechanism (an articulated asset's joints) bakes one looping clip;
1257
+ // the baked viewer can only play clips its identity node lists.
1258
+ const root = new THREE.Group()
1259
+ const nodeGroup = new THREE.Group()
1260
+ const joint = new THREE.Group()
1261
+ joint.add(meshWithNodeMaterial(nodeMaterial()))
1262
+ nodeGroup.add(joint)
1263
+ root.add(nodeGroup)
1264
+
1265
+ const kind = `test:articulated-${crypto.randomUUID()}`
1266
+ const nodeId = 'plugin_articulated'
1267
+ registerNode({
1268
+ kind,
1269
+ schemaVersion: 1,
1270
+ category: 'furnish',
1271
+ defaults: () => ({}),
1272
+ capabilities: {},
1273
+ exportAnimation: ({ node, object }: { node: AnyNode; object: THREE.Object3D }) => {
1274
+ const target = object.children[0]!
1275
+ const clip = new THREE.AnimationClip(`${node.id}: loop`, 2, [
1276
+ new THREE.QuaternionKeyframeTrack(
1277
+ `${target.uuid}.quaternion`,
1278
+ [0, 2],
1279
+ [0, 0, 0, 1, 0, 0, 0, 1],
1280
+ ),
1281
+ ])
1282
+ clip.userData = { loop: true }
1283
+ return clip
1284
+ },
1285
+ } as never)
1286
+ sceneRegistry.nodes.set(nodeId, nodeGroup)
1287
+
1288
+ const { scene, animations } = prepareSceneForExport(root, {
1289
+ [nodeId]: {
1290
+ object: 'node',
1291
+ id: nodeId,
1292
+ type: kind,
1293
+ name: 'Articulated',
1294
+ } as unknown as AnyNode,
1295
+ })
1296
+
1297
+ expect(animations.map((clip) => clip.name)).toEqual(['plugin_articulated: loop'])
1298
+ const exported = scene.getObjectByProperty('name', nodeId)
1299
+ expect(exported?.userData.clips).toEqual(['plugin_articulated: loop'])
1300
+ expect(exported?.userData.openable).toBeUndefined()
1301
+ })
1302
+
958
1303
  test('bakes a sliding door into a sampled position clip', () => {
959
1304
  // Operation doors build their moving parts in a named group posed by
960
1305
  // `poseDoorMovingParts`; the exporter samples it into keyframes. The active
@@ -1908,3 +2253,186 @@ describe('portable clips', () => {
1908
2253
  expect(legacy.animations).toEqual([])
1909
2254
  })
1910
2255
  })
2256
+
2257
+ // Plugin API v1: a synthetic installed plugin through the real export path.
2258
+ describe('plugin bake policies through export', () => {
2259
+ const pluginId = 'fixture:bake-export'
2260
+ const levelId = 'level_plugin_bake'
2261
+ const overlayId = 'fxoverlay_export'
2262
+ const meadowId = 'fxmeadow_export'
2263
+ const rockId = 'fxrock_export'
2264
+
2265
+ const namedBox = (name: string) =>
2266
+ Object.assign(new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial()), {
2267
+ name,
2268
+ })
2269
+
2270
+ async function withBakePlugin(
2271
+ installedPlugins: string[],
2272
+ run: (args: {
2273
+ root: THREE.Group
2274
+ live: Record<string, THREE.Group>
2275
+ nodes: Record<string, AnyNode>
2276
+ calls: string[]
2277
+ }) => Promise<void>,
2278
+ ) {
2279
+ const restoreRegistry = nodeRegistry._snapshot()
2280
+ const previousScene = useScene.getState()
2281
+ const calls: string[] = []
2282
+ const Fixture = (prefix: 'fxoverlay' | 'fxmeadow' | 'fxrock') =>
2283
+ BaseNode.extend({ id: objectId(prefix), type: nodeType(`fixture:${prefix.slice(2)}`) })
2284
+ const Overlay = Fixture('fxoverlay')
2285
+ const Meadow = Fixture('fxmeadow')
2286
+ const Rock = Fixture('fxrock')
2287
+ const base = { object: 'node', parentId: null, visible: true, metadata: {} } as const
2288
+ const overlay: NodeDefinition<typeof Overlay> = {
2289
+ kind: 'fixture:overlay',
2290
+ schemaVersion: 1,
2291
+ schema: Overlay,
2292
+ category: 'site',
2293
+ defaults: () => base,
2294
+ capabilities: {},
2295
+ bake: 'strip',
2296
+ }
2297
+ const meadow: NodeDefinition<typeof Meadow> = {
2298
+ kind: 'fixture:meadow',
2299
+ schemaVersion: 1,
2300
+ schema: Meadow,
2301
+ category: 'site',
2302
+ defaults: () => base,
2303
+ capabilities: {},
2304
+ bake: 'replace',
2305
+ bakeGeometry: () => {
2306
+ calls.push('bakeGeometry')
2307
+ return new THREE.Group().add(namedBox('meadow-bake'))
2308
+ },
2309
+ bakeGeometryAsync: async () => {
2310
+ calls.push('bakeGeometryAsync')
2311
+ await Promise.resolve()
2312
+ return new THREE.Group().add(namedBox('meadow-bake-async'))
2313
+ },
2314
+ bakeReplaceRenderer: { module: async () => ({ default: () => null }) },
2315
+ }
2316
+ const rock: NodeDefinition<typeof Rock> = {
2317
+ kind: 'fixture:rock',
2318
+ schemaVersion: 1,
2319
+ schema: Rock,
2320
+ category: 'site',
2321
+ defaults: () => base,
2322
+ capabilities: {},
2323
+ }
2324
+ // API v1 boundary casts: typed definitions do not widen to AnyNodeDefinition
2325
+ // and plugin nodes are outside the AnyNode union.
2326
+ const asPluginNode = <S extends AnyNodeDefinition['schema']>(def: NodeDefinition<S>) =>
2327
+ def as unknown as AnyNodeDefinition
2328
+ const asSceneNode = (node: { id: string; type: string }) => node as unknown as AnyNode
2329
+ try {
2330
+ await loadPlugin({
2331
+ id: pluginId,
2332
+ apiVersion: 1,
2333
+ nodes: [asPluginNode(overlay), asPluginNode(meadow), asPluginNode(rock)],
2334
+ })
2335
+ useScene.setState({ installedPlugins, hasExplicitPluginInstallState: true })
2336
+
2337
+ const root = new THREE.Group()
2338
+ const level = new THREE.Group()
2339
+ root.add(level)
2340
+ sceneRegistry.nodes.set(levelId, level)
2341
+ const live: Record<string, THREE.Group> = {}
2342
+ const pluginNodes = [
2343
+ Overlay.parse({ id: overlayId, parentId: levelId }),
2344
+ Meadow.parse({ id: meadowId, parentId: levelId }),
2345
+ Rock.parse({ id: rockId, parentId: levelId }),
2346
+ ].map(asSceneNode)
2347
+ const nodes: Record<string, AnyNode> = {
2348
+ [levelId]: asSceneNode(
2349
+ LevelNode.parse({ id: levelId, children: pluginNodes.map((node) => node.id) }),
2350
+ ),
2351
+ }
2352
+ for (const node of pluginNodes) {
2353
+ const group = new THREE.Group().add(namedBox(`${node.id}-live`))
2354
+ level.add(group)
2355
+ sceneRegistry.nodes.set(node.id, group)
2356
+ live[node.id] = group
2357
+ nodes[node.id] = node
2358
+ }
2359
+ await run({ root, live, nodes, calls })
2360
+ } finally {
2361
+ restoreRegistry()
2362
+ useScene.setState(previousScene)
2363
+ }
2364
+ }
2365
+
2366
+ function expectLiveTreeUntouched(live: Record<string, THREE.Group>) {
2367
+ for (const [id, group] of Object.entries(live)) {
2368
+ expect(group.parent).not.toBeNull()
2369
+ expect(group.getObjectByName(`${id}-live`)).toBeDefined()
2370
+ }
2371
+ }
2372
+
2373
+ test.each([
2374
+ ['sync', 'meadow-bake', ['bakeGeometry']],
2375
+ ['async', 'meadow-bake-async', ['bakeGeometryAsync']],
2376
+ ] as const)('%s export strips, replaces and keeps static plugin kinds without touching the live tree', async (mode, bakedName, expectedCalls) => {
2377
+ await withBakePlugin([pluginId], async ({ root, live, nodes, calls }) => {
2378
+ const prepared =
2379
+ mode === 'sync'
2380
+ ? prepareSceneForExport(root, nodes)
2381
+ : await prepareSceneForExportAsync(root, nodes)
2382
+ try {
2383
+ const scene = prepared.scene
2384
+ expect(scene.getObjectByName(overlayId)).toBeUndefined()
2385
+ expect(scene.getObjectByName(`${overlayId}-live`)).toBeUndefined()
2386
+
2387
+ const meadow = scene.getObjectByName(meadowId)
2388
+ expect(meadow?.getObjectByName(bakedName)).toBeDefined()
2389
+ expect(meadow?.getObjectByName(`${meadowId}-live`)).toBeUndefined()
2390
+
2391
+ expect(scene.getObjectByName(rockId)?.getObjectByName(`${rockId}-live`)).toBeDefined()
2392
+ expect(calls).toEqual([...expectedCalls])
2393
+ expectLiveTreeUntouched(live)
2394
+ } finally {
2395
+ prepared.dispose()
2396
+ }
2397
+ })
2398
+ })
2399
+
2400
+ test('an uninstalled plugin contributes nothing to the export', async () => {
2401
+ await withBakePlugin([], async ({ root, live, nodes, calls }) => {
2402
+ const prepared = prepareSceneForExport(root, nodes)
2403
+ try {
2404
+ for (const id of [overlayId, meadowId, rockId]) {
2405
+ expect(prepared.scene.getObjectByName(id)).toBeUndefined()
2406
+ }
2407
+ expect(calls).toEqual([])
2408
+ expectLiveTreeUntouched(live)
2409
+ } finally {
2410
+ prepared.dispose()
2411
+ }
2412
+ })
2413
+ })
2414
+ })
2415
+
2416
+ test('prepared exports preserve surface identities and IFC subpart roles on registered meshes', () => {
2417
+ const root = new THREE.Group()
2418
+ root.name = 'scene-renderer'
2419
+ const owner = { ...BaseNode.parse({ id: 'item_surface_owner' }), type: 'item' } as AnyNode
2420
+ const child = {
2421
+ ...BaseNode.parse({ id: 'item_surface_child', parentId: owner.id }),
2422
+ type: 'item',
2423
+ } as AnyNode
2424
+ const mesh = meshWithNodeMaterial(nodeMaterial())
2425
+ mesh.userData = {
2426
+ surfaceNodeIds: [child.id],
2427
+ pascalIfcRole: 'railing',
2428
+ pascalIfcParts: [{ start: 0, count: 3, role: 'handrail' }],
2429
+ }
2430
+ root.add(mesh)
2431
+ sceneRegistry.nodes.set(owner.id, mesh)
2432
+ const { scene } = prepareSceneForExport(root, { [owner.id]: owner, [child.id]: child })
2433
+ const exported = scene.getObjectByName(owner.id)!
2434
+ expect(exported.userData.pascalId).toBe(owner.id)
2435
+ expect(exported.userData.surfaceNodeIds).toEqual([child.id])
2436
+ expect(exported.userData.pascalIfcRole).toBe('railing')
2437
+ expect(exported.userData.pascalIfcParts).toEqual([{ start: 0, count: 3, role: 'handrail' }])
2438
+ })