@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
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { getLinearDisplay } from './linear-display'
3
+
4
+ describe('linear property control units', () => {
5
+ test('keeps meter storage while displaying and accepting millimeters', () => {
6
+ const display = getLinearDisplay('m', 'metric', 'millimeters', 2, 0.05)
7
+ expect(display.displayUnit).toBe('mm')
8
+ expect(display.parseUnit).toBe('mm')
9
+ expect(display.toDisplay(2.5)).toBe(2500)
10
+ expect(display.toStored(1800)).toBe(1.8)
11
+ expect(display.toStored(-250)).toBe(-0.25)
12
+ expect(display.precision).toBe(0)
13
+ expect(display.step).toBe(50)
14
+ expect(display.toStored(display.step)).toBe(0.05)
15
+ })
16
+
17
+ test('retains sub-millimeter precision when the field supports it', () => {
18
+ const display = getLinearDisplay('m', 'metric', 'millimeters', 4, 0.0001)
19
+ expect(display.precision).toBe(1)
20
+ expect(display.step).toBe(0.1)
21
+ expect(display.toDisplay(0.0125)).toBe(12.5)
22
+ expect(display.roundStored(0.01254)).toBe(0.0125)
23
+ })
24
+
25
+ test('imperial preference takes precedence over the saved metric notation', () => {
26
+ const display = getLinearDisplay('m', 'imperial', 'millimeters', 2, 0.05)
27
+ expect(display.displayUnit).toBe('ft')
28
+ expect(display.parseUnit).toBe('ft')
29
+ expect(display.toDisplay(0.9144)).toBeCloseTo(3, 10)
30
+ expect(display.toStored(7)).toBeCloseTo(2.1336, 10)
31
+ expect(display.precision).toBe(2)
32
+ expect(display.step).toBe(0.05)
33
+ })
34
+
35
+ test('switching notation round-trips the same stored dimensions', () => {
36
+ for (const stored of [0, 0.01, 0.125, 0.9, 2.1, -0.25]) {
37
+ for (const [unit, notation] of [
38
+ ['metric', 'meters'],
39
+ ['metric', 'millimeters'],
40
+ ['imperial', 'meters'],
41
+ ] as const) {
42
+ const display = getLinearDisplay('m', unit, notation, 2, 0.01)
43
+ expect(display.toStored(display.toDisplay(stored))).toBeCloseTo(stored, 12)
44
+ }
45
+ }
46
+ })
47
+
48
+ test('leaves non-meter fields and their gesture steps unchanged', () => {
49
+ for (const unit of ['°', 'rad', '%', '', 'in', 'cm']) {
50
+ const display = getLinearDisplay(unit, 'metric', 'millimeters', 2, 0.05)
51
+ expect(display.displayUnit).toBe(unit)
52
+ expect(display.parseUnit).toBeUndefined()
53
+ expect(display.toDisplay(12.5)).toBe(12.5)
54
+ expect(display.toStored(12.5)).toBe(12.5)
55
+ expect(display.precision).toBe(2)
56
+ expect(display.step).toBe(0.05)
57
+ }
58
+ })
59
+ })
@@ -0,0 +1,39 @@
1
+ import {
2
+ getLinearUnitLabel,
3
+ type LinearUnit,
4
+ linearUnitToMeters,
5
+ type MetricNotation,
6
+ metersToLinearUnit,
7
+ } from './measurements'
8
+
9
+ export function getLinearDisplay(
10
+ unit: string,
11
+ viewerUnit: LinearUnit,
12
+ metricNotation: MetricNotation,
13
+ precision: number,
14
+ step: number,
15
+ ) {
16
+ const isImperial = unit === 'm' && viewerUnit === 'imperial'
17
+ const isMillimeters = unit === 'm' && viewerUnit === 'metric' && metricNotation === 'millimeters'
18
+ const displayUnit = isImperial ? getLinearUnitLabel('imperial') : isMillimeters ? 'mm' : unit
19
+ const displayPrecision = isMillimeters ? Math.max(0, precision - 3) : precision
20
+ const displayStep = isMillimeters ? step * 1000 : step
21
+ const parseUnit = isImperial ? 'ft' : isMillimeters ? 'mm' : undefined
22
+ const toDisplay = (stored: number) =>
23
+ isImperial ? metersToLinearUnit(stored, 'imperial') : isMillimeters ? stored * 1000 : stored
24
+ const toStored = (display: number) =>
25
+ isImperial ? linearUnitToMeters(display, 'imperial') : isMillimeters ? display / 1000 : display
26
+ const roundStored = (stored: number) =>
27
+ toStored(Number.parseFloat(toDisplay(stored).toFixed(displayPrecision)))
28
+
29
+ return {
30
+ isImperial,
31
+ displayUnit,
32
+ parseUnit,
33
+ precision: displayPrecision,
34
+ step: displayStep,
35
+ toDisplay,
36
+ toStored,
37
+ roundStored,
38
+ }
39
+ }
@@ -0,0 +1,24 @@
1
+ import { expect, mock, test } from 'bun:test'
2
+ import * as core from '@pascal-app/core'
3
+
4
+ const saveAsset = mock(async () => 'asset://stored-scan')
5
+
6
+ mock.module('@pascal-app/core', () => ({ ...core, saveAsset }))
7
+
8
+ const { createLocalScan } = await import('./local-guide-image')
9
+
10
+ test('createLocalScan stores and attaches a scan asset to its level', async () => {
11
+ const createNode = mock()
12
+ const file = new File(['scan data'], 'living-room.glb', { type: 'model/gltf-binary' })
13
+
14
+ const { scan, url } = await createLocalScan({ createNode, file, levelId: 'level_ground' })
15
+
16
+ expect(saveAsset).toHaveBeenCalledWith(file)
17
+ expect(scan).toMatchObject({
18
+ name: 'living-room',
19
+ type: 'scan',
20
+ url: 'asset://stored-scan',
21
+ })
22
+ expect(url).toBe('asset://stored-scan')
23
+ expect(createNode).toHaveBeenCalledWith(scan, 'level_ground')
24
+ })
@@ -2,13 +2,23 @@ import {
2
2
  type AnyNodeId,
3
3
  GuideNode,
4
4
  type GuideNode as GuideNodeType,
5
+ ScanNode,
6
+ type ScanNode as ScanNodeType,
5
7
  saveAsset,
6
8
  } from '@pascal-app/core'
