@pascal-app/editor 1.0.0 → 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 (97) hide show
  1. package/package.json +7 -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/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
@@ -1,25 +1,30 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeDefinition,
5
+ BuildingNode,
5
6
  type FloorplanGeometry,
6
7
  type GeometryContext,
8
+ LevelNode,
7
9
  loadPlugin,
8
10
  type NodeCategory,
9
11
  nodeRegistry,
10
12
  registerNode,
11
13
  } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
12
15
  import PDFDocument from 'pdfkit'
13
16
  import { z } from 'zod'
14
17
  import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
15
18
  import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
16
19
  import {
17
20
  collectFloorplanGeometry,
21
+ collectFloorplanSchedules,
18
22
  filterFloorplanExportOverlay,
19
23
  fitPlanToBox,
20
24
  isFloorplanExportAnnotationGeometry,
21
25
  isFloorplanNodeInExportScope,
22
26
  partitionFloorplanExportOverlay,
27
+ resolveExportLevels,
23
28
  resolveFloorplanExportAnnotationVisibility,
24
29
  resolveFloorplanExportNodeGeometry,
25
30
  resolveFloorplanExportPlacement,
@@ -424,6 +429,81 @@ describe('isFloorplanNodeInExportScope', () => {
424
429
  })
425
430
  })
426
431
 
