@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
@@ -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 {
@@ -1685,6 +1709,21 @@ async function withCanvasCapture(
1685
1709
  putImageData: (image: ImageData) => {
1686
1710
  pixelsByCanvas.set(canvas, new Uint8ClampedArray(image.data))
1687
1711
  },
1712
+ fillRect: (_x: number, _y: number, width: number, height: number) => {
1713
+ pixelsByCanvas.set(canvas, new Uint8ClampedArray(width * height * 4).fill(255))
1714
+ },
1715
+ drawImage: (image: HTMLCanvasElement) => {
1716
+ const pixels = pixelsByCanvas.get(image)
1717
+ if (!pixels) throw new Error('Source canvas has no captured pixels')
1718
+ pixelsByCanvas.set(canvas, new Uint8ClampedArray(pixels))
1719
+ },
1720
+ getImageData: (_x: number, _y: number, width: number, height: number) =>
1721
+ ({
1722
+ colorSpace: 'srgb',
1723
+ data: pixelsByCanvas.get(canvas) ?? new Uint8ClampedArray(width * height * 4),
1724
+ height,
1725
+ width,
1726
+ }) as ImageData,
1688
1727
  }),
1689
1728
  } as unknown as HTMLCanvasElement
1690
1729
  return canvas
@@ -1702,3 +1741,170 @@ async function withCanvasCapture(
1702
1741
  else delete globals.document
1703
1742
  }
1704
1743
  }