7
9
 
8
10
  export function getGuideImageName(filename: string) {
11
+ return getAssetName(filename, 'Guide image')
12
+ }
13
+
14
+ export function getScanName(filename: string) {
15
+ return getAssetName(filename, 'Scan')
16
+ }
17
+
18
+ function getAssetName(filename: string, fallback: string) {
9
19
  const trimmed = filename.trim()
10
20
  if (!trimmed) {
11
- return 'Guide image'
21
+ return fallback
12
22
  }
13
23
 
14
24
  const dotIndex = trimmed.lastIndexOf('.')
@@ -40,3 +50,26 @@ export async function createLocalGuideImage({
40
50
  createNode(guide, levelId as AnyNodeId)
41
51
  return guide
42
52
  }
53
+
54
+ export async function createLocalScan({
55
+ createNode,
56
+ file,
57
+ levelId,
58
+ }: {
59
+ createNode: (node: ScanNodeType, parentId: AnyNodeId) => void
60
+ file: File
61
+ levelId: string
62
+ }) {
63
+ const assetUrl = await saveAsset(file)
64
+ const scan = ScanNode.parse({
65
+ name: getScanName(file.name),
66
+ url: assetUrl,
67
+ position: [0, 0, 0],
68
+ rotation: [0, 0, 0],
69
+ scale: 1,
70
+ opacity: 100,
71
+ })
72
+
73
+ createNode(scan, levelId as AnyNodeId)
74
+ return { scan, url: assetUrl }
75
+ }
@@ -0,0 +1,21 @@
1
+ import { type AnyNode, createSceneApi, isMovable, nodeRegistry, useScene } from '@pascal-app/core'
2
+
3
+ export function registryMoveDisabled(node: AnyNode): boolean {
4
+ const def = nodeRegistry.get(node.type)
5
+ return Boolean(
6
+ def?.capabilities.movable &&
7
+ !isMovable(node) &&
8
+ !def.floorplanMoveTarget &&
9
+ !def.affordanceTools?.move,
10
+ )
11
+ }
12
+
13
+ export function duplicateWithoutMove(node: AnyNode) {
14
+ const api = createSceneApi(useScene)
15
+ const subtree = api.getSubtree(node.id)
16
+ if (!subtree || !node.parentId) return null
17
+ return api.cloneNodesInto([subtree.root, ...subtree.descendants], {
18
+ rootId: node.id,
19
+ parentId: node.parentId as AnyNode['id'],
20
+ })
21
+ }
@@ -5,6 +5,8 @@ import {
5
5
  BoxGeometry,
6
6
  BufferGeometry,
7
7
  Color,
8
+ CompressedTexture,
9
+ DataTexture,
8
10
  DoubleSide,
9
11
  Float32BufferAttribute,
10
12
  FrontSide,
@@ -15,11 +17,13 @@ import {
15
17
  Mesh,
16
18
  MeshStandardMaterial,
17
19
  Quaternion,
20
+ Texture,
18
21
  Vector3,
19
22
  } from 'three'
20
23
  import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
21
24
  import {
22
25
  createUsdzScene,
26
+ decompressCanonicalNormalMaps,
23
27
  expandInstancedMeshes,
24
28
  freezeDeformedMeshes,
25
29
  normalizePortableScene,
@@ -345,6 +349,17 @@ function withCanvasCapture(run: (capture: () => ImageData) => void): void {
345
349
  putImageData: (image: ImageData) => {
346
350
  captured = image
347
351
  },
352
+ drawImage: () => {
353
+ if (!captured) throw new Error('Source canvas has no captured pixels')
354
+ },
355
+ getImageData: (_x: number, _y: number, width: number, height: number) =>
356
+ captured ??
357
+ ({
358
+ colorSpace: 'srgb',
359
+ data: new Uint8ClampedArray(width * height * 4),
360
+ height,
361
+ width,
362
+ } as ImageData),
348
363
  }),
349
364
  } as unknown as HTMLCanvasElement
350
365
  globals.document = {
@@ -364,3 +379,77 @@ function withCanvasCapture(run: (capture: () => ImageData) => void): void {
364
379
  else delete globals.document
365
380
  }
366
381
  }
382
+
383
+ describe('portable normal maps', () => {
384
+ function flatNormalPixels(size: number): Uint8Array {
385
+ const pixels = new Uint8Array(size * size * 4)
386
+ for (let index = 0; index < pixels.length; index += 4) pixels.set([128, 128, 255, 255], index)
387
+ return pixels
388
+ }
389
+
390
+ test('decompresses a shared compressed normal map once before canonicalising it', async () => {
391
+ const compressed = new CompressedTexture([], 2, 2)
392
+ compressed.name = 'NormalGL_test'
393
+ compressed.repeat.set(2, 2)
394
+ const first = new MeshStandardMaterial({ normalMap: compressed })
395
+ first.normalScale.set(0.5, 0.5)
396
+ const second = new MeshStandardMaterial({ normalMap: compressed })
397
+ second.normalScale.set(0.5, -0.5)
398
+ const root = new Group()
399
+ root.add(new Mesh(new BoxGeometry(), first), new Mesh(new BoxGeometry(), second))
400
+
401
+ let calls = 0
402
+ await decompressCanonicalNormalMaps(root, async () => {
403
+ calls += 1
404
+ return new DataTexture(flatNormalPixels(2), 2, 2)
405
+ })
406
+
407
+ expect(calls).toBe(1)
408
+ expect(first.normalMap).toBe(second.normalMap)
409
+ expect(first.normalMap?.repeat.toArray()).toEqual([2, 2])
410
+ expect(first.normalMap?.name).toBe('NormalGL_test')
411
+
412
+ withCanvasCapture(() => {
413
+ normalizePortableScene(root)
414
+ })
415
+
416
+ expect((first.normalMap as { isCanvasTexture?: boolean }).isCanvasTexture).toBe(true)
417
+ expect(first.normalMap).not.toBe(second.normalMap)
418
+ expect(first.normalScale.toArray()).toEqual([1, 1])
419
+ expect(second.normalScale.toArray()).toEqual([1, 1])
420
+ })
421
+
422
+ test('a compressed normal map that was not decompressed still fails loudly', () => {
423
+ const material = new MeshStandardMaterial({ normalMap: new CompressedTexture([], 2, 2) })
424
+ material.normalScale.set(0.5, 0.5)
425
+ const root = new Group()
426
+ root.add(new Mesh(new BoxGeometry(), material))
427
+ withCanvasCapture(() => {
428
+ expect(() => normalizePortableScene(root)).toThrow('must be baked before portable export')
429
+ })
430
+ })
431
+
432
+ test('preserved normal maps keep their texture and normal scale', async () => {
433
+ const placeholder = new Texture()
434
+ placeholder.image = { width: 1, height: 1 }
435
+ const material = new MeshStandardMaterial({ normalMap: placeholder })
436
+ material.normalScale.set(1, -1)
437
+ const root = new Group()
438
+ root.add(new Mesh(new BoxGeometry(), material))
439
+ const options = { preserveNormalMap: (texture: Texture) => texture === placeholder }
440
+
441
+ await decompressCanonicalNormalMaps(
442
+ root,
443
+ async () => {
444
+ throw new Error('preserved maps must not be decompressed')
445
+ },
446
+ options,
447
+ )
448
+ withCanvasCapture(() => {
449
+ normalizePortableScene(root, options)
450
+ })
451
+
452
+ expect(material.normalMap).toBe(placeholder)
453
+ expect(material.normalScale.toArray()).toEqual([1, -1])
454
+ })
455
+ })
@@ -1,5 +1,6 @@
1
1
  import * as THREE from 'three'
2
2
  import { StaticGeometryGenerator } from 'three-mesh-bvh'
3
+ import { cloneExportUserData } from './export-user-data'
3
4
 
4
5
  const VERTEX_COLOR_UV_CHANNEL = 0
5
6
  const VERTEX_COLOR_TILE_SIZE = 8
@@ -64,7 +65,7 @@ function copyObjectState(source: THREE.Object3D, target: THREE.Object3D): void {
64
65
  target.layers.mask = source.layers.mask
65
66
  target.renderOrder = source.renderOrder
66
67
  target.frustumCulled = source.frustumCulled
67
- target.userData = structuredClone(source.userData)
68
+ target.userData = cloneExportUserData(source.userData)
68
69
  }
69
70
 
70
71
  function replaceObject(source: THREE.Object3D, replacement: THREE.Object3D): void {
@@ -554,7 +555,7 @@ function cloneTextureSettings(source: THREE.Texture, target: THREE.Texture): voi
554
555
  target.flipY = source.flipY
555
556
  target.unpackAlignment = source.unpackAlignment
556
557
  target.colorSpace = THREE.NoColorSpace
557
- target.userData = structuredClone(source.userData)
558
+ target.userData = cloneExportUserData(source.userData)
558
559
  target.needsUpdate = true
559
560
  }
560
561
  function materializeDataTextures(root: THREE.Object3D): void {
@@ -671,19 +672,68 @@ function canonicalNormalTexture(source: THREE.Texture, scale: THREE.Vector2): TH
671
672
  return texture
672
673
  }
673
674
 
674
- function canonicalizeNormalMaps(root: THREE.Object3D): void {
675
+ export type PortableNormalizeOptions = {
676
+ /**
677
+ * Leave this normal map and its material's `normalScale` untouched. By-reference
678
+ * placeholders get their real bytes re-attached later, so baking the scale into
679
+ * placeholder pixels would drop both the reference and the strength.
680
+ */
681
+ preserveNormalMap?: (texture: THREE.Texture) => boolean
682
+ }
683
+
684
+ export type CompressedTextureDecompressor = (
685
+ texture: THREE.CompressedTexture,
686
+ ) => Promise<THREE.Texture>
687
+
688
+ function canonicalNormalMaterials(
689
+ root: THREE.Object3D,
690
+ options: PortableNormalizeOptions,
691
+ ): THREE.MeshStandardMaterial[] {
675
692
  const materials = new Set<THREE.Material>()
676
693
  root.traverse((object) => {
677
694
  if (!(object as THREE.Mesh).isMesh) return
678
695
  for (const material of materialsOf(object as THREE.Mesh)) materials.add(material)
679
696
  })
680
-
681
- const cache = new Map<THREE.Texture, Map<string, THREE.CanvasTexture>>()
697
+ const result: THREE.MeshStandardMaterial[] = []
682
698
  for (const material of materials) {
683
699
  const standard = material as THREE.MeshStandardMaterial
684
700
  if (!standard.normalMap || !standard.normalScale) continue
685
701
  if (standard.normalScale.x === 1 && standard.normalScale.y === 1) continue
686
- const source = standard.normalMap
702
+ if (options.preserveNormalMap?.(standard.normalMap)) continue
703
+ result.push(standard)
704
+ }
705
+ return result
706
+ }
707
+
708
+ /**
709
+ * Canonicalizing a normal map reads its pixels on the CPU, which compressed
710
+ * (KTX2) textures cannot provide — decompress the ones that will be baked
711
+ * first. Runs before `normalizePortableScene` / `normalizeViewerArtifactMaterials`.
712
+ */
713
+ export async function decompressCanonicalNormalMaps(
714
+ root: THREE.Object3D,
715
+ decompress: CompressedTextureDecompressor,
716
+ options: PortableNormalizeOptions = {},
717
+ ): Promise<void> {
718
+ const readable = new Map<THREE.Texture, THREE.Texture>()
719
+ for (const standard of canonicalNormalMaterials(root, options)) {
720
+ const source = standard.normalMap as THREE.CompressedTexture
721
+ if (!source.isCompressedTexture) continue
722
+ let texture = readable.get(source)
723
+ if (!texture) {
724
+ texture = await decompress(source)
725
+ cloneTextureSettings(source, texture)
726
+ readable.set(source, texture)
727
+ rememberDiscarded(root, source)
728
+ }
729
+ standard.normalMap = texture
730
+ }
731
+ }
732
+
733
+ function canonicalizeNormalMaps(root: THREE.Object3D, options: PortableNormalizeOptions): void {
734
+ const cache = new Map<THREE.Texture, Map<string, THREE.CanvasTexture>>()
735
+ for (const standard of canonicalNormalMaterials(root, options)) {
736
+ const source = standard.normalMap!
687
737
  const key = `${standard.normalScale.x}:${standard.normalScale.y}`
688
738
  let variants = cache.get(source)
689
739
  if (!variants) {
@@ -817,7 +867,10 @@ function clampPortableMaterialColors(root: THREE.Object3D): boolean {
817
867
  return clipped
818
868
  }
819
869
 
820
- export function normalizePortableScene(root: THREE.Object3D): string[] {
870
+ export function normalizePortableScene(
871
+ root: THREE.Object3D,
872
+ options: PortableNormalizeOptions = {},
873
+ ): string[] {
821
874
  expandInstancedMeshes(root)
822
875
  root.updateMatrixWorld(true)
823
876
  freezeDeformedMeshes(root)
@@ -826,7 +879,7 @@ export function normalizePortableScene(root: THREE.Object3D): string[] {
826
879
  const clampedVertexColors = bakeVertexColors(root)
827
880
  const clampedHdr = clampedMaterialColors || clampedVertexColors
828
881
  canonicalizeAlphaMaps(root)
829
- canonicalizeNormalMaps(root)
882
+ canonicalizeNormalMaps(root, options)
830
883
  bakeDoubleSidedMeshes(root)
831
884
  root.updateMatrixWorld(true)
832
885
  return clampedHdr
@@ -839,12 +892,15 @@ export function normalizePortableScene(root: THREE.Object3D): string[] {
839
892
  * Canonicalize baked static material details without freezing authored item
840
893
  * deformation that existing saved-viewer animation clips still target.
841
894
  */
842
- export function normalizeViewerArtifactMaterials(root: THREE.Object3D): string[] {
895
+ export function normalizeViewerArtifactMaterials(
896
+ root: THREE.Object3D,
897
+ options: PortableNormalizeOptions = {},
898
+ ): string[] {
843
899
  const clampedMaterialColors = clampPortableMaterialColors(root)
844
900
  materializeDataTextures(root)
845
901
  const clampedVertexColors = bakeVertexColors(root, true)
846
902
  canonicalizeAlphaMaps(root)
847
- canonicalizeNormalMaps(root)
903
+ canonicalizeNormalMaps(root, options)
848
904
  return clampedMaterialColors || clampedVertexColors
849
905
  ? [
850
906
  'Some rendered colors were outside the portable range (including colors brighter than the portable range) and were clipped to 0–1; the brightest areas may lose contrast.',
@@ -852,9 +908,26 @@ export function normalizeViewerArtifactMaterials(root: THREE.Object3D): string[]
852
908
  : []
853
909
  }
854
910
 
911
+ /** Where the portable GLB conversion parks the authored opacity of glass it
912
+ * rewrote as transmission, so formats without transmission can fall back. */
913
+ export const GLASS_OPACITY_USERDATA = 'pascalGlassOpacity'
914
+
855
915
  function cloneMaterialForUsdz(material: THREE.Material): THREE.Material {
856
916
  const clone = material.clone()
857
917
  clone.side = THREE.FrontSide
918
+ // USDPreviewSurface has no transmission and USDZExporter only writes
919
+ // `inputs:opacity`; hand glass back its authored opacity for Quick Look.
920
+ const physical = clone as THREE.MeshPhysicalMaterial
921
+ const authoredOpacity = clone.userData[GLASS_OPACITY_USERDATA]
922
+ if (
923
+ physical.isMeshPhysicalMaterial &&
924
+ physical.transmission > 0 &&
925
+ typeof authoredOpacity === 'number'
926
+ ) {
927
+ physical.transmission = 0
928
+ physical.transparent = true
929
+ physical.opacity = authoredOpacity
930
+ }
858
931
  return clone
859
932
  }
860
933
 
@@ -925,7 +998,7 @@ export function createUsdzScene(source: THREE.Object3D): THREE.Object3D {
925
998
  clone.visible = object.visible
926
999
  clone.layers.mask = object.layers.mask
927
1000
  clone.renderOrder = object.renderOrder
928
- clone.userData = structuredClone(object.userData)
1001
+ clone.userData = cloneExportUserData(object.userData)
929
1002
  for (const child of object.children) clone.add(cloneObject(child))
930
1003
  return clone
931
1004
  }
@@ -1,6 +1,10 @@
1
- import ManifoldModule, { type Manifold as ManifoldSolid, type ManifoldToplevel } from 'manifold-3d'
1
+ import type { Manifold as ManifoldSolid, ManifoldToplevel } from 'manifold-3d'
2
2
  import type { PrintShellCompileDiagnostic } from './print-shell-compiler-baseline'
3
- import type { ManifoldCompileOutput, ManifoldMeshData } from './print-shell-compiler-protocol'
3
+ import type {
4
+ ManifoldCompileOutput,
5
+ ManifoldMeshData,
6
+ ManifoldRuntimeOptions,
7
+ } from './print-shell-compiler-protocol'
4
8
 
5
9
  let modulePromise: Promise<ManifoldToplevel> | null = null
6
10
  const MANIFOLD_OUTPUT_WELD_EPSILON_METERS = 2e-5
@@ -8,14 +12,59 @@ const COLLINEAR_SEAM_CROSS_LENGTH_SQ = 1e-20
8
12
 
9
13
  type Triangle = [number, number, number]
10
14
 
11
- async function getManifoldModule(wasmUrl?: string): Promise<ManifoldToplevel> {
12
- modulePromise ??= ManifoldModule(wasmUrl ? { locateFile: () => wasmUrl } : undefined).then(
13
- (module) => {
15
+ type ManifoldFactory = (config?: {
16
+ locateFile?: (path: string) => string
17
+ }) => Promise<ManifoldToplevel>
18
+
19
+ // manifold-3d's emscripten glue awaits import('node:module') behind a Node
20
+ // check. The branch never executes in a browser, but webpack refuses to
21
+ // *build* a graph that can reach it, so a static specifier here poisons every
22
+ // external bundler that compiles this package's source (#715). The factory is
23
+ // therefore loaded through an import() no bundler can trace: the bare
24
+ // specifier resolves wherever node-style resolution exists at runtime (bun
25
+ // tests, dev servers, bundlers that inline it anyway), and the version-pinned
26
+ // CDN copy covers bundled browser builds that left the specifier unresolved —
27
+ // emscripten then locates manifold.wasm relative to the glue's own URL. Hosts
28
+ // that can't reach the CDN (offline, CSP) pass their own URLs through
29
+ // configureManifoldRuntime.
30
+ const MANIFOLD_VERSION = '3.5.1'
31
+ const FALLBACK_MODULE_URL = `https://cdn.jsdelivr.net/npm/manifold-3d@${MANIFOLD_VERSION}/manifold.js`
32
+
33
+ function importUntraced(specifier: string): Promise<{ default: ManifoldFactory }> {
34
+ return import(/* webpackIgnore: true */ /* @vite-ignore */ specifier)
35
+ }
36
+
37
+ async function loadManifoldFactory(moduleUrl?: string): Promise<ManifoldFactory> {
38
+ const specifiers = moduleUrl ? [moduleUrl] : ['manifold-3d', FALLBACK_MODULE_URL]
39
+ let lastError: unknown
40
+ for (const specifier of specifiers) {
41
+ try {
42
+ return (await importUntraced(specifier)).default
43
+ } catch (error) {
44
+ lastError = error
45
+ }
46
+ }
47
+ throw lastError instanceof Error ? lastError : new Error('Failed to load manifold-3d.')
48
+ }
49
+
50
+ async function getManifoldModule(runtime?: ManifoldRuntimeOptions): Promise<ManifoldToplevel> {
51
+ modulePromise ??= loadManifoldFactory(runtime?.moduleUrl)
52
+ .then((factory) => {
53
+ const wasmUrl = runtime?.wasmUrl
54
+ return factory(wasmUrl ? { locateFile: () => wasmUrl } : undefined)
55
+ })
56
+ .then((module) => {
14
57
  module.setup()
15
58
  return module
16
- },
17
- )
18
- return modulePromise
59
+ })
60
+ try {
61
+ return await modulePromise
62
+ } catch (error) {
63
+ // A transient load failure (offline, blocked CDN) must not poison every
64
+ // later compile attempt with the cached rejection.
65
+ modulePromise = null
66
+ throw error
67
+ }
19
68
  }
20
69
 
21
70
  function manifoldMesh(
@@ -211,7 +260,7 @@ function elapsed(startedAt: number): number {
211
260
 
212
261
  export async function compileManifoldMeshData(
213
262
  meshes: ManifoldMeshData[],
214
- wasmUrl?: string,
263
+ runtime?: ManifoldRuntimeOptions,
215
264
  ): Promise<ManifoldCompileOutput> {
216
265
  const startedAt = performance.now()
217
266
  const sourceNodeIds = Array.from(new Set(meshes.map((mesh) => mesh.nodeId))).sort()
@@ -238,7 +287,7 @@ export async function compileManifoldMeshData(
238
287
  }
239
288
 
240
289
  try {
241
- const module = await getManifoldModule(wasmUrl)
290
+ const module = await getManifoldModule(runtime)
242
291
  for (const mesh of meshes) {
243
292
  try {
244
293
  solids.push(new module.Manifold(manifoldMesh(module, mesh)))
@@ -16,12 +16,25 @@ import {
16
16
  import type {
17
17
  ManifoldCompileOutput,
18
18
  ManifoldMeshData,
19
+ ManifoldRuntimeOptions,
19
20
  ManifoldWorkerRequest,
20
21
  ManifoldWorkerResponse,
21
22
  } from './print-shell-compiler-protocol'
22
23
 
23
24
  const WORKER_TIMEOUT_MS = 60_000
24
25
 
26
+ let manifoldRuntime: ManifoldRuntimeOptions | undefined
27
+
28
+ /**
29
+ * Overrides where the print-export worker loads the manifold-3d module and
30
+ * wasm from. See the loader in print-shell-compiler-manifold-core.ts for the
31
+ * default resolution order; hosts with restrictive networks should call this
32
+ * with self-hosted asset URLs before the first print export.
33
+ */
34
+ export function configureManifoldRuntime(options: ManifoldRuntimeOptions | undefined): void {
35
+ manifoldRuntime = options
36
+ }
37
+
25
38
  export type ManifoldCompileRunner = (meshes: ManifoldMeshData[]) => Promise<ManifoldCompileOutput>
26
39
 
27
40
  export type SemanticManifoldCompileOptions = SemanticPrintCompileOptions & {
@@ -76,7 +89,7 @@ export const runManifoldWorker: ManifoldCompileRunner = (meshes) => {
76
89
  const activeWorker = getWorker()
77
90
  const id = nextRequestId
78
91
  nextRequestId += 1
79
- const request: ManifoldWorkerRequest = { id, meshes }
92
+ const request: ManifoldWorkerRequest = { id, meshes, runtime: manifoldRuntime }
80
93
  const transfer = meshes.flatMap((mesh) => [
81
94
  mesh.positions.buffer as ArrayBuffer,
82
95
  mesh.indices.buffer as ArrayBuffer,
@@ -10,7 +10,7 @@ const workerScope = self as unknown as {
10
10
  }
11
11
 
12
12
  workerScope.addEventListener('message', async (event) => {
13
- const output = await compileManifoldMeshData(event.data.meshes)
13
+ const output = await compileManifoldMeshData(event.data.meshes, event.data.runtime)
14
14
  const response: ManifoldWorkerResponse = { id: event.data.id, ...output }
15
15
  const transfer: Transferable[] = []
16
16
  if (response.status === 'compiled') {
@@ -22,9 +22,17 @@ export type ManifoldCompileOutput =
22
22
  durationMs: number
23
23
  }
24
24
 
25
+ export type ManifoldRuntimeOptions = {
26
+ /** URL of the manifold-3d emscripten glue module. Defaults to the pinned CDN copy. */
27
+ moduleUrl?: string
28
+ /** URL of manifold.wasm. Defaults to resolving relative to the glue module. */
29
+ wasmUrl?: string
30
+ }
31
+
25
32
  export type ManifoldWorkerRequest = {
26
33
  id: number
27
34
  meshes: ManifoldMeshData[]
35
+ runtime?: ManifoldRuntimeOptions
28
36
  }
29
37
 
30
38
  export type ManifoldWorkerResponse = ManifoldCompileOutput & { id: number }
@@ -382,7 +382,7 @@ function parseClipboardPayload(text: string): ClipboardPayload | null {
382
382
  if (materials.some((result) => !result.success)) return null
383
383
 
384
384
  const parsedNodes = nodes.filter((result) => result.success).map((result) => result.data)
385
- const nodeIds = new Set(parsedNodes.map((node) => node.id))
385
+ const nodeIds = new Set<AnyNodeId>(parsedNodes.map((node) => node.id))
386
386
  const rootIds = candidate.rootIds as AnyNodeId[]
387
387
  if (rootIds.length === 0 || rootIds.some((id) => !nodeIds.has(id))) return null
388
388