432
+ describe('collectFloorplanSchedules', () => {
433
+ test('omits non-structure schedule contributors under structure scope', () => {
434
+ const restoreRegistry = nodeRegistry._snapshot()
435
+ const structureKind = 'test:structure-schedule'
436
+ const siteKind = 'test:site-schedule'
437
+ const levelId = 'level_schedules' as AnyNode['id']
438
+ const structureNodeId = 'structure_scheduled' as AnyNode['id']
439
+ const siteNodeId = 'site_scheduled' as AnyNode['id']
440
+
441
+ const scheduleFor = (title: string) => ({
442
+ id: title.toLowerCase(),
443
+ title,
444
+ columns: [{ key: 'id', label: 'ID' }],
445
+ rows: [{ id: 'row', cells: { id: '1' } }],
446
+ })
447
+
448
+ try {
449
+ nodeRegistry._reset()
450
+ registerNode({
451
+ kind: structureKind,
452
+ schemaVersion: 1,
453
+ schema: z.object({ type: z.literal(structureKind) }) as never,
454
+ category: 'structure',
455
+ defaults: () => ({}) as never,
456
+ capabilities: {},
457
+ extensions: {
458
+ 'pascal:editor/floorplan': {
459
+ schedule: () => scheduleFor('Doors'),
460
+ },
461
+ },
462
+ } as AnyNodeDefinition)
463
+ registerNode({
464
+ kind: siteKind,
465
+ schemaVersion: 1,
466
+ schema: z.object({ type: z.literal(siteKind) }) as never,
467
+ category: 'site',
468
+ defaults: () => ({}) as never,
469
+ capabilities: {},
470
+ extensions: {
471
+ 'pascal:editor/floorplan': {
472
+ schedule: () => scheduleFor('Rooms'),
473
+ },
474
+ },
475
+ } as AnyNodeDefinition)
476
+
477
+ const nodes = {
478
+ [levelId]: {
479
+ id: levelId,
480
+ type: 'level',
481
+ visible: true,
482
+ children: [structureNodeId, siteNodeId],
483
+ },
484
+ [structureNodeId]: {
485
+ id: structureNodeId,
486
+ type: structureKind,
487
+ visible: true,
488
+ },
489
+ [siteNodeId]: {
490
+ id: siteNodeId,
491
+ type: siteKind,
492
+ visible: true,
493
+ },
494
+ } as unknown as Record<string, AnyNode>
495
+
496
+ const full = collectFloorplanSchedules(nodes, levelId, 'metric', 'full')
497
+ expect(full.map((schedule) => schedule.title).sort()).toEqual(['Doors', 'Rooms'])
498
+
499
+ const structure = collectFloorplanSchedules(nodes, levelId, 'metric', 'structure')
500
+ expect(structure.map((schedule) => schedule.title)).toEqual(['Doors'])
501
+ } finally {
502
+ restoreRegistry()
503
+ }
504
+ })
505
+ })
506
+
427
507
  describe('collectFloorplanGeometry', () => {
428
508
  test('collects the active Site once below architecture with semantic context and projection', async () => {
429
509
  const restoreRegistry = nodeRegistry._snapshot()
@@ -713,3 +793,61 @@ describe('collectFloorplanGeometry', () => {
713
793
  }
714
794
  })
715
795
  })
796
+
797
+ describe('resolveExportLevels', () => {
798
+ const ground = LevelNode.parse({ id: 'level_ground', parentId: 'building_a', level: 0 })
799
+ const upper = LevelNode.parse({ id: 'level_upper', parentId: 'building_a', level: 1 })
800
+ const roof = LevelNode.parse({
801
+ id: 'level_roof',
802
+ parentId: 'building_a',
803
+ level: 2,
804
+ metadata: { role: 'roof', referenceLevelId: upper.id },
805
+ })
806
+ const attic = LevelNode.parse({
807
+ id: 'level_attic',
808
+ parentId: 'building_a',
809
+ level: 3,
810
+ metadata: { role: 'attic' },
811
+ })
812
+ const building = BuildingNode.parse({
813
+ id: 'building_a',
814
+ children: [ground.id, upper.id, roof.id, attic.id],
815
+ })
816
+ const nodes: Record<string, AnyNode> = Object.fromEntries(
817
+ [building, ground, upper, roof, attic].map((node) => [node.id, node]),
818
+ )
819
+
820
+ // The viewer store is a process-wide singleton, so an earlier test file can
821
+ // leak a selection into these tests; restore it instead of leaving ours.
822
+ const previousSelection = useViewer.getState().selection
823
+
824
+ const selectLevel = (levelId: string | null) => {
825
+ useViewer.setState({
826
+ selection: { ...previousSelection, buildingId: building.id, levelId },
827
+ } as never)
828
+ }
829
+
830
+ afterEach(() => {
831
+ useViewer.setState({ selection: previousSelection } as never)
832
+ })
833
+
834
+ test('skips a dedicated roof support level', () => {
835
+ selectLevel(ground.id)
836
+
837
+ expect(resolveExportLevels(nodes)).toEqual([
838
+ { id: ground.id, label: 'Level 0' },
839
+ { id: upper.id, label: 'Level 1' },
840
+ { id: attic.id, label: 'Level 3' },
841
+ ])
842
+ })
843
+
844
+ test('skips the roof level when it is the selected level', () => {
845
+ selectLevel(roof.id)
846
+
847
+ expect(resolveExportLevels(nodes)).toEqual([
848
+ { id: ground.id, label: 'Level 0' },
849
+ { id: upper.id, label: 'Level 1' },
850
+ { id: attic.id, label: 'Level 3' },
851
+ ])
852
+ })
853
+ })
@@ -219,7 +219,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
219
219
  wallDimensionReference,
220
220
  installedPlugins,
221
221
  )
222
- const schedules = collectFloorplanSchedules(nodes, level.id, unit)
222
+ const schedules = collectFloorplanSchedules(nodes, level.id, unit, scope)
223
223
  if (geometries.length === 0 && schedules.length === 0) continue
224
224
  const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
225
225
 
