@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
@@ -0,0 +1,280 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ DoorNode,
6
+ nodeRegistry,
7
+ registerNode,
8
+ sceneRegistry,
9
+ } from '@pascal-app/core'
10
+ import * as THREE from 'three'
11
+ import type { ExportTextureUtils } from './export-texture-utils'
12
+ import {
13
+ exportSceneToGlb,
14
+ type GlbExport,
15
+ serializePreparedSceneToGlb,
16
+ withExportDeadline,
17
+ } from './glb-export'
18
+
19
+ // GLTFExporter's binary path runs through document.createElement('canvas'),
20
+ // canvas.toBlob and three FileReader reads (image bufferView, merged buffer,
21
+ // final GLB). Bun has none of those, so a minimal stand-in drives the exporter
22
+ // the way a browser does — including the failure the browser reports only via
23
+ // `result === null` inside `onloadend`.
24
+ type Globals = Record<string, unknown>
25
+ const globals = globalThis as unknown as Globals
26
+ const DOM_KEYS = ['document', 'FileReader', 'ImageData', 'HTMLCanvasElement'] as const
27
+ const previous = new Map<string, unknown>()
28
+
29
+ class FakeImageData {
30
+ constructor(
31
+ public data: Uint8ClampedArray,
32
+ public width: number,
33
+ public height: number,
34
+ ) {}
35
+ }
36
+
37
+ class FakeCanvas {
38
+ width = 0
39
+ height = 0
40
+ private pixels: Uint8ClampedArray | null = null
41
+
42
+ getContext() {
43
+ return {
44
+ translate: () => {},
45
+ scale: () => {},
46
+ drawImage: () => {},
47
+ fillRect: () => {},
48
+ createImageData: (width: number, height: number) =>
49
+ new FakeImageData(new Uint8ClampedArray(width * height * 4), width, height),
50
+ putImageData: (image: FakeImageData) => {
51
+ this.pixels = new Uint8ClampedArray(image.data)
52
+ },
53
+ getImageData: (_x: number, _y: number, width: number, height: number) =>
54
+ new FakeImageData(this.pixels ?? new Uint8ClampedArray(width * height * 4), width, height),
55
+ }
56
+ }
57
+
58
+ toBlob(callback: (blob: Blob) => void, mimeType: string) {
59
+ callback(new Blob([new Uint8Array([137, 80, 78, 71])], { type: mimeType }))
60
+ }
61
+ }
62
+
63
+ type FileReaderBehaviour = {
64
+ reads: number
65
+ /** 1-based index of the read whose result is `null`, as after a failed read. */
66
+ failAt: number
67
+ }
68
+
69
+ function installExporterDom(behaviour: FileReaderBehaviour) {
70
+ for (const key of DOM_KEYS) previous.set(key, globals[key])
71
+
72
+ class FakeFileReader {
73
+ result: ArrayBuffer | null = null
74
+ onloadend: (() => void) | null = null
75
+ readAsArrayBuffer(blob: Blob) {
76
+ behaviour.reads += 1
77
+ const read = behaviour.reads
78
+ void blob.arrayBuffer().then((buffer) => {
79
+ this.result = read === behaviour.failAt ? null : buffer
80
+ // Browsers report an exception thrown inside the event handler to
81
+ // window.onerror; it never reaches the exporter's promise chain.
82
+ try {
83
+ this.onloadend?.()
84
+ } catch {}
85
+ })
86
+ }
87
+ }
88
+
89
+ globals.ImageData = FakeImageData
90
+ globals.HTMLCanvasElement = FakeCanvas
91
+ globals.FileReader = FakeFileReader
92
+ globals.document = {
93
+ createElement: (tagName: string) => {
94
+ if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`)
95
+ return new FakeCanvas()
96
+ },
97
+ }
98
+ }
99
+
100
+ afterEach(() => {
101
+ for (const key of DOM_KEYS) globals[key] = previous.get(key)
102
+ })
103
+
104
+ function texturedRoot(texture: THREE.Texture): THREE.Group {
105
+ const root = new THREE.Group()
106
+ root.add(
107
+ new THREE.Mesh(
108
+ new THREE.BoxGeometry(1, 1, 1),
109
+ new THREE.MeshStandardMaterial({ map: texture }),
110
+ ),
111
+ )
112
+ return root
113
+ }
114
+
115
+ function texturedScene(texture: THREE.Texture): GlbExport {
116
+ return { scene: texturedRoot(texture), animations: [], warnings: [], dispose: () => {} }
117
+ }
118
+
119
+ function readableTexture(): THREE.DataTexture {
120
+ const texture = new THREE.DataTexture(new Uint8Array([255, 0, 0, 255]), 1, 1)
121
+ texture.needsUpdate = true
122
+ return texture
123
+ }
124
+
125
+ function passthroughUtils(): ExportTextureUtils & { calls: THREE.Texture[] } {
126
+ const calls: THREE.Texture[] = []
127
+ return {
128
+ calls,
129
+ decompress: async (texture) => {
130
+ calls.push(texture)
131
+ return readableTexture()
132
+ },
133
+ dispose: async () => {},
134
+ }
135
+ }
136
+
137
+ const GLB_MAGIC = 0x46546c67
138
+
139
+ describe('serializePreparedSceneToGlb', () => {
140
+ test('resolves a binary glTF and routes compressed textures through the shared decompressor', async () => {
141
+ installExporterDom({ reads: 0, failAt: Number.POSITIVE_INFINITY })
142
+ const compressed = new THREE.CompressedTexture([], 2, 2)
143
+ compressed.name = 'Color_ktx2'
144
+ const textureUtils = passthroughUtils()
145
+
146
+ const glb = await serializePreparedSceneToGlb(texturedScene(compressed), { textureUtils })
147
+
148
+ expect(glb).toBeInstanceOf(ArrayBuffer)
149
+ expect(new DataView(glb).getUint32(0, true)).toBe(GLB_MAGIC)
150
+ expect(new DataView(glb).getUint32(8, true)).toBe(glb.byteLength)
151
+ expect(textureUtils.calls.map((texture) => texture.name)).toEqual(['Color_ktx2'])
152
+ })
153
+
154
+ test('rejects instead of resolving null when the final GLB read fails', async () => {
155
+ installExporterDom({ reads: 0, failAt: 3 })
156
+
157
+ await expect(
158
+ serializePreparedSceneToGlb(texturedScene(readableTexture()), {
159
+ textureUtils: passthroughUtils(),
160
+ }),
161
+ ).rejects.toThrow('GLB export produced no data')
162
+ })
163
+ })
164
+
165
+ describe('withExportDeadline', () => {
166
+ test('passes a settled value through and clears its timer', async () => {
167
+ await expect(withExportDeadline(Promise.resolve(7), 1_000, 'GLB')).resolves.toBe(7)
168
+ })
169
+
170
+ test('names the format and the wait in the timeout error', async () => {
171
+ const pending = new Promise<never>(() => {})
172
+ await expect(withExportDeadline(pending, 10, 'USDZ')).rejects.toThrow(
173
+ /USDZ export timed out after \d+ s/,
174
+ )
175
+ })
176
+
177
+ test('an infinite deadline returns the original promise', () => {
178
+ const pending = new Promise<never>(() => {})
179
+ expect(withExportDeadline(pending, Number.POSITIVE_INFINITY, 'GLB')).toBe(pending)
180
+ })
181
+ })
182
+
183
+ describe('exportSceneToGlb', () => {
184
+ afterEach(() => {
185
+ sceneRegistry.clear()
186
+ })
187
+
188
+ test('rejects at the deadline when a FileReader inside GLTFExporter fails silently', async () => {
189
+ // The image bufferView read is the first one; a null result throws inside
190
+ // onloadend, so GLTFExporter's pending list never drains.
191
+ installExporterDom({ reads: 0, failAt: 1 })
192
+
193
+ await expect(
194
+ exportSceneToGlb(
195
+ texturedRoot(readableTexture()),
196
+ {},
197
+ {
198
+ textureUtils: passthroughUtils(),
199
+ timeoutMs: 50,
200
+ },
201
+ ),
202
+ ).rejects.toThrow('GLB export timed out')
203
+ })
204
+
205
+ test('exports a live scene through a caller-owned decompressor without disposing it', async () => {
206
+ installExporterDom({ reads: 0, failAt: Number.POSITIVE_INFINITY })
207
+ const root = new THREE.Group()
208
+ root.add(
209
+ new THREE.Mesh(
210
+ new THREE.BoxGeometry(1, 1, 1),
211
+ new THREE.MeshStandardMaterial({ map: readableTexture() }),
212
+ ),
213
+ )
214
+ const textureUtils = passthroughUtils()
215
+ let disposals = 0
216
+ textureUtils.dispose = async () => {
217
+ disposals += 1
218
+ }
219
+
220
+ const glb = await exportSceneToGlb(root, {}, { textureUtils, timeoutMs: 5_000 })
221
+
222
+ expect(new DataView(glb).getUint32(0, true)).toBe(GLB_MAGIC)
223
+ expect(disposals).toBe(0)
224
+ })
225
+
226
+ test('rejects at the deadline when a plugin bake hook never settles', async () => {
227
+ installExporterDom({ reads: 0, failAt: Number.POSITIVE_INFINITY })
228
+ const restoreRegistry = nodeRegistry._snapshot()
229
+ try {
230
+ const kind = 'test:bake-never-settles'
231
+ registerNode({
232
+ kind,
233
+ schemaVersion: 1,
234
+ schema: DoorNode,
235
+ category: 'furnish',
236
+ defaults: () => ({}) as never,
237
+ capabilities: {},
238
+ bake: 'replace',
239
+ bakeGeometryAsync: () => new Promise<THREE.Object3D>(() => {}),
240
+ } as AnyNodeDefinition)
241
+ const source = new THREE.Group()
242
+ source.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial()))
243
+ const root = new THREE.Group()
244
+ root.add(source)
245
+ sceneRegistry.nodes.set('stuck_1', source)
246
+ const nodes = {
247
+ stuck_1: { id: 'stuck_1', type: kind, visible: true },
248
+ } as unknown as Record<string, AnyNode>
249
+
250
+ await expect(
251
+ exportSceneToGlb(root, nodes, { textureUtils: passthroughUtils(), timeoutMs: 50 }),
252
+ ).rejects.toThrow('GLB export timed out')
253
+ } finally {
254
+ restoreRegistry()
255
+ }
256
+ })
257
+
258
+ test('preparation and serialisation share one decompressor', async () => {
259
+ installExporterDom({ reads: 0, failAt: Number.POSITIVE_INFINITY })
260
+ const map = new THREE.CompressedTexture([], 2, 2)
261
+ map.name = 'Color_ktx2'
262
+ const normalMap = new THREE.CompressedTexture([], 2, 2)
263
+ normalMap.name = 'NormalGL_ktx2'
264
+ const material = new THREE.MeshStandardMaterial({ map, normalMap })
265
+ // A non-unit normalScale forces the preparation pass to bake (and so
266
+ // decompress) the normal map before the exporter ever sees it.
267
+ material.normalScale.set(0.5, 0.5)
268
+ const root = new THREE.Group()
269
+ root.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material))
270
+ const textureUtils = passthroughUtils()
271
+
272
+ const glb = await exportSceneToGlb(root, {}, { textureUtils, timeoutMs: 5_000 })
273
+
274
+ expect(new DataView(glb).getUint32(0, true)).toBe(GLB_MAGIC)
275
+ expect(textureUtils.calls.map((texture) => texture.name).sort()).toEqual([
276
+ 'Color_ktx2',
277
+ 'NormalGL_ktx2',
278
+ ])
279
+ })
280
+ })