@pascal-app/editor 1.0.0-beta.5 → 1.0.1

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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,9 +1,33 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
- import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core'
3
- import { buildDoorPreviewMesh } from '@pascal-app/viewer'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ DoorNode,
6
+ type GeometryContext,
7
+ loadPlugin,
8
+ nodeRegistry,
9
+ registerNode,
10
+ SiteNode,
11
+ sceneRegistry,
12
+ useScene,
13
+ } from '@pascal-app/core'
14
+ import {
15
+ buildDoorPreviewMesh,
16
+ markViewerPresentationTextureBorrowed,
17
+ type ViewerPresentationContribution,
18
+ viewerPresentationRegistry,
19
+ } from '@pascal-app/viewer'
4
20
  import * as THREE from 'three'
5
21
  import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js'
6
- import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export'
22
+ import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
23
+ import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
24
+ import { MeshStandardNodeMaterial } from 'three/webgpu'
25
+ import {
26
+ prepareSceneForExport,
27
+ prepareSceneForExportAsync,
28
+ writeTextureReferenceExtras,
29
+ } from './glb-export'
30
+ import { createUsdzScene } from './portable-export'
7
31
 
8
32
  // The reference module reads the storage origin lazily on first use, so
9
33
  // setting the env here (before any validation call) pins it for the file.
@@ -14,25 +38,15 @@ afterEach(() => {
14
38
  sceneRegistry.clear()
15
39
  })
16
40
 
17
- function nodeMaterial(overrides: Record<string, unknown> = {}) {
18
- // Duck-typed stand-in for the viewer's MeshStandard/LambertNodeMaterial:
19
- // the exporter keys off `isNodeMaterial` and reads plain PBR props.
20
- return {
21
- isNodeMaterial: true,
22
- name: 'painted',
23
- color: new THREE.Color('#cc3300'),
41
+ function nodeMaterial(overrides: Record<string, unknown> = {}): THREE.Material {
42
+ const material = new MeshStandardNodeMaterial({
43
+ color: '#cc3300',
24
44
  roughness: 0.3,
25
45
  metalness: 0.7,
26
- transparent: false,
27
- opacity: 1,
28
- side: THREE.FrontSide,
29
- alphaTest: 0,
30
- depthWrite: true,
31
- depthTest: true,
32
- vertexColors: false,
33
- toneMapped: true,
34
- ...overrides,
35
- } as unknown as THREE.Material
46
+ })
47
+ material.name = 'painted'
48
+ Object.assign(material, overrides)
49
+ return material
36
50
  }
37
51
 
38
52
  function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
@@ -40,6 +54,35 @@ function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
40
54
  return new THREE.Mesh(geometry, material)
41
55
  }
42
56
 