@@ -305,6 +305,7 @@ export function collectFloorplanSchedules(
305
305
  nodes: Record<string, AnyNode>,
306
306
  levelId: AnyNodeId,
307
307
  unit: 'metric' | 'imperial',
308
+ scope: FloorplanExportScope = 'full',
308
309
  ): FloorplanSchedule[] {
309
310
  const siblingsByType = new Map<string, AnyNode[]>()
310
311
  const visit = (id: AnyNodeId) => {
@@ -324,6 +325,9 @@ export function collectFloorplanSchedules(
324
325
  for (const [kind, definition] of nodeRegistry.entries()) {
325
326
  const scheduleContribution = getFloorplanNodeExtension(definition)?.schedule
326
327
  if (!scheduleContribution) continue
328
+ // Same scope gate as geometry: a structure-only PDF must not list rooms
329
+ // or other site-category contributors whose plan geometry was excluded.
330
+ if (!isFloorplanNodeInExportScope(definition, scope)) continue
327
331
  const siblings = siblingsByType.get(kind) ?? []
328
332
  const schedule = scheduleContribution({ siblings, nodes, levelId, unit })
329
333
  if (schedule && schedule.rows.length > 0) schedules.push(schedule)
@@ -1021,9 +1025,10 @@ function combineGeometryList(
1021
1025
  * Levels to export, ordered bottom-to-top. The active building (the building
1022
1026
  * owning the selected level, or the first one found) contributes all of its
1023
1027
  * level children; if there is no building wrapper we fall back to the single
1024
- * resolved level.
1028
+ * resolved level. Roof support levels are excluded: they are not occupied
1029
+ * stories, so they do not get a floor-plan page.
1025
1030
  */
1026
- function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
1031
+ export function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
1027
1032
  const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined
1028
1033
  const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes)
1029
1034
  if (!activeLevelId) return []
@@ -1038,6 +1043,7 @@ function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
1038
1043
  levelNodes = node ? [node] : []
1039
1044
  }
1040
1045
 
1046
+ levelNodes = levelNodes.filter((n) => n.metadata.role !== 'roof')
1041
1047
  levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b))
1042
1048
  return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) }))
1043
1049
  }
@@ -14,6 +14,8 @@ export type FloorplanViewState = {
14
14
  highlighted: boolean
15
15
  hovered: boolean
16
16
  moving: boolean
17
+ focusedUnitId?: string
18
+ focusedUnitMemberIds?: readonly string[]
17
19
  palette: FloorplanPalette | undefined
18
20
  }
19
21
 
@@ -66,6 +68,8 @@ export function buildFloorplanContext(
66
68
  highlighted: viewState.highlighted,
67
69
  hovered: viewState.hovered,
68
70
  moving: viewState.moving,
71
+ focusedUnitId: viewState.focusedUnitId,
72
+ focusedUnitMemberIds: viewState.focusedUnitMemberIds,
69
73
  palette: viewState.palette,
70
74
  }
71
75
  : undefined,
@@ -8,6 +8,11 @@ import {
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
10
  import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
11
+ import {
12
+ surfaceAttachmentId,
13
+ surfaceAttachmentUpdates,
14
+ updateSurfaceNode,
15
+ } from './surface-attachment'
11
16
 
12
17
  function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
13
18
  return {
@@ -131,16 +136,19 @@ export function commitFreshPlacementSubtree(
131
136
  parentId,
132
137
  })
133
138
 
139
+ const surfaceId = surfaceAttachmentId(subtree.root)
134
140
  const temporal = useScene.temporal.getState()
135
141
  const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
136
142
  if (wasTracking) temporal.pause()
143
+ updateSurfaceNode(rootId, {}, null)
137
144
  useScene.getState().deleteNode(rootId)
138
145
  temporal.resume()
139
- useScene
140
- .getState()
141
- .createNodes(
142
- cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
143
- )
146
+ useScene.getState().applyNodeChanges({
147
+ create: cloned.nodes.map((node, index) =>
148
+ index === 0 && parentId ? { node, parentId } : { node },
149
+ ),
150
+ update: surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceId),
151
+ })
144
152
  if (!wasTracking) temporal.pause()
145
153
 
146
154
  return cloned.rootId
@@ -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
+ })