@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
@@ -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
 
@@ -0,0 +1,99 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import { nodeRegistry, registerNode, useScene } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { z } from 'zod'
5
+ import useEditor from '../store/use-editor'
6
+ import { normalizeSceneGraphNodes, syncEditorSelectionFromCurrentScene } from './scene'
7
+
8
+ const building = {
9
+ children: ['level_scene-root'],
10
+ id: 'building_scene-root',
11
+ object: 'node',
12
+ parentId: null,
13
+ position: [0, 0, 0],
14
+ rotation: [0, 0, 0],
15
+ type: 'building',
16
+ visible: true,
17
+ }
18
+
19
+ const level = {
20
+ children: ['wall_scene-root'],
21
+ id: 'level_scene-root',
22
+ level: 0,
23
+ object: 'node',
24
+ parentId: building.id,
25
+ type: 'level',
26
+ visible: true,
27
+ }
28
+
29
+ const wall = {
30
+ children: [],
31
+ end: [4, 0],
32
+ id: 'wall_scene-root',
33
+ object: 'node',
34
+ parentId: level.id,
35
+ start: [0, 0],
36
+ type: 'wall',
37
+ visible: true,
38
+ }
39
+
40
+ describe('scene selection synchronization', () => {
41
+ beforeEach(() => {
42
+ useViewer.getState().resetSelection()
43
+ useEditor.setState({ mode: 'select', phase: 'site', tool: null })
44
+ })
45
+
46
+ test('enters the first level when a scene graph is rooted at a building', () => {
47
+ useScene.setState({
48
+ nodes: {
49
+ [building.id]: building,
50
+ [level.id]: level,
51
+ [wall.id]: wall,
52
+ },
53
+ rootNodeIds: [building.id],
54
+ } as never)
55
+
56
+ syncEditorSelectionFromCurrentScene()
57
+
58
+ expect(useViewer.getState().selection).toMatchObject({
59
+ buildingId: building.id,
60
+ levelId: level.id,
61
+ })
62
+ expect(useEditor.getState().phase).toBe('structure')
63
+ })
64
+ })
65
+
66
+ describe('scene graph normalization', () => {
67
+ test('materializes registered schema defaults before the graph reaches renderers', () => {
68
+ const restoreRegistry = nodeRegistry._snapshot()
69
+ try {
70
+ registerNode({
71
+ kind: 'test-scene-normalization',
72
+ schema: z.object({
73
+ id: z.string(),
74
+ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
75
+ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
76
+ type: z.literal('test-scene-normalization'),
77
+ }),
78
+ schemaVersion: 1,
79
+ } as never)
80
+
81
+ expect(
82
+ normalizeSceneGraphNodes({
83
+ test: { id: 'test', type: 'test-scene-normalization' },
84
+ unknown: { id: 'unknown', type: 'unknown-kind', custom: true },
85
+ }),
86
+ ).toEqual({
87
+ test: {
88
+ id: 'test',
89
+ position: [0, 0, 0],
90
+ rotation: [0, 0, 0],
91
+ type: 'test-scene-normalization',
92
+ },
93
+ unknown: { id: 'unknown', type: 'unknown-kind', custom: true },
94
+ })
95
+ } finally {
96
+ restoreRegistry()
97
+ }
98
+ })
99
+ })
package/src/lib/scene.ts CHANGED
@@ -276,9 +276,13 @@ function getRestoredSelectionForScene(
276
276
  export function syncEditorSelectionFromCurrentScene() {
277
277
  const sceneNodes = useScene.getState().nodes as Record<string, any>
278
278
  const sceneRootIds = useScene.getState().rootNodeIds
279
- const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
280
279
  const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
281
- const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
280
+ const rootNodes = sceneRootIds.map((id) => sceneNodes[id]).filter(Boolean)
281
+ const firstBuilding =
282
+ rootNodes.find((node) => node.type === 'building') ??
283
+ rootNodes
284
+ .flatMap((node) => (Array.isArray(node.children) ? node.children.map(resolve) : []))
285
+ .find((node) => node?.type === 'building')
282
286
  const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
283
287
  const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState())
284
288
  const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState)
@@ -396,11 +400,25 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen
396
400
  )
397
401
  }
398
402
 
403
+ export function normalizeSceneGraphNodes(
404
+ nodes: Readonly<Record<string, unknown>>,
405
+ ): Record<string, unknown> {
406
+ return Object.fromEntries(
407
+ Object.entries(nodes).map(([id, value]) => {
408
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return [id, value]
409
+ const type = (value as { type?: unknown }).type
410
+ if (typeof type !== 'string') return [id, value]
411
+ const parsed = nodeRegistry.get(type)?.schema.safeParse(value)
412
+ return [id, parsed?.success ? parsed.data : value]
413
+ }),
414
+ )
415
+ }
416
+
399
417
  export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
400
418
  const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds()
401
419
  if (hasUsableSceneGraph(sceneGraph)) {
402
420
  const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph
403
- useScene.getState().setScene(nodes as any, rootNodeIds as any, {
421
+ useScene.getState().setScene(normalizeSceneGraphNodes(nodes) as any, rootNodeIds as any, {
404
422
  collections: collections as any,
405
423
  materials: materials as any,
406
424
  installedPlugins: installedPlugins ?? defaultInstalledPlugins,