@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -3,11 +3,24 @@ import {
3
3
  type AnyNodeId,
4
4
  cloneNodesInto,
5
5
  collectSubtree,
6
+ createSceneApi,
6
7
  type DuplicableConfig,
8
+ getSurfaceProvider,
7
9
  nodeRegistry,
10
+ resolveSurfacePlacement,
11
+ type SurfaceRejectReason,
8
12
  useScene,
9
13
  } from '@pascal-app/core'
14
+ import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
15
+ import useInteractionScope from '../store/use-interaction-scope'
16
+ import usePlacementPreview from '../store/use-placement-preview'
10
17
  import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
18
+ import {
19
+ surfaceAttachmentId,
20
+ surfaceAttachmentUpdates,
21
+ surfaceFramePose,
22
+ updateSurfaceNode,
23
+ } from './surface-attachment'
11
24
 
12
25
  function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
13
26
  return {
@@ -27,7 +40,13 @@ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
27
40
  }
28
41
 
29
42
  export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
30
- return duplicableConfigFor(node)?.subtree === true
43
+ // A surface-local root needs the same attachment lifecycle even without descendants.
44
+ if (surfaceAttachmentId(node) !== null) return true
45
+ const policy = duplicableConfigFor(node)?.subtree
46
+ return (
47
+ policy === true ||
48
+ (policy === 'with-children' && 'children' in node && node.children.length > 0)
49
+ )
31
50
  }
32
51
 
33
52
  /**
@@ -94,54 +113,175 @@ export function createFreshPlacementSubtree(
94
113
  parentId,
95
114
  })
96
115
 
97
- useScene
98
- .getState()
99
- .createNodes(
100
- cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
101
- )
116
+ scene.applyNodeChanges({
117
+ create: cloned.nodes.map((node, index) =>
118
+ index === 0 && parentId ? { node, parentId } : { node },
119
+ ),
120
+ update: surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceAttachmentId(subtree.root)),
121
+ })
102
122
 
123
+ const created = useScene.getState().nodes[cloned.rootId]
124
+ if (!created) return null
125
+ useInteractionScope.getState().noteSubtreeCreation(created)
103
126
  return cloned.rootId
104
127
  }
105
128
 
129
+ export function discardFreshPlacementSubtree(rootId: AnyNodeId): void {
130
+ const scene = useScene.getState()
131
+ if (!scene.nodes[rootId]) return
132
+ scene.applyNodeChanges({
133
+ delete: [rootId],
134
+ update: surfaceAttachmentUpdates(rootId, null, null),
135
+ })
136
+ }
137
+
138
+ function namedSurfaceRejection(
139
+ root: AnyNode,
140
+ surfaceId: string | null,
141
+ ): SurfaceRejectReason | null {
142
+ if (surfaceId === null) return null
143
+ const scene = createSceneApi(useScene)
144
+ const host = root.parentId ? scene.get(root.parentId as AnyNodeId) : undefined
145
+ if (!host || !('position' in root) || !Array.isArray(root.rotation)) return 'no-surface'
146
+ const surface = getSurfaceProvider(host)
147
+ .surfaces?.(host, { scene })
148
+ .find((s) => s.id === surfaceId)
149
+ if (!surface) return 'no-surface'
150
+ const pose = surfaceFramePose(
151
+ root.parentId,
152
+ surfaceId,
153
+ root as {
154
+ position: [number, number, number]
155
+ rotation: [number, number, number]
156
+ },
157
+ false,
158
+ )
159
+ const capabilities = nodeRegistry.get(root.type)?.capabilities
160
+ const evaluated = isProceduralItem(root) ? evaluateRecipe(root.recipe, root.parameters) : null
161
+ const bounds =
162
+ capabilities?.dragBounds?.(root, scene.nodes()) ??
163
+ (evaluated
164
+ ? {
165
+ size: evaluated.dimensions,
166
+ center: evaluated.min.map((v, i) => (v + evaluated.max[i]!) / 2) as [
167
+ number,
168
+ number,
169
+ number,
170
+ ],
171
+ }
172
+ : undefined)
173
+ const size = bounds?.size ??
174
+ capabilities?.floorPlaced?.footprint?.(root, { nodes: scene.nodes() }).dimensions ?? [0, 0, 0]
175
+ const center = bounds?.center
176
+ const localBounds = center
177
+ ? {
178
+ min: center.map((value, axis) => value - size[axis]! / 2) as [number, number, number],
179
+ max: center.map((value, axis) => value + size[axis]! / 2) as [number, number, number],
180
+ }
181
+ : undefined
182
+ let reason: SurfaceRejectReason | null = null
183
+ const placement = resolveSurfacePlacement({
184
+ host,
185
+ surface,
186
+ childKind: root.type,
187
+ childId: root.id,
188
+ childFootprint: {
189
+ size,
190
+ rotationY: pose.rotation[1],
191
+ rotation: pose.rotation,
192
+ localBounds,
193
+ },
194
+ hit: { point: pose.position, normalWorldY: 1 },
195
+ origin: pose.position,
196
+ scene,
197
+ onReject: (value) => {
198
+ reason = value
199
+ },
200
+ })
201
+ if (!placement) return reason ?? 'no-surface'
202
+ return null
203
+ }
204
+
106
205
  /**
107
- * Finalises a fresh catalog/duplicate draft as a single undoable creation.
108
- *
109
- * Fresh drafts already exist in the scene so renderers and move tools can
110
- * preview real geometry. On commit we delete that draft while history is
111
- * paused, then create a clean clone at the final cursor position with history
112
- * resumed. Undo therefore removes the placed node instead of resurrecting the
113
- * hidden draft at its origin.
206
+ * Replace the draft in one validated write. History already excludes fresh
207
+ * subtrees, so this records one creation without first deleting the preview.
114
208
  */
115
209
  export function commitFreshPlacementSubtree(
116
210
  rootId: AnyNodeId,
117
211
  rootPatch: Partial<AnyNode>,
212
+ onReject?: (reason: SurfaceRejectReason) => void,
118
213
  ): AnyNodeId | null {
119
214
  const scene = useScene.getState()
120
215
  const subtree = collectSubtree(scene.nodes, rootId)
121
- if (!subtree) return null
216
+ if (!subtree || scene.readOnly) return null
122
217
 
123
- const root = cleanPlacementMetadata({
124
- ...subtree.root,
125
- ...rootPatch,
126
- } as AnyNode)
218
+ const root = cleanPlacementMetadata({ ...subtree.root, ...rootPatch } as AnyNode)
219
+ const surfaceId =
220
+ root.parentId === subtree.root.parentId ? surfaceAttachmentId(subtree.root) : null
221
+ const rejection = namedSurfaceRejection(root, surfaceId)
222
+ if (rejection) {
223
+ onReject?.(rejection)
224
+ return null
225
+ }
127
226
  const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
128
227
  const parentId = parentIdOf(root)
129
- const cloned = cloneNodesInto([root, ...descendants], {
130
- rootId,
131
- parentId,
132
- })
228
+ const cloned = cloneNodesInto([root, ...descendants], { rootId, parentId })
229
+ const updates = surfaceAttachmentUpdates(rootId, null, null)
230
+ for (const update of surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceId)) {
231
+ const attachments = { ...(update.data as { attachments: Record<string, string> }).attachments }
232
+ delete attachments[rootId]
233
+ const previous = updates.findIndex((entry) => entry.id === update.id)
234
+ const merged = { id: update.id, data: { attachments } }
235
+ if (previous === -1) updates.push(merged)
236
+ else updates[previous] = merged
237
+ }
133
238
 
239
+ const scope = useInteractionScope.getState()
240
+ const factoryCreated =
241
+ scope.ownedSubtree?.creation.rootId === rootId || scope.pendingSubtree?.rootId === rootId
134
242
  const temporal = useScene.temporal.getState()
135
- const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
136
- if (wasTracking) temporal.pause()
137
- useScene.getState().deleteNode(rootId)
138
- temporal.resume()
139
- useScene
140
- .getState()
141
- .createNodes(
142
- cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
143
- )
144
- if (!wasTracking) temporal.pause()
145
-
243
+ const wasTracking = temporal.isTracking
244
+ if (!factoryCreated && descendants.length === 0 && surfaceId === null) {
245
+ // Preserve the established root-only lifecycle for placements outside the subtree factory.
246
+ if (wasTracking) temporal.pause()
247
+ updateSurfaceNode(rootId, {}, null)
248
+ useScene.getState().deleteNode(rootId)
249
+ temporal.resume()
250
+ useScene.getState().applyNodeChanges({
251
+ create: cloned.nodes.map((node, index) =>
252
+ index === 0 && parentId ? { node, parentId } : { node },
253
+ ),
254
+ update: updates,
255
+ })
256
+ if (!wasTracking) temporal.pause()
257
+ } else {
258
+ temporal.resume()
259
+ try {
260
+ // applyNodeChanges validates the complete proposed graph before publishing any part of it.
261
+ scene.applyNodeChanges({
262
+ delete: [rootId],
263
+ create: cloned.nodes.map((node, index) =>
264
+ index === 0 && parentId ? { node, parentId } : { node },
265
+ ),
266
+ update: updates,
267
+ })
268
+ for (const node of [subtree.root, ...subtree.descendants]) scene.clearDirty(node.id)
269
+ } catch (error) {
270
+ // Zustand publishes before notifying subscribers. A subscriber error must restore
271
+ // the draft and its ownership/history, never masquerade as a placement refusal.
272
+ temporal.pause()
273
+ try {
274
+ if (useScene.getState() !== scene) useScene.setState(scene, true)
275
+ } finally {
276
+ useInteractionScope.setState(scope)
277
+ useScene.temporal.setState(temporal)
278
+ }
279
+ throw error
280
+ } finally {
281
+ if (!wasTracking) temporal.pause()
282
+ }
283
+ }
284
+ useInteractionScope.getState().finishSubtree(rootId)
285
+ if (usePlacementPreview.getState().node?.id === rootId) usePlacementPreview.getState().clear()
146
286
  return cloned.rootId
147
287
  }
@@ -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
+ })