@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
@@ -27,6 +27,7 @@ import {
27
27
  prepareSceneForExportAsync,
28
28
  writeTextureReferenceExtras,
29
29
  } from './glb-export'
30
+ import { createUsdzScene } from './portable-export'
30
31
 
31
32
  // The reference module reads the storage origin lazily on first use, so
32
33
  // setting the env here (before any validation call) pins it for the file.
@@ -99,6 +100,29 @@ describe('prepareSceneForExport', () => {
99
100
  expect(material.color.getHexString()).toBe('cc3300')
100
101
  })
101
102
 
103
+ test('clones userData that holds runtime resources and functions', () => {
104
+ const root = new THREE.Group()
105
+ root.name = 'scene-renderer'
106
+ const effectMaterial = new THREE.MeshStandardMaterial()
107
+ effectMaterial.addEventListener('dispose', () => {})
108
+ const pool = meshWithNodeMaterial(nodeMaterial())
109
+ pool.userData = { waterEffect: { material: effectMaterial, onFrame: () => {} } }
110
+ const overlay = meshWithNodeMaterial(nodeMaterial())
111
+ overlay.userData = { pascalExport: 'strip', mesh: pool }
112
+ root.add(pool, overlay)
113
+ expect(() => structuredClone(pool.userData)).toThrow()
114
+
115
+ const { scene } = prepareSceneForExport(root, {})
116
+
117
+ const meshes: THREE.Mesh[] = []
118
+ scene.traverse((object) => {
119
+ if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
120
+ })
121
+ expect(meshes).toHaveLength(1)
122
+ expect(meshes[0]?.userData.waterEffect).toBeUndefined()
123
+ expect(pool.userData.waterEffect.material).toBe(effectMaterial)
124
+ })
125
+
102
126
  test('replaces only the cloned registered subtree with bake-only geometry', () => {
103
127
  const restoreRegistry = nodeRegistry._snapshot()
104
128
  try {
@@ -566,19 +590,64 @@ describe('prepareSceneForExport', () => {
566
590
  expect(animations).toHaveLength(0)
567
591
  })
568
592
 
569
- test('inherits hidden Site visibility for detached declared children and their descendants', async () => {
593
+ test('keeps the buildings on a hidden Site and drops only the Site ground', () => {
594
+ // A layout authored outside the editor can hide the root Site (the
595
+ // renderer ignores that flag) while every node on it stays visible. The
596
+ // Site's own flag must stop at the Site: it is the parcel reference, not
597
+ // a container the building inherits visibility from.
598
+ const root = new THREE.Group()
599
+ const siteGroup = new THREE.Group()
600
+ const siteGround = meshWithNodeMaterial(nodeMaterial())
601
+ const buildingGroup = new THREE.Group()
602
+ const levelGroup = new THREE.Group()
603
+ const itemGroup = new THREE.Group()
604
+ itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
605
+ levelGroup.add(itemGroup)
606
+ buildingGroup.add(levelGroup)
607
+ siteGroup.add(buildingGroup, siteGround)
608
+ root.add(siteGroup)
609
+
610
+ const siteId = 'site_hidden'
611
+ const buildingId = 'building_on_hidden_site'
612
+ const levelId = 'level_on_hidden_site'
613
+ const itemId = 'item_on_hidden_site'
614
+ sceneRegistry.nodes.set(siteId, siteGroup)
615
+ sceneRegistry.nodes.set(buildingId, buildingGroup)
616
+ sceneRegistry.nodes.set(levelId, levelGroup)
617
+ sceneRegistry.nodes.set(itemId, itemGroup)
618
+ const node = (id: string, type: string, parentId: string | null, visible: boolean) =>
619
+ ({ object: 'node', id, type, parentId, visible }) as unknown as AnyNode
620
+ const nodes: Record<string, AnyNode> = {
621
+ [siteId]: node(siteId, 'site', null, false),
622
+ [buildingId]: node(buildingId, 'building', siteId, true),
623
+ [levelId]: node(levelId, 'level', buildingId, true),
624
+ [itemId]: node(itemId, 'item', levelId, true),
625
+ }
626
+
627
+ const { scene } = prepareSceneForExport(root, nodes)
628
+
629
+ const meshes: THREE.Mesh[] = []
630
+ scene.traverse((object) => {
631
+ if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
632
+ })
633
+ expect(scene.getObjectByName(buildingId)).toBeDefined()
634
+ expect(scene.getObjectByName(itemId)).toBeDefined()
635
+ expect(meshes).toHaveLength(1)
636
+ expect(meshes[0]?.parent?.name).toBe(itemId)
637
+ })
638
+
639
+ test('keeps the nodes a hidden Site hosts, declared or detached, unless hidden themselves', async () => {
570
640
  const restoreRegistry = nodeRegistry._snapshot()
571
641
  try {
572
- const kind = 'test:detached-site-visibility'
642
+ const kind = 'test:hidden-site-host'
573
643
  const childId = 'detached_site_child'
574
644
  const descendantId = 'detached_site_descendant'
575
645
  const explicitId = 'explicit_site_child'
576
- const unownedId = 'unowned_site_child'
646
+ const hiddenChildId = 'hidden_site_child'
577
647
  const hiddenSite = SiteNode.parse({
578
648
  visible: false,
579
- children: [childId, explicitId],
649
+ children: [childId, explicitId, hiddenChildId],
580
650
  })
581
- const visibleSite = SiteNode.parse({ visible: true })
582
651
  registerNode({
583
652
  kind,
584
653
  schemaVersion: 1,
@@ -591,17 +660,19 @@ describe('prepareSceneForExport', () => {
591
660
  } as AnyNodeDefinition)
592
661
  const nodes = {
593
662
  [hiddenSite.id]: hiddenSite,
594
- [visibleSite.id]: visibleSite,
595
663
  [childId]: { id: childId, type: kind, parentId: null, visible: true },
596
664
  [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 },
665
+ [explicitId]: { id: explicitId, type: kind, parentId: hiddenSite.id, visible: true },
666
+ [hiddenChildId]: { id: hiddenChildId, type: kind, parentId: hiddenSite.id, visible: false },
599
667
  } as unknown as Record<string, AnyNode>
600
- const allIds = [childId, descendantId, explicitId, unownedId]
668
+ const allIds = [childId, descendantId, explicitId, hiddenChildId]
601
669
  const root = new THREE.Group()
602
- for (const id of [hiddenSite.id, visibleSite.id, ...allIds]) {
670
+ const siteObject = new THREE.Group()
671
+ root.add(siteObject)
672
+ sceneRegistry.nodes.set(hiddenSite.id, siteObject)
673
+ for (const id of allIds) {
603
674
  const object = new THREE.Group()
604
- root.add(object)
675
+ siteObject.add(object)
605
676
  sceneRegistry.nodes.set(id, object)
606
677
  }
607
678
  const exportedIds = async (onlyVisible?: boolean) => {
@@ -613,12 +684,8 @@ describe('prepareSceneForExport', () => {
613
684
  }
614
685
  }
615
686
 
616
- expect(await exportedIds()).toEqual([explicitId, unownedId])
687
+ expect(await exportedIds()).toEqual([childId, descendantId, explicitId])
617
688
  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
689
  } finally {
623
690
  restoreRegistry()
624
691
  }
@@ -1685,6 +1752,21 @@ async function withCanvasCapture(
1685
1752
  putImageData: (image: ImageData) => {
1686
1753
  pixelsByCanvas.set(canvas, new Uint8ClampedArray(image.data))
1687
1754
  },
1755
+ fillRect: (_x: number, _y: number, width: number, height: number) => {
1756
+ pixelsByCanvas.set(canvas, new Uint8ClampedArray(width * height * 4).fill(255))
1757
+ },
1758
+ drawImage: (image: HTMLCanvasElement) => {
1759
+ const pixels = pixelsByCanvas.get(image)
1760
+ if (!pixels) throw new Error('Source canvas has no captured pixels')
1761
+ pixelsByCanvas.set(canvas, new Uint8ClampedArray(pixels))
1762
+ },
1763
+ getImageData: (_x: number, _y: number, width: number, height: number) =>
1764
+ ({
1765
+ colorSpace: 'srgb',
1766
+ data: pixelsByCanvas.get(canvas) ?? new Uint8ClampedArray(width * height * 4),
1767
+ height,
1768
+ width,
1769
+ }) as ImageData,
1688
1770
  }),
1689
1771
  } as unknown as HTMLCanvasElement
1690
1772
  return canvas
@@ -1702,3 +1784,170 @@ async function withCanvasCapture(
1702
1784
  else delete globals.document
1703
1785
  }
1704
1786
  }
1787
+
1788
+ describe('normal maps in async export preparation', () => {
1789
+ test('decompresses a compressed normal map through the provided decompressor before baking its scale', async () => {
1790
+ await withCanvasCapture(async () => {
1791
+ const root = new THREE.Group()
1792
+ const compressed = new THREE.CompressedTexture([], 2, 2)
1793
+ compressed.name = 'NormalGL_3a132c5f'
1794
+ const material = nodeMaterial({ normalMap: compressed }) as THREE.MeshStandardMaterial
1795
+ material.normalScale.set(0.5, 0.5)
1796
+ root.add(meshWithNodeMaterial(material))
1797
+ let calls = 0
1798
+
1799
+ const prepared = await prepareSceneForExportAsync(
1800
+ root,
1801
+ {},
1802
+ {
1803
+ decompressTexture: async (texture) => {
1804
+ calls += 1
1805
+ expect(texture.name).toBe('NormalGL_3a132c5f')
1806
+ return new THREE.DataTexture(
1807
+ new Uint8Array(
1808
+ [128, 128, 255, 255].concat(
1809
+ [128, 128, 255, 255],
1810
+ [128, 128, 255, 255],
1811
+ [128, 128, 255, 255],
1812
+ ),
1813
+ ),
1814
+ 2,
1815
+ 2,
1816
+ )
1817
+ },
1818
+ },
1819
+ )
1820
+
1821
+ const exported = (prepared.scene.children[0] as THREE.Mesh)
1822
+ .material as THREE.MeshStandardMaterial
1823
+ expect(calls).toBe(1)
1824
+ expect((exported.normalMap as { isCanvasTexture?: boolean }).isCanvasTexture).toBe(true)
1825
+ expect(exported.normalScale.toArray()).toEqual([1, 1])
1826
+ expect(material.normalMap).toBe(compressed)
1827
+ expect(material.normalScale.toArray()).toEqual([0.5, 0.5])
1828
+ })
1829
+ })
1830
+
1831
+ test('reference mode keeps a stamped normal-map placeholder and its scale for the packer', async () => {
1832
+ await withCanvasCapture(async () => {
1833
+ const root = new THREE.Group()
1834
+ const stamped = new THREE.CompressedTexture([], 4, 4)
1835
+ stamped.userData.pascalTextureRef = {
1836
+ v: 1,
1837
+ kind: 'library-material',
1838
+ src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_normal_512.ktx2`,
1839
+ map: 'normal',
1840
+ colorSpace: 'linear',
1841
+ }
1842
+ const material = nodeMaterial({ normalMap: stamped }) as THREE.MeshStandardMaterial
1843
+ material.normalScale.set(1, -1)
1844
+ root.add(meshWithNodeMaterial(material))
1845
+
1846
+ const prepared = await prepareSceneForExportAsync(
1847
+ root,
1848
+ {},
1849
+ {
1850
+ textures: 'reference',
1851
+ purpose: 'viewer',
1852
+ decompressTexture: async () => {
1853
+ throw new Error('by-reference placeholders must not be decompressed')
1854
+ },
1855
+ },
1856
+ )
1857
+
1858
+ const exported = (prepared.scene.children[0] as THREE.Mesh)
1859
+ .material as THREE.MeshStandardMaterial
1860
+ expect(exported.normalMap?.userData.pascalTextureRef).toEqual(
1861
+ stamped.userData.pascalTextureRef,
1862
+ )
1863
+ expect(exported.normalScale.toArray()).toEqual([1, -1])
1864
+ })
1865
+ })
1866
+ })
1867
+
1868
+ describe('portable glass', () => {
1869
+ test('see-through untextured surfaces become transmission glass in portable exports only', async () => {
1870
+ const root = new THREE.Group()
1871
+ const glass = new MeshStandardNodeMaterial({
1872
+ color: '#3d9ed4',
1873
+ transparent: true,
1874
+ opacity: 0.3,
1875
+ roughness: 0.1,
1876
+ })
1877
+ const tintedPlastic = new MeshStandardNodeMaterial({
1878
+ color: '#3d9ed4',
1879
+ transparent: true,
1880
+ opacity: 0.8,
1881
+ })
1882
+ root.add(meshWithNodeMaterial(glass), meshWithNodeMaterial(tintedPlastic))
1883
+
1884
+ const portable = await prepareSceneForExportAsync(root, {})
1885
+ const [exportedGlass, exportedPlastic] = portable.scene.children.map(
1886
+ (child) => (child as THREE.Mesh).material as THREE.MeshPhysicalMaterial,
1887
+ )
1888
+ expect(exportedGlass!.isMeshPhysicalMaterial).toBe(true)
1889
+ expect(exportedGlass!.transmission).toBe(1)
1890
+ expect(exportedGlass!.transparent).toBe(false)
1891
+ expect(exportedGlass!.opacity).toBe(1)
1892
+ expect(exportedGlass!.roughness).toBeCloseTo(0.1)
1893
+ // 30% authored opacity keeps 30% of the (linear) tint.
1894
+ const tint = new THREE.Color('#3d9ed4')
1895
+ expect(exportedGlass!.color.r).toBeCloseTo(tint.r + (1 - tint.r) * 0.7, 4)
1896
+ expect(exportedGlass!.color.b).toBeCloseTo(tint.b + (1 - tint.b) * 0.7, 4)
1897
+ expect(exportedPlastic!.isMeshPhysicalMaterial).toBeUndefined()
1898
+ expect(exportedPlastic!.transparent).toBe(true)
1899
+
1900
+ const viewer = prepareSceneForExport(root, {}, { purpose: 'viewer' })
1901
+ const kept = (viewer.scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial
1902
+ expect((kept as { isMeshPhysicalMaterial?: boolean }).isMeshPhysicalMaterial).toBeUndefined()
1903
+ expect(kept.transparent).toBe(true)
1904
+ expect(kept.opacity).toBeCloseTo(0.3)
1905
+
1906
+ // USDZ has no transmission: the same prep hands glass its opacity back.
1907
+ const usdz = createUsdzScene(portable.scene)
1908
+ const usdzGlass = (usdz.children[0] as THREE.Mesh).material as THREE.MeshPhysicalMaterial
1909
+ expect(usdzGlass.transmission).toBe(0)
1910
+ expect(usdzGlass.transparent).toBe(true)
1911
+ expect(usdzGlass.opacity).toBeCloseTo(0.3)
1912
+ expect(exportedGlass!.transmission).toBe(1)
1913
+ })
1914
+ })
1915
+
1916
+ describe('portable clips', () => {
1917
+ function swingDoorScene() {
1918
+ const root = new THREE.Group()
1919
+ const doorGroup = new THREE.Group()
1920
+ const leaf = new THREE.Group()
1921
+ leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
1922
+ leaf.add(meshWithNodeMaterial(nodeMaterial()))
1923
+ doorGroup.add(leaf)
1924
+ root.add(doorGroup)
1925
+ const doorId = 'door_portable'
1926
+ sceneRegistry.nodes.set(doorId, doorGroup)
1927
+ const nodes: Record<string, AnyNode> = {
1928
+ [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode,
1929
+ }
1930
+ return { root, nodes, doorId }
1931
+ }
1932
+
1933
+ test('GLB downloads keep door clips when asked, USDZ passes none', async () => {
1934
+ const { root, nodes, doorId } = swingDoorScene()
1935
+
1936
+ const glb = await prepareSceneForExportAsync(root, nodes, { animations: 'keep' })
1937
+ expect(glb.animations).toHaveLength(1)
1938
+ expect(glb.animations[0]!.name).toBe(`${doorId}: open`)
1939
+ const track = glb.animations[0]!.tracks[0]!
1940
+ const targetUuid = track.name.split('.')[0]
1941
+ expect(glb.scene.getObjectByProperty('uuid', targetUuid)).toBeDefined()
1942
+ expect(glb.scene.getObjectByProperty('name', doorId)?.userData.clips).toEqual([
1943
+ `${doorId}: open`,
1944
+ ])
1945
+
1946
+ const usdz = await prepareSceneForExportAsync(root, nodes, { animations: 'none' })
1947
+ expect(usdz.animations).toEqual([])
1948
+ expect(usdz.scene.getObjectByProperty('name', doorId)?.userData.clips).toBeUndefined()
1949
+
1950
+ const legacy = await prepareSceneForExportAsync(root, nodes)
1951
+ expect(legacy.animations).toEqual([])
1952
+ })
1953
+ })