@pascal-app/editor 1.0.0-beta.5 → 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.
- package/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- 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 +78 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -239
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +304 -171
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +56 -17
- 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 +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +63 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +44 -21
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +103 -10
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
- 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/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- 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/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +5 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- 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/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +455 -0
- package/src/lib/portable-export.ts +1042 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +38 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- 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/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-send-to-app.ts +21 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
package/src/lib/glb-export.ts
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
3
4
|
bakePolicyOf,
|
|
4
5
|
type DoorNode,
|
|
5
6
|
emitter,
|
|
7
|
+
findLevelAncestorId,
|
|
8
|
+
type GeometryContext,
|
|
6
9
|
getLevelDisplayName,
|
|
10
|
+
isNodeKindEnabled,
|
|
7
11
|
isOperationDoorType,
|
|
8
12
|
itemClipRegistry,
|
|
9
13
|
type LevelNode,
|
|
14
|
+
levelBaseElevationAt,
|
|
10
15
|
nodeRegistry,
|
|
11
16
|
sceneRegistry,
|
|
17
|
+
useScene,
|
|
12
18
|
type WindowNode,
|
|
13
19
|
type ZoneNode,
|
|
14
20
|
} from '@pascal-app/core'
|
|
15
21
|
import {
|
|
16
22
|
getPascalTextureRef,
|
|
23
|
+
isViewerPresentationTextureBorrowed,
|
|
17
24
|
poseDoorMovingParts,
|
|
18
25
|
poseWindowMovingParts,
|
|
19
26
|
SCENE_LAYER,
|
|
20
27
|
snapLevelsToTruePositions,
|
|
28
|
+
type ViewerPresentationContribution,
|
|
29
|
+
viewerPresentationRegistry,
|
|
21
30
|
} from '@pascal-app/viewer'
|
|
22
31
|
import type { Object3D } from 'three'
|
|
23
32
|
import * as THREE from 'three'
|
|
@@ -26,7 +35,16 @@ import {
|
|
|
26
35
|
type GLTFExporterPlugin,
|
|
27
36
|
type GLTFWriter,
|
|
28
37
|
} from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
29
|
-
import
|
|
38
|
+
import { createExportTextureUtils, type ExportTextureUtils } from './export-texture-utils'
|
|
39
|
+
import { cloneExportUserData } from './export-user-data'
|
|
40
|
+
import {
|
|
41
|
+
type CompressedTextureDecompressor,
|
|
42
|
+
decompressCanonicalNormalMaps,
|
|
43
|
+
disposeExportResources,
|
|
44
|
+
GLASS_OPACITY_USERDATA,
|
|
45
|
+
normalizePortableScene,
|
|
46
|
+
normalizeViewerArtifactMaterials,
|
|
47
|
+
} from './portable-export'
|
|
30
48
|
|
|
31
49
|
/**
|
|
32
50
|
* Two TRS samples (closed vs open) differing by less than this are treated as
|
|
@@ -44,10 +62,72 @@ type SwingLeafMarker = { axis: 'y'; openRotationY: number }
|
|
|
44
62
|
export type GlbExport = {
|
|
45
63
|
scene: THREE.Object3D
|
|
46
64
|
animations: THREE.AnimationClip[]
|
|
65
|
+
warnings: string[]
|
|
66
|
+
dispose: () => void
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
export type GlbExportOptions = {
|
|
50
70
|
textures?: 'embed' | 'reference'
|
|
71
|
+
onlyVisible?: boolean
|
|
72
|
+
/** Omit these node kinds and their rendered subtrees before baking. */
|
|
73
|
+
excludedNodeTypes?: readonly string[]
|
|
74
|
+
/** Selected static viewer-presentation contributions; omitted means none. */
|
|
75
|
+
includedPresentationIds?: readonly string[]
|
|
76
|
+
/** Portable materials/geometry normalisation vs the baked viewer artifact. */
|
|
77
|
+
purpose?: 'portable' | 'viewer'
|
|
78
|
+
/**
|
|
79
|
+
* Door/window open clips. Defaults to `keep` for the viewer bake and for GLB
|
|
80
|
+
* downloads (Blender turns them into actions); USDZ and print pass `none`
|
|
81
|
+
* because those formats freeze geometry and cannot play them.
|
|
82
|
+
*/
|
|
83
|
+
animations?: 'keep' | 'none'
|
|
84
|
+
/** Called for actual lossy portable conversions discovered during preparation. */
|
|
85
|
+
onWarning?: (warning: string) => void
|
|
86
|
+
/** Reject retained node kinds whose export geometry can only be baked asynchronously. */
|
|
87
|
+
requireSynchronousBake?: boolean
|
|
88
|
+
/** GPU decompressor for compressed normal maps that must be baked; defaults to `textureUtils`. */
|
|
89
|
+
decompressTexture?: CompressedTextureDecompressor
|
|
90
|
+
/**
|
|
91
|
+
* Shared GPU decompressor for every compressed texture in the export. The
|
|
92
|
+
* export entry points create one per export and dispose it; supplying your
|
|
93
|
+
* own keeps ownership with you.
|
|
94
|
+
*/
|
|
95
|
+
textureUtils?: ExportTextureUtils
|
|
96
|
+
/**
|
|
97
|
+
* Wall-clock deadline for the whole export (preparation and serialisation),
|
|
98
|
+
* after which it rejects instead of leaving the caller waiting. Defaults to
|
|
99
|
+
* `DEFAULT_MODEL_EXPORT_TIMEOUT_MS`; `Infinity` disables it.
|
|
100
|
+
*/
|
|
101
|
+
timeoutMs?: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const DEFAULT_MODEL_EXPORT_TIMEOUT_MS = 180_000
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* three's exporters finish inside `FileReader.onloadend` and `canvas.toBlob`
|
|
108
|
+
* callbacks that carry no error path: a failed read or a callback the browser
|
|
109
|
+
* never invokes calls neither `onDone` nor `onError`, so without a deadline the
|
|
110
|
+
* returned promise stays pending forever and the export UI is stuck.
|
|
111
|
+
*/
|
|
112
|
+
export function withExportDeadline<Result>(
|
|
113
|
+
promise: Promise<Result>,
|
|
114
|
+
timeoutMs: number,
|
|
115
|
+
format: string,
|
|
116
|
+
onTimeout?: () => void,
|
|
117
|
+
): Promise<Result> {
|
|
118
|
+
if (!Number.isFinite(timeoutMs)) return promise
|
|
119
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
120
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
121
|
+
timer = setTimeout(() => {
|
|
122
|
+
onTimeout?.()
|
|
123
|
+
reject(
|
|
124
|
+
new Error(
|
|
125
|
+
`${format} export timed out after ${Math.max(1, Math.round(timeoutMs / 1000))} s. Try again with “Visible nodes only” to shrink the scene, or reload the page if it keeps failing.`,
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
}, timeoutMs)
|
|
129
|
+
})
|
|
130
|
+
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
|
|
51
131
|
}
|
|
52
132
|
|
|
53
133
|
/** Resolve after the next couple of animation frames, giving React/R3F time to
|
|
@@ -117,127 +197,667 @@ export async function exportSceneToGlb(
|
|
|
117
197
|
options: GlbExportOptions = {},
|
|
118
198
|
): Promise<ArrayBuffer> {
|
|
119
199
|
const textureMode = options.textures ?? 'embed'
|
|
200
|
+
const textureUtils = options.textureUtils ?? createExportTextureUtils()
|
|
201
|
+
// Preparation awaits plugin bake hooks and presentation builders, so the
|
|
202
|
+
// deadline has to cover it as well as the serialisation.
|
|
203
|
+
const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
|
|
204
|
+
...options,
|
|
205
|
+
textureUtils,
|
|
206
|
+
animations: options.animations ?? 'keep',
|
|
207
|
+
})
|
|
208
|
+
// Assigned inside the raced closure, so a plain `let` narrows to `never` in
|
|
209
|
+
// the `finally`.
|
|
210
|
+
const run: { prepared: GlbExport | null; abandoned: boolean } = {
|
|
211
|
+
prepared: null,
|
|
212
|
+
abandoned: false,
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
return await withExportDeadline(
|
|
216
|
+
(async () => {
|
|
217
|
+
run.prepared = await preparation
|
|
218
|
+
// The race has already rejected; don't serialise a file nobody will get.
|
|
219
|
+
if (run.abandoned) throw new Error('GLB export abandoned')
|
|
220
|
+
for (const warning of run.prepared.warnings) options.onWarning?.(warning)
|
|
221
|
+
return serializePreparedSceneToGlb(run.prepared, {
|
|
222
|
+
textures: textureMode,
|
|
223
|
+
onlyVisible: options.onlyVisible,
|
|
224
|
+
textureUtils,
|
|
225
|
+
})
|
|
226
|
+
})(),
|
|
227
|
+
options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
|
|
228
|
+
'GLB',
|
|
229
|
+
() => {
|
|
230
|
+
run.abandoned = true
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
} finally {
|
|
234
|
+
if (run.prepared) run.prepared.dispose()
|
|
235
|
+
else preparation.then((late) => late.dispose()).catch(() => {})
|
|
236
|
+
if (!options.textureUtils) await textureUtils.dispose()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Capture the live renderer synchronously, restore editor presentation, then do
|
|
241
|
+
* async offscreen material/presentation work against only the owned clone.
|
|
242
|
+
*/
|
|
243
|
+
export async function preparePortableSceneFromViewer(
|
|
244
|
+
sceneGroup: Object3D,
|
|
245
|
+
nodes: Record<string, AnyNode>,
|
|
246
|
+
options: GlbExportOptions = {},
|
|
247
|
+
): Promise<GlbExport> {
|
|
120
248
|
emitter.emit('thumbnail:before-capture', undefined)
|
|
121
|
-
// Snap levels to their true stacked positions (like thumbnail capture) so the
|
|
122
|
-
// export always reflects the clean stacked building, regardless of the live
|
|
123
|
-
// levelMode (exploded/solo) or an unsettled level lerp that could otherwise
|
|
124
|
-
// bake a level at a stray offset.
|
|
125
249
|
const restoreLevels = snapLevelsToTruePositions()
|
|
126
|
-
let
|
|
250
|
+
let preparation: SceneExportPreparation
|
|
127
251
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
252
|
+
preparation = startSceneExportPreparation(sceneGroup, nodes, {
|
|
253
|
+
...options,
|
|
254
|
+
purpose: options.purpose ?? 'portable',
|
|
255
|
+
})
|
|
132
256
|
} finally {
|
|
133
257
|
restoreLevels()
|
|
134
258
|
emitter.emit('thumbnail:after-capture', undefined)
|
|
135
259
|
}
|
|
136
|
-
|
|
260
|
+
return completeSceneExportPreparation(preparation)
|
|
261
|
+
}
|
|
137
262
|
|
|
263
|
+
/** Serialise a prepared scene; callers own the deadline (see `exportSceneToGlb`). */
|
|
264
|
+
export function serializePreparedSceneToGlb(
|
|
265
|
+
prepared: GlbExport,
|
|
266
|
+
options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> & {
|
|
267
|
+
textureUtils: ExportTextureUtils
|
|
268
|
+
},
|
|
269
|
+
): Promise<ArrayBuffer> {
|
|
138
270
|
const exporter = new GLTFExporter()
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// editor canvas), letting the exporter embed standard textures.
|
|
144
|
-
exporter.setTextureUtils(WebGPUTextureUtils)
|
|
271
|
+
if ((options.textures ?? 'embed') === 'reference') {
|
|
272
|
+
exporter.register(textureReferencePlugin)
|
|
273
|
+
}
|
|
274
|
+
exporter.setTextureUtils(options.textureUtils)
|
|
145
275
|
|
|
146
276
|
return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
147
277
|
exporter.parse(
|
|
148
|
-
|
|
278
|
+
prepared.scene,
|
|
149
279
|
(gltf) => {
|
|
150
|
-
|
|
280
|
+
// A failed final FileReader hands GLTFExporter's onDone `null`, which
|
|
281
|
+
// `new Blob([null])` would happily turn into a four-byte download.
|
|
282
|
+
if (gltf instanceof ArrayBuffer) resolve(gltf)
|
|
283
|
+
else reject(new Error('GLB export produced no data'))
|
|
151
284
|
},
|
|
152
285
|
(error) => {
|
|
153
|
-
reject(error)
|
|
286
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
binary: true,
|
|
290
|
+
animations: prepared.animations,
|
|
291
|
+
onlyVisible: options.onlyVisible ?? true,
|
|
154
292
|
},
|
|
155
|
-
{ binary: true, animations },
|
|
156
293
|
)
|
|
157
294
|
})
|
|
158
295
|
}
|
|
159
296
|
|
|
297
|
+
type RegistryEntry = readonly [id: string, original: THREE.Object3D]
|
|
298
|
+
|
|
299
|
+
type SelectedPresentation = {
|
|
300
|
+
contribution: ViewerPresentationContribution
|
|
301
|
+
configuration: unknown
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
type SceneExportPreparation = {
|
|
305
|
+
scene: THREE.Object3D
|
|
306
|
+
nodes: Record<string, AnyNode>
|
|
307
|
+
options: GlbExportOptions
|
|
308
|
+
registryEntries: RegistryEntry[]
|
|
309
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>
|
|
310
|
+
builders: Map<
|
|
311
|
+
string,
|
|
312
|
+
{
|
|
313
|
+
sync: ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D) | undefined
|
|
314
|
+
async: ((node: AnyNode, ctx: GeometryContext) => Promise<THREE.Object3D>) | undefined
|
|
315
|
+
}
|
|
316
|
+
>
|
|
317
|
+
geometryContext: Pick<GeometryContext, 'materials' | 'levelData'>
|
|
318
|
+
presentations: SelectedPresentation[]
|
|
319
|
+
}
|
|
320
|
+
|
|
160
321
|
/**
|
|
161
|
-
* Build
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* - Clones the source so live objects are never mutated.
|
|
166
|
-
* - Converts WebGPU NodeMaterials to classic glTF-standard materials.
|
|
167
|
-
* `GLTFExporter` only recognises `isMeshStandardMaterial` /
|
|
168
|
-
* `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set
|
|
169
|
-
* `isNodeMaterial` instead, so without this every surface exports as a blank
|
|
170
|
-
* default material.
|
|
171
|
-
* - Bakes open motions into glTF animation clips via kind-owned registry
|
|
172
|
-
* hooks, plus the legacy door/window build-once + pose-at-t primitives
|
|
173
|
-
* (`pascalSwingLeaf` for doors, `poseWindowMovingParts` for windows).
|
|
174
|
-
* - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover
|
|
175
|
-
* survive the bake with no in-memory registry, and strips all other userData
|
|
176
|
-
* so editor/runtime ephemera never leak into glTF extras.
|
|
322
|
+
* Build the legacy synchronous artifact used by geometry-only/print exports.
|
|
323
|
+
* The default remains viewer-purpose so existing internal baked-viewer clips
|
|
324
|
+
* are unchanged; portable downloads use the async entry point below.
|
|
177
325
|
*/
|
|
178
326
|
export function prepareSceneForExport(
|
|
179
327
|
source: THREE.Object3D,
|
|
180
328
|
nodes: Record<string, AnyNode>,
|
|
181
329
|
options: GlbExportOptions = {},
|
|
182
330
|
): GlbExport {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
331
|
+
const preparation = startSceneExportPreparation(source, nodes, {
|
|
332
|
+
...options,
|
|
333
|
+
purpose: options.purpose ?? 'viewer',
|
|
334
|
+
})
|
|
335
|
+
try {
|
|
336
|
+
replaceBakeGeometrySync(preparation)
|
|
337
|
+
return finishSceneExportPreparation(preparation)
|
|
338
|
+
} catch (error) {
|
|
339
|
+
disposeExportResources(preparation.scene)
|
|
340
|
+
throw error
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Capture once, await async bake hooks, then normalize a static portable tree. */
|
|
345
|
+
export async function prepareSceneForExportAsync(
|
|
346
|
+
source: THREE.Object3D,
|
|
347
|
+
nodes: Record<string, AnyNode>,
|
|
348
|
+
options: GlbExportOptions = {},
|
|
349
|
+
): Promise<GlbExport> {
|
|
350
|
+
const preparation = startSceneExportPreparation(source, nodes, {
|
|
351
|
+
...options,
|
|
352
|
+
purpose: options.purpose ?? 'portable',
|
|
353
|
+
})
|
|
354
|
+
return completeSceneExportPreparation(preparation)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function startSceneExportPreparation(
|
|
358
|
+
source: THREE.Object3D,
|
|
359
|
+
inputNodes: Record<string, AnyNode>,
|
|
360
|
+
options: GlbExportOptions,
|
|
361
|
+
): SceneExportPreparation {
|
|
362
|
+
const nodes = structuredClone(inputNodes)
|
|
363
|
+
const registryEntries = Array.from(sceneRegistry.nodes.entries())
|
|
364
|
+
const excludedNodeTypes = new Set(options.excludedNodeTypes)
|
|
365
|
+
const excludedObjects = new Set<THREE.Object3D>()
|
|
366
|
+
const builders: SceneExportPreparation['builders'] = new Map()
|
|
367
|
+
const sceneState = useScene.getState()
|
|
368
|
+
|
|
369
|
+
for (const [id, original] of registryEntries) {
|
|
194
370
|
const node = nodes[id]
|
|
195
|
-
if (node
|
|
196
|
-
|
|
371
|
+
if (!node) continue
|
|
372
|
+
const installedPlugins = sceneState.hasExplicitPluginInstallState
|
|
373
|
+
? sceneState.installedPlugins
|
|
374
|
+
: undefined
|
|
375
|
+
if (!isNodeKindEnabled(node.type, installedPlugins)) {
|
|
376
|
+
excludedObjects.add(original)
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
const definition = nodeRegistry.get(node.type)
|
|
380
|
+
builders.set(id, {
|
|
381
|
+
sync: definition?.bakeGeometry as
|
|
382
|
+
| ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D)
|
|
383
|
+
| undefined,
|
|
384
|
+
async: definition?.bakeGeometryAsync as
|
|
385
|
+
| ((node: AnyNode, ctx: GeometryContext) => Promise<THREE.Object3D>)
|
|
386
|
+
| undefined,
|
|
387
|
+
})
|
|
388
|
+
if (bakePolicyOf(node.type) === 'strip' || excludedNodeTypes.has(node.type)) {
|
|
389
|
+
excludedObjects.add(original)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const selectedIds = new Set(options.includedPresentationIds ?? [])
|
|
394
|
+
const registeredPresentations = viewerPresentationRegistry.getSnapshot()
|
|
395
|
+
const presentations: SelectedPresentation[] = []
|
|
396
|
+
for (const id of selectedIds) {
|
|
397
|
+
const contribution = registeredPresentations.find((entry) => entry.id === id)
|
|
398
|
+
if (!contribution?.staticExport) {
|
|
399
|
+
throw new Error(`Static viewer presentation "${id}" is not registered`)
|
|
400
|
+
}
|
|
401
|
+
if (contribution.pluginId && !sceneState.installedPlugins.includes(contribution.pluginId)) {
|
|
402
|
+
throw new Error(`Static viewer presentation "${id}" belongs to an uninstalled plugin`)
|
|
403
|
+
}
|
|
404
|
+
presentations.push({
|
|
405
|
+
contribution,
|
|
406
|
+
configuration: contribution.configuration?.getSnapshot(),
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const cloneByOriginal = new Map<THREE.Object3D, THREE.Object3D>()
|
|
411
|
+
const scene = cloneSceneForExport(source, excludedObjects, cloneByOriginal)
|
|
412
|
+
cloneSkinnedSkeletons(cloneByOriginal)
|
|
413
|
+
if (options.onlyVisible ?? true) {
|
|
414
|
+
pruneHiddenSceneNodes(cloneByOriginal, nodes, registryEntries)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
scene,
|
|
419
|
+
nodes,
|
|
420
|
+
options,
|
|
421
|
+
registryEntries,
|
|
422
|
+
cloneByOriginal,
|
|
423
|
+
builders,
|
|
424
|
+
geometryContext: {
|
|
425
|
+
levelData: undefined,
|
|
426
|
+
materials: structuredClone(sceneState.materials),
|
|
427
|
+
},
|
|
428
|
+
presentations,
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Own a throwaway decompressor only when the caller supplied neither form. */
|
|
433
|
+
function resolveNormalMapDecompressor(options: GlbExportOptions): {
|
|
434
|
+
decompress: CompressedTextureDecompressor
|
|
435
|
+
dispose: () => Promise<void>
|
|
436
|
+
} {
|
|
437
|
+
if (options.decompressTexture) {
|
|
438
|
+
return { decompress: options.decompressTexture, dispose: async () => {} }
|
|
439
|
+
}
|
|
440
|
+
const utils = options.textureUtils ?? createExportTextureUtils()
|
|
441
|
+
return {
|
|
442
|
+
decompress: (texture) => utils.decompress(texture),
|
|
443
|
+
dispose: options.textureUtils ? async () => {} : () => utils.dispose(),
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function completeSceneExportPreparation(
|
|
448
|
+
preparation: SceneExportPreparation,
|
|
449
|
+
): Promise<GlbExport> {
|
|
450
|
+
const decompressor = resolveNormalMapDecompressor(preparation.options)
|
|
451
|
+
try {
|
|
452
|
+
await replaceBakeGeometryAsync(preparation)
|
|
453
|
+
await appendSelectedPresentations(preparation)
|
|
454
|
+
const prepared = finishSceneExportPreparation(preparation)
|
|
455
|
+
const { options } = preparation
|
|
456
|
+
const byReference = (options.textures ?? 'embed') === 'reference'
|
|
457
|
+
const normalizeOptions = {
|
|
458
|
+
preserveNormalMap: (texture: THREE.Texture) =>
|
|
459
|
+
byReference && getPascalTextureRef(texture) !== null,
|
|
197
460
|
}
|
|
461
|
+
await decompressCanonicalNormalMaps(prepared.scene, decompressor.decompress, normalizeOptions)
|
|
462
|
+
prepared.warnings.push(
|
|
463
|
+
...(options.purpose === 'viewer'
|
|
464
|
+
? normalizeViewerArtifactMaterials(prepared.scene, normalizeOptions)
|
|
465
|
+
: normalizePortableScene(prepared.scene, normalizeOptions)),
|
|
466
|
+
)
|
|
467
|
+
return prepared
|
|
468
|
+
} catch (error) {
|
|
469
|
+
disposeExportResources(preparation.scene)
|
|
470
|
+
throw error
|
|
471
|
+
} finally {
|
|
472
|
+
await decompressor.dispose()
|
|
198
473
|
}
|
|
474
|
+
}
|
|
199
475
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// are stripped, but its identity node stays to carry the polygon that /viewer
|
|
203
|
-
// reconstructs the room from.
|
|
476
|
+
function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbExport {
|
|
477
|
+
const { scene, cloneByOriginal, nodes, options, registryEntries } = preparation
|
|
204
478
|
const identityNodes = new Set<THREE.Object3D>()
|
|
205
|
-
for (const original of
|
|
479
|
+
for (const [, original] of registryEntries) {
|
|
206
480
|
const clone = cloneByOriginal.get(original)
|
|
207
481
|
if (clone) identityNodes.add(clone)
|
|
208
482
|
}
|
|
209
483
|
|
|
210
484
|
pruneNonRenderableMeshes(scene, identityNodes)
|
|
211
485
|
sanitizeMaterialGroups(scene, identityNodes)
|
|
212
|
-
convertMaterials(scene, options.textures ?? 'embed')
|
|
486
|
+
convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer')
|
|
487
|
+
|
|
488
|
+
const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
|
|
489
|
+
const keepClips = options.animations
|
|
490
|
+
? options.animations === 'keep'
|
|
491
|
+
: options.purpose === 'viewer'
|
|
492
|
+
const animation = keepClips
|
|
493
|
+
? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
|
|
494
|
+
: { clips: [], clipNamesByNode: new Map<string, string[]>() }
|
|
495
|
+
stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries)
|
|
496
|
+
|
|
497
|
+
let disposed = false
|
|
498
|
+
return {
|
|
499
|
+
scene,
|
|
500
|
+
animations: animation.clips,
|
|
501
|
+
warnings: [],
|
|
502
|
+
dispose: () => {
|
|
503
|
+
if (disposed) return
|
|
504
|
+
disposed = true
|
|
505
|
+
disposeExportResources(scene)
|
|
506
|
+
},
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function replaceBakeGeometrySync(preparation: SceneExportPreparation): void {
|
|
511
|
+
for (const [id, original] of preparation.registryEntries) {
|
|
512
|
+
const node = preparation.nodes[id]
|
|
513
|
+
const builders = preparation.builders.get(id)
|
|
514
|
+
if (!node || !builders) continue
|
|
515
|
+
const cloned = preparation.cloneByOriginal.get(original)
|
|
516
|
+
if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue
|
|
517
|
+
if (preparation.options.requireSynchronousBake && builders.async && !builders.sync) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`Node kind "${node.type}" can only bake geometry asynchronously. Choose GLB/USDZ or exclude it from the export.`,
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
if (!builders.sync) continue
|
|
523
|
+
replaceBakedNode(
|
|
524
|
+
preparation,
|
|
525
|
+
id,
|
|
526
|
+
original,
|
|
527
|
+
builders.sync(
|
|
528
|
+
node,
|
|
529
|
+
buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext),
|
|
530
|
+
),
|
|
531
|
+
'bakeGeometry',
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
213
535
|
|
|
214
|
-
|
|
536
|
+
async function replaceBakeGeometryAsync(preparation: SceneExportPreparation): Promise<void> {
|
|
537
|
+
for (const [id, original] of preparation.registryEntries) {
|
|
538
|
+
const node = preparation.nodes[id]
|
|
539
|
+
const builders = preparation.builders.get(id)
|
|
540
|
+
if (!node || (!builders?.async && !builders?.sync)) continue
|
|
541
|
+
const cloned = preparation.cloneByOriginal.get(original)
|
|
542
|
+
if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue
|
|
543
|
+
const context = buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext)
|
|
544
|
+
const replacement = builders.async
|
|
545
|
+
? await builders.async(node, context)
|
|
546
|
+
: builders.sync!(node, context)
|
|
547
|
+
replaceBakedNode(
|
|
548
|
+
preparation,
|
|
549
|
+
id,
|
|
550
|
+
original,
|
|
551
|
+
replacement,
|
|
552
|
+
builders.async ? 'bakeGeometryAsync' : 'bakeGeometry',
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
215
556
|
|
|
216
|
-
|
|
557
|
+
function replaceBakedNode(
|
|
558
|
+
preparation: SceneExportPreparation,
|
|
559
|
+
id: string,
|
|
560
|
+
original: THREE.Object3D,
|
|
561
|
+
replacement: THREE.Object3D,
|
|
562
|
+
hook: 'bakeGeometry' | 'bakeGeometryAsync',
|
|
563
|
+
): void {
|
|
564
|
+
const cloned = preparation.cloneByOriginal.get(original)
|
|
565
|
+
if (!cloned || !isDescendantOf(cloned, preparation.scene)) return
|
|
566
|
+
const parent = cloned.parent
|
|
567
|
+
if (!parent) throw new Error(`Cannot replace root export geometry for node ${id}`)
|
|
568
|
+
if (replacement === cloned || replacement.parent) {
|
|
569
|
+
throw new Error(
|
|
570
|
+
`${hook} for ${preparation.nodes[id]?.type} must return a new detached Object3D`,
|
|
571
|
+
)
|
|
572
|
+
}
|
|
217
573
|
|
|
218
|
-
|
|
574
|
+
const siblingIndex = parent.children.indexOf(cloned)
|
|
575
|
+
replacement.position.copy(cloned.position)
|
|
576
|
+
replacement.quaternion.copy(cloned.quaternion)
|
|
577
|
+
replacement.scale.copy(cloned.scale)
|
|
578
|
+
replacement.matrix.copy(cloned.matrix)
|
|
579
|
+
replacement.matrixAutoUpdate = cloned.matrixAutoUpdate
|
|
580
|
+
replacement.visible = cloned.visible
|
|
581
|
+
replacement.layers.mask = cloned.layers.mask
|
|
582
|
+
replacement.renderOrder = cloned.renderOrder
|
|
583
|
+
parent.remove(cloned)
|
|
584
|
+
parent.add(replacement)
|
|
585
|
+
const appendedIndex = parent.children.indexOf(replacement)
|
|
586
|
+
|
|
587
|
+
parent.children.splice(appendedIndex, 1)
|
|
588
|
+
parent.children.splice(siblingIndex, 0, replacement)
|
|
589
|
+
preparation.cloneByOriginal.set(original, replacement)
|
|
590
|
+
}
|
|
591
|
+
function ownBorrowedPresentationTextures(root: THREE.Object3D): void {
|
|
592
|
+
const ownedTextures = new Map<THREE.Texture, THREE.Texture>()
|
|
593
|
+
root.traverse((object) => {
|
|
594
|
+
const renderable = object as THREE.Mesh
|
|
595
|
+
if (!renderable.material) return
|
|
596
|
+
const materials = Array.isArray(renderable.material)
|
|
597
|
+
? renderable.material
|
|
598
|
+
: [renderable.material]
|
|
599
|
+
for (const material of materials) {
|
|
600
|
+
const textured = material as THREE.Material & Record<string, unknown>
|
|
601
|
+
for (const slot of REFERENCE_MAP_SLOTS) {
|
|
602
|
+
const sourceTexture = textured[slot]
|
|
603
|
+
if (
|
|
604
|
+
!(sourceTexture instanceof THREE.Texture) ||
|
|
605
|
+
!isViewerPresentationTextureBorrowed(sourceTexture)
|
|
606
|
+
) {
|
|
607
|
+
continue
|
|
608
|
+
}
|
|
609
|
+
let ownedTexture = ownedTextures.get(sourceTexture)
|
|
610
|
+
if (!ownedTexture) {
|
|
611
|
+
ownedTexture = sourceTexture.clone()
|
|
612
|
+
ownedTexture.userData = cloneExportUserData(sourceTexture.userData)
|
|
613
|
+
ownedTexture.needsUpdate = true
|
|
614
|
+
ownedTextures.set(sourceTexture, ownedTexture)
|
|
615
|
+
}
|
|
616
|
+
textured[slot] = ownedTexture
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
})
|
|
219
620
|
}
|
|
220
621
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
622
|
+
async function appendSelectedPresentations(preparation: SceneExportPreparation): Promise<void> {
|
|
623
|
+
for (const { contribution, configuration } of preparation.presentations) {
|
|
624
|
+
const staticExport = contribution.staticExport!
|
|
625
|
+
const built = await staticExport.build({
|
|
626
|
+
nodes: preparation.nodes,
|
|
627
|
+
configuration,
|
|
628
|
+
onlyVisible: preparation.options.onlyVisible ?? true,
|
|
629
|
+
excludedNodeTypes: preparation.options.excludedNodeTypes ?? [],
|
|
630
|
+
})
|
|
631
|
+
if (!built) continue
|
|
632
|
+
if (built.parent) {
|
|
633
|
+
throw new Error(`Static viewer presentation "${contribution.id}" returned an attached root`)
|
|
634
|
+
}
|
|
635
|
+
ownBorrowedPresentationTextures(built)
|
|
636
|
+
const wrapper = new THREE.Group()
|
|
637
|
+
wrapper.name = contribution.id
|
|
638
|
+
wrapper.userData = {
|
|
639
|
+
pascalPresentationId: contribution.id,
|
|
640
|
+
label: staticExport.label,
|
|
641
|
+
}
|
|
642
|
+
wrapper.add(built)
|
|
643
|
+
preparation.scene.add(wrapper)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function buildBakeGeometryContext(
|
|
648
|
+
node: AnyNode,
|
|
649
|
+
nodes: Record<string, AnyNode>,
|
|
650
|
+
base: Pick<GeometryContext, 'materials' | 'levelData'>,
|
|
651
|
+
): GeometryContext {
|
|
652
|
+
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
|
653
|
+
const childIds = Array.isArray((node as { children?: AnyNodeId[] }).children)
|
|
654
|
+
? (node as { children: AnyNodeId[] }).children
|
|
655
|
+
: []
|
|
656
|
+
const children = childIds
|
|
657
|
+
.map((id) => nodes[id])
|
|
658
|
+
.filter((child): child is AnyNode => child !== undefined)
|
|
659
|
+
const allNodes = node.parentId ? undefined : Object.values(nodes)
|
|
660
|
+
const parent = node.parentId
|
|
661
|
+
? (nodes[node.parentId] ?? null)
|
|
662
|
+
: (allNodes?.find(
|
|
663
|
+
(candidate) =>
|
|
664
|
+
candidate.type === 'site' &&
|
|
665
|
+
'children' in candidate &&
|
|
666
|
+
Array.isArray(candidate.children) &&
|
|
667
|
+
candidate.children.includes(node.id),
|
|
668
|
+
) ?? null)
|
|
669
|
+
const siblingIds =
|
|
670
|
+
parent && Array.isArray((parent as { children?: AnyNodeId[] }).children)
|
|
671
|
+
? (parent as { children: AnyNodeId[] }).children
|
|
672
|
+
: []
|
|
673
|
+
let siblings: AnyNode[]
|
|
674
|
+
if (parent?.type === 'site') {
|
|
675
|
+
const declaredChildren = new Set(siblingIds)
|
|
676
|
+
siblings = (allNodes ?? Object.values(nodes)).filter(
|
|
677
|
+
(candidate) =>
|
|
678
|
+
candidate.id !== node.id &&
|
|
679
|
+
candidate.type === node.type &&
|
|
680
|
+
(candidate.parentId === parent.id || declaredChildren.has(candidate.id)),
|
|
681
|
+
)
|
|
682
|
+
} else {
|
|
683
|
+
siblings = siblingIds
|
|
684
|
+
.filter((id) => id !== node.id)
|
|
685
|
+
.map((id) => nodes[id])
|
|
686
|
+
.filter((sibling): sibling is AnyNode => sibling?.type === node.type)
|
|
687
|
+
}
|
|
688
|
+
const levelId = findLevelAncestorId(node.id, nodes)
|
|
689
|
+
const levelBaseAt = (x: number, z: number) =>
|
|
690
|
+
levelId ? levelBaseElevationAt(nodes, levelId, x, z) : 0
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
resolve,
|
|
694
|
+
children,
|
|
695
|
+
siblings,
|
|
696
|
+
parent,
|
|
697
|
+
levelBaseAt,
|
|
698
|
+
levelData: base.levelData,
|
|
699
|
+
materials: base.materials,
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function isDescendantOf(object: THREE.Object3D, ancestor: THREE.Object3D): boolean {
|
|
704
|
+
let current: THREE.Object3D | null = object
|
|
705
|
+
while (current) {
|
|
706
|
+
if (current === ancestor) return true
|
|
707
|
+
current = current.parent
|
|
708
|
+
}
|
|
709
|
+
return false
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function pruneHiddenSceneNodes(
|
|
713
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
714
|
+
nodes: Record<string, AnyNode>,
|
|
715
|
+
registryEntries: readonly RegistryEntry[],
|
|
716
|
+
) {
|
|
717
|
+
const visibility = new Map<string, boolean>()
|
|
718
|
+
const declaredSiteParents = new Map<string, string>()
|
|
719
|
+
for (const node of Object.values(nodes)) {
|
|
720
|
+
if (node.type !== 'site' || !('children' in node) || !Array.isArray(node.children)) continue
|
|
721
|
+
for (const childId of node.children) {
|
|
722
|
+
const child = nodes[childId]
|
|
723
|
+
if (child && !child.parentId && !declaredSiteParents.has(childId)) {
|
|
724
|
+
declaredSiteParents.set(childId, node.id)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const isVisible = (id: string, path: Set<string>): boolean => {
|
|
730
|
+
const cached = visibility.get(id)
|
|
731
|
+
if (cached !== undefined) return cached
|
|
732
|
+
|
|
733
|
+
const node = nodes[id]
|
|
734
|
+
if (!node) return true
|
|
735
|
+
if (node.visible === false) {
|
|
736
|
+
visibility.set(id, false)
|
|
737
|
+
return false
|
|
738
|
+
}
|
|
739
|
+
const parentId = node.parentId || declaredSiteParents.get(id)
|
|
740
|
+
if (!parentId || path.has(id)) {
|
|
741
|
+
visibility.set(id, true)
|
|
742
|
+
return true
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
path.add(id)
|
|
746
|
+
const visible = isVisible(parentId, path)
|
|
747
|
+
path.delete(id)
|
|
748
|
+
visibility.set(id, visible)
|
|
749
|
+
return visible
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
for (const [id, original] of registryEntries) {
|
|
753
|
+
if (isVisible(id, new Set())) continue
|
|
754
|
+
cloneByOriginal.get(original)?.removeFromParent()
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function retainedClones(
|
|
759
|
+
root: THREE.Object3D,
|
|
760
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
229
761
|
): Map<THREE.Object3D, THREE.Object3D> {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
762
|
+
const retained = new Set<THREE.Object3D>()
|
|
763
|
+
root.traverse((object) => retained.add(object))
|
|
764
|
+
return new Map(Array.from(cloneByOriginal.entries()).filter(([, clone]) => retained.has(clone)))
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
type ResourceCloneCache = {
|
|
768
|
+
geometries: Map<THREE.BufferGeometry, THREE.BufferGeometry>
|
|
769
|
+
materials: Map<THREE.Material, THREE.Material>
|
|
770
|
+
textures: Map<THREE.Texture, THREE.Texture>
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Skip excluded subtrees before cloning large procedural instance buffers. */
|
|
774
|
+
function cloneSceneForExport(
|
|
775
|
+
source: THREE.Object3D,
|
|
776
|
+
excludedObjects: Set<THREE.Object3D>,
|
|
777
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
778
|
+
cache: ResourceCloneCache = {
|
|
779
|
+
geometries: new Map(),
|
|
780
|
+
materials: new Map(),
|
|
781
|
+
textures: new Map(),
|
|
782
|
+
},
|
|
783
|
+
): THREE.Object3D {
|
|
784
|
+
if (excludedObjects.has(source)) return new THREE.Group()
|
|
785
|
+
|
|
786
|
+
const clone = source.clone(false)
|
|
787
|
+
clone.userData = cloneExportUserData(source.userData)
|
|
788
|
+
const renderable = source as THREE.Mesh
|
|
789
|
+
const renderableClone = clone as THREE.Mesh
|
|
790
|
+
if (renderable.geometry) {
|
|
791
|
+
let geometry = cache.geometries.get(renderable.geometry)
|
|
792
|
+
if (!geometry) {
|
|
793
|
+
geometry = renderable.geometry.clone()
|
|
794
|
+
cache.geometries.set(renderable.geometry, geometry)
|
|
795
|
+
}
|
|
796
|
+
renderableClone.geometry = geometry
|
|
797
|
+
}
|
|
798
|
+
if (renderable.material) {
|
|
799
|
+
const cloneMaterial = (material: THREE.Material): THREE.Material => {
|
|
800
|
+
let result = cache.materials.get(material)
|
|
801
|
+
if (result) return result
|
|
802
|
+
result = material.clone()
|
|
803
|
+
const textured = result as THREE.Material & Record<string, unknown>
|
|
804
|
+
for (const slot of REFERENCE_MAP_SLOTS) {
|
|
805
|
+
const texture = textured[slot]
|
|
806
|
+
if (!(texture instanceof THREE.Texture)) continue
|
|
807
|
+
let textureClone = cache.textures.get(texture)
|
|
808
|
+
if (!textureClone) {
|
|
809
|
+
textureClone = texture.clone()
|
|
810
|
+
textureClone.userData = cloneExportUserData(texture.userData)
|
|
811
|
+
textureClone.needsUpdate = true
|
|
812
|
+
cache.textures.set(texture, textureClone)
|
|
813
|
+
}
|
|
814
|
+
textured[slot] = textureClone
|
|
815
|
+
}
|
|
816
|
+
cache.materials.set(material, result)
|
|
817
|
+
return result
|
|
818
|
+
}
|
|
819
|
+
if (Array.isArray(renderable.material)) {
|
|
820
|
+
const materialSlots = renderable.material as Array<THREE.Material | null | undefined>
|
|
821
|
+
renderableClone.material = materialSlots.map((material) =>
|
|
822
|
+
material ? cloneMaterial(material) : material,
|
|
823
|
+
) as THREE.Material[]
|
|
824
|
+
} else {
|
|
825
|
+
renderableClone.material = cloneMaterial(renderable.material)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
cloneByOriginal.set(source, clone)
|
|
830
|
+
for (const child of source.children) {
|
|
831
|
+
if (!excludedObjects.has(child)) {
|
|
832
|
+
clone.add(cloneSceneForExport(child, excludedObjects, cloneByOriginal, cache))
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return clone
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function cloneSkinnedSkeletons(cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>): void {
|
|
839
|
+
for (const [original, cloned] of cloneByOriginal) {
|
|
840
|
+
const source = original as THREE.SkinnedMesh
|
|
841
|
+
const target = cloned as THREE.SkinnedMesh
|
|
842
|
+
if (!source.isSkinnedMesh) continue
|
|
843
|
+
const bones = source.skeleton.bones.map((bone) => {
|
|
844
|
+
const clonedBone = cloneByOriginal.get(bone)
|
|
845
|
+
if (!(clonedBone as THREE.Bone | undefined)?.isBone) {
|
|
846
|
+
throw new Error(
|
|
847
|
+
`Skinned mesh "${source.name}" references a bone outside its export subtree`,
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
return clonedBone as THREE.Bone
|
|
851
|
+
})
|
|
852
|
+
target.bindMode = source.bindMode
|
|
853
|
+
target.bind(
|
|
854
|
+
new THREE.Skeleton(
|
|
855
|
+
bones,
|
|
856
|
+
source.skeleton.boneInverses.map((inverse) => inverse.clone()),
|
|
857
|
+
),
|
|
858
|
+
source.bindMatrix.clone(),
|
|
859
|
+
)
|
|
239
860
|
}
|
|
240
|
-
return map
|
|
241
861
|
}
|
|
242
862
|
|
|
243
863
|
// A single empty geometry shared by every container mesh we neutralise below —
|
|
@@ -296,8 +916,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
|
|
|
296
916
|
renderable.material == null
|
|
297
917
|
) {
|
|
298
918
|
if (object.children.length > 0) {
|
|
299
|
-
renderable.geometry = EMPTY_GEOMETRY
|
|
300
|
-
renderable.material = PLACEHOLDER_MATERIAL
|
|
919
|
+
renderable.geometry = EMPTY_GEOMETRY.clone()
|
|
920
|
+
renderable.material = PLACEHOLDER_MATERIAL.clone()
|
|
301
921
|
} else {
|
|
302
922
|
toRemove.push(object)
|
|
303
923
|
}
|
|
@@ -315,12 +935,12 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
|
|
|
315
935
|
Array.isArray(renderable.material) &&
|
|
316
936
|
renderable.material.some((m) => m == null)
|
|
317
937
|
) {
|
|
318
|
-
renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL)
|
|
938
|
+
renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL.clone())
|
|
319
939
|
}
|
|
320
940
|
const mesh = object as THREE.Mesh
|
|
321
941
|
if (!mesh.isMesh || isRenderableMesh(mesh)) return
|
|
322
942
|
if (mesh.children.length > 0) {
|
|
323
|
-
mesh.geometry = EMPTY_GEOMETRY
|
|
943
|
+
mesh.geometry = EMPTY_GEOMETRY.clone()
|
|
324
944
|
} else {
|
|
325
945
|
toRemove.push(mesh)
|
|
326
946
|
}
|
|
@@ -343,8 +963,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
|
|
|
343
963
|
* system's degenerate placeholder) — is neutralised like other
|
|
344
964
|
* non-renderables (kept as a bare transform node, or removed if a leaf
|
|
345
965
|
* that carries no node identity).
|
|
346
|
-
*
|
|
347
|
-
*
|
|
966
|
+
* The export tree owns its resources; repairs still avoid copying vertex
|
|
967
|
+
* buffers when replacing only group metadata.
|
|
348
968
|
*/
|
|
349
969
|
function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
|
|
350
970
|
const toRemove: THREE.Object3D[] = []
|
|
@@ -364,15 +984,15 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.O
|
|
|
364
984
|
)
|
|
365
985
|
if (validGroups.length === 0) {
|
|
366
986
|
if (mesh.children.length > 0 || identityNodes.has(mesh)) {
|
|
367
|
-
mesh.geometry = EMPTY_GEOMETRY
|
|
368
|
-
mesh.material = PLACEHOLDER_MATERIAL
|
|
987
|
+
mesh.geometry = EMPTY_GEOMETRY.clone()
|
|
988
|
+
mesh.material = PLACEHOLDER_MATERIAL.clone()
|
|
369
989
|
} else {
|
|
370
990
|
toRemove.push(mesh)
|
|
371
991
|
}
|
|
372
992
|
return
|
|
373
993
|
}
|
|
374
|
-
// Only the group list needs repair
|
|
375
|
-
//
|
|
994
|
+
// Only the group list needs repair; reuse export-owned attributes rather
|
|
995
|
+
// than duplicating every vertex buffer.
|
|
376
996
|
if (validGroups.length !== groups.length) {
|
|
377
997
|
const geometry = new THREE.BufferGeometry()
|
|
378
998
|
geometry.index = mesh.geometry.index
|
|
@@ -385,7 +1005,7 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.O
|
|
|
385
1005
|
geometry.groups = validGroups.map((g) => ({ ...g }))
|
|
386
1006
|
mesh.geometry = geometry
|
|
387
1007
|
}
|
|
388
|
-
mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL)
|
|
1008
|
+
mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL.clone())
|
|
389
1009
|
})
|
|
390
1010
|
for (const object of toRemove) {
|
|
391
1011
|
object.removeFromParent()
|
|
@@ -435,29 +1055,44 @@ const REFERENCE_MAP_SLOTS = [
|
|
|
435
1055
|
'anisotropyMap',
|
|
436
1056
|
] as const
|
|
437
1057
|
|
|
438
|
-
function convertMaterials(
|
|
1058
|
+
function convertMaterials(
|
|
1059
|
+
root: THREE.Object3D,
|
|
1060
|
+
textureMode: 'embed' | 'reference',
|
|
1061
|
+
purpose: 'portable' | 'viewer',
|
|
1062
|
+
) {
|
|
439
1063
|
const cache = new Map<THREE.Material, THREE.Material>()
|
|
440
1064
|
const placeholderCache = new Map<THREE.Texture, THREE.Texture>()
|
|
441
1065
|
root.traverse((object) => {
|
|
442
1066
|
const mesh = object as THREE.Mesh
|
|
443
1067
|
if (!mesh.isMesh) return
|
|
444
1068
|
const material = mesh.material
|
|
445
|
-
|
|
446
|
-
mesh.material = material.map((m) => convertMaterial(m, cache, textureMode, placeholderCache))
|
|
447
|
-
return
|
|
448
|
-
}
|
|
449
|
-
// glTF has no BackSide — GLTFExporter renders the *front* face for any
|
|
450
|
-
// non-DoubleSide material, which inverts a BackSide surface (e.g. the
|
|
451
|
-
// ceiling underside, meant to be seen from the room). Flip the mesh winding
|
|
452
|
-
// so the intended face shows with the FrontSide material convertMaterial
|
|
453
|
-
// produces. Per-mesh geometry clone keeps shared geometry untouched.
|
|
1069
|
+
const materialArray = Array.isArray(material) ? material : [material]
|
|
454
1070
|
if (
|
|
455
|
-
|
|
456
|
-
|
|
1071
|
+
purpose === 'viewer' &&
|
|
1072
|
+
materialArray.length === 1 &&
|
|
1073
|
+
(materialArray[0] as { isNodeMaterial?: boolean }).isNodeMaterial &&
|
|
1074
|
+
materialArray[0]!.side === THREE.BackSide
|
|
457
1075
|
) {
|
|
458
1076
|
mesh.geometry = flipGeometryWinding(mesh.geometry)
|
|
459
1077
|
}
|
|
460
|
-
|
|
1078
|
+
const converted = materialArray.map((entry) =>
|
|
1079
|
+
convertMaterial(entry, cache, textureMode, placeholderCache, purpose),
|
|
1080
|
+
)
|
|
1081
|
+
const color = mesh.geometry.getAttribute('color')
|
|
1082
|
+
let hasVertexAlpha = false
|
|
1083
|
+
if (color?.itemSize === 4) {
|
|
1084
|
+
for (let index = 0; index < color.count; index++) {
|
|
1085
|
+
if (color.getW(index) < 1) {
|
|
1086
|
+
hasVertexAlpha = true
|
|
1087
|
+
break
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
for (const entry of converted) {
|
|
1092
|
+
const textured = entry as THREE.Material & { alphaMap?: THREE.Texture | null }
|
|
1093
|
+
if (hasVertexAlpha || textured.alphaMap) entry.transparent = true
|
|
1094
|
+
}
|
|
1095
|
+
mesh.material = Array.isArray(material) ? converted : converted[0]!
|
|
461
1096
|
})
|
|
462
1097
|
}
|
|
463
1098
|
|
|
@@ -509,6 +1144,7 @@ function convertMaterial(
|
|
|
509
1144
|
cache: Map<THREE.Material, THREE.Material>,
|
|
510
1145
|
textureMode: 'embed' | 'reference',
|
|
511
1146
|
placeholderCache: Map<THREE.Texture, THREE.Texture>,
|
|
1147
|
+
purpose: 'portable' | 'viewer',
|
|
512
1148
|
): THREE.Material {
|
|
513
1149
|
const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true
|
|
514
1150
|
if (!isNodeMaterial) {
|
|
@@ -525,7 +1161,8 @@ function convertMaterial(
|
|
|
525
1161
|
if (cached) return cached
|
|
526
1162
|
|
|
527
1163
|
const src = material as THREE.Material & Record<string, unknown>
|
|
528
|
-
const
|
|
1164
|
+
const glass = purpose === 'portable' && isPortableGlass(material)
|
|
1165
|
+
const target = glass ? new THREE.MeshPhysicalMaterial() : new THREE.MeshStandardMaterial()
|
|
529
1166
|
|
|
530
1167
|
target.name = material.name
|
|
531
1168
|
if (src.color instanceof THREE.Color) target.color.copy(src.color)
|
|
@@ -543,7 +1180,8 @@ function convertMaterial(
|
|
|
543
1180
|
target.opacity = material.opacity
|
|
544
1181
|
// BackSide is flipped to FrontSide (with the mesh winding reversed in
|
|
545
1182
|
// convertMaterials) because glTF has no back-face-only mode.
|
|
546
|
-
target.side =
|
|
1183
|
+
target.side =
|
|
1184
|
+
purpose === 'viewer' && material.side === THREE.BackSide ? THREE.FrontSide : material.side
|
|
547
1185
|
target.alphaTest = material.alphaTest
|
|
548
1186
|
target.depthWrite = material.depthWrite
|
|
549
1187
|
target.depthTest = material.depthTest
|
|
@@ -560,12 +1198,44 @@ function convertMaterial(
|
|
|
560
1198
|
}
|
|
561
1199
|
}
|
|
562
1200
|
|
|
1201
|
+
if (glass) applyPortableGlass(target as THREE.MeshPhysicalMaterial, material)
|
|
563
1202
|
if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache)
|
|
564
1203
|
|
|
565
1204
|
cache.set(material, target)
|
|
566
1205
|
return target
|
|
567
1206
|
}
|
|
568
1207
|
|
|
1208
|
+
/** Mirrors the viewer's own rule (`maybeApplyGlassFresnel`): an untextured
|
|
1209
|
+
* see-through surface below this opacity is glass, not tinted plastic. */
|
|
1210
|
+
const GLASS_OPACITY_THRESHOLD = 0.6
|
|
1211
|
+
|
|
1212
|
+
function isPortableGlass(material: THREE.Material): boolean {
|
|
1213
|
+
const src = material as THREE.Material & Record<string, unknown>
|
|
1214
|
+
return (
|
|
1215
|
+
material.transparent &&
|
|
1216
|
+
material.opacity < GLASS_OPACITY_THRESHOLD &&
|
|
1217
|
+
!(src.map instanceof THREE.Texture)
|
|
1218
|
+
)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* The viewer sells glass with a fresnel-driven opacity node, which glTF cannot
|
|
1223
|
+
* carry; a plain alpha blend lands in every other tool as a blue film. Real
|
|
1224
|
+
* transmission (KHR_materials_transmission + ior) is what Blender, Unity and
|
|
1225
|
+
* Unreal all render as glass. The authored opacity becomes the tint strength.
|
|
1226
|
+
*/
|
|
1227
|
+
function applyPortableGlass(target: THREE.MeshPhysicalMaterial, source: THREE.Material) {
|
|
1228
|
+
target.color.lerp(new THREE.Color(0xffffff), 1 - source.opacity)
|
|
1229
|
+
target.transmission = 1
|
|
1230
|
+
target.ior = 1.5
|
|
1231
|
+
target.roughness = Math.min(target.roughness, 0.15)
|
|
1232
|
+
target.metalness = 0
|
|
1233
|
+
target.transparent = false
|
|
1234
|
+
target.opacity = 1
|
|
1235
|
+
target.depthWrite = true
|
|
1236
|
+
target.userData[GLASS_OPACITY_USERDATA] = source.opacity
|
|
1237
|
+
}
|
|
1238
|
+
|
|
569
1239
|
function replaceReferencedTextures(
|
|
570
1240
|
material: THREE.Material,
|
|
571
1241
|
placeholderCache: Map<THREE.Texture, THREE.Texture>,
|
|
@@ -649,11 +1319,12 @@ function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture {
|
|
|
649
1319
|
function bakeAnimationClips(
|
|
650
1320
|
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
651
1321
|
nodes: Record<string, AnyNode>,
|
|
1322
|
+
registryEntries: readonly RegistryEntry[],
|
|
652
1323
|
): { clips: THREE.AnimationClip[]; clipNamesByNode: Map<string, string[]> } {
|
|
653
1324
|
const clips: THREE.AnimationClip[] = []
|
|
654
1325
|
const clipNamesByNode = new Map<string, string[]>()
|
|
655
1326
|
|
|
656
|
-
for (const [id, original] of
|
|
1327
|
+
for (const [id, original] of registryEntries) {
|
|
657
1328
|
const node = nodes[id]
|
|
658
1329
|
const target = cloneByOriginal.get(original)
|
|
659
1330
|
if (!node || !target) continue
|
|
@@ -1006,12 +1677,16 @@ function stampIdentity(
|
|
|
1006
1677
|
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
1007
1678
|
nodes: Record<string, AnyNode>,
|
|
1008
1679
|
clipNamesByNode: Map<string, string[]>,
|
|
1680
|
+
registryEntries: readonly RegistryEntry[],
|
|
1009
1681
|
) {
|
|
1010
1682
|
scene.traverse((object) => {
|
|
1011
|
-
object.userData
|
|
1683
|
+
const presentationId = object.userData.pascalPresentationId
|
|
1684
|
+
const label = object.userData.label
|
|
1685
|
+
object.userData =
|
|
1686
|
+
typeof presentationId === 'string' ? { pascalPresentationId: presentationId, label } : {}
|
|
1012
1687
|
})
|
|
1013
1688
|
|
|
1014
|
-
for (const [id, original] of
|
|
1689
|
+
for (const [id, original] of registryEntries) {
|
|
1015
1690
|
const node = nodes[id]
|
|
1016
1691
|
const target = cloneByOriginal.get(original)
|
|
1017
1692
|
if (!node || !target) continue
|