1744
+
1745
+ describe('normal maps in async export preparation', () => {
1746
+ test('decompresses a compressed normal map through the provided decompressor before baking its scale', async () => {
1747
+ await withCanvasCapture(async () => {
1748
+ const root = new THREE.Group()
1749
+ const compressed = new THREE.CompressedTexture([], 2, 2)
1750
+ compressed.name = 'NormalGL_3a132c5f'
1751
+ const material = nodeMaterial({ normalMap: compressed }) as THREE.MeshStandardMaterial
1752
+ material.normalScale.set(0.5, 0.5)
1753
+ root.add(meshWithNodeMaterial(material))
1754
+ let calls = 0
1755
+
1756
+ const prepared = await prepareSceneForExportAsync(
1757
+ root,
1758
+ {},
1759
+ {
1760
+ decompressTexture: async (texture) => {
1761
+ calls += 1
1762
+ expect(texture.name).toBe('NormalGL_3a132c5f')
1763
+ return new THREE.DataTexture(
1764
+ new Uint8Array(
1765
+ [128, 128, 255, 255].concat(
1766
+ [128, 128, 255, 255],
1767
+ [128, 128, 255, 255],
1768
+ [128, 128, 255, 255],
1769
+ ),
1770
+ ),
1771
+ 2,
1772
+ 2,
1773
+ )
1774
+ },
1775
+ },
1776
+ )
1777
+
1778
+ const exported = (prepared.scene.children[0] as THREE.Mesh)
1779
+ .material as THREE.MeshStandardMaterial
1780
+ expect(calls).toBe(1)
1781
+ expect((exported.normalMap as { isCanvasTexture?: boolean }).isCanvasTexture).toBe(true)
1782
+ expect(exported.normalScale.toArray()).toEqual([1, 1])
1783
+ expect(material.normalMap).toBe(compressed)
1784
+ expect(material.normalScale.toArray()).toEqual([0.5, 0.5])
1785
+ })
1786
+ })
1787
+
1788
+ test('reference mode keeps a stamped normal-map placeholder and its scale for the packer', async () => {
1789
+ await withCanvasCapture(async () => {
1790
+ const root = new THREE.Group()
1791
+ const stamped = new THREE.CompressedTexture([], 4, 4)
1792
+ stamped.userData.pascalTextureRef = {
1793
+ v: 1,
1794
+ kind: 'library-material',
1795
+ src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_normal_512.ktx2`,
1796
+ map: 'normal',
1797
+ colorSpace: 'linear',
1798
+ }
1799
+ const material = nodeMaterial({ normalMap: stamped }) as THREE.MeshStandardMaterial
1800
+ material.normalScale.set(1, -1)
1801
+ root.add(meshWithNodeMaterial(material))
1802
+
1803
+ const prepared = await prepareSceneForExportAsync(
1804
+ root,
1805
+ {},
1806
+ {
1807
+ textures: 'reference',
1808
+ purpose: 'viewer',
1809
+ decompressTexture: async () => {
1810
+ throw new Error('by-reference placeholders must not be decompressed')
1811
+ },
1812
+ },
1813
+ )
1814
+
1815
+ const exported = (prepared.scene.children[0] as THREE.Mesh)
1816
+ .material as THREE.MeshStandardMaterial
1817
+ expect(exported.normalMap?.userData.pascalTextureRef).toEqual(
1818
+ stamped.userData.pascalTextureRef,
1819
+ )
1820
+ expect(exported.normalScale.toArray()).toEqual([1, -1])
1821
+ })
1822
+ })
1823
+ })
1824
+
1825
+ describe('portable glass', () => {
1826
+ test('see-through untextured surfaces become transmission glass in portable exports only', async () => {
1827
+ const root = new THREE.Group()
1828
+ const glass = new MeshStandardNodeMaterial({
1829
+ color: '#3d9ed4',
1830
+ transparent: true,
1831
+ opacity: 0.3,
1832
+ roughness: 0.1,
1833
+ })
1834
+ const tintedPlastic = new MeshStandardNodeMaterial({
1835
+ color: '#3d9ed4',
1836
+ transparent: true,
1837
+ opacity: 0.8,
1838
+ })
1839
+ root.add(meshWithNodeMaterial(glass), meshWithNodeMaterial(tintedPlastic))
1840
+
1841
+ const portable = await prepareSceneForExportAsync(root, {})
1842
+ const [exportedGlass, exportedPlastic] = portable.scene.children.map(
1843
+ (child) => (child as THREE.Mesh).material as THREE.MeshPhysicalMaterial,
1844
+ )
1845
+ expect(exportedGlass!.isMeshPhysicalMaterial).toBe(true)
1846
+ expect(exportedGlass!.transmission).toBe(1)
1847
+ expect(exportedGlass!.transparent).toBe(false)
1848
+ expect(exportedGlass!.opacity).toBe(1)
1849
+ expect(exportedGlass!.roughness).toBeCloseTo(0.1)
1850
+ // 30% authored opacity keeps 30% of the (linear) tint.
1851
+ const tint = new THREE.Color('#3d9ed4')
1852
+ expect(exportedGlass!.color.r).toBeCloseTo(tint.r + (1 - tint.r) * 0.7, 4)
1853
+ expect(exportedGlass!.color.b).toBeCloseTo(tint.b + (1 - tint.b) * 0.7, 4)
1854
+ expect(exportedPlastic!.isMeshPhysicalMaterial).toBeUndefined()
1855
+ expect(exportedPlastic!.transparent).toBe(true)
1856
+
1857
+ const viewer = prepareSceneForExport(root, {}, { purpose: 'viewer' })
1858
+ const kept = (viewer.scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial
1859
+ expect((kept as { isMeshPhysicalMaterial?: boolean }).isMeshPhysicalMaterial).toBeUndefined()
1860
+ expect(kept.transparent).toBe(true)
1861
+ expect(kept.opacity).toBeCloseTo(0.3)
1862
+
1863
+ // USDZ has no transmission: the same prep hands glass its opacity back.
1864
+ const usdz = createUsdzScene(portable.scene)
1865
+ const usdzGlass = (usdz.children[0] as THREE.Mesh).material as THREE.MeshPhysicalMaterial
1866
+ expect(usdzGlass.transmission).toBe(0)
1867
+ expect(usdzGlass.transparent).toBe(true)
1868
+ expect(usdzGlass.opacity).toBeCloseTo(0.3)
1869
+ expect(exportedGlass!.transmission).toBe(1)
1870
+ })
1871
+ })
1872
+
1873
+ describe('portable clips', () => {
1874
+ function swingDoorScene() {
1875
+ const root = new THREE.Group()
1876
+ const doorGroup = new THREE.Group()
1877
+ const leaf = new THREE.Group()
1878
+ leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
1879
+ leaf.add(meshWithNodeMaterial(nodeMaterial()))
1880
+ doorGroup.add(leaf)
1881
+ root.add(doorGroup)
1882
+ const doorId = 'door_portable'
1883
+ sceneRegistry.nodes.set(doorId, doorGroup)
1884
+ const nodes: Record<string, AnyNode> = {
1885
+ [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode,
1886
+ }
1887
+ return { root, nodes, doorId }
1888
+ }
1889
+
1890
+ test('GLB downloads keep door clips when asked, USDZ passes none', async () => {
1891
+ const { root, nodes, doorId } = swingDoorScene()
1892
+
1893
+ const glb = await prepareSceneForExportAsync(root, nodes, { animations: 'keep' })
1894
+ expect(glb.animations).toHaveLength(1)
1895
+ expect(glb.animations[0]!.name).toBe(`${doorId}: open`)
1896
+ const track = glb.animations[0]!.tracks[0]!
1897
+ const targetUuid = track.name.split('.')[0]
1898
+ expect(glb.scene.getObjectByProperty('uuid', targetUuid)).toBeDefined()
1899
+ expect(glb.scene.getObjectByProperty('name', doorId)?.userData.clips).toEqual([
1900
+ `${doorId}: open`,
1901
+ ])
1902
+
1903
+ const usdz = await prepareSceneForExportAsync(root, nodes, { animations: 'none' })
1904
+ expect(usdz.animations).toEqual([])
1905
+ expect(usdz.scene.getObjectByProperty('name', doorId)?.userData.clips).toBeUndefined()
1906
+
1907
+ const legacy = await prepareSceneForExportAsync(root, nodes)
1908
+ expect(legacy.animations).toEqual([])
1909
+ })
1910
+ })
@@ -35,9 +35,13 @@ import {
35
35
  type GLTFExporterPlugin,
36
36
  type GLTFWriter,
37
37
  } from 'three/examples/jsm/exporters/GLTFExporter.js'
38
- import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
38
+ import { createExportTextureUtils, type ExportTextureUtils } from './export-texture-utils'
39
+ import { cloneExportUserData } from './export-user-data'
39
40
  import {
41
+ type CompressedTextureDecompressor,
42
+ decompressCanonicalNormalMaps,
40
43
  disposeExportResources,
44
+ GLASS_OPACITY_USERDATA,
41
45
  normalizePortableScene,
42
46
  normalizeViewerArtifactMaterials,
43
47
  } from './portable-export'
@@ -69,12 +73,61 @@ export type GlbExportOptions = {
69
73
  excludedNodeTypes?: readonly string[]
70
74
  /** Selected static viewer-presentation contributions; omitted means none. */
71
75
  includedPresentationIds?: readonly string[]
72
- /** Portable downloads are static; the baked viewer retains internal clips. */
76
+ /** Portable materials/geometry normalisation vs the baked viewer artifact. */
73
77
  purpose?: 'portable' | 'viewer'
78
+ /**
79
+ * Door/window open clips. Defaults to `keep` for the viewer bake and for GLB
80
+ * downloads (Blender turns them into actions); USDZ and print pass `none`
81
+ * because those formats freeze geometry and cannot play them.
82
+ */
83
+ animations?: 'keep' | 'none'
74
84
  /** Called for actual lossy portable conversions discovered during preparation. */
75
85
  onWarning?: (warning: string) => void
76
86
  /** Reject retained node kinds whose export geometry can only be baked asynchronously. */
77
87
  requireSynchronousBake?: boolean
88
+ /** GPU decompressor for compressed normal maps that must be baked; defaults to `textureUtils`. */
89
+ decompressTexture?: CompressedTextureDecompressor
90
+ /**
91
+ * Shared GPU decompressor for every compressed texture in the export. The
92
+ * export entry points create one per export and dispose it; supplying your
93
+ * own keeps ownership with you.
94
+ */
95
+ textureUtils?: ExportTextureUtils
96
+ /**
97
+ * Wall-clock deadline for the whole export (preparation and serialisation),
98
+ * after which it rejects instead of leaving the caller waiting. Defaults to
99
+ * `DEFAULT_MODEL_EXPORT_TIMEOUT_MS`; `Infinity` disables it.
100
+ */
101
+ timeoutMs?: number
102
+ }
103
+
104
+ export const DEFAULT_MODEL_EXPORT_TIMEOUT_MS = 180_000
105
+
106
+ /**
107
+ * three's exporters finish inside `FileReader.onloadend` and `canvas.toBlob`
108
+ * callbacks that carry no error path: a failed read or a callback the browser
109
+ * never invokes calls neither `onDone` nor `onError`, so without a deadline the
110
+ * returned promise stays pending forever and the export UI is stuck.
111
+ */
112
+ export function withExportDeadline<Result>(
113
+ promise: Promise<Result>,
114
+ timeoutMs: number,
115
+ format: string,
116
+ onTimeout?: () => void,
117
+ ): Promise<Result> {
118
+ if (!Number.isFinite(timeoutMs)) return promise
119
+ let timer: ReturnType<typeof setTimeout> | undefined
120
+ const deadline = new Promise<never>((_, reject) => {
121
+ timer = setTimeout(() => {
122
+ onTimeout?.()
123
+ reject(
124
+ new Error(
125
+ `${format} export timed out after ${Math.max(1, Math.round(timeoutMs / 1000))} s. Try again with “Visible nodes only” to shrink the scene, or reload the page if it keeps failing.`,
126
+ ),
127
+ )
128
+ }, timeoutMs)
129
+ })
130
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
78
131
  }
79
132
 
80
133
  /** Resolve after the next couple of animation frames, giving React/R3F time to
@@ -144,15 +197,43 @@ export async function exportSceneToGlb(
144
197
  options: GlbExportOptions = {},
145
198
  ): Promise<ArrayBuffer> {
146
199
  const textureMode = options.textures ?? 'embed'
147
- const prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, options)
148
- for (const warning of prepared.warnings) options.onWarning?.(warning)
200
+ const textureUtils = options.textureUtils ?? createExportTextureUtils()
201
+ // Preparation awaits plugin bake hooks and presentation builders, so the
202
+ // deadline has to cover it as well as the serialisation.
203
+ const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
204
+ ...options,
205
+ textureUtils,
206
+ animations: options.animations ?? 'keep',
207
+ })
208
+ // Assigned inside the raced closure, so a plain `let` narrows to `never` in
209
+ // the `finally`.
210
+ const run: { prepared: GlbExport | null; abandoned: boolean } = {
211
+ prepared: null,
212
+ abandoned: false,
213
+ }
149
214
  try {
150
- return await serializePreparedSceneToGlb(prepared, {
151
- textures: textureMode,
152
- onlyVisible: options.onlyVisible,
153
- })
215
+ return await withExportDeadline(
216
+ (async () => {
217
+ run.prepared = await preparation
218
+ // The race has already rejected; don't serialise a file nobody will get.
219
+ if (run.abandoned) throw new Error('GLB export abandoned')
220
+ for (const warning of run.prepared.warnings) options.onWarning?.(warning)
221
+ return serializePreparedSceneToGlb(run.prepared, {
222
+ textures: textureMode,
223
+ onlyVisible: options.onlyVisible,
224
+ textureUtils,
225
+ })
226
+ })(),
227
+ options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
228
+ 'GLB',
229
+ () => {
230
+ run.abandoned = true
231
+ },
232
+ )
154
233
  } finally {
155
- prepared.dispose()
234
+ if (run.prepared) run.prepared.dispose()
235
+ else preparation.then((late) => late.dispose()).catch(() => {})
236
+ if (!options.textureUtils) await textureUtils.dispose()
156
237
  }
157
238
  }
158
239
  /**
@@ -179,24 +260,30 @@ export async function preparePortableSceneFromViewer(
179
260
  return completeSceneExportPreparation(preparation)
180
261
  }
181
262
 
263
+ /** Serialise a prepared scene; callers own the deadline (see `exportSceneToGlb`). */
182
264
  export function serializePreparedSceneToGlb(
183
265
  prepared: GlbExport,
184
- options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> = {},
266
+ options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> & {
267
+ textureUtils: ExportTextureUtils
268
+ },
185
269
  ): Promise<ArrayBuffer> {
186
270
  const exporter = new GLTFExporter()
187
271
  if ((options.textures ?? 'embed') === 'reference') {
188
272
  exporter.register(textureReferencePlugin)
189
273
  }
190
- exporter.setTextureUtils(WebGPUTextureUtils)
274
+ exporter.setTextureUtils(options.textureUtils)
191
275
 
192
276
  return new Promise<ArrayBuffer>((resolve, reject) => {
193
277
  exporter.parse(
194
278
  prepared.scene,
195
279
  (gltf) => {
196
- resolve(gltf as ArrayBuffer)
280
+ // A failed final FileReader hands GLTFExporter's onDone `null`, which
281
+ // `new Blob([null])` would happily turn into a four-byte download.
282
+ if (gltf instanceof ArrayBuffer) resolve(gltf)
283
+ else reject(new Error('GLB export produced no data'))
197
284
  },
198
285
  (error) => {
199
- reject(error)
286
+ reject(error instanceof Error ? error : new Error(String(error)))
200
287
  },
201
288
  {
202
289
  binary: true,
@@ -342,22 +429,47 @@ function startSceneExportPreparation(
342
429
  }
343
430
  }
344
431
 
432
+ /** Own a throwaway decompressor only when the caller supplied neither form. */
433
+ function resolveNormalMapDecompressor(options: GlbExportOptions): {
434
+ decompress: CompressedTextureDecompressor
435
+ dispose: () => Promise<void>
436
+ } {
437
+ if (options.decompressTexture) {
438
+ return { decompress: options.decompressTexture, dispose: async () => {} }
439
+ }
440
+ const utils = options.textureUtils ?? createExportTextureUtils()
441
+ return {
442
+ decompress: (texture) => utils.decompress(texture),
443
+ dispose: options.textureUtils ? async () => {} : () => utils.dispose(),
444
+ }
445
+ }
446
+
345
447
  async function completeSceneExportPreparation(
346
448
  preparation: SceneExportPreparation,
347
449
  ): Promise<GlbExport> {
450
+ const decompressor = resolveNormalMapDecompressor(preparation.options)
348
451
  try {
349
452
  await replaceBakeGeometryAsync(preparation)
350
453
  await appendSelectedPresentations(preparation)
351
454
  const prepared = finishSceneExportPreparation(preparation)
455
+ const { options } = preparation
456
+ const byReference = (options.textures ?? 'embed') === 'reference'
457
+ const normalizeOptions = {
458
+ preserveNormalMap: (texture: THREE.Texture) =>
459
+ byReference && getPascalTextureRef(texture) !== null,
460
+ }
461
+ await decompressCanonicalNormalMaps(prepared.scene, decompressor.decompress, normalizeOptions)
352
462
  prepared.warnings.push(
353
- ...(preparation.options.purpose === 'viewer'
354
- ? normalizeViewerArtifactMaterials(prepared.scene)
355
- : normalizePortableScene(prepared.scene)),
463
+ ...(options.purpose === 'viewer'
464
+ ? normalizeViewerArtifactMaterials(prepared.scene, normalizeOptions)
465
+ : normalizePortableScene(prepared.scene, normalizeOptions)),
356
466
  )
357
467
  return prepared
358
468
  } catch (error) {
359
469
  disposeExportResources(preparation.scene)
360
470
  throw error
471
+ } finally {
472
+ await decompressor.dispose()
361
473
  }
362
474
  }
363
475
 
@@ -374,10 +486,12 @@ function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbE
374
486
  convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer')
375
487
 
376
488
  const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
377
- const animation =
378
- options.purpose === 'viewer'
379
- ? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
380
- : { clips: [], clipNamesByNode: new Map<string, string[]>() }
489
+ const keepClips = options.animations
490
+ ? options.animations === 'keep'
491
+ : options.purpose === 'viewer'
492
+ const animation = keepClips
493
+ ? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
494
+ : { clips: [], clipNamesByNode: new Map<string, string[]>() }
381
495
  stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries)
382
496
 
383
497
  let disposed = false
@@ -495,7 +609,7 @@ function ownBorrowedPresentationTextures(root: THREE.Object3D): void {
495
609
  let ownedTexture = ownedTextures.get(sourceTexture)
496
610
  if (!ownedTexture) {
497
611
  ownedTexture = sourceTexture.clone()
498
- ownedTexture.userData = structuredClone(sourceTexture.userData)
612
+ ownedTexture.userData = cloneExportUserData(sourceTexture.userData)
499
613
  ownedTexture.needsUpdate = true
500
614
  ownedTextures.set(sourceTexture, ownedTexture)
501
615
  }
@@ -670,7 +784,7 @@ function cloneSceneForExport(
670
784
  if (excludedObjects.has(source)) return new THREE.Group()
671
785
 
672
786
  const clone = source.clone(false)
673
- clone.userData = structuredClone(source.userData)
787
+ clone.userData = cloneExportUserData(source.userData)
674
788
  const renderable = source as THREE.Mesh
675
789
  const renderableClone = clone as THREE.Mesh
676
790
  if (renderable.geometry) {
@@ -693,7 +807,7 @@ function cloneSceneForExport(
693
807
  let textureClone = cache.textures.get(texture)
694
808
  if (!textureClone) {
695
809
  textureClone = texture.clone()
696
- textureClone.userData = structuredClone(texture.userData)
810
+ textureClone.userData = cloneExportUserData(texture.userData)
697
811
  textureClone.needsUpdate = true
698
812
  cache.textures.set(texture, textureClone)
699
813
  }
@@ -1047,7 +1161,8 @@ function convertMaterial(
1047
1161
  if (cached) return cached
1048
1162
 
1049
1163
  const src = material as THREE.Material & Record<string, unknown>
1050
- const target = new THREE.MeshStandardMaterial()
1164
+ const glass = purpose === 'portable' && isPortableGlass(material)
1165
+ const target = glass ? new THREE.MeshPhysicalMaterial() : new THREE.MeshStandardMaterial()
1051
1166
 
1052
1167
  target.name = material.name
1053
1168
  if (src.color instanceof THREE.Color) target.color.copy(src.color)
@@ -1083,12 +1198,44 @@ function convertMaterial(
1083
1198
  }
1084
1199
  }
1085
1200
 
1201
+ if (glass) applyPortableGlass(target as THREE.MeshPhysicalMaterial, material)
1086
1202
  if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache)
1087
1203
 
1088
1204
  cache.set(material, target)
1089
1205
  return target
1090
1206
  }
1091
1207
 
1208
+ /** Mirrors the viewer's own rule (`maybeApplyGlassFresnel`): an untextured
1209
+ * see-through surface below this opacity is glass, not tinted plastic. */
1210
+ const GLASS_OPACITY_THRESHOLD = 0.6
1211
+
1212
+ function isPortableGlass(material: THREE.Material): boolean {
1213
+ const src = material as THREE.Material & Record<string, unknown>
1214
+ return (
1215
+ material.transparent &&
1216
+ material.opacity < GLASS_OPACITY_THRESHOLD &&
1217
+ !(src.map instanceof THREE.Texture)
1218
+ )
1219
+ }
1220
+
1221
+ /**
1222
+ * The viewer sells glass with a fresnel-driven opacity node, which glTF cannot
1223
+ * carry; a plain alpha blend lands in every other tool as a blue film. Real
1224
+ * transmission (KHR_materials_transmission + ior) is what Blender, Unity and
1225
+ * Unreal all render as glass. The authored opacity becomes the tint strength.
1226
+ */
1227
+ function applyPortableGlass(target: THREE.MeshPhysicalMaterial, source: THREE.Material) {
1228
+ target.color.lerp(new THREE.Color(0xffffff), 1 - source.opacity)
1229
+ target.transmission = 1
1230
+ target.ior = 1.5
1231
+ target.roughness = Math.min(target.roughness, 0.15)
1232
+ target.metalness = 0
1233
+ target.transparent = false
1234
+ target.opacity = 1
1235
+ target.depthWrite = true
1236
+ target.userData[GLASS_OPACITY_USERDATA] = source.opacity
1237
+ }
1238
+
1092
1239
  function replaceReferencedTextures(
1093
1240
  material: THREE.Material,
1094
1241
  placeholderCache: Map<THREE.Texture, THREE.Texture>,
@@ -5,7 +5,9 @@ import {
5
5
  BuildingNode,
6
6
  LevelNode,
7
7
  SpawnNode,
8
+ UnitNode,
8
9
  WallNode,
10
+ ZoneNode,
9
11
  } from '@pascal-app/core/schema'
10
12
  import { buildLevelDuplicateCreateOps } from './level-duplication'
11
13
 
@@ -69,3 +71,74 @@ describe('buildLevelDuplicateCreateOps', () => {
69
71
  expect(copiedLevel?.children).toEqual([])
70
72
  })
71
73
  })
74
+
75
+ describe('unit duplication', () => {
76
+ for (const preset of [
77
+ 'everything',
78
+ 'structure',
79
+ 'structure-materials',
80
+ 'structure-furniture',
81
+ ] as const) {
82
+ test(`duplicates only units wholly on the source level with ${preset}`, () => {
83
+ const building = BuildingNode.parse({})
84
+ const level = LevelNode.parse({ parentId: building.id, level: 0 })
85
+ const upper = LevelNode.parse({ parentId: building.id, level: 1 })
86
+ const kitchen = ZoneNode.parse({ parentId: level.id, name: 'Kitchen', polygon: [] })
87
+ const living = ZoneNode.parse({ parentId: level.id, name: 'Living', polygon: [] })
88
+ const bedroom = ZoneNode.parse({ parentId: upper.id, name: 'Bedroom', polygon: [] })
89
+ const single = UnitNode.parse({
90
+ parentId: building.id,
91
+ name: 'Suite',
92
+ kind: 'hotel-room',
93
+ color: '#123456',
94
+ members: [kitchen.id, living.id],
95
+ })
96
+ const duplex = UnitNode.parse({
97
+ parentId: building.id,
98
+ name: 'Duplex',
99
+ members: [living.id, bedroom.id],
100
+ })
101
+ const empty = UnitNode.parse({ parentId: building.id })
102
+ const dangling = UnitNode.parse({
103
+ parentId: building.id,
104
+ members: [kitchen.id, 'zone_missing'],
105
+ })
106
+ level.children = [kitchen.id, living.id]
107
+ upper.children = [bedroom.id]
108
+ building.children = [level.id, upper.id, single.id, duplex.id, empty.id, dangling.id]
109
+ const nodes: Record<AnyNodeId, AnyNode> = Object.fromEntries(
110
+ [building, level, upper, kitchen, living, bedroom, single, duplex, empty, dangling].map(
111
+ (node) => [node.id, node],
112
+ ),
113
+ )
114
+ const before = structuredClone(nodes)
115
+ const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
116
+ nodes,
117
+ level,
118
+ levels: [level, upper],
119
+ preset,
120
+ })
121
+ const unitOps = createOps.filter((op) => op.node.type === 'unit')
122
+ expect(unitOps).toHaveLength(1)
123
+ const op = unitOps[0]!
124
+ expect(op.parentId).toBe(building.id)
125
+ expect(op.node.type).toBe('unit')
126
+ if (op.node.type !== 'unit') return
127
+ expect(op.node.id).not.toBe(single.id)
128
+ expect(op.node.name).toBe('Suite copy')
129
+ expect(op.node.kind).toBe(single.kind)
130
+ expect(op.node.color).toBe(single.color)
131
+ expect(op.node.parentId).toBe(building.id)
132
+ expect(op.node.members).toEqual(
133
+ createOps.flatMap((entry) => (entry.node.type === 'zone' ? [entry.node.id] : [])),
134
+ )
135
+ expect(
136
+ createOps
137
+ .filter((entry) => entry.node.type === 'zone')
138
+ .every((entry) => entry.parentId === newLevelId),
139
+ ).toBe(true)
140
+ expect(shiftedLevels).toEqual([{ id: upper.id, level: 2 }])
141
+ expect(nodes).toEqual(before)
142
+ })
143
+ }
144
+ })
@@ -1,5 +1,5 @@
1
1
  import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph'
2
- import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core/schema'
2
+ import { type AnyNode, type AnyNodeId, type LevelNode, UnitNode } from '@pascal-app/core/schema'
3
3
 
4
4
  export type LevelDuplicatePreset =
5
5
  | 'everything'
@@ -117,7 +117,7 @@ export function buildLevelDuplicateCreateOps({
117
117
  levels: LevelNode[]
118
118
  preset: LevelDuplicatePreset
119
119
  }) {
120
- const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id)
120
+ const { clonedNodes, newLevelId, idMap } = cloneLevelSubtree(nodes, level.id)
121
121
  const parentBuildingId =
122
122
  (level.parentId as AnyNodeId | null) ?? findLevelBuildingId(nodes, level.id)
123
123
  const nextLevelNumber = level.level + 1
@@ -145,6 +145,36 @@ export function buildLevelDuplicateCreateOps({
145
145
  } as AnyNode
146
146
  })
147
147
 
148
+ if (parentBuildingId && nodes[parentBuildingId]?.type === 'building') {
149
+ for (const unit of Object.values(nodes)) {
150
+ if (
151
+ unit.type !== 'unit' ||
152
+ unit.parentId !== parentBuildingId ||
153
+ unit.members.length === 0 ||
154
+ !unit.members.every((id) => {
155
+ const zone = nodes[id]
156
+ const remapped = idMap.get(id)
157
+ return (
158
+ zone?.type === 'zone' &&
159
+ zone.parentId === level.id &&
160
+ remapped !== undefined &&
161
+ keptIds.has(remapped as AnyNodeId)
162
+ )
163
+ })
164
+ )
165
+ continue
166
+ cleanedNodes.push(
167
+ UnitNode.parse({
168
+ ...unit,
169
+ id: undefined,
170
+ parentId: parentBuildingId,
171
+ name: `${unit.name} copy`,
172
+ members: unit.members.map((id) => idMap.get(id)),
173
+ }),
174
+ )
175
+ }
176
+ }
177
+
148
178
  return {
149
179
  createOps: cleanedNodes.map((node) => ({
150
180
  node: