@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) 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/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  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 +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,9 +1,32 @@
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'
7
30
 
8
31
  // The reference module reads the storage origin lazily on first use, so
9
32
  // setting the env here (before any validation call) pins it for the file.
@@ -14,25 +37,15 @@ afterEach(() => {
14
37
  sceneRegistry.clear()
15
38
  })
16
39
 
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'),
40
+ function nodeMaterial(overrides: Record<string, unknown> = {}): THREE.Material {
41
+ const material = new MeshStandardNodeMaterial({
42
+ color: '#cc3300',
24
43
  roughness: 0.3,
25
44
  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
45
+ })
46
+ material.name = 'painted'
47
+ Object.assign(material, overrides)
48
+ return material
36
49
  }
37
50
 
38
51
  function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
@@ -40,6 +53,35 @@ function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
40
53
  return new THREE.Mesh(geometry, material)
41
54
  }
42
55
 
56
+ function sceneWithVisibleAndHiddenBoxes(): {
57
+ root: THREE.Group
58
+ nodes: Record<string, AnyNode>
59
+ } {
60
+ const root = new THREE.Group()
61
+ const nodes: Record<string, AnyNode> = {}
62
+
63
+ for (const [id, visible, x] of [
64
+ ['item_visible', true, 0],
65
+ ['item_hidden', false, 2],
66
+ ] as const) {
67
+ const group = new THREE.Group()
68
+ const mesh = meshWithNodeMaterial(nodeMaterial())
69
+ mesh.position.x = x
70
+ group.add(mesh)
71
+ root.add(group)
72
+ sceneRegistry.nodes.set(id, group)
73
+ nodes[id] = {
74
+ object: 'node',
75
+ id,
76
+ type: 'item',
77
+ parentId: null,
78
+ visible,
79
+ } as unknown as AnyNode
80
+ }
81
+
82
+ return { root, nodes }
83
+ }
84
+
43
85
  describe('prepareSceneForExport', () => {
44
86
  test('converts NodeMaterials to classic glTF-standard materials', () => {
45
87
  const root = new THREE.Group()
@@ -57,6 +99,317 @@ describe('prepareSceneForExport', () => {
57
99
  expect(material.color.getHexString()).toBe('cc3300')
58
100
  })
59
101
 
102
+ test('replaces only the cloned registered subtree with bake-only geometry', () => {
103
+ const restoreRegistry = nodeRegistry._snapshot()
104
+ try {
105
+ let receivedParentId: string | null | undefined
106
+ registerNode({
107
+ kind: 'test:bake-geometry',
108
+ schemaVersion: 1,
109
+ schema: DoorNode,
110
+ category: 'utility',
111
+ defaults: () => ({}) as never,
112
+ capabilities: {},
113
+ bake: 'replace',
114
+ bakeGeometry: (_node, context) => {
115
+ receivedParentId = context.parent?.id
116
+ const group = new THREE.Group()
117
+ const instances = new THREE.InstancedMesh(
118
+ new THREE.BoxGeometry(0.1, 1, 0.1),
119
+ new THREE.MeshStandardMaterial({ color: '#228833' }),
120
+ 1,
121
+ )
122
+ instances.name = 'baked-instance'
123
+ instances.setMatrixAt(0, new THREE.Matrix4().makeTranslation(4, 0, 2))
124
+ group.add(instances)
125
+ return group
126
+ },
127
+ } as AnyNodeDefinition)
128
+
129
+ const root = new THREE.Group()
130
+ const liveGroup = new THREE.Group()
131
+ liveGroup.position.set(2, 0, 3)
132
+ const liveMesh = meshWithNodeMaterial(nodeMaterial())
133
+ liveMesh.name = 'live-procedural-candidate'
134
+ liveGroup.add(liveMesh)
135
+ const before = meshWithNodeMaterial(nodeMaterial())
136
+ before.name = 'before-bake-replacement'
137
+ const after = meshWithNodeMaterial(nodeMaterial())
138
+ after.name = 'after-bake-replacement'
139
+ root.add(before, liveGroup, after)
140
+
141
+ const siteId = 'site_bake'
142
+ const grassId = 'grass_bake'
143
+ sceneRegistry.nodes.set(grassId, liveGroup)
144
+ const nodes = {
145
+ [siteId]: {
146
+ object: 'node',
147
+ id: siteId,
148
+ type: 'site',
149
+ parentId: null,
150
+ children: [grassId],
151
+ } as unknown as AnyNode,
152
+ [grassId]: {
153
+ object: 'node',
154
+ id: grassId,
155
+ type: 'test:bake-geometry',
156
+ parentId: siteId,
157
+ visible: true,
158
+ } as unknown as AnyNode,
159
+ }
160
+
161
+ const { scene } = prepareSceneForExport(root, nodes)
162
+ const exported = scene.getObjectByName(grassId)
163
+ const baked = exported?.getObjectByName('baked-instance')
164
+
165
+ expect(receivedParentId).toBe(siteId)
166
+ expect(liveGroup.getObjectByName('live-procedural-candidate')).toBe(liveMesh)
167
+ expect(scene.getObjectByName('live-procedural-candidate')).toBeUndefined()
168
+ expect(exported?.position.toArray()).toEqual([2, 0, 3])
169
+ expect(scene.children.map((child) => child.name)).toEqual([
170
+ 'before-bake-replacement',
171
+ grassId,
172
+ 'after-bake-replacement',
173
+ ])
174
+ expect((baked as THREE.InstancedMesh | undefined)?.isInstancedMesh).toBe(true)
175
+ expect((baked as THREE.InstancedMesh | undefined)?.count).toBe(1)
176
+ expect(((baked as THREE.InstancedMesh).material as THREE.Material).isMaterial).toBe(true)
177
+ } finally {
178
+ restoreRegistry()
179
+ }
180
+ })
181
+
182
+ test.each([
183
+ 'sync',
184
+ 'portable-sync',
185
+ 'portable-async',
186
+ ] as const)('bakes Site bounds and same-kind sibling reservations through %s hooks', async (mode) => {
187
+ const restoreRegistry = nodeRegistry._snapshot()
188
+ try {
189
+ const kind = 'test:site-context-bake'
190
+ const nodeId = 'site_context_bake'
191
+ const site = SiteNode.parse({
192
+ polygon: {
193
+ type: 'polygon',
194
+ points: [
195
+ [0, 0],
196
+ [12, 0],
197
+ [12, 8],
198
+ [0, 8],
199
+ ],
200
+ },
201
+ children: [nodeId, 'declared_sibling', 'linked_sibling', 'other_kind'],
202
+ })
203
+ const otherSite = SiteNode.parse({
204
+ polygon: {
205
+ type: 'polygon',
206
+ points: [
207
+ [0, 0],
208
+ [30, 0],
209
+ [30, 4],
210
+ [0, 4],
211
+ ],
212
+ },
213
+ children: ['other_site_sibling'],
214
+ })
215
+ const buildGeometry = (context: GeometryContext) => {
216
+ const points = (context.parent as SiteNode | null)?.polygon?.points ?? [[0, 0]]
217
+ const xs = points.map(([x]) => x)
218
+ const zs = points.map(([, z]) => z)
219
+ const reservedWidth = context.siblings.reduce(
220
+ (width, sibling) => width + Number(sibling.metadata?.reservedWidth ?? 0),
221
+ 0,
222
+ )
223
+ return new THREE.Mesh(
224
+ new THREE.BoxGeometry(
225
+ Math.max(...xs) - Math.min(...xs) - reservedWidth,
226
+ 1,
227
+ Math.max(...zs) - Math.min(...zs),
228
+ ),
229
+ new THREE.MeshStandardMaterial(),
230
+ )
231
+ }
232
+ registerNode({
233
+ kind,
234
+ schemaVersion: 1,
235
+ schema: DoorNode,
236
+ category: 'furnish',
237
+ defaults: () => ({}) as never,
238
+ capabilities: {},
239
+ bake: 'replace',
240
+ ...(mode === 'portable-async'
241
+ ? { bakeGeometryAsync: async (_node, context) => buildGeometry(context) }
242
+ : { bakeGeometry: (_node, context) => buildGeometry(context) }),
243
+ } as AnyNodeDefinition)
244
+ const nodes = {
245
+ [site.id]: site,
246
+ [otherSite.id]: otherSite,
247
+ [nodeId]: {
248
+ id: nodeId,
249
+ type: kind,
250
+ parentId: null,
251
+ visible: true,
252
+ metadata: { reservedWidth: 100 },
253
+ },
254
+ declared_sibling: {
255
+ id: 'declared_sibling',
256
+ type: kind,
257
+ parentId: null,
258
+ metadata: { reservedWidth: 2 },
259
+ },
260
+ pointer_sibling: {
261
+ id: 'pointer_sibling',
262
+ type: kind,
263
+ parentId: site.id,
264
+ metadata: { reservedWidth: 3 },
265
+ },
266
+ linked_sibling: {
267
+ id: 'linked_sibling',
268
+ type: kind,
269
+ parentId: site.id,
270
+ metadata: { reservedWidth: 1 },
271
+ },
272
+ other_kind: {
273
+ id: 'other_kind',
274
+ type: 'test:other-kind',
275
+ parentId: site.id,
276
+ metadata: { reservedWidth: 100 },
277
+ },
278
+ other_site_sibling: {
279
+ id: 'other_site_sibling',
280
+ type: kind,
281
+ parentId: otherSite.id,
282
+ metadata: { reservedWidth: 4 },
283
+ },
284
+ } as unknown as Record<string, AnyNode>
285
+ const root = new THREE.Group()
286
+ const source = new THREE.Group()
287
+ root.add(source)
288
+ sceneRegistry.nodes.set(nodeId, source)
289
+ const prepare = mode === 'sync' ? prepareSceneForExport : prepareSceneForExportAsync
290
+ const exportedSize = async () => {
291
+ const prepared = await prepare(root, nodes)
292
+ try {
293
+ const exported = prepared.scene.getObjectByName(nodeId)!
294
+ return new THREE.Box3().setFromObject(exported).getSize(new THREE.Vector3()).toArray()
295
+ } finally {
296
+ prepared.dispose()
297
+ }
298
+ }
299
+
300
+ expect(await exportedSize()).toEqual([6, 1, 8])
301
+ nodes[nodeId]!.parentId = otherSite.id
302
+ expect(await exportedSize()).toEqual([26, 1, 4])
303
+ nodes[nodeId]!.parentId = null
304
+ site.children = site.children.filter((id) => id !== nodeId)
305
+ expect(await exportedSize()).toEqual([0, 1, 0])
306
+ } finally {
307
+ restoreRegistry()
308
+ }
309
+ })
310
+
311
+ test('selective exports omit a procedural kind without changing the live scene or later exports', () => {
312
+ const restoreRegistry = nodeRegistry._snapshot()
313
+ try {
314
+ const kind = 'test:optional-ground-cover'
315
+ registerNode({
316
+ kind,
317
+ schemaVersion: 1,
318
+ schema: DoorNode,
319
+ category: 'furnish',
320
+ defaults: () => ({}) as never,
321
+ capabilities: {},
322
+ bake: 'replace',
323
+ bakeGeometry: () =>
324
+ new THREE.Mesh(
325
+ new THREE.BoxGeometry(1, 1, 1),
326
+ new THREE.MeshStandardMaterial({ color: '#228833' }),
327
+ ),
328
+ } as AnyNodeDefinition)
329
+ const root = new THREE.Group()
330
+ const building = meshWithNodeMaterial(nodeMaterial())
331
+ const grass = new THREE.Group()
332
+ const water = meshWithNodeMaterial(nodeMaterial())
333
+ root.add(building, grass, water)
334
+ const nodes: Record<string, AnyNode> = {}
335
+ for (const [id, type, object] of [
336
+ ['building_export', 'building', building],
337
+ ['grass_export', kind, grass],
338
+ ['water_export', 'test:water', water],
339
+ ] as const) {
340
+ sceneRegistry.nodes.set(id, object)
341
+ nodes[id] = { id, type, visible: true } as unknown as AnyNode
342
+ }
343
+
344
+ const complete = prepareSceneForExport(root, nodes)
345
+ const selected = prepareSceneForExport(root, nodes, {
346
+ excludedNodeTypes: [kind],
347
+ onlyVisible: false,
348
+ })
349
+ const stl = new STLExporter()
350
+ expect(stl.parse(complete.scene, { binary: true }).getUint32(80, true)).toBe(36)
351
+ expect(stl.parse(selected.scene, { binary: true }).getUint32(80, true)).toBe(24)
352
+ const obj = new OBJExporter().parse(selected.scene)
353
+ expect(obj).not.toContain('grass_export')
354
+ expect(obj).toContain('building_export')
355
+ expect(obj).toContain('water_export')
356
+ expect(selected.scene.getObjectByName('grass_export')).toBeUndefined()
357
+ expect(root.children).toEqual([building, grass, water])
358
+ expect(grass.visible).toBe(true)
359
+ expect(nodes.grass_export?.visible).toBe(true)
360
+ const later = prepareSceneForExport(root, nodes)
361
+ expect(stl.parse(later.scene, { binary: true }).getUint32(80, true)).toBe(36)
362
+ } finally {
363
+ restoreRegistry()
364
+ }
365
+ })
366
+
367
+ test('excluding a parent skips descendant bake failures and animation tracks', () => {
368
+ const restoreRegistry = nodeRegistry._snapshot()
369
+ try {
370
+ registerNode({
371
+ kind: 'test:unavailable-bake',
372
+ schemaVersion: 1,
373
+ schema: DoorNode,
374
+ category: 'furnish',
375
+ defaults: () => ({}) as never,
376
+ capabilities: {},
377
+ bakeGeometry: () => {
378
+ throw new Error('Procedural content unavailable')
379
+ },
380
+ } as AnyNodeDefinition)
381
+ const root = new THREE.Group()
382
+ const excluded = new THREE.Group()
383
+ const procedural = new THREE.Group()
384
+ const door = new THREE.Group()
385
+ const leaf = meshWithNodeMaterial(nodeMaterial())
386
+ leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
387
+ door.add(leaf)
388
+ excluded.add(procedural, door)
389
+ root.add(excluded, meshWithNodeMaterial(nodeMaterial()))
390
+ const nodes: Record<string, AnyNode> = {}
391
+ for (const [id, type, object, parentId] of [
392
+ ['procedural_child', 'test:unavailable-bake', procedural, 'excluded_parent'],
393
+ ['door_child', 'door', door, 'excluded_parent'],
394
+ ['excluded_parent', 'test:optional-parent', excluded, null],
395
+ ] as const) {
396
+ sceneRegistry.nodes.set(id, object)
397
+ nodes[id] = { id, type, parentId, visible: true } as unknown as AnyNode
398
+ }
399
+
400
+ expect(() => prepareSceneForExport(root, nodes)).toThrow('Procedural content unavailable')
401
+ const selected = prepareSceneForExport(root, nodes, {
402
+ excludedNodeTypes: ['test:optional-parent'],
403
+ })
404
+ expect(new STLExporter().parse(selected.scene, { binary: true }).getUint32(80, true)).toBe(12)
405
+ expect(selected.animations).toEqual([])
406
+ expect(selected.scene.getObjectByName('door_child')).toBeUndefined()
407
+ expect(excluded.children).toEqual([procedural, door])
408
+ } finally {
409
+ restoreRegistry()
410
+ }
411
+ })
412
+
60
413
  test('shared NodeMaterial instances convert to a single shared material', () => {
61
414
  const root = new THREE.Group()
62
415
  const shared = nodeMaterial()
@@ -114,7 +467,11 @@ describe('prepareSceneForExport', () => {
114
467
  expect(placeholder.flipY).toBe(stamped.flipY)
115
468
  expect(placeholder.colorSpace).toBe(stamped.colorSpace)
116
469
  expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef)
117
- expect(material.normalMap).toBe(unstamped)
470
+ expect(material.normalMap).toBeInstanceOf(THREE.DataTexture)
471
+ expect(Array.from((material.normalMap as THREE.DataTexture).image.data as Uint8Array)).toEqual([
472
+ 128, 128, 255, 255,
473
+ ])
474
+ expect(material.normalMap?.userData.pascalTextureRef).toBeUndefined()
118
475
  const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial
119
476
  expect(sharedMaterial.map).toBe(placeholder)
120
477
  })
@@ -173,6 +530,151 @@ describe('prepareSceneForExport', () => {
173
530
  expect(meshes).toHaveLength(1)
174
531
  })
175
532
 
533
+ test('excludes hidden scene nodes and descendants by default', () => {
534
+ const root = new THREE.Group()
535
+ const levelGroup = new THREE.Group()
536
+ const itemGroup = new THREE.Group()
537
+ itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
538
+ levelGroup.add(itemGroup)
539
+ root.add(levelGroup)
540
+
541
+ const levelId = 'level_hidden'
542
+ const itemId = 'item_visible_child'
543
+ sceneRegistry.nodes.set(levelId, levelGroup)
544
+ sceneRegistry.nodes.set(itemId, itemGroup)
545
+ const nodes: Record<string, AnyNode> = {
546
+ [levelId]: {
547
+ object: 'node',
548
+ id: levelId,
549
+ type: 'level',
550
+ parentId: null,
551
+ visible: false,
552
+ } as unknown as AnyNode,
553
+ [itemId]: {
554
+ object: 'node',
555
+ id: itemId,
556
+ type: 'item',
557
+ parentId: levelId,
558
+ visible: true,
559
+ } as unknown as AnyNode,
560
+ }
561
+
562
+ const { scene, animations } = prepareSceneForExport(root, nodes)
563
+
564
+ expect(scene.getObjectByName(levelId)).toBeUndefined()
565
+ expect(scene.getObjectByName(itemId)).toBeUndefined()
566
+ expect(animations).toHaveLength(0)
567
+ })
568
+
569
+ test('inherits hidden Site visibility for detached declared children and their descendants', async () => {
570
+ const restoreRegistry = nodeRegistry._snapshot()
571
+ try {
572
+ const kind = 'test:detached-site-visibility'
573
+ const childId = 'detached_site_child'
574
+ const descendantId = 'detached_site_descendant'
575
+ const explicitId = 'explicit_site_child'
576
+ const unownedId = 'unowned_site_child'
577
+ const hiddenSite = SiteNode.parse({
578
+ visible: false,
579
+ children: [childId, explicitId],
580
+ })
581
+ const visibleSite = SiteNode.parse({ visible: true })
582
+ registerNode({
583
+ kind,
584
+ schemaVersion: 1,
585
+ schema: DoorNode,
586
+ category: 'furnish',
587
+ defaults: () => ({}) as never,
588
+ capabilities: {},
589
+ bakeGeometryAsync: async () =>
590
+ new THREE.Mesh(new THREE.BoxGeometry(2, 1, 3), new THREE.MeshStandardMaterial()),
591
+ } as AnyNodeDefinition)
592
+ const nodes = {
593
+ [hiddenSite.id]: hiddenSite,
594
+ [visibleSite.id]: visibleSite,
595
+ [childId]: { id: childId, type: kind, parentId: null, visible: true },
596
+ [descendantId]: { id: descendantId, type: kind, parentId: childId, visible: true },
597
+ [explicitId]: { id: explicitId, type: kind, parentId: visibleSite.id, visible: true },
598
+ [unownedId]: { id: unownedId, type: kind, parentId: null, visible: true },
599
+ } as unknown as Record<string, AnyNode>
600
+ const allIds = [childId, descendantId, explicitId, unownedId]
601
+ const root = new THREE.Group()
602
+ for (const id of [hiddenSite.id, visibleSite.id, ...allIds]) {
603
+ const object = new THREE.Group()
604
+ root.add(object)
605
+ sceneRegistry.nodes.set(id, object)
606
+ }
607
+ const exportedIds = async (onlyVisible?: boolean) => {
608
+ const prepared = await prepareSceneForExportAsync(root, nodes, { onlyVisible })
609
+ try {
610
+ return allIds.filter((id) => prepared.scene.getObjectByName(id) !== undefined)
611
+ } finally {
612
+ prepared.dispose()
613
+ }
614
+ }
615
+
616
+ expect(await exportedIds()).toEqual([explicitId, unownedId])
617
+ expect(await exportedIds(false)).toEqual(allIds)
618
+ nodes[hiddenSite.id] = { ...hiddenSite, visible: true }
619
+ expect(await exportedIds()).toEqual(allIds)
620
+ nodes[hiddenSite.id] = hiddenSite
621
+ expect(await exportedIds()).toEqual([explicitId, unownedId])
622
+ } finally {
623
+ restoreRegistry()
624
+ }
625
+ })
626
+
627
+ test('can include hidden scene nodes when visible-only export is disabled', () => {
628
+ const root = new THREE.Group()
629
+ const itemGroup = new THREE.Group()
630
+ itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
631
+ root.add(itemGroup)
632
+
633
+ const itemId = 'item_hidden'
634
+ sceneRegistry.nodes.set(itemId, itemGroup)
635
+ const nodes: Record<string, AnyNode> = {
636
+ [itemId]: {
637
+ object: 'node',
638
+ id: itemId,
639
+ type: 'item',
640
+ parentId: null,
641
+ visible: false,
642
+ } as unknown as AnyNode,
643
+ }
644
+
645
+ const { scene } = prepareSceneForExport(root, nodes, { onlyVisible: false })
646
+
647
+ expect(scene.getObjectByName(itemId)?.userData).toMatchObject({
648
+ pascalId: itemId,
649
+ kind: 'item',
650
+ })
651
+ })
652
+
653
+ test('traditional binary STL excludes hidden nodes by default and can include them', () => {
654
+ const { root, nodes } = sceneWithVisibleAndHiddenBoxes()
655
+
656
+ const visibleScene = prepareSceneForExport(root, nodes).scene
657
+ const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene
658
+ const visibleStl = new STLExporter().parse(visibleScene, { binary: true })
659
+ const completeStl = new STLExporter().parse(completeScene, { binary: true })
660
+
661
+ expect(visibleStl.getUint32(80, true)).toBe(12)
662
+ expect(completeStl.getUint32(80, true)).toBe(24)
663
+ })
664
+
665
+ test('traditional OBJ excludes hidden nodes by default and can include them', () => {
666
+ const { root, nodes } = sceneWithVisibleAndHiddenBoxes()
667
+
668
+ const visibleScene = prepareSceneForExport(root, nodes).scene
669
+ const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene
670
+ const visibleObj = new OBJExporter().parse(visibleScene)
671
+ const completeObj = new OBJExporter().parse(completeScene)
672
+ const vertexCount = (obj: string) => obj.match(/^v /gm)?.length ?? 0
673
+
674
+ expect(vertexCount(visibleObj)).toBe(24)
675
+ expect(vertexCount(completeObj)).toBe(48)
676
+ })
677
+
176
678
  test('neutralises an invisible hitbox root but keeps its visible children', () => {
177
679
  // Door/window roots are selection hitboxes: a box geometry with an invisible
178
680
  // material (object stays visible). Left intact it would plug the wall opening.
@@ -553,4 +1055,650 @@ describe('prepareSceneForExport', () => {
553
1055
  expect(panel!.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-4)
554
1056
  }
555
1057
  })
1058
+
1059
+ describe('requireSynchronousBake', () => {
1060
+ test('rejects a retained async-only kind instead of returning its proxy', () => {
1061
+ const restoreRegistry = nodeRegistry._snapshot()
1062
+ try {
1063
+ const kind = 'test:async-only-synchronous-export'
1064
+ const nodeId = 'async_only_synchronous_export'
1065
+ registerNode({
1066
+ kind,
1067
+ schemaVersion: 1,
1068
+ schema: DoorNode,
1069
+ category: 'furnish',
1070
+ defaults: () => ({}) as never,
1071
+ capabilities: {},
1072
+ bake: 'replace',
1073
+ bakeGeometryAsync: async () =>
1074
+ new THREE.Mesh(new THREE.BoxGeometry(4, 5, 6), new THREE.MeshStandardMaterial()),
1075
+ } as AnyNodeDefinition)
1076
+
1077
+ const proxyGeometry = new THREE.BoxGeometry(0.25, 0.5, 0.75)
1078
+ const proxyMaterial = new THREE.MeshStandardMaterial()
1079
+ let sourceGeometryDisposals = 0
1080
+ let sourceMaterialDisposals = 0
1081
+ proxyGeometry.addEventListener('dispose', () => {
1082
+ sourceGeometryDisposals += 1
1083
+ })
1084
+ proxyMaterial.addEventListener('dispose', () => {
1085
+ sourceMaterialDisposals += 1
1086
+ })
1087
+ const proxy = new THREE.Mesh(proxyGeometry, proxyMaterial)
1088
+ proxy.name = 'async-only-proxy'
1089
+ const source = new THREE.Group()
1090
+ source.add(proxy)
1091
+ const root = new THREE.Group()
1092
+ root.add(source)
1093
+ sceneRegistry.nodes.set(nodeId, source)
1094
+ const nodes = {
1095
+ [nodeId]: { id: nodeId, type: kind, visible: true },
1096
+ } as unknown as Record<string, AnyNode>
1097
+
1098
+ const compatible = prepareSceneForExport(root, nodes)
1099
+ expect(compatible.scene.getObjectByName('async-only-proxy')).toBeDefined()
1100
+ compatible.dispose()
1101
+
1102
+ let rejection: unknown
1103
+ try {
1104
+ prepareSceneForExport(root, nodes, { requireSynchronousBake: true })
1105
+ } catch (error) {
1106
+ rejection = error
1107
+ }
1108
+
1109
+ expect(rejection).toBeInstanceOf(Error)
1110
+ const message = rejection instanceof Error ? rejection.message : ''
1111
+ expect(message).toContain(kind)
1112
+ expect(message).toMatch(/\bGLB\b/)
1113
+ expect(message).toMatch(/\bUSDZ\b/)
1114
+ expect(message).toMatch(/exclud/i)
1115
+ expect(sourceGeometryDisposals).toBe(0)
1116
+ expect(sourceMaterialDisposals).toBe(0)
1117
+ expect(root.children).toEqual([source])
1118
+ expect(source.children).toEqual([proxy])
1119
+ } finally {
1120
+ restoreRegistry()
1121
+ }
1122
+ })
1123
+
1124
+ test('allows a valid model when every async-only kind is pruned', async () => {
1125
+ const restoreRegistry = nodeRegistry._snapshot()
1126
+ const { installedPlugins, hasExplicitPluginInstallState } = useScene.getState()
1127
+ try {
1128
+ const excludedKind = 'test:excluded-synchronous-export'
1129
+ const hiddenKind = 'test:hidden-synchronous-export'
1130
+ const descendantKind = 'test:descendant-synchronous-export'
1131
+ const disabledKind = 'test:disabled-synchronous-export'
1132
+ const disabledPluginId = 'test:disabled-synchronous-export-plugin'
1133
+ const asyncOnlyDefinition = (kind: string): AnyNodeDefinition =>
1134
+ ({
1135
+ kind,
1136
+ schemaVersion: 1,
1137
+ schema: DoorNode,
1138
+ category: 'furnish',
1139
+ defaults: () => ({}) as never,
1140
+ capabilities: {},
1141
+ bake: 'replace',
1142
+ bakeGeometryAsync: async () => {
1143
+ throw new Error(`Pruned async-only builder ran for ${kind}`)
1144
+ },
1145
+ }) as AnyNodeDefinition
1146
+
1147
+ for (const kind of [excludedKind, hiddenKind, descendantKind]) {
1148
+ registerNode(asyncOnlyDefinition(kind))
1149
+ }
1150
+ await loadPlugin({
1151
+ id: disabledPluginId,
1152
+ apiVersion: 1,
1153
+ nodes: [asyncOnlyDefinition(disabledKind)],
1154
+ })
1155
+ useScene.getState().setInstalledPlugins([], { explicit: true })
1156
+
1157
+ const valid = new THREE.Mesh(
1158
+ new THREE.BoxGeometry(2, 3, 4),
1159
+ new THREE.MeshStandardMaterial(),
1160
+ )
1161
+ valid.name = 'valid-model'
1162
+ const excluded = new THREE.Group()
1163
+ const hidden = new THREE.Group()
1164
+ const disabled = new THREE.Group()
1165
+ const excludedParent = new THREE.Group()
1166
+ const descendant = new THREE.Group()
1167
+ excludedParent.add(descendant)
1168
+ const root = new THREE.Group()
1169
+ root.add(valid, excluded, hidden, disabled, excludedParent)
1170
+
1171
+ const excludedId = 'excluded_async_only'
1172
+ const hiddenId = 'hidden_async_only'
1173
+ const disabledId = 'disabled_async_only'
1174
+ const excludedParentId = 'excluded_async_only_parent'
1175
+ const descendantId = 'descendant_async_only'
1176
+ sceneRegistry.nodes.set(excludedId, excluded)
1177
+ sceneRegistry.nodes.set(hiddenId, hidden)
1178
+ sceneRegistry.nodes.set(disabledId, disabled)
1179
+ sceneRegistry.nodes.set(excludedParentId, excludedParent)
1180
+ sceneRegistry.nodes.set(descendantId, descendant)
1181
+ const nodes = {
1182
+ [excludedId]: { id: excludedId, type: excludedKind, visible: true },
1183
+ [hiddenId]: { id: hiddenId, type: hiddenKind, visible: false },
1184
+ [disabledId]: { id: disabledId, type: disabledKind, visible: true },
1185
+ [excludedParentId]: {
1186
+ id: excludedParentId,
1187
+ type: 'test:excluded-synchronous-export-parent',
1188
+ visible: true,
1189
+ children: [descendantId],
1190
+ },
1191
+ [descendantId]: {
1192
+ id: descendantId,
1193
+ type: descendantKind,
1194
+ parentId: excludedParentId,
1195
+ visible: true,
1196
+ },
1197
+ } as unknown as Record<string, AnyNode>
1198
+
1199
+ const prepared = prepareSceneForExport(root, nodes, {
1200
+ excludedNodeTypes: [excludedKind, 'test:excluded-synchronous-export-parent'],
1201
+ requireSynchronousBake: true,
1202
+ })
1203
+
1204
+ const validModel = prepared.scene.getObjectByName('valid-model')
1205
+ expect(validModel).toBeDefined()
1206
+ const size = new THREE.Box3().setFromObject(validModel!).getSize(new THREE.Vector3())
1207
+ expect(size.toArray()).toEqual([2, 3, 4])
1208
+ for (const id of [excludedId, hiddenId, disabledId, excludedParentId, descendantId]) {
1209
+ expect(prepared.scene.getObjectByName(id)).toBeUndefined()
1210
+ }
1211
+ prepared.dispose()
1212
+ } finally {
1213
+ useScene
1214
+ .getState()
1215
+ .setInstalledPlugins(installedPlugins, { explicit: hasExplicitPluginInstallState })
1216
+ restoreRegistry()
1217
+ }
1218
+ })
1219
+
1220
+ test('uses synchronous geometry when a retained kind provides both hooks', () => {
1221
+ const restoreRegistry = nodeRegistry._snapshot()
1222
+ try {
1223
+ const kind = 'test:dual-hook-synchronous-export'
1224
+ const nodeId = 'dual_hook_synchronous_export'
1225
+ registerNode({
1226
+ kind,
1227
+ schemaVersion: 1,
1228
+ schema: DoorNode,
1229
+ category: 'furnish',
1230
+ defaults: () => ({}) as never,
1231
+ capabilities: {},
1232
+ bake: 'replace',
1233
+ bakeGeometry: () =>
1234
+ new THREE.Mesh(new THREE.BoxGeometry(2, 3, 4), new THREE.MeshStandardMaterial()),
1235
+ bakeGeometryAsync: async () =>
1236
+ new THREE.Mesh(new THREE.BoxGeometry(8, 8, 8), new THREE.MeshStandardMaterial()),
1237
+ } as AnyNodeDefinition)
1238
+
1239
+ const source = new THREE.Group()
1240
+ source.add(
1241
+ new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.5, 0.75), new THREE.MeshStandardMaterial()),
1242
+ )
1243
+ const root = new THREE.Group()
1244
+ root.add(source)
1245
+ sceneRegistry.nodes.set(nodeId, source)
1246
+ const nodes = {
1247
+ [nodeId]: { id: nodeId, type: kind, visible: true },
1248
+ } as unknown as Record<string, AnyNode>
1249
+
1250
+ const prepared = prepareSceneForExport(root, nodes, {
1251
+ requireSynchronousBake: true,
1252
+ })
1253
+
1254
+ const exported = prepared.scene.getObjectByName(nodeId)
1255
+ expect(exported).toBeDefined()
1256
+ const size = new THREE.Box3().setFromObject(exported!).getSize(new THREE.Vector3())
1257
+ expect(size.toArray()).toEqual([2, 3, 4])
1258
+ expect(root.children).toEqual([source])
1259
+ prepared.dispose()
1260
+ } finally {
1261
+ restoreRegistry()
1262
+ }
1263
+ })
1264
+ })
1265
+
1266
+ test('awaits async bake geometry with full semantic context after selection', async () => {
1267
+ const restoreRegistry = nodeRegistry._snapshot()
1268
+ try {
1269
+ const retainedKind = 'test:async-context-bake'
1270
+ let asyncCalls = 0
1271
+ let syncCalls = 0
1272
+ registerNode({
1273
+ kind: retainedKind,
1274
+ schemaVersion: 1,
1275
+ schema: DoorNode,
1276
+ category: 'furnish',
1277
+ defaults: () => ({}) as never,
1278
+ capabilities: {},
1279
+ bake: 'replace',
1280
+ bakeGeometry: () => {
1281
+ syncCalls += 1
1282
+ throw new Error('sync builder must not run when async geometry is available')
1283
+ },
1284
+ bakeGeometryAsync: async (_node, context) => {
1285
+ asyncCalls += 1
1286
+ await Promise.resolve()
1287
+ const obstacle = context.resolve<{ metadata?: { acceptanceWidth?: number } }>(
1288
+ 'door_obstacle_async_context',
1289
+ )
1290
+ return new THREE.Mesh(
1291
+ new THREE.BoxGeometry(obstacle?.metadata?.acceptanceWidth ?? 0, 1, 1),
1292
+ new THREE.MeshStandardMaterial(),
1293
+ )
1294
+ },
1295
+ } as AnyNodeDefinition)
1296
+ const root = new THREE.Group()
1297
+ const source = new THREE.Group()
1298
+ const obstacle = new THREE.Group()
1299
+ root.add(source, obstacle)
1300
+ sceneRegistry.nodes.set('async_context_node', source)
1301
+ sceneRegistry.nodes.set('door_obstacle_async_context', obstacle)
1302
+ const nodes = {
1303
+ async_context_node: {
1304
+ id: 'async_context_node',
1305
+ type: retainedKind,
1306
+ visible: true,
1307
+ },
1308
+ door_obstacle_async_context: {
1309
+ id: 'door_obstacle_async_context',
1310
+ type: 'test:excluded-obstacle',
1311
+ visible: true,
1312
+ metadata: { acceptanceWidth: 2 },
1313
+ },
1314
+ } as unknown as Record<string, AnyNode>
1315
+
1316
+ const prepared = await prepareSceneForExportAsync(root, nodes, {
1317
+ excludedNodeTypes: ['test:excluded-obstacle'],
1318
+ })
1319
+ const baked = prepared.scene.children.find((child) => child instanceof THREE.Mesh)
1320
+ expect(asyncCalls).toBe(1)
1321
+ expect(syncCalls).toBe(0)
1322
+ expect(baked).toBeInstanceOf(THREE.Mesh)
1323
+ if (baked instanceof THREE.Mesh) {
1324
+ baked.geometry.computeBoundingBox()
1325
+ expect(baked.geometry.boundingBox?.getSize(new THREE.Vector3()).x).toBeCloseTo(2)
1326
+ }
1327
+ expect(prepared.animations).toEqual([])
1328
+ expect(root.children).toEqual([source, obstacle])
1329
+ prepared.dispose()
1330
+ } finally {
1331
+ restoreRegistry()
1332
+ }
1333
+ })
1334
+
1335
+ test('does not invoke async builders for excluded or invisible nodes', async () => {
1336
+ const restoreRegistry = nodeRegistry._snapshot()
1337
+ try {
1338
+ let calls = 0
1339
+ for (const kind of ['test:excluded-async-bake', 'test:hidden-async-bake']) {
1340
+ registerNode({
1341
+ kind,
1342
+ schemaVersion: 1,
1343
+ schema: DoorNode,
1344
+ category: 'furnish',
1345
+ defaults: () => ({}) as never,
1346
+ capabilities: {},
1347
+ bake: 'replace',
1348
+ bakeGeometryAsync: async () => {
1349
+ calls += 1
1350
+ throw new Error('unselected async builder ran')
1351
+ },
1352
+ } as AnyNodeDefinition)
1353
+ }
1354
+ const root = new THREE.Group()
1355
+ const excluded = new THREE.Group()
1356
+ const hidden = new THREE.Group()
1357
+ const retained = meshWithNodeMaterial(nodeMaterial())
1358
+ root.add(excluded, hidden, retained)
1359
+ sceneRegistry.nodes.set('excluded_async_node', excluded)
1360
+ sceneRegistry.nodes.set('hidden_async_node', hidden)
1361
+ const nodes = {
1362
+ excluded_async_node: {
1363
+ id: 'excluded_async_node',
1364
+ type: 'test:excluded-async-bake',
1365
+ visible: true,
1366
+ },
1367
+ hidden_async_node: {
1368
+ id: 'hidden_async_node',
1369
+ type: 'test:hidden-async-bake',
1370
+ visible: false,
1371
+ },
1372
+ } as unknown as Record<string, AnyNode>
1373
+
1374
+ const prepared = await prepareSceneForExportAsync(root, nodes, {
1375
+ excludedNodeTypes: ['test:excluded-async-bake'],
1376
+ onlyVisible: true,
1377
+ })
1378
+ expect(calls).toBe(0)
1379
+ expect(prepared.scene.children).toHaveLength(1)
1380
+ prepared.dispose()
1381
+ } finally {
1382
+ restoreRegistry()
1383
+ }
1384
+ })
1385
+
1386
+ test('disposes only export-owned resources when a later async builder fails', async () => {
1387
+ const restoreRegistry = nodeRegistry._snapshot()
1388
+ try {
1389
+ const ownedGeometry = new THREE.BoxGeometry()
1390
+ const ownedMaterial = new THREE.MeshStandardMaterial()
1391
+ const sourceGeometry = new THREE.BoxGeometry()
1392
+ const sourceMaterial = new THREE.MeshStandardMaterial()
1393
+ let ownedGeometryDisposals = 0
1394
+ let ownedMaterialDisposals = 0
1395
+ let sourceGeometryDisposals = 0
1396
+ let sourceMaterialDisposals = 0
1397
+ ownedGeometry.addEventListener('dispose', () => {
1398
+ ownedGeometryDisposals += 1
1399
+ })
1400
+ ownedMaterial.addEventListener('dispose', () => {
1401
+ ownedMaterialDisposals += 1
1402
+ })
1403
+ sourceGeometry.addEventListener('dispose', () => {
1404
+ sourceGeometryDisposals += 1
1405
+ })
1406
+ sourceMaterial.addEventListener('dispose', () => {
1407
+ sourceMaterialDisposals += 1
1408
+ })
1409
+ registerNode({
1410
+ kind: 'test:first-owned-async-bake',
1411
+ schemaVersion: 1,
1412
+ schema: DoorNode,
1413
+ category: 'furnish',
1414
+ defaults: () => ({}) as never,
1415
+ capabilities: {},
1416
+ bake: 'replace',
1417
+ bakeGeometryAsync: async () => new THREE.Mesh(ownedGeometry, ownedMaterial),
1418
+ } as AnyNodeDefinition)
1419
+ registerNode({
1420
+ kind: 'test:later-failing-async-bake',
1421
+ schemaVersion: 1,
1422
+ schema: DoorNode,
1423
+ category: 'furnish',
1424
+ defaults: () => ({}) as never,
1425
+ capabilities: {},
1426
+ bake: 'replace',
1427
+ bakeGeometryAsync: async () => {
1428
+ throw new Error('acceptance injected async failure')
1429
+ },
1430
+ } as AnyNodeDefinition)
1431
+ const root = new THREE.Group()
1432
+ const first = new THREE.Group()
1433
+ const failing = new THREE.Group()
1434
+ const source = new THREE.Mesh(sourceGeometry, sourceMaterial)
1435
+ root.add(first, failing, source)
1436
+ sceneRegistry.nodes.set('first_owned_async_node', first)
1437
+ sceneRegistry.nodes.set('later_failing_async_node', failing)
1438
+ const nodes = {
1439
+ first_owned_async_node: {
1440
+ id: 'first_owned_async_node',
1441
+ type: 'test:first-owned-async-bake',
1442
+ visible: true,
1443
+ },
1444
+ later_failing_async_node: {
1445
+ id: 'later_failing_async_node',
1446
+ type: 'test:later-failing-async-bake',
1447
+ visible: true,
1448
+ },
1449
+ } as unknown as Record<string, AnyNode>
1450
+
1451
+ await expect(prepareSceneForExportAsync(root, nodes)).rejects.toThrow(
1452
+ 'acceptance injected async failure',
1453
+ )
1454
+ expect(ownedGeometryDisposals).toBe(1)
1455
+ expect(ownedMaterialDisposals).toBe(1)
1456
+ expect(sourceGeometryDisposals).toBe(0)
1457
+ expect(sourceMaterialDisposals).toBe(0)
1458
+ expect(root.children).toEqual([first, failing, source])
1459
+ } finally {
1460
+ restoreRegistry()
1461
+ }
1462
+ })
1463
+
1464
+ test('includes a registered static presentation only when explicitly selected', async () => {
1465
+ const priorPresentations = viewerPresentationRegistry.getSnapshot()
1466
+ viewerPresentationRegistry.reset()
1467
+ try {
1468
+ const contribution: ViewerPresentationContribution = {
1469
+ id: 'test:static-export-presentation',
1470
+ component: async () => ({ default: () => null }),
1471
+ staticExport: {
1472
+ label: 'Acceptance surroundings',
1473
+ build: async ({ nodes, onlyVisible, excludedNodeTypes }) => {
1474
+ const semantic = nodes.semantic_context_node as
1475
+ | { metadata?: { acceptanceDimensions?: [number, number, number] } }
1476
+ | undefined
1477
+ const dimensions = semantic?.metadata?.acceptanceDimensions ?? [0, 0, 0]
1478
+ return new THREE.Mesh(
1479
+ new THREE.BoxGeometry(
1480
+ dimensions[0],
1481
+ onlyVisible ? 0 : dimensions[1],
1482
+ excludedNodeTypes.includes('test:excluded-context') ? dimensions[2] : 0,
1483
+ ),
1484
+ new THREE.MeshStandardMaterial({ color: '#4a6b3d' }),
1485
+ )
1486
+ },
1487
+ },
1488
+ }
1489
+ viewerPresentationRegistry.register(contribution)
1490
+ const root = new THREE.Group()
1491
+ root.add(meshWithNodeMaterial(nodeMaterial()))
1492
+ const nodes = {
1493
+ semantic_context_node: {
1494
+ id: 'semantic_context_node',
1495
+ type: 'test:semantic-context',
1496
+ visible: true,
1497
+ metadata: { acceptanceDimensions: [3, 1, 2] },
1498
+ },
1499
+ } as unknown as Record<string, AnyNode>
1500
+
1501
+ const defaultArtifact = await prepareSceneForExportAsync(root, nodes, {
1502
+ onlyVisible: false,
1503
+ })
1504
+ expect(
1505
+ defaultArtifact.scene.getObjectByProperty('name', 'test:static-export-presentation'),
1506
+ ).toBeUndefined()
1507
+ defaultArtifact.dispose()
1508
+
1509
+ const selectedArtifact = await prepareSceneForExportAsync(root, nodes, {
1510
+ excludedNodeTypes: ['test:excluded-context'],
1511
+ includedPresentationIds: [contribution.id],
1512
+ onlyVisible: false,
1513
+ })
1514
+ const presentation = selectedArtifact.scene.getObjectByProperty('name', contribution.id)
1515
+ expect(presentation?.userData).toMatchObject({
1516
+ label: 'Acceptance surroundings',
1517
+ pascalPresentationId: contribution.id,
1518
+ })
1519
+ const bounds = new THREE.Box3().setFromObject(presentation!)
1520
+ expect(bounds.getSize(new THREE.Vector3()).toArray()).toEqual([3, 1, 2])
1521
+ selectedArtifact.dispose()
1522
+ } finally {
1523
+ viewerPresentationRegistry.reset()
1524
+ for (const contribution of priorPresentations) {
1525
+ viewerPresentationRegistry.register(contribution)
1526
+ }
1527
+ }
1528
+ })
1529
+
1530
+ test('preserves marked borrowed presentation textures and disposes owned maps', async () => {
1531
+ await withCanvasCapture(async (canvasPixels) => {
1532
+ const priorPresentations = viewerPresentationRegistry.getSnapshot()
1533
+ viewerPresentationRegistry.reset()
1534
+ const borrowed = new THREE.DataTexture(new Uint8Array([20, 40, 60, 255]), 1, 1)
1535
+ const owned = new THREE.DataTexture(new Uint8Array([80, 100, 120, 255]), 1, 1)
1536
+ const borrowedOnFailure = new THREE.DataTexture(new Uint8Array([140, 160, 180, 255]), 1, 1)
1537
+ const ownedOnFailure = new THREE.DataTexture(new Uint8Array([200, 220, 240, 255]), 1, 1)
1538
+ markViewerPresentationTextureBorrowed(borrowed)
1539
+ markViewerPresentationTextureBorrowed(borrowedOnFailure)
1540
+ const disposals = {
1541
+ borrowed: 0,
1542
+ owned: 0,
1543
+ borrowedOnFailure: 0,
1544
+ ownedOnFailure: 0,
1545
+ }
1546
+ for (const [texture, key] of [
1547
+ [borrowed, 'borrowed'],
1548
+ [owned, 'owned'],
1549
+ [borrowedOnFailure, 'borrowedOnFailure'],
1550
+ [ownedOnFailure, 'ownedOnFailure'],
1551
+ ] as const) {
1552
+ texture.addEventListener('dispose', () => {
1553
+ disposals[key] += 1
1554
+ })
1555
+ }
1556
+ const textureContribution = (
1557
+ id: string,
1558
+ texture: THREE.Texture,
1559
+ ): ViewerPresentationContribution => ({
1560
+ id,
1561
+ component: async () => ({ default: () => null }),
1562
+ staticExport: {
1563
+ label: id,
1564
+ build: () =>
1565
+ new THREE.Mesh(
1566
+ new THREE.PlaneGeometry(1, 1),
1567
+ new THREE.MeshStandardMaterial({ map: texture }),
1568
+ ),
1569
+ },
1570
+ })
1571
+ try {
1572
+ viewerPresentationRegistry.register(
1573
+ textureContribution('test:borrowed-presentation-texture', borrowed),
1574
+ )
1575
+ viewerPresentationRegistry.register(
1576
+ textureContribution('test:owned-presentation-texture', owned),
1577
+ )
1578
+ const success = await prepareSceneForExportAsync(
1579
+ new THREE.Group(),
1580
+ {},
1581
+ {
1582
+ includedPresentationIds: [
1583
+ 'test:borrowed-presentation-texture',
1584
+ 'test:owned-presentation-texture',
1585
+ ],
1586
+ },
1587
+ )
1588
+ const generatedTextures: THREE.Texture[] = []
1589
+ success.scene.traverse((object) => {
1590
+ if (!(object as THREE.Mesh).isMesh) return
1591
+ const mesh = object as THREE.Mesh
1592
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
1593
+ for (const material of materials) {
1594
+ const map = (material as THREE.MeshStandardMaterial).map
1595
+ if (map) generatedTextures.push(map)
1596
+ }
1597
+ })
1598
+ expect(generatedTextures).toHaveLength(2)
1599
+ expect(
1600
+ generatedTextures.every((texture) => (texture as THREE.CanvasTexture).isCanvasTexture),
1601
+ ).toBe(true)
1602
+ expect(generatedTextures.map(canvasPixels).sort()).toEqual(
1603
+ [
1604
+ [20, 40, 60, 255],
1605
+ [80, 100, 120, 255],
1606
+ ].sort(),
1607
+ )
1608
+ let generatedTextureDisposals = 0
1609
+ for (const texture of generatedTextures) {
1610
+ texture.addEventListener('dispose', () => {
1611
+ generatedTextureDisposals += 1
1612
+ })
1613
+ }
1614
+ success.dispose()
1615
+ expect(generatedTextureDisposals).toBe(2)
1616
+ expect(disposals).toMatchObject({ borrowed: 0, owned: 1 })
1617
+ expect(Array.from(borrowed.image.data as Uint8Array)).toEqual([20, 40, 60, 255])
1618
+
1619
+ viewerPresentationRegistry.register(
1620
+ textureContribution('test:borrowed-presentation-texture-failure', borrowedOnFailure),
1621
+ )
1622
+ viewerPresentationRegistry.register(
1623
+ textureContribution('test:owned-presentation-texture-failure', ownedOnFailure),
1624
+ )
1625
+ viewerPresentationRegistry.register({
1626
+ id: 'test:failing-presentation-after-textures',
1627
+ component: async () => ({ default: () => null }),
1628
+ staticExport: {
1629
+ label: 'Injected failure after texture ownership',
1630
+ build: () => {
1631
+ throw new Error('acceptance presentation texture failure')
1632
+ },
1633
+ },
1634
+ })
1635
+ await expect(
1636
+ prepareSceneForExportAsync(
1637
+ new THREE.Group(),
1638
+ {},
1639
+ {
1640
+ includedPresentationIds: [
1641
+ 'test:borrowed-presentation-texture-failure',
1642
+ 'test:owned-presentation-texture-failure',
1643
+ 'test:failing-presentation-after-textures',
1644
+ ],
1645
+ },
1646
+ ),
1647
+ ).rejects.toThrow('acceptance presentation texture failure')
1648
+ expect(disposals).toEqual({
1649
+ borrowed: 0,
1650
+ owned: 1,
1651
+ borrowedOnFailure: 0,
1652
+ ownedOnFailure: 1,
1653
+ })
1654
+ expect(Array.from(borrowedOnFailure.image.data as Uint8Array)).toEqual([140, 160, 180, 255])
1655
+ } finally {
1656
+ viewerPresentationRegistry.reset()
1657
+ for (const contribution of priorPresentations) {
1658
+ viewerPresentationRegistry.register(contribution)
1659
+ }
1660
+ }
1661
+ })
1662
+ })
556
1663
  })
1664
+
1665
+ async function withCanvasCapture(
1666
+ run: (pixels: (texture: THREE.Texture) => number[]) => Promise<void>,
1667
+ ): Promise<void> {
1668
+ const globals = globalThis as unknown as { document?: Document }
1669
+ const previousDocument = globals.document
1670
+ const pixelsByCanvas = new WeakMap<HTMLCanvasElement, Uint8ClampedArray>()
1671
+ globals.document = {
1672
+ createElement: (tagName: string) => {
1673
+ if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`)
1674
+ const canvas = {
1675
+ width: 0,
1676
+ height: 0,
1677
+ getContext: () => ({
1678
+ createImageData: (width: number, height: number) =>
1679
+ ({
1680
+ colorSpace: 'srgb',
1681
+ data: new Uint8ClampedArray(width * height * 4),
1682
+ height,
1683
+ width,
1684
+ }) as ImageData,
1685
+ putImageData: (image: ImageData) => {
1686
+ pixelsByCanvas.set(canvas, new Uint8ClampedArray(image.data))
1687
+ },
1688
+ }),
1689
+ } as unknown as HTMLCanvasElement
1690
+ return canvas
1691
+ },
1692
+ } as unknown as Document
1693
+
1694
+ try {
1695
+ await run((texture) => {
1696
+ const pixels = pixelsByCanvas.get(texture.image as HTMLCanvasElement)
1697
+ if (!pixels) throw new Error('Generated canvas texture has no captured pixels')
1698
+ return Array.from(pixels)
1699
+ })
1700
+ } finally {
1701
+ if (previousDocument) globals.document = previousDocument
1702
+ else delete globals.document
1703
+ }
1704
+ }