@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.
- package/package.json +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- 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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
|
1
|
+
import type { Manifold as ManifoldSolid, ManifoldToplevel } from 'manifold-3d'
|
|
2
2
|
import type { PrintShellCompileDiagnostic } from './print-shell-compiler-baseline'
|
|
3
|
-
import type {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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,
|