57
+ function sceneWithVisibleAndHiddenBoxes(): {
58
+ root: THREE.Group
59
+ nodes: Record<string, AnyNode>
60
+ } {
61
+ const root = new THREE.Group()
62
+ const nodes: Record<string, AnyNode> = {}
63
+
64
+ for (const [id, visible, x] of [
65
+ ['item_visible', true, 0],
66
+ ['item_hidden', false, 2],
67
+ ] as const) {
68
+ const group = new THREE.Group()
69
+ const mesh = meshWithNodeMaterial(nodeMaterial())
70
+ mesh.position.x = x
71
+ group.add(mesh)
72
+ root.add(group)
73
+ sceneRegistry.nodes.set(id, group)
74
+ nodes[id] = {
75
+ object: 'node',
76
+ id,
77
+ type: 'item',
78
+ parentId: null,
79
+ visible,
80
+ } as unknown as AnyNode
81
+ }
82
+
83
+ return { root, nodes }
84
+ }
85
+
43
86
  describe('prepareSceneForExport', () => {
44
87
  test('converts NodeMaterials to classic glTF-standard materials', () => {
45
88
  const root = new THREE.Group()
@@ -57,6 +100,340 @@ describe('prepareSceneForExport', () => {
57
100
  expect(material.color.getHexString()).toBe('cc3300')
58
101
  })
59
102
 
103
+ test('clones userData that holds runtime resources and functions', () => {
104
+ const root = new THREE.Group()
105
+ root.name = 'scene-renderer'
106
+ const effectMaterial = new THREE.MeshStandardMaterial()
107
+ effectMaterial.addEventListener('dispose', () => {})
108
+ const pool = meshWithNodeMaterial(nodeMaterial())
109
+ pool.userData = { waterEffect: { material: effectMaterial, onFrame: () => {} } }
110
+ const overlay = meshWithNodeMaterial(nodeMaterial())
111
+ overlay.userData = { pascalExport: 'strip', mesh: pool }
112
+ root.add(pool, overlay)
113
+ expect(() => structuredClone(pool.userData)).toThrow()
114
+
115
+ const { scene } = prepareSceneForExport(root, {})
116
+
117
+ const meshes: THREE.Mesh[] = []
118
+ scene.traverse((object) => {
119
+ if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
120
+ })
121
+ expect(meshes).toHaveLength(1)
122
+ expect(meshes[0]?.userData.waterEffect).toBeUndefined()
123
+ expect(pool.userData.waterEffect.material).toBe(effectMaterial)
124
+ })
125
+
126
+ test('replaces only the cloned registered subtree with bake-only geometry', () => {
127
+ const restoreRegistry = nodeRegistry._snapshot()
128
+ try {
129
+ let receivedParentId: string | null | undefined
130
+ registerNode({
131
+ kind: 'test:bake-geometry',
132
+ schemaVersion: 1,
133
+ schema: DoorNode,
134
+ category: 'utility',
135
+ defaults: () => ({}) as never,
136
+ capabilities: {},
137
+ bake: 'replace',
138
+ bakeGeometry: (_node, context) => {
139
+ receivedParentId = context.parent?.id
140
+ const group = new THREE.Group()
141
+ const instances = new THREE.InstancedMesh(
142
+ new THREE.BoxGeometry(0.1, 1, 0.1),
143
+ new THREE.MeshStandardMaterial({ color: '#228833' }),
144
+ 1,
145
+ )
146
+ instances.name = 'baked-instance'
147
+ instances.setMatrixAt(0, new THREE.Matrix4().makeTranslation(4, 0, 2))
148
+ group.add(instances)
149
+ return group
150
+ },
151
+ } as AnyNodeDefinition)
152
+
153
+ const root = new THREE.Group()
154
+ const liveGroup = new THREE.Group()
155
+ liveGroup.position.set(2, 0, 3)
156
+ const liveMesh = meshWithNodeMaterial(nodeMaterial())
157
+ liveMesh.name = 'live-procedural-candidate'
158
+ liveGroup.add(liveMesh)
159
+ const before = meshWithNodeMaterial(nodeMaterial())
160
+ before.name = 'before-bake-replacement'
161
+ const after = meshWithNodeMaterial(nodeMaterial())
162
+ after.name = 'after-bake-replacement'
163
+ root.add(before, liveGroup, after)
164
+
165
+ const siteId = 'site_bake'
166
+ const grassId = 'grass_bake'
167
+ sceneRegistry.nodes.set(grassId, liveGroup)
168
+ const nodes = {
169
+ [siteId]: {
170
+ object: 'node',
171
+ id: siteId,
172
+ type: 'site',
173
+ parentId: null,
174
+ children: [grassId],
175
+ } as unknown as AnyNode,
176
+ [grassId]: {
177
+ object: 'node',
178
+ id: grassId,
179
+ type: 'test:bake-geometry',
180
+ parentId: siteId,
181
+ visible: true,
182
+ } as unknown as AnyNode,
183
+ }
184
+
185
+ const { scene } = prepareSceneForExport(root, nodes)
186
+ const exported = scene.getObjectByName(grassId)
187
+ const baked = exported?.getObjectByName('baked-instance')
188
+
189
+ expect(receivedParentId).toBe(siteId)
190
+ expect(liveGroup.getObjectByName('live-procedural-candidate')).toBe(liveMesh)
191
+ expect(scene.getObjectByName('live-procedural-candidate')).toBeUndefined()
192
+ expect(exported?.position.toArray()).toEqual([2, 0, 3])
193
+ expect(scene.children.map((child) => child.name)).toEqual([
194
+ 'before-bake-replacement',
195
+ grassId,
196
+ 'after-bake-replacement',
197
+ ])
198
+ expect((baked as THREE.InstancedMesh | undefined)?.isInstancedMesh).toBe(true)
199
+ expect((baked as THREE.InstancedMesh | undefined)?.count).toBe(1)
200
+ expect(((baked as THREE.InstancedMesh).material as THREE.Material).isMaterial).toBe(true)
201
+ } finally {
202
+ restoreRegistry()
203
+ }
204
+ })
205
+
206
+ test.each([
207
+ 'sync',
208
+ 'portable-sync',
209
+ 'portable-async',
210
+ ] as const)('bakes Site bounds and same-kind sibling reservations through %s hooks', async (mode) => {
211
+ const restoreRegistry = nodeRegistry._snapshot()
212
+ try {
213
+ const kind = 'test:site-context-bake'
214
+ const nodeId = 'site_context_bake'
215
+ const site = SiteNode.parse({
216
+ polygon: {
217
+ type: 'polygon',
218
+ points: [
219
+ [0, 0],
220
+ [12, 0],
221
+ [12, 8],
222
+ [0, 8],
223
+ ],
224
+ },
225
+ children: [nodeId, 'declared_sibling', 'linked_sibling', 'other_kind'],
226
+ })
227
+ const otherSite = SiteNode.parse({
228
+ polygon: {
229
+ type: 'polygon',
230
+ points: [
231
+ [0, 0],
232
+ [30, 0],
233
+ [30, 4],
234
+ [0, 4],
235
+ ],
236
+ },
237
+ children: ['other_site_sibling'],
238
+ })
239
+ const buildGeometry = (context: GeometryContext) => {
240
+ const points = (context.parent as SiteNode | null)?.polygon?.points ?? [[0, 0]]
241
+ const xs = points.map(([x]) => x)
242
+ const zs = points.map(([, z]) => z)
243
+ const reservedWidth = context.siblings.reduce(
244
+ (width, sibling) => width + Number(sibling.metadata?.reservedWidth ?? 0),
245
+ 0,
246
+ )
247
+ return new THREE.Mesh(
248
+ new THREE.BoxGeometry(
249
+ Math.max(...xs) - Math.min(...xs) - reservedWidth,
250
+ 1,
251
+ Math.max(...zs) - Math.min(...zs),
252
+ ),
253
+ new THREE.MeshStandardMaterial(),
254
+ )
255
+ }
256
+ registerNode({
257
+ kind,
258
+ schemaVersion: 1,
259
+ schema: DoorNode,
260
+ category: 'furnish',
261
+ defaults: () => ({}) as never,
262
+ capabilities: {},
263
+ bake: 'replace',
264
+ ...(mode === 'portable-async'
265
+ ? { bakeGeometryAsync: async (_node, context) => buildGeometry(context) }
266
+ : { bakeGeometry: (_node, context) => buildGeometry(context) }),
267
+ } as AnyNodeDefinition)
268
+ const nodes = {
269
+ [site.id]: site,
270
+ [otherSite.id]: otherSite,
271
+ [nodeId]: {
272
+ id: nodeId,
273
+ type: kind,
274
+ parentId: null,
275
+ visible: true,
276
+ metadata: { reservedWidth: 100 },
277
+ },
278
+ declared_sibling: {
279
+ id: 'declared_sibling',
280
+ type: kind,
281
+ parentId: null,
282
+ metadata: { reservedWidth: 2 },
283
+ },
284
+ pointer_sibling: {
285
+ id: 'pointer_sibling',
286
+ type: kind,
287
+ parentId: site.id,
288
+ metadata: { reservedWidth: 3 },
289
+ },
290
+ linked_sibling: {
291
+ id: 'linked_sibling',
292
+ type: kind,
293
+ parentId: site.id,
294
+ metadata: { reservedWidth: 1 },
295
+ },
296
+ other_kind: {
297
+ id: 'other_kind',
298
+ type: 'test:other-kind',
299
+ parentId: site.id,
300
+ metadata: { reservedWidth: 100 },
301
+ },
302
+ other_site_sibling: {
303
+ id: 'other_site_sibling',
304
+ type: kind,
305
+ parentId: otherSite.id,
306
+ metadata: { reservedWidth: 4 },
307
+ },
308
+ } as unknown as Record<string, AnyNode>
309
+ const root = new THREE.Group()
310
+ const source = new THREE.Group()
311
+ root.add(source)
312
+ sceneRegistry.nodes.set(nodeId, source)
313
+ const prepare = mode === 'sync' ? prepareSceneForExport : prepareSceneForExportAsync
314
+ const exportedSize = async () => {
315
+ const prepared = await prepare(root, nodes)
316
+ try {
317
+ const exported = prepared.scene.getObjectByName(nodeId)!
318
+ return new THREE.Box3().setFromObject(exported).getSize(new THREE.Vector3()).toArray()
319
+ } finally {
320
+ prepared.dispose()
321
+ }
322
+ }
323
+
324
+ expect(await exportedSize()).toEqual([6, 1, 8])
325
+ nodes[nodeId]!.parentId = otherSite.id
326
+ expect(await exportedSize()).toEqual([26, 1, 4])
327
+ nodes[nodeId]!.parentId = null
328
+ site.children = site.children.filter((id) => id !== nodeId)
329
+ expect(await exportedSize()).toEqual([0, 1, 0])
330
+ } finally {
331
+ restoreRegistry()
332
+ }
333
+ })
334
+
335
+ test('selective exports omit a procedural kind without changing the live scene or later exports', () => {
336
+ const restoreRegistry = nodeRegistry._snapshot()
337
+ try {
338
+ const kind = 'test:optional-ground-cover'
339
+ registerNode({
340
+ kind,
341
+ schemaVersion: 1,
342
+ schema: DoorNode,
343
+ category: 'furnish',
344
+ defaults: () => ({}) as never,
345
+ capabilities: {},
346
+ bake: 'replace',
347
+ bakeGeometry: () =>
348
+ new THREE.Mesh(
349
+ new THREE.BoxGeometry(1, 1, 1),
350
+ new THREE.MeshStandardMaterial({ color: '#228833' }),
351
+ ),
352
+ } as AnyNodeDefinition)
353
+ const root = new THREE.Group()
354
+ const building = meshWithNodeMaterial(nodeMaterial())
355
+ const grass = new THREE.Group()
356
+ const water = meshWithNodeMaterial(nodeMaterial())
357
+ root.add(building, grass, water)
358
+ const nodes: Record<string, AnyNode> = {}
359
+ for (const [id, type, object] of [
360
+ ['building_export', 'building', building],
361
+ ['grass_export', kind, grass],
362
+ ['water_export', 'test:water', water],
363
+ ] as const) {
364
+ sceneRegistry.nodes.set(id, object)
365
+ nodes[id] = { id, type, visible: true } as unknown as AnyNode
366
+ }
367
+
368
+ const complete = prepareSceneForExport(root, nodes)
369
+ const selected = prepareSceneForExport(root, nodes, {
370
+ excludedNodeTypes: [kind],
371
+ onlyVisible: false,
372
+ })
373
+ const stl = new STLExporter()
374
+ expect(stl.parse(complete.scene, { binary: true }).getUint32(80, true)).toBe(36)
375
+ expect(stl.parse(selected.scene, { binary: true }).getUint32(80, true)).toBe(24)
376
+ const obj = new OBJExporter().parse(selected.scene)
377
+ expect(obj).not.toContain('grass_export')
378
+ expect(obj).toContain('building_export')
379
+ expect(obj).toContain('water_export')
380
+ expect(selected.scene.getObjectByName('grass_export')).toBeUndefined()
381
+ expect(root.children).toEqual([building, grass, water])
382
+ expect(grass.visible).toBe(true)
383
+ expect(nodes.grass_export?.visible).toBe(true)
384
+ const later = prepareSceneForExport(root, nodes)
385
+ expect(stl.parse(later.scene, { binary: true }).getUint32(80, true)).toBe(36)
386
+ } finally {
387
+ restoreRegistry()
388
+ }
389
+ })
390
+
391
+ test('excluding a parent skips descendant bake failures and animation tracks', () => {
392
+ const restoreRegistry = nodeRegistry._snapshot()
393
+ try {
394
+ registerNode({
395
+ kind: 'test:unavailable-bake',
396
+ schemaVersion: 1,
397
+ schema: DoorNode,
398
+ category: 'furnish',
399
+ defaults: () => ({}) as never,
400
+ capabilities: {},
401
+ bakeGeometry: () => {
402
+ throw new Error('Procedural content unavailable')
403
+ },
404
+ } as AnyNodeDefinition)
405
+ const root = new THREE.Group()
406
+ const excluded = new THREE.Group()
407
+ const procedural = new THREE.Group()
408
+ const door = new THREE.Group()
409
+ const leaf = meshWithNodeMaterial(nodeMaterial())
410
+ leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
411
+ door.add(leaf)
412
+ excluded.add(procedural, door)
413
+ root.add(excluded, meshWithNodeMaterial(nodeMaterial()))
414
+ const nodes: Record<string, AnyNode> = {}
415
+ for (const [id, type, object, parentId] of [
416
+ ['procedural_child', 'test:unavailable-bake', procedural, 'excluded_parent'],
417
+ ['door_child', 'door', door, 'excluded_parent'],
418
+ ['excluded_parent', 'test:optional-parent', excluded, null],
419
+ ] as const) {
420
+ sceneRegistry.nodes.set(id, object)
421
+ nodes[id] = { id, type, parentId, visible: true } as unknown as AnyNode
422
+ }
423
+
424
+ expect(() => prepareSceneForExport(root, nodes)).toThrow('Procedural content unavailable')
425
+ const selected = prepareSceneForExport(root, nodes, {
426
+ excludedNodeTypes: ['test:optional-parent'],
427
+ })
428
+ expect(new STLExporter().parse(selected.scene, { binary: true }).getUint32(80, true)).toBe(12)
429
+ expect(selected.animations).toEqual([])
430
+ expect(selected.scene.getObjectByName('door_child')).toBeUndefined()
431
+ expect(excluded.children).toEqual([procedural, door])
432
+ } finally {
433
+ restoreRegistry()
434
+ }
435
+ })
436
+
60
437
  test('shared NodeMaterial instances convert to a single shared material', () => {
61
438
  const root = new THREE.Group()
62
439
  const shared = nodeMaterial()
@@ -114,7 +491,11 @@ describe('prepareSceneForExport', () => {
114
491
  expect(placeholder.flipY).toBe(stamped.flipY)
115
492
  expect(placeholder.colorSpace).toBe(stamped.colorSpace)
116
493
  expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef)
117
- expect(material.normalMap).toBe(unstamped)
494
+ expect(material.normalMap).toBeInstanceOf(THREE.DataTexture)
495
+ expect(Array.from((material.normalMap as THREE.DataTexture).image.data as Uint8Array)).toEqual([
496
+ 128, 128, 255, 255,
497
+ ])
498
+ expect(material.normalMap?.userData.pascalTextureRef).toBeUndefined()
118
499
  const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial
119
500
  expect(sharedMaterial.map).toBe(placeholder)
120
501
  })
@@ -173,6 +554,151 @@ describe('prepareSceneForExport', () => {
173
554
  expect(meshes).toHaveLength(1)
174
555
  })
175
556
 
557
+ test('excludes hidden scene nodes and descendants by default', () => {
558
+ const root = new THREE.Group()
559
+ const levelGroup = new THREE.Group()
560
+ const itemGroup = new THREE.Group()
561
+ itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
562
+ levelGroup.add(itemGroup)
563
+ root.add(levelGroup)
564
+
565
+ const levelId = 'level_hidden'
566
+ const itemId = 'item_visible_child'
567
+ sceneRegistry.nodes.set(levelId, levelGroup)
568
+ sceneRegistry.nodes.set(itemId, itemGroup)
569
+ const nodes: Record<string, AnyNode> = {
570
+ [levelId]: {
571
+ object: 'node',
572
+ id: levelId,
573
+ type: 'level',
574
+ parentId: null,
575
+ visible: false,
576
+ } as unknown as AnyNode,
577
+ [itemId]: {
578
+ object: 'node',
579
+ id: itemId,
580
+ type: 'item',
581
+ parentId: levelId,
582
+ visible: true,
583
+ } as unknown as AnyNode,
584
+ }
585
+
586
+ const { scene, animations } = prepareSceneForExport(root, nodes)
587
+
588
+ expect(scene.getObjectByName(levelId)).toBeUndefined()
589
+ expect(scene.getObjectByName(itemId)).toBeUndefined()
590
+ expect(animations).toHaveLength(0)
591
+ })
592
+
593
+ test('inherits hidden Site visibility for detached declared children and their descendants', async () => {
594
+ const restoreRegistry = nodeRegistry._snapshot()
595
+ try {
596
+ const kind = 'test:detached-site-visibility'
597
+ const childId = 'detached_site_child'
598
+ const descendantId = 'detached_site_descendant'
599
+ const explicitId = 'explicit_site_child'
600
+ const unownedId = 'unowned_site_child'
601
+ const hiddenSite = SiteNode.parse({
602
+ visible: false,
603
+ children: [childId, explicitId],
604
+ })
605
+ const visibleSite = SiteNode.parse({ visible: true })
606
+ registerNode({
607
+ kind,
608
+ schemaVersion: 1,
609
+ schema: DoorNode,
610
+ category: 'furnish',
611
+ defaults: () => ({}) as never,
612
+ capabilities: {},
613
+ bakeGeometryAsync: async () =>
614
+ new THREE.Mesh(new THREE.BoxGeometry(2, 1, 3), new THREE.MeshStandardMaterial()),
615
+ } as AnyNodeDefinition)
616
+ const nodes = {
617
+ [hiddenSite.id]: hiddenSite,
618
+ [visibleSite.id]: visibleSite,
619
+ [childId]: { id: childId, type: kind, parentId: null, visible: true },
620
+ [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 },
623
+ } as unknown as Record<string, AnyNode>
624
+ const allIds = [childId, descendantId, explicitId, unownedId]
625
+ const root = new THREE.Group()
626
+ for (const id of [hiddenSite.id, visibleSite.id, ...allIds]) {
627
+ const object = new THREE.Group()
628
+ root.add(object)
629
+ sceneRegistry.nodes.set(id, object)
630
+ }
631
+ const exportedIds = async (onlyVisible?: boolean) => {
632
+ const prepared = await prepareSceneForExportAsync(root, nodes, { onlyVisible })
633
+ try {
634
+ return allIds.filter((id) => prepared.scene.getObjectByName(id) !== undefined)
635
+ } finally {
636
+ prepared.dispose()
637
+ }
638
+ }
639
+
640
+ expect(await exportedIds()).toEqual([explicitId, unownedId])
641
+ 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
+ } finally {
647
+ restoreRegistry()
648
+ }
649
+ })
650
+
651
+ test('can include hidden scene nodes when visible-only export is disabled', () => {
652
+ const root = new THREE.Group()
653
+ const itemGroup = new THREE.Group()
654
+ itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
655
+ root.add(itemGroup)
656
+
657
+ const itemId = 'item_hidden'
658
+ sceneRegistry.nodes.set(itemId, itemGroup)
659
+ const nodes: Record<string, AnyNode> = {
660
+ [itemId]: {
661
+ object: 'node',
662
+ id: itemId,
663
+ type: 'item',
664
+ parentId: null,
665
+ visible: false,
666
+ } as unknown as AnyNode,
667
+ }
668
+
669
+ const { scene } = prepareSceneForExport(root, nodes, { onlyVisible: false })
670
+
671
+ expect(scene.getObjectByName(itemId)?.userData).toMatchObject({
672
+ pascalId: itemId,
673
+ kind: 'item',
674
+ })
675
+ })
676
+
677
+ test('traditional binary STL excludes hidden nodes by default and can include them', () => {
678
+ const { root, nodes } = sceneWithVisibleAndHiddenBoxes()
679
+
680
+ const visibleScene = prepareSceneForExport(root, nodes).scene
681
+ const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene
682
+ const visibleStl = new STLExporter().parse(visibleScene, { binary: true })
683
+ const completeStl = new STLExporter().parse(completeScene, { binary: true })
684
+
685
+ expect(visibleStl.getUint32(80, true)).toBe(12)
686
+ expect(completeStl.getUint32(80, true)).toBe(24)
687
+ })
688
+
689
+ test('traditional OBJ excludes hidden nodes by default and can include them', () => {
690
+ const { root, nodes } = sceneWithVisibleAndHiddenBoxes()
691
+
692
+ const visibleScene = prepareSceneForExport(root, nodes).scene
693
+ const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene
694
+ const visibleObj = new OBJExporter().parse(visibleScene)
695
+ const completeObj = new OBJExporter().parse(completeScene)
696
+ const vertexCount = (obj: string) => obj.match(/^v /gm)?.length ?? 0
697
+
698
+ expect(vertexCount(visibleObj)).toBe(24)
699
+ expect(vertexCount(completeObj)).toBe(48)
700
+ })
701
+
176
702
  test('neutralises an invisible hitbox root but keeps its visible children', () => {
177
703
  // Door/window roots are selection hitboxes: a box geometry with an invisible
178
704
  // material (object stays visible). Left intact it would plug the wall opening.
@@ -553,4 +1079,832 @@ describe('prepareSceneForExport', () => {
553
1079
  expect(panel!.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-4)
554
1080
  }
555
1081
  })
1082
+
1083
+ describe('requireSynchronousBake', () => {
1084
+ test('rejects a retained async-only kind instead of returning its proxy', () => {
1085
+ const restoreRegistry = nodeRegistry._snapshot()
1086
+ try {
1087
+ const kind = 'test:async-only-synchronous-export'
1088
+ const nodeId = 'async_only_synchronous_export'
1089
+ registerNode({
1090
+ kind,
1091
+ schemaVersion: 1,
1092
+ schema: DoorNode,
1093
+ category: 'furnish',
1094
+ defaults: () => ({}) as never,
1095
+ capabilities: {},
1096
+ bake: 'replace',
1097
+ bakeGeometryAsync: async () =>
1098
+ new THREE.Mesh(new THREE.BoxGeometry(4, 5, 6), new THREE.MeshStandardMaterial()),
1099
+ } as AnyNodeDefinition)
1100
+
1101
+ const proxyGeometry = new THREE.BoxGeometry(0.25, 0.5, 0.75)
1102
+ const proxyMaterial = new THREE.MeshStandardMaterial()
1103
+ let sourceGeometryDisposals = 0
1104
+ let sourceMaterialDisposals = 0
1105
+ proxyGeometry.addEventListener('dispose', () => {
1106
+ sourceGeometryDisposals += 1
1107
+ })
1108
+ proxyMaterial.addEventListener('dispose', () => {
1109
+ sourceMaterialDisposals += 1
1110
+ })
1111
+ const proxy = new THREE.Mesh(proxyGeometry, proxyMaterial)
1112
+ proxy.name = 'async-only-proxy'
1113
+ const source = new THREE.Group()
1114
+ source.add(proxy)
1115
+ const root = new THREE.Group()
1116
+ root.add(source)
1117
+ sceneRegistry.nodes.set(nodeId, source)
1118
+ const nodes = {
1119
+ [nodeId]: { id: nodeId, type: kind, visible: true },
1120
+ } as unknown as Record<string, AnyNode>
1121
+
1122
+ const compatible = prepareSceneForExport(root, nodes)
1123
+ expect(compatible.scene.getObjectByName('async-only-proxy')).toBeDefined()
1124
+ compatible.dispose()
1125
+
1126
+ let rejection: unknown
1127
+ try {
1128
+ prepareSceneForExport(root, nodes, { requireSynchronousBake: true })
1129
+ } catch (error) {
1130
+ rejection = error
1131
+ }
1132
+
1133
+ expect(rejection).toBeInstanceOf(Error)
1134
+ const message = rejection instanceof Error ? rejection.message : ''
1135
+ expect(message).toContain(kind)
1136
+ expect(message).toMatch(/\bGLB\b/)
1137
+ expect(message).toMatch(/\bUSDZ\b/)
1138
+ expect(message).toMatch(/exclud/i)
1139
+ expect(sourceGeometryDisposals).toBe(0)
1140
+ expect(sourceMaterialDisposals).toBe(0)
1141
+ expect(root.children).toEqual([source])
1142
+ expect(source.children).toEqual([proxy])
1143
+ } finally {
1144
+ restoreRegistry()
1145
+ }
1146
+ })
1147
+
1148
+ test('allows a valid model when every async-only kind is pruned', async () => {
1149
+ const restoreRegistry = nodeRegistry._snapshot()
1150
+ const { installedPlugins, hasExplicitPluginInstallState } = useScene.getState()
1151
+ try {
1152
+ const excludedKind = 'test:excluded-synchronous-export'
1153
+ const hiddenKind = 'test:hidden-synchronous-export'
1154
+ const descendantKind = 'test:descendant-synchronous-export'
1155
+ const disabledKind = 'test:disabled-synchronous-export'
1156
+ const disabledPluginId = 'test:disabled-synchronous-export-plugin'
1157
+ const asyncOnlyDefinition = (kind: string): AnyNodeDefinition =>
1158
+ ({
1159
+ kind,
1160
+ schemaVersion: 1,
1161
+ schema: DoorNode,
1162
+ category: 'furnish',
1163
+ defaults: () => ({}) as never,
1164
+ capabilities: {},
1165
+ bake: 'replace',
1166
+ bakeGeometryAsync: async () => {
1167
+ throw new Error(`Pruned async-only builder ran for ${kind}`)
1168
+ },
1169
+ }) as AnyNodeDefinition
1170
+
1171
+ for (const kind of [excludedKind, hiddenKind, descendantKind]) {
1172
+ registerNode(asyncOnlyDefinition(kind))
1173
+ }
1174
+ await loadPlugin({
1175
+ id: disabledPluginId,
1176
+ apiVersion: 1,
1177
+ nodes: [asyncOnlyDefinition(disabledKind)],
1178
+ })
1179
+ useScene.getState().setInstalledPlugins([], { explicit: true })
1180
+
1181
+ const valid = new THREE.Mesh(
1182
+ new THREE.BoxGeometry(2, 3, 4),
1183
+ new THREE.MeshStandardMaterial(),
1184
+ )
1185
+ valid.name = 'valid-model'
1186
+ const excluded = new THREE.Group()
1187
+ const hidden = new THREE.Group()
1188
+ const disabled = new THREE.Group()
1189
+ const excludedParent = new THREE.Group()
1190
+ const descendant = new THREE.Group()
1191
+ excludedParent.add(descendant)
1192
+ const root = new THREE.Group()
1193
+ root.add(valid, excluded, hidden, disabled, excludedParent)
1194
+
1195
+ const excludedId = 'excluded_async_only'
1196
+ const hiddenId = 'hidden_async_only'
1197
+ const disabledId = 'disabled_async_only'
1198
+ const excludedParentId = 'excluded_async_only_parent'
1199
+ const descendantId = 'descendant_async_only'
1200
+ sceneRegistry.nodes.set(excludedId, excluded)
1201
+ sceneRegistry.nodes.set(hiddenId, hidden)
1202
+ sceneRegistry.nodes.set(disabledId, disabled)
1203
+ sceneRegistry.nodes.set(excludedParentId, excludedParent)
1204
+ sceneRegistry.nodes.set(descendantId, descendant)
1205
+ const nodes = {
1206
+ [excludedId]: { id: excludedId, type: excludedKind, visible: true },
1207
+ [hiddenId]: { id: hiddenId, type: hiddenKind, visible: false },
1208
+ [disabledId]: { id: disabledId, type: disabledKind, visible: true },
1209
+ [excludedParentId]: {
1210
+ id: excludedParentId,
1211
+ type: 'test:excluded-synchronous-export-parent',
1212
+ visible: true,
1213
+ children: [descendantId],
1214
+ },
1215
+ [descendantId]: {
1216
+ id: descendantId,
1217
+ type: descendantKind,
1218
+ parentId: excludedParentId,
1219
+ visible: true,
1220
+ },
1221
+ } as unknown as Record<string, AnyNode>
1222
+
1223
+ const prepared = prepareSceneForExport(root, nodes, {
1224
+ excludedNodeTypes: [excludedKind, 'test:excluded-synchronous-export-parent'],
1225
+ requireSynchronousBake: true,
1226
+ })
1227
+
1228
+ const validModel = prepared.scene.getObjectByName('valid-model')
1229
+ expect(validModel).toBeDefined()
1230
+ const size = new THREE.Box3().setFromObject(validModel!).getSize(new THREE.Vector3())
1231
+ expect(size.toArray()).toEqual([2, 3, 4])
1232
+ for (const id of [excludedId, hiddenId, disabledId, excludedParentId, descendantId]) {
1233
+ expect(prepared.scene.getObjectByName(id)).toBeUndefined()
1234
+ }
1235
+ prepared.dispose()
1236
+ } finally {
1237
+ useScene
1238
+ .getState()
1239
+ .setInstalledPlugins(installedPlugins, { explicit: hasExplicitPluginInstallState })
1240
+ restoreRegistry()
1241
+ }
1242
+ })
1243
+
1244
+ test('uses synchronous geometry when a retained kind provides both hooks', () => {
1245
+ const restoreRegistry = nodeRegistry._snapshot()
1246
+ try {
1247
+ const kind = 'test:dual-hook-synchronous-export'
1248
+ const nodeId = 'dual_hook_synchronous_export'
1249
+ registerNode({
1250
+ kind,
1251
+ schemaVersion: 1,
1252
+ schema: DoorNode,
1253
+ category: 'furnish',
1254
+ defaults: () => ({}) as never,
1255
+ capabilities: {},
1256
+ bake: 'replace',
1257
+ bakeGeometry: () =>
1258
+ new THREE.Mesh(new THREE.BoxGeometry(2, 3, 4), new THREE.MeshStandardMaterial()),
1259
+ bakeGeometryAsync: async () =>
1260
+ new THREE.Mesh(new THREE.BoxGeometry(8, 8, 8), new THREE.MeshStandardMaterial()),
1261
+ } as AnyNodeDefinition)
1262
+
1263
+ const source = new THREE.Group()
1264
+ source.add(
1265
+ new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.5, 0.75), new THREE.MeshStandardMaterial()),
1266
+ )
1267
+ const root = new THREE.Group()
1268
+ root.add(source)
1269
+ sceneRegistry.nodes.set(nodeId, source)
1270
+ const nodes = {
1271
+ [nodeId]: { id: nodeId, type: kind, visible: true },
1272
+ } as unknown as Record<string, AnyNode>
1273
+
1274
+ const prepared = prepareSceneForExport(root, nodes, {
1275
+ requireSynchronousBake: true,
1276
+ })
1277
+
1278
+ const exported = prepared.scene.getObjectByName(nodeId)
1279
+ expect(exported).toBeDefined()
1280
+ const size = new THREE.Box3().setFromObject(exported!).getSize(new THREE.Vector3())
1281
+ expect(size.toArray()).toEqual([2, 3, 4])
1282
+ expect(root.children).toEqual([source])
1283
+ prepared.dispose()
1284
+ } finally {
1285
+ restoreRegistry()
1286
+ }
1287
+ })
1288
+ })
1289
+
1290
+ test('awaits async bake geometry with full semantic context after selection', async () => {
1291
+ const restoreRegistry = nodeRegistry._snapshot()
1292
+ try {
1293
+ const retainedKind = 'test:async-context-bake'
1294
+ let asyncCalls = 0
1295
+ let syncCalls = 0
1296
+ registerNode({
1297
+ kind: retainedKind,
1298
+ schemaVersion: 1,
1299
+ schema: DoorNode,
1300
+ category: 'furnish',
1301
+ defaults: () => ({}) as never,
1302
+ capabilities: {},
1303
+ bake: 'replace',
1304
+ bakeGeometry: () => {
1305
+ syncCalls += 1
1306
+ throw new Error('sync builder must not run when async geometry is available')
1307
+ },
1308
+ bakeGeometryAsync: async (_node, context) => {
1309
+ asyncCalls += 1
1310
+ await Promise.resolve()
1311
+ const obstacle = context.resolve<{ metadata?: { acceptanceWidth?: number } }>(
1312
+ 'door_obstacle_async_context',
1313
+ )
1314
+ return new THREE.Mesh(
1315
+ new THREE.BoxGeometry(obstacle?.metadata?.acceptanceWidth ?? 0, 1, 1),
1316
+ new THREE.MeshStandardMaterial(),
1317
+ )
1318
+ },
1319
+ } as AnyNodeDefinition)
1320
+ const root = new THREE.Group()
1321
+ const source = new THREE.Group()
1322
+ const obstacle = new THREE.Group()
1323
+ root.add(source, obstacle)
1324
+ sceneRegistry.nodes.set('async_context_node', source)
1325
+ sceneRegistry.nodes.set('door_obstacle_async_context', obstacle)
1326
+ const nodes = {
1327
+ async_context_node: {
1328
+ id: 'async_context_node',
1329
+ type: retainedKind,
1330
+ visible: true,
1331
+ },
1332
+ door_obstacle_async_context: {
1333
+ id: 'door_obstacle_async_context',
1334
+ type: 'test:excluded-obstacle',
1335
+ visible: true,
1336
+ metadata: { acceptanceWidth: 2 },
1337
+ },
1338
+ } as unknown as Record<string, AnyNode>
1339
+
1340
+ const prepared = await prepareSceneForExportAsync(root, nodes, {
1341
+ excludedNodeTypes: ['test:excluded-obstacle'],
1342
+ })
1343
+ const baked = prepared.scene.children.find((child) => child instanceof THREE.Mesh)
1344
+ expect(asyncCalls).toBe(1)
1345
+ expect(syncCalls).toBe(0)
1346
+ expect(baked).toBeInstanceOf(THREE.Mesh)
1347
+ if (baked instanceof THREE.Mesh) {
1348
+ baked.geometry.computeBoundingBox()
1349
+ expect(baked.geometry.boundingBox?.getSize(new THREE.Vector3()).x).toBeCloseTo(2)
1350
+ }
1351
+ expect(prepared.animations).toEqual([])
1352
+ expect(root.children).toEqual([source, obstacle])
1353
+ prepared.dispose()
1354
+ } finally {
1355
+ restoreRegistry()
1356
+ }
1357
+ })
1358
+
1359
+ test('does not invoke async builders for excluded or invisible nodes', async () => {
1360
+ const restoreRegistry = nodeRegistry._snapshot()
1361
+ try {
1362
+ let calls = 0
1363
+ for (const kind of ['test:excluded-async-bake', 'test:hidden-async-bake']) {
1364
+ registerNode({
1365
+ kind,
1366
+ schemaVersion: 1,
1367
+ schema: DoorNode,
1368
+ category: 'furnish',
1369
+ defaults: () => ({}) as never,
1370
+ capabilities: {},
1371
+ bake: 'replace',
1372
+ bakeGeometryAsync: async () => {
1373
+ calls += 1
1374
+ throw new Error('unselected async builder ran')
1375
+ },
1376
+ } as AnyNodeDefinition)
1377
+ }
1378
+ const root = new THREE.Group()
1379
+ const excluded = new THREE.Group()
1380
+ const hidden = new THREE.Group()
1381
+ const retained = meshWithNodeMaterial(nodeMaterial())
1382
+ root.add(excluded, hidden, retained)
1383
+ sceneRegistry.nodes.set('excluded_async_node', excluded)
1384
+ sceneRegistry.nodes.set('hidden_async_node', hidden)
1385
+ const nodes = {
1386
+ excluded_async_node: {
1387
+ id: 'excluded_async_node',
1388
+ type: 'test:excluded-async-bake',
1389
+ visible: true,
1390
+ },
1391
+ hidden_async_node: {
1392
+ id: 'hidden_async_node',
1393
+ type: 'test:hidden-async-bake',
1394
+ visible: false,
1395
+ },
1396
+ } as unknown as Record<string, AnyNode>
1397
+
1398
+ const prepared = await prepareSceneForExportAsync(root, nodes, {
1399
+ excludedNodeTypes: ['test:excluded-async-bake'],
1400
+ onlyVisible: true,
1401
+ })
1402
+ expect(calls).toBe(0)
1403
+ expect(prepared.scene.children).toHaveLength(1)
1404
+ prepared.dispose()
1405
+ } finally {
1406
+ restoreRegistry()
1407
+ }
1408
+ })
1409
+
1410
+ test('disposes only export-owned resources when a later async builder fails', async () => {
1411
+ const restoreRegistry = nodeRegistry._snapshot()
1412
+ try {
1413
+ const ownedGeometry = new THREE.BoxGeometry()
1414
+ const ownedMaterial = new THREE.MeshStandardMaterial()
1415
+ const sourceGeometry = new THREE.BoxGeometry()
1416
+ const sourceMaterial = new THREE.MeshStandardMaterial()
1417
+ let ownedGeometryDisposals = 0
1418
+ let ownedMaterialDisposals = 0
1419
+ let sourceGeometryDisposals = 0
1420
+ let sourceMaterialDisposals = 0
1421
+ ownedGeometry.addEventListener('dispose', () => {
1422
+ ownedGeometryDisposals += 1
1423
+ })
1424
+ ownedMaterial.addEventListener('dispose', () => {
1425
+ ownedMaterialDisposals += 1
1426
+ })
1427
+ sourceGeometry.addEventListener('dispose', () => {
1428
+ sourceGeometryDisposals += 1
1429
+ })
1430
+ sourceMaterial.addEventListener('dispose', () => {
1431
+ sourceMaterialDisposals += 1
1432
+ })
1433
+ registerNode({
1434
+ kind: 'test:first-owned-async-bake',
1435
+ schemaVersion: 1,
1436
+ schema: DoorNode,
1437
+ category: 'furnish',
1438
+ defaults: () => ({}) as never,
1439
+ capabilities: {},
1440
+ bake: 'replace',
1441
+ bakeGeometryAsync: async () => new THREE.Mesh(ownedGeometry, ownedMaterial),
1442
+ } as AnyNodeDefinition)
1443
+ registerNode({
1444
+ kind: 'test:later-failing-async-bake',
1445
+ schemaVersion: 1,
1446
+ schema: DoorNode,
1447
+ category: 'furnish',
1448
+ defaults: () => ({}) as never,
1449
+ capabilities: {},
1450
+ bake: 'replace',
1451
+ bakeGeometryAsync: async () => {
1452
+ throw new Error('acceptance injected async failure')
1453
+ },
1454
+ } as AnyNodeDefinition)
1455
+ const root = new THREE.Group()
1456
+ const first = new THREE.Group()
1457
+ const failing = new THREE.Group()
1458
+ const source = new THREE.Mesh(sourceGeometry, sourceMaterial)
1459
+ root.add(first, failing, source)
1460
+ sceneRegistry.nodes.set('first_owned_async_node', first)
1461
+ sceneRegistry.nodes.set('later_failing_async_node', failing)
1462
+ const nodes = {
1463
+ first_owned_async_node: {
1464
+ id: 'first_owned_async_node',
1465
+ type: 'test:first-owned-async-bake',
1466
+ visible: true,
1467
+ },
1468
+ later_failing_async_node: {
1469
+ id: 'later_failing_async_node',
1470
+ type: 'test:later-failing-async-bake',
1471
+ visible: true,
1472
+ },
1473
+ } as unknown as Record<string, AnyNode>
1474
+
1475
+ await expect(prepareSceneForExportAsync(root, nodes)).rejects.toThrow(
1476
+ 'acceptance injected async failure',
1477
+ )
1478
+ expect(ownedGeometryDisposals).toBe(1)
1479
+ expect(ownedMaterialDisposals).toBe(1)
1480
+ expect(sourceGeometryDisposals).toBe(0)
1481
+ expect(sourceMaterialDisposals).toBe(0)
1482
+ expect(root.children).toEqual([first, failing, source])
1483
+ } finally {
1484
+ restoreRegistry()
1485
+ }
1486
+ })
1487
+
1488
+ test('includes a registered static presentation only when explicitly selected', async () => {
1489
+ const priorPresentations = viewerPresentationRegistry.getSnapshot()
1490
+ viewerPresentationRegistry.reset()
1491
+ try {
1492
+ const contribution: ViewerPresentationContribution = {
1493
+ id: 'test:static-export-presentation',
1494
+ component: async () => ({ default: () => null }),
1495
+ staticExport: {
1496
+ label: 'Acceptance surroundings',
1497
+ build: async ({ nodes, onlyVisible, excludedNodeTypes }) => {
1498
+ const semantic = nodes.semantic_context_node as
1499
+ | { metadata?: { acceptanceDimensions?: [number, number, number] } }
1500
+ | undefined
1501
+ const dimensions = semantic?.metadata?.acceptanceDimensions ?? [0, 0, 0]
1502
+ return new THREE.Mesh(
1503
+ new THREE.BoxGeometry(
1504
+ dimensions[0],
1505
+ onlyVisible ? 0 : dimensions[1],
1506
+ excludedNodeTypes.includes('test:excluded-context') ? dimensions[2] : 0,
1507
+ ),
1508
+ new THREE.MeshStandardMaterial({ color: '#4a6b3d' }),
1509
+ )
1510
+ },
1511
+ },
1512
+ }
1513
+ viewerPresentationRegistry.register(contribution)
1514
+ const root = new THREE.Group()
1515
+ root.add(meshWithNodeMaterial(nodeMaterial()))
1516
+ const nodes = {
1517
+ semantic_context_node: {
1518
+ id: 'semantic_context_node',
1519
+ type: 'test:semantic-context',
1520
+ visible: true,
1521
+ metadata: { acceptanceDimensions: [3, 1, 2] },
1522
+ },
1523
+ } as unknown as Record<string, AnyNode>
1524
+
1525
+ const defaultArtifact = await prepareSceneForExportAsync(root, nodes, {
1526
+ onlyVisible: false,
1527
+ })
1528
+ expect(
1529
+ defaultArtifact.scene.getObjectByProperty('name', 'test:static-export-presentation'),
1530
+ ).toBeUndefined()
1531
+ defaultArtifact.dispose()
1532
+
1533
+ const selectedArtifact = await prepareSceneForExportAsync(root, nodes, {
1534
+ excludedNodeTypes: ['test:excluded-context'],
1535
+ includedPresentationIds: [contribution.id],
1536
+ onlyVisible: false,
1537
+ })
1538
+ const presentation = selectedArtifact.scene.getObjectByProperty('name', contribution.id)
1539
+ expect(presentation?.userData).toMatchObject({
1540
+ label: 'Acceptance surroundings',
1541
+ pascalPresentationId: contribution.id,
1542
+ })
1543
+ const bounds = new THREE.Box3().setFromObject(presentation!)
1544
+ expect(bounds.getSize(new THREE.Vector3()).toArray()).toEqual([3, 1, 2])
1545
+ selectedArtifact.dispose()
1546
+ } finally {
1547
+ viewerPresentationRegistry.reset()
1548
+ for (const contribution of priorPresentations) {
1549
+ viewerPresentationRegistry.register(contribution)
1550
+ }
1551
+ }
1552
+ })
1553
+
1554
+ test('preserves marked borrowed presentation textures and disposes owned maps', async () => {
1555
+ await withCanvasCapture(async (canvasPixels) => {
1556
+ const priorPresentations = viewerPresentationRegistry.getSnapshot()
1557
+ viewerPresentationRegistry.reset()
1558
+ const borrowed = new THREE.DataTexture(new Uint8Array([20, 40, 60, 255]), 1, 1)
1559
+ const owned = new THREE.DataTexture(new Uint8Array([80, 100, 120, 255]), 1, 1)
1560
+ const borrowedOnFailure = new THREE.DataTexture(new Uint8Array([140, 160, 180, 255]), 1, 1)
1561
+ const ownedOnFailure = new THREE.DataTexture(new Uint8Array([200, 220, 240, 255]), 1, 1)
1562
+ markViewerPresentationTextureBorrowed(borrowed)
1563
+ markViewerPresentationTextureBorrowed(borrowedOnFailure)
1564
+ const disposals = {
1565
+ borrowed: 0,
1566
+ owned: 0,
1567
+ borrowedOnFailure: 0,
1568
+ ownedOnFailure: 0,
1569
+ }
1570
+ for (const [texture, key] of [
1571
+ [borrowed, 'borrowed'],
1572
+ [owned, 'owned'],
1573
+ [borrowedOnFailure, 'borrowedOnFailure'],
1574
+ [ownedOnFailure, 'ownedOnFailure'],
1575
+ ] as const) {
1576
+ texture.addEventListener('dispose', () => {
1577
+ disposals[key] += 1
1578
+ })
1579
+ }
1580
+ const textureContribution = (
1581
+ id: string,
1582
+ texture: THREE.Texture,
1583
+ ): ViewerPresentationContribution => ({
1584
+ id,
1585
+ component: async () => ({ default: () => null }),
1586
+ staticExport: {
1587
+ label: id,
1588
+ build: () =>
1589
+ new THREE.Mesh(
1590
+ new THREE.PlaneGeometry(1, 1),
1591
+ new THREE.MeshStandardMaterial({ map: texture }),
1592
+ ),
1593
+ },
1594
+ })
1595
+ try {
1596
+ viewerPresentationRegistry.register(
1597
+ textureContribution('test:borrowed-presentation-texture', borrowed),
1598
+ )
1599
+ viewerPresentationRegistry.register(
1600
+ textureContribution('test:owned-presentation-texture', owned),
1601
+ )
1602
+ const success = await prepareSceneForExportAsync(
1603
+ new THREE.Group(),
1604
+ {},
1605
+ {
1606
+ includedPresentationIds: [
1607
+ 'test:borrowed-presentation-texture',
1608
+ 'test:owned-presentation-texture',
1609
+ ],
1610
+ },
1611
+ )
1612
+ const generatedTextures: THREE.Texture[] = []
1613
+ success.scene.traverse((object) => {
1614
+ if (!(object as THREE.Mesh).isMesh) return
1615
+ const mesh = object as THREE.Mesh
1616
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
1617
+ for (const material of materials) {
1618
+ const map = (material as THREE.MeshStandardMaterial).map
1619
+ if (map) generatedTextures.push(map)
1620
+ }
1621
+ })
1622
+ expect(generatedTextures).toHaveLength(2)
1623
+ expect(
1624
+ generatedTextures.every((texture) => (texture as THREE.CanvasTexture).isCanvasTexture),
1625
+ ).toBe(true)
1626
+ expect(generatedTextures.map(canvasPixels).sort()).toEqual(
1627
+ [
1628
+ [20, 40, 60, 255],
1629
+ [80, 100, 120, 255],
1630
+ ].sort(),
1631
+ )
1632
+ let generatedTextureDisposals = 0
1633
+ for (const texture of generatedTextures) {
1634
+ texture.addEventListener('dispose', () => {
1635
+ generatedTextureDisposals += 1
1636
+ })
1637
+ }
1638
+ success.dispose()
1639
+ expect(generatedTextureDisposals).toBe(2)
1640
+ expect(disposals).toMatchObject({ borrowed: 0, owned: 1 })
1641
+ expect(Array.from(borrowed.image.data as Uint8Array)).toEqual([20, 40, 60, 255])
1642
+
1643
+ viewerPresentationRegistry.register(
1644
+ textureContribution('test:borrowed-presentation-texture-failure', borrowedOnFailure),
1645
+ )
1646
+ viewerPresentationRegistry.register(
1647
+ textureContribution('test:owned-presentation-texture-failure', ownedOnFailure),
1648
+ )
1649
+ viewerPresentationRegistry.register({
1650
+ id: 'test:failing-presentation-after-textures',
1651
+ component: async () => ({ default: () => null }),
1652
+ staticExport: {
1653
+ label: 'Injected failure after texture ownership',
1654
+ build: () => {
1655
+ throw new Error('acceptance presentation texture failure')
1656
+ },
1657
+ },
1658
+ })
1659
+ await expect(
1660
+ prepareSceneForExportAsync(
1661
+ new THREE.Group(),
1662
+ {},
1663
+ {
1664
+ includedPresentationIds: [
1665
+ 'test:borrowed-presentation-texture-failure',
1666
+ 'test:owned-presentation-texture-failure',
1667
+ 'test:failing-presentation-after-textures',
1668
+ ],
1669
+ },
1670
+ ),
1671
+ ).rejects.toThrow('acceptance presentation texture failure')
1672
+ expect(disposals).toEqual({
1673
+ borrowed: 0,
1674
+ owned: 1,
1675
+ borrowedOnFailure: 0,
1676
+ ownedOnFailure: 1,
1677
+ })
1678
+ expect(Array.from(borrowedOnFailure.image.data as Uint8Array)).toEqual([140, 160, 180, 255])
1679
+ } finally {
1680
+ viewerPresentationRegistry.reset()
1681
+ for (const contribution of priorPresentations) {
1682
+ viewerPresentationRegistry.register(contribution)
1683
+ }
1684
+ }
1685
+ })
1686
+ })
1687
+ })
1688
+
1689
+ async function withCanvasCapture(
1690
+ run: (pixels: (texture: THREE.Texture) => number[]) => Promise<void>,
1691
+ ): Promise<void> {
1692
+ const globals = globalThis as unknown as { document?: Document }
1693
+ const previousDocument = globals.document
1694
+ const pixelsByCanvas = new WeakMap<HTMLCanvasElement, Uint8ClampedArray>()
1695
+ globals.document = {
1696
+ createElement: (tagName: string) => {
1697
+ if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`)
1698
+ const canvas = {
1699
+ width: 0,
1700
+ height: 0,
1701
+ getContext: () => ({
1702
+ createImageData: (width: number, height: number) =>
1703
+ ({
1704
+ colorSpace: 'srgb',
1705
+ data: new Uint8ClampedArray(width * height * 4),
1706
+ height,
1707
+ width,
1708
+ }) as ImageData,
1709
+ putImageData: (image: ImageData) => {
1710
+ pixelsByCanvas.set(canvas, new Uint8ClampedArray(image.data))
1711
+ },
1712
+ fillRect: (_x: number, _y: number, width: number, height: number) => {
1713
+ pixelsByCanvas.set(canvas, new Uint8ClampedArray(width * height * 4).fill(255))
1714
+ },
1715
+ drawImage: (image: HTMLCanvasElement) => {
1716
+ const pixels = pixelsByCanvas.get(image)
1717
+ if (!pixels) throw new Error('Source canvas has no captured pixels')
1718
+ pixelsByCanvas.set(canvas, new Uint8ClampedArray(pixels))
1719
+ },
1720
+ getImageData: (_x: number, _y: number, width: number, height: number) =>
1721
+ ({
1722
+ colorSpace: 'srgb',
1723
+ data: pixelsByCanvas.get(canvas) ?? new Uint8ClampedArray(width * height * 4),
1724
+ height,
1725
+ width,
1726
+ }) as ImageData,
1727
+ }),
1728
+ } as unknown as HTMLCanvasElement
1729
+ return canvas
1730
+ },
1731
+ } as unknown as Document
1732
+
1733
+ try {
1734
+ await run((texture) => {
1735
+ const pixels = pixelsByCanvas.get(texture.image as HTMLCanvasElement)
1736
+ if (!pixels) throw new Error('Generated canvas texture has no captured pixels')
1737
+ return Array.from(pixels)
1738
+ })
1739
+ } finally {
1740
+ if (previousDocument) globals.document = previousDocument
1741
+ else delete globals.document
1742
+ }
1743
+ }
1744
+
1745
+ describe('normal maps in async export preparation', () => {
1746
+ test('decompresses a compressed normal map through the provided decompressor before baking its scale', async () => {
1747
+ await withCanvasCapture(async () => {
1748
+ const root = new THREE.Group()
1749
+ const compressed = new THREE.CompressedTexture([], 2, 2)
1750
+ compressed.name = 'NormalGL_3a132c5f'
1751
+ const material = nodeMaterial({ normalMap: compressed }) as THREE.MeshStandardMaterial
1752
+ material.normalScale.set(0.5, 0.5)
1753
+ root.add(meshWithNodeMaterial(material))
1754
+ let calls = 0
1755
+
1756
+ const prepared = await prepareSceneForExportAsync(
1757
+ root,
1758
+ {},
1759
+ {
1760
+ decompressTexture: async (texture) => {
1761
+ calls += 1
1762
+ expect(texture.name).toBe('NormalGL_3a132c5f')
1763
+ return new THREE.DataTexture(
1764
+ new Uint8Array(
1765
+ [128, 128, 255, 255].concat(
1766
+ [128, 128, 255, 255],
1767
+ [128, 128, 255, 255],
1768
+ [128, 128, 255, 255],
1769
+ ),
1770
+ ),
1771
+ 2,
1772
+ 2,
1773
+ )
1774
+ },
1775
+ },
1776
+ )
1777
+
1778
+ const exported = (prepared.scene.children[0] as THREE.Mesh)
1779
+ .material as THREE.MeshStandardMaterial
1780
+ expect(calls).toBe(1)
1781
+ expect((exported.normalMap as { isCanvasTexture?: boolean }).isCanvasTexture).toBe(true)
1782
+ expect(exported.normalScale.toArray()).toEqual([1, 1])
1783
+ expect(material.normalMap).toBe(compressed)
1784
+ expect(material.normalScale.toArray()).toEqual([0.5, 0.5])
1785
+ })
1786
+ })
1787
+
1788
+ test('reference mode keeps a stamped normal-map placeholder and its scale for the packer', async () => {
1789
+ await withCanvasCapture(async () => {
1790
+ const root = new THREE.Group()
1791
+ const stamped = new THREE.CompressedTexture([], 4, 4)
1792
+ stamped.userData.pascalTextureRef = {
1793
+ v: 1,
1794
+ kind: 'library-material',
1795
+ src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_normal_512.ktx2`,
1796
+ map: 'normal',
1797
+ colorSpace: 'linear',
1798
+ }
1799
+ const material = nodeMaterial({ normalMap: stamped }) as THREE.MeshStandardMaterial
1800
+ material.normalScale.set(1, -1)
1801
+ root.add(meshWithNodeMaterial(material))
1802
+
1803
+ const prepared = await prepareSceneForExportAsync(
1804
+ root,
1805
+ {},
1806
+ {
1807
+ textures: 'reference',
1808
+ purpose: 'viewer',
1809
+ decompressTexture: async () => {
1810
+ throw new Error('by-reference placeholders must not be decompressed')
1811
+ },
1812
+ },
1813
+ )
1814
+
1815
+ const exported = (prepared.scene.children[0] as THREE.Mesh)
1816
+ .material as THREE.MeshStandardMaterial
1817
+ expect(exported.normalMap?.userData.pascalTextureRef).toEqual(
1818
+ stamped.userData.pascalTextureRef,
1819
+ )
1820
+ expect(exported.normalScale.toArray()).toEqual([1, -1])
1821
+ })
1822
+ })
1823
+ })
1824
+
1825
+ describe('portable glass', () => {
1826
+ test('see-through untextured surfaces become transmission glass in portable exports only', async () => {
1827
+ const root = new THREE.Group()
1828
+ const glass = new MeshStandardNodeMaterial({
1829
+ color: '#3d9ed4',
1830
+ transparent: true,
1831
+ opacity: 0.3,
1832
+ roughness: 0.1,
1833
+ })
1834
+ const tintedPlastic = new MeshStandardNodeMaterial({
1835
+ color: '#3d9ed4',
1836
+ transparent: true,
1837
+ opacity: 0.8,
1838
+ })
1839
+ root.add(meshWithNodeMaterial(glass), meshWithNodeMaterial(tintedPlastic))
1840
+
1841
+ const portable = await prepareSceneForExportAsync(root, {})
1842
+ const [exportedGlass, exportedPlastic] = portable.scene.children.map(
1843
+ (child) => (child as THREE.Mesh).material as THREE.MeshPhysicalMaterial,
1844
+ )
1845
+ expect(exportedGlass!.isMeshPhysicalMaterial).toBe(true)
1846
+ expect(exportedGlass!.transmission).toBe(1)
1847
+ expect(exportedGlass!.transparent).toBe(false)
1848
+ expect(exportedGlass!.opacity).toBe(1)
1849
+ expect(exportedGlass!.roughness).toBeCloseTo(0.1)
1850
+ // 30% authored opacity keeps 30% of the (linear) tint.
1851
+ const tint = new THREE.Color('#3d9ed4')
1852
+ expect(exportedGlass!.color.r).toBeCloseTo(tint.r + (1 - tint.r) * 0.7, 4)
1853
+ expect(exportedGlass!.color.b).toBeCloseTo(tint.b + (1 - tint.b) * 0.7, 4)
1854
+ expect(exportedPlastic!.isMeshPhysicalMaterial).toBeUndefined()
1855
+ expect(exportedPlastic!.transparent).toBe(true)
1856
+
1857
+ const viewer = prepareSceneForExport(root, {}, { purpose: 'viewer' })
1858
+ const kept = (viewer.scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial
1859
+ expect((kept as { isMeshPhysicalMaterial?: boolean }).isMeshPhysicalMaterial).toBeUndefined()
1860
+ expect(kept.transparent).toBe(true)
1861
+ expect(kept.opacity).toBeCloseTo(0.3)
1862
+
1863
+ // USDZ has no transmission: the same prep hands glass its opacity back.
1864
+ const usdz = createUsdzScene(portable.scene)
1865
+ const usdzGlass = (usdz.children[0] as THREE.Mesh).material as THREE.MeshPhysicalMaterial
1866
+ expect(usdzGlass.transmission).toBe(0)
1867
+ expect(usdzGlass.transparent).toBe(true)
1868
+ expect(usdzGlass.opacity).toBeCloseTo(0.3)
1869
+ expect(exportedGlass!.transmission).toBe(1)
1870
+ })
1871
+ })
1872
+
1873
+ describe('portable clips', () => {
1874
+ function swingDoorScene() {
1875
+ const root = new THREE.Group()
1876
+ const doorGroup = new THREE.Group()
1877
+ const leaf = new THREE.Group()
1878
+ leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
1879
+ leaf.add(meshWithNodeMaterial(nodeMaterial()))
1880
+ doorGroup.add(leaf)
1881
+ root.add(doorGroup)
1882
+ const doorId = 'door_portable'
1883
+ sceneRegistry.nodes.set(doorId, doorGroup)
1884
+ const nodes: Record<string, AnyNode> = {
1885
+ [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode,
1886
+ }
1887
+ return { root, nodes, doorId }
1888
+ }
1889
+
1890
+ test('GLB downloads keep door clips when asked, USDZ passes none', async () => {
1891
+ const { root, nodes, doorId } = swingDoorScene()
1892
+
1893
+ const glb = await prepareSceneForExportAsync(root, nodes, { animations: 'keep' })
1894
+ expect(glb.animations).toHaveLength(1)
1895
+ expect(glb.animations[0]!.name).toBe(`${doorId}: open`)
1896
+ const track = glb.animations[0]!.tracks[0]!
1897
+ const targetUuid = track.name.split('.')[0]
1898
+ expect(glb.scene.getObjectByProperty('uuid', targetUuid)).toBeDefined()
1899
+ expect(glb.scene.getObjectByProperty('name', doorId)?.userData.clips).toEqual([
1900
+ `${doorId}: open`,
1901
+ ])
1902
+
1903
+ const usdz = await prepareSceneForExportAsync(root, nodes, { animations: 'none' })
1904
+ expect(usdz.animations).toEqual([])
1905
+ expect(usdz.scene.getObjectByProperty('name', doorId)?.userData.clips).toBeUndefined()
1906
+
1907
+ const legacy = await prepareSceneForExportAsync(root, nodes)
1908
+ expect(legacy.animations).toEqual([])
1909
+ })
556
1910
  })