@pascal-app/editor 1.0.0-beta.5 → 1.0.0
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 +13 -7
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +1 -0
- 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 +42 -51
- package/src/components/editor/floorplan-panel.tsx +76 -12
- 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/linear-resize-drag.ts +67 -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 +18 -1
- package/src/components/editor/index.tsx +140 -21
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/selection-manager.tsx +104 -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 +284 -237
- 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-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 +180 -32
- 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/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/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 +135 -2
- 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 +6 -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 +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- 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 +45 -8
- 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/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 +9 -12
- 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/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- 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 +22 -5
- 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 +263 -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 +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- 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 +390 -36
- 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 +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
- 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/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- 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 +122 -0
- 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 +129 -62
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +82 -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/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 +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- 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 +1 -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/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- 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-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- 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/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 +366 -0
- package/src/lib/portable-export.ts +969 -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 +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -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 +30 -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.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -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/usdz-export.ts +38 -0
- 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-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/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
import { StaticGeometryGenerator } from 'three-mesh-bvh'
|
|
3
|
+
|
|
4
|
+
const VERTEX_COLOR_UV_CHANNEL = 0
|
|
5
|
+
const VERTEX_COLOR_TILE_SIZE = 8
|
|
6
|
+
const MAX_VERTEX_COLOR_ATLAS_SIZE = 2048
|
|
7
|
+
|
|
8
|
+
const MATERIAL_TEXTURE_SLOTS = [
|
|
9
|
+
'map',
|
|
10
|
+
'normalMap',
|
|
11
|
+
'roughnessMap',
|
|
12
|
+
'metalnessMap',
|
|
13
|
+
'aoMap',
|
|
14
|
+
'emissiveMap',
|
|
15
|
+
'alphaMap',
|
|
16
|
+
'lightMap',
|
|
17
|
+
'bumpMap',
|
|
18
|
+
'displacementMap',
|
|
19
|
+
'clearcoatMap',
|
|
20
|
+
'clearcoatNormalMap',
|
|
21
|
+
'clearcoatRoughnessMap',
|
|
22
|
+
'iridescenceMap',
|
|
23
|
+
'iridescenceThicknessMap',
|
|
24
|
+
'transmissionMap',
|
|
25
|
+
'thicknessMap',
|
|
26
|
+
'specularIntensityMap',
|
|
27
|
+
'specularColorMap',
|
|
28
|
+
'sheenRoughnessMap',
|
|
29
|
+
'sheenColorMap',
|
|
30
|
+
'anisotropyMap',
|
|
31
|
+
] as const
|
|
32
|
+
|
|
33
|
+
type TexturedMaterial = THREE.Material & Record<string, unknown>
|
|
34
|
+
type DisposableResource = THREE.BufferGeometry | THREE.Material | THREE.Texture
|
|
35
|
+
|
|
36
|
+
function uvAttributeName(channel: number): string {
|
|
37
|
+
return channel === 0 ? 'uv' : `uv${channel}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const discardedResources = new WeakMap<THREE.Object3D, Set<DisposableResource>>()
|
|
41
|
+
|
|
42
|
+
function rememberDiscarded(root: THREE.Object3D, resource: DisposableResource): void {
|
|
43
|
+
let resources = discardedResources.get(root)
|
|
44
|
+
if (!resources) {
|
|
45
|
+
resources = new Set()
|
|
46
|
+
discardedResources.set(root, resources)
|
|
47
|
+
}
|
|
48
|
+
resources.add(resource)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function materialsOf(mesh: THREE.Mesh): THREE.Material[] {
|
|
52
|
+
return Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function copyObjectState(source: THREE.Object3D, target: THREE.Object3D): void {
|
|
56
|
+
target.name = source.name
|
|
57
|
+
target.up.copy(source.up)
|
|
58
|
+
target.position.copy(source.position)
|
|
59
|
+
target.quaternion.copy(source.quaternion)
|
|
60
|
+
target.scale.copy(source.scale)
|
|
61
|
+
target.matrix.copy(source.matrix)
|
|
62
|
+
target.matrixAutoUpdate = source.matrixAutoUpdate
|
|
63
|
+
target.visible = source.visible
|
|
64
|
+
target.layers.mask = source.layers.mask
|
|
65
|
+
target.renderOrder = source.renderOrder
|
|
66
|
+
target.frustumCulled = source.frustumCulled
|
|
67
|
+
target.userData = structuredClone(source.userData)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function replaceObject(source: THREE.Object3D, replacement: THREE.Object3D): void {
|
|
71
|
+
const parent = source.parent
|
|
72
|
+
if (!parent) throw new Error(`Cannot replace detached export object "${source.name}"`)
|
|
73
|
+
const index = parent.children.indexOf(source)
|
|
74
|
+
parent.remove(source)
|
|
75
|
+
parent.add(replacement)
|
|
76
|
+
const appended = parent.children.indexOf(replacement)
|
|
77
|
+
parent.children.splice(appended, 1)
|
|
78
|
+
parent.children.splice(index, 0, replacement)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tintMaterial(material: THREE.Material, tint: THREE.Color | null): THREE.Material {
|
|
82
|
+
if (!tint) return material
|
|
83
|
+
const clone = material.clone() as TexturedMaterial
|
|
84
|
+
const color = clone.color
|
|
85
|
+
if (!(color instanceof THREE.Color)) {
|
|
86
|
+
throw new Error(`Instance-colored material "${material.name}" has no portable color factor`)
|
|
87
|
+
}
|
|
88
|
+
color.multiply(tint)
|
|
89
|
+
return clone
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function tintMaterials(
|
|
93
|
+
material: THREE.Material | THREE.Material[],
|
|
94
|
+
tint: THREE.Color | null,
|
|
95
|
+
): THREE.Material | THREE.Material[] {
|
|
96
|
+
return Array.isArray(material)
|
|
97
|
+
? material.map((entry) => tintMaterial(entry, tint))
|
|
98
|
+
: tintMaterial(material, tint)
|
|
99
|
+
}
|
|
100
|
+
type InstancedGeometryAttribute = [string, THREE.InstancedBufferAttribute]
|
|
101
|
+
|
|
102
|
+
function geometryForInstance(
|
|
103
|
+
source: THREE.BufferGeometry,
|
|
104
|
+
instancedAttributes: readonly InstancedGeometryAttribute[],
|
|
105
|
+
instanceIndex: number,
|
|
106
|
+
): THREE.BufferGeometry {
|
|
107
|
+
if (instancedAttributes.length === 0) return source
|
|
108
|
+
|
|
109
|
+
const vertexCount = source.getAttribute('position')?.count
|
|
110
|
+
if (vertexCount === undefined) {
|
|
111
|
+
throw new Error('Instanced geometry has no position attribute')
|
|
112
|
+
}
|
|
113
|
+
const geometry = source.clone()
|
|
114
|
+
for (const [name, attribute] of instancedAttributes) {
|
|
115
|
+
const valueIndex = Math.floor(instanceIndex / attribute.meshPerAttribute)
|
|
116
|
+
if (valueIndex >= attribute.count) {
|
|
117
|
+
geometry.dispose()
|
|
118
|
+
throw new Error(`Instanced attribute "${name}" has no value for instance ${instanceIndex}`)
|
|
119
|
+
}
|
|
120
|
+
const ArrayType = attribute.array.constructor as new (length: number) => typeof attribute.array
|
|
121
|
+
const values = new ArrayType(vertexCount * attribute.itemSize)
|
|
122
|
+
const sourceOffset = valueIndex * attribute.itemSize
|
|
123
|
+
for (let vertex = 0; vertex < vertexCount; vertex++) {
|
|
124
|
+
const targetOffset = vertex * attribute.itemSize
|
|
125
|
+
for (let component = 0; component < attribute.itemSize; component++) {
|
|
126
|
+
values[targetOffset + component] = attribute.array[sourceOffset + component]!
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const resolved = new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized)
|
|
130
|
+
resolved.name = attribute.name
|
|
131
|
+
resolved.setUsage(attribute.usage)
|
|
132
|
+
resolved.gpuType = attribute.gpuType
|
|
133
|
+
geometry.setAttribute(name, resolved)
|
|
134
|
+
}
|
|
135
|
+
return geometry
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Replace every InstancedMesh with ordinary meshes in the same local hierarchy. */
|
|
139
|
+
export function expandInstancedMeshes(root: THREE.Object3D): void {
|
|
140
|
+
const instances: THREE.InstancedMesh[] = []
|
|
141
|
+
root.traverse((object) => {
|
|
142
|
+
if ((object as THREE.InstancedMesh).isInstancedMesh) {
|
|
143
|
+
instances.push(object as THREE.InstancedMesh)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const matrix = new THREE.Matrix4()
|
|
148
|
+
const tint = new THREE.Color()
|
|
149
|
+
for (const source of instances) {
|
|
150
|
+
const wrapper = new THREE.Group()
|
|
151
|
+
copyObjectState(source, wrapper)
|
|
152
|
+
const instancedAttributes = Object.entries(source.geometry.attributes).filter(
|
|
153
|
+
([, attribute]) => (attribute as THREE.InstancedBufferAttribute).isInstancedBufferAttribute,
|
|
154
|
+
) as InstancedGeometryAttribute[]
|
|
155
|
+
|
|
156
|
+
for (let index = 0; index < source.count; index++) {
|
|
157
|
+
const color = source.instanceColor ? source.getColorAt(index, tint).clone() : null
|
|
158
|
+
const geometry = geometryForInstance(source.geometry, instancedAttributes, index)
|
|
159
|
+
const mesh = new THREE.Mesh(geometry, tintMaterials(source.material, color))
|
|
160
|
+
mesh.name = source.name ? `${source.name}_${index + 1}` : `instance_${index + 1}`
|
|
161
|
+
mesh.castShadow = source.castShadow
|
|
162
|
+
mesh.receiveShadow = source.receiveShadow
|
|
163
|
+
mesh.frustumCulled = source.frustumCulled
|
|
164
|
+
source.getMatrixAt(index, matrix)
|
|
165
|
+
matrix.decompose(mesh.position, mesh.quaternion, mesh.scale)
|
|
166
|
+
|
|
167
|
+
if (source.morphTexture) {
|
|
168
|
+
source.getMorphAt(index, mesh)
|
|
169
|
+
}
|
|
170
|
+
wrapper.add(mesh)
|
|
171
|
+
}
|
|
172
|
+
if (instancedAttributes.length > 0) rememberDiscarded(root, source.geometry)
|
|
173
|
+
|
|
174
|
+
for (const child of [...source.children]) wrapper.add(child)
|
|
175
|
+
replaceObject(source, wrapper)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function bakeDeformedGeometry(mesh: THREE.Mesh): THREE.BufferGeometry {
|
|
180
|
+
const geometry = mesh.geometry
|
|
181
|
+
const generator = new StaticGeometryGenerator(mesh)
|
|
182
|
+
generator.applyWorldTransforms = false
|
|
183
|
+
generator.useGroups = false
|
|
184
|
+
generator.attributes = Object.keys(geometry.attributes).filter(
|
|
185
|
+
(name) => name !== 'skinIndex' && name !== 'skinWeight',
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
const savedMatrixWorld = mesh.matrixWorld.clone()
|
|
189
|
+
mesh.matrixWorld.identity()
|
|
190
|
+
try {
|
|
191
|
+
const baked = generator.generate()
|
|
192
|
+
baked.groups = geometry.groups.map((group) => ({ ...group }))
|
|
193
|
+
baked.setDrawRange(geometry.drawRange.start, geometry.drawRange.count)
|
|
194
|
+
return baked
|
|
195
|
+
} finally {
|
|
196
|
+
mesh.matrixWorld.copy(savedMatrixWorld)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Freeze current morph and skin deformation into ordinary vertex buffers. */
|
|
201
|
+
export function freezeDeformedMeshes(root: THREE.Object3D): void {
|
|
202
|
+
const meshes: THREE.Mesh[] = []
|
|
203
|
+
root.traverse((object) => {
|
|
204
|
+
if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
for (const mesh of meshes) {
|
|
208
|
+
const skinnedMesh = mesh as THREE.SkinnedMesh
|
|
209
|
+
const hasMorphTargets = Object.values(mesh.geometry.morphAttributes).some(
|
|
210
|
+
(attributes) => attributes.length > 0,
|
|
211
|
+
)
|
|
212
|
+
if (!(skinnedMesh.isSkinnedMesh || hasMorphTargets)) continue
|
|
213
|
+
|
|
214
|
+
const originalGeometry = mesh.geometry
|
|
215
|
+
mesh.geometry = bakeDeformedGeometry(mesh)
|
|
216
|
+
mesh.geometry.morphAttributes = {}
|
|
217
|
+
mesh.morphTargetDictionary = undefined
|
|
218
|
+
mesh.morphTargetInfluences = undefined
|
|
219
|
+
if (skinnedMesh.isSkinnedMesh) {
|
|
220
|
+
const ordinaryMesh = mesh as unknown as { isSkinnedMesh: boolean }
|
|
221
|
+
ordinaryMesh.isSkinnedMesh = false
|
|
222
|
+
}
|
|
223
|
+
rememberDiscarded(root, originalGeometry)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function canvas2d(
|
|
228
|
+
width: number,
|
|
229
|
+
height: number,
|
|
230
|
+
): {
|
|
231
|
+
canvas: HTMLCanvasElement
|
|
232
|
+
context: CanvasRenderingContext2D
|
|
233
|
+
} {
|
|
234
|
+
if (typeof document === 'undefined') {
|
|
235
|
+
throw new Error('Portable texture baking requires a browser canvas')
|
|
236
|
+
}
|
|
237
|
+
const canvas = document.createElement('canvas')
|
|
238
|
+
canvas.width = width
|
|
239
|
+
canvas.height = height
|
|
240
|
+
const context = canvas.getContext('2d', { willReadFrequently: true })
|
|
241
|
+
if (!context) throw new Error('Portable texture baking could not create a 2D canvas')
|
|
242
|
+
return { canvas, context }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function srgbByte(linear: number): number {
|
|
246
|
+
const value = Math.max(0, Math.min(1, linear))
|
|
247
|
+
const encoded = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055
|
|
248
|
+
return Math.round(encoded * 255)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function colorComponent(
|
|
252
|
+
attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
|
|
253
|
+
index: number,
|
|
254
|
+
component: number,
|
|
255
|
+
): number {
|
|
256
|
+
if (component === 0) return attribute.getX(index)
|
|
257
|
+
if (component === 1) return attribute.getY(index)
|
|
258
|
+
if (component === 2) return attribute.getZ(index)
|
|
259
|
+
return attribute.itemSize >= 4 ? attribute.getW(index) : 1
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildVertexColorAtlas(
|
|
263
|
+
geometry: THREE.BufferGeometry,
|
|
264
|
+
materials: readonly THREE.Material[],
|
|
265
|
+
relocatedUvChannel?: number,
|
|
266
|
+
): {
|
|
267
|
+
geometry: THREE.BufferGeometry
|
|
268
|
+
texture: THREE.CanvasTexture
|
|
269
|
+
hasAlpha: boolean
|
|
270
|
+
clampedHdr: boolean
|
|
271
|
+
} {
|
|
272
|
+
const expanded = geometry.index ? geometry.toNonIndexed() : geometry.clone()
|
|
273
|
+
if (relocatedUvChannel !== undefined) {
|
|
274
|
+
const sourceUv = expanded.getAttribute('uv')
|
|
275
|
+
if (!sourceUv) {
|
|
276
|
+
expanded.dispose()
|
|
277
|
+
throw new Error('Textured vertex-color geometry has no UV coordinates to preserve')
|
|
278
|
+
}
|
|
279
|
+
expanded.setAttribute(uvAttributeName(relocatedUvChannel), sourceUv.clone())
|
|
280
|
+
}
|
|
281
|
+
const color = expanded.getAttribute('color')
|
|
282
|
+
const position = expanded.getAttribute('position')
|
|
283
|
+
if (!color || !position || position.count === 0 || position.count % 3 !== 0) {
|
|
284
|
+
expanded.dispose()
|
|
285
|
+
throw new Error('Vertex-color baking requires triangle geometry with a color attribute')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const faceCount = position.count / 3
|
|
289
|
+
const tilesPerRow = Math.ceil(Math.sqrt(faceCount))
|
|
290
|
+
const atlasSize = THREE.MathUtils.ceilPowerOfTwo(tilesPerRow * VERTEX_COLOR_TILE_SIZE)
|
|
291
|
+
if (atlasSize > MAX_VERTEX_COLOR_ATLAS_SIZE) {
|
|
292
|
+
expanded.dispose()
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Vertex-color atlas requires ${atlasSize}px, exceeding the ${MAX_VERTEX_COLOR_ATLAS_SIZE}px export limit`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const { canvas, context } = canvas2d(atlasSize, atlasSize)
|
|
299
|
+
const image = context.createImageData(atlasSize, atlasSize)
|
|
300
|
+
|
|
301
|
+
const uv = new Float32Array(position.count * 2)
|
|
302
|
+
let hasAlpha = false
|
|
303
|
+
let clampedHdr = false
|
|
304
|
+
for (let index = 0; index < color.count && !clampedHdr; index++) {
|
|
305
|
+
const triangleOffset = index - (index % 3)
|
|
306
|
+
const materialIndex = materialIndexForTriangle(expanded, triangleOffset)
|
|
307
|
+
if (!materials[materialIndex]?.vertexColors) continue
|
|
308
|
+
const red = colorComponent(color, index, 0)
|
|
309
|
+
const green = colorComponent(color, index, 1)
|
|
310
|
+
const blue = colorComponent(color, index, 2)
|
|
311
|
+
clampedHdr = red < 0 || red > 1 || green < 0 || green > 1 || blue < 0 || blue > 1
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (let face = 0; face < faceCount; face++) {
|
|
315
|
+
const tileX = (face % tilesPerRow) * VERTEX_COLOR_TILE_SIZE
|
|
316
|
+
const tileY = Math.floor(face / tilesPerRow) * VERTEX_COLOR_TILE_SIZE
|
|
317
|
+
const vertices = [face * 3, face * 3 + 1, face * 3 + 2]
|
|
318
|
+
const colors = vertices.map((vertex) => [
|
|
319
|
+
colorComponent(color, vertex, 0),
|
|
320
|
+
colorComponent(color, vertex, 1),
|
|
321
|
+
colorComponent(color, vertex, 2),
|
|
322
|
+
colorComponent(color, vertex, 3),
|
|
323
|
+
])
|
|
324
|
+
if (
|
|
325
|
+
materials[materialIndexForTriangle(expanded, face * 3)]?.vertexColors &&
|
|
326
|
+
colors.some((entry) => entry[3]! < 1)
|
|
327
|
+
) {
|
|
328
|
+
hasAlpha = true
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const inset = 0.5
|
|
332
|
+
const span = VERTEX_COLOR_TILE_SIZE - 1
|
|
333
|
+
const atlasUvs = [
|
|
334
|
+
[tileX + inset, tileY + inset],
|
|
335
|
+
[tileX + inset + span, tileY + inset],
|
|
336
|
+
[tileX + inset, tileY + inset + span],
|
|
337
|
+
]
|
|
338
|
+
for (let corner = 0; corner < 3; corner++) {
|
|
339
|
+
uv[(face * 3 + corner) * 2] = atlasUvs[corner]![0]! / atlasSize
|
|
340
|
+
uv[(face * 3 + corner) * 2 + 1] = 1 - atlasUvs[corner]![1]! / atlasSize
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (let y = 0; y < VERTEX_COLOR_TILE_SIZE; y++) {
|
|
344
|
+
for (let x = 0; x < VERTEX_COLOR_TILE_SIZE; x++) {
|
|
345
|
+
let b = x / span
|
|
346
|
+
let c = y / span
|
|
347
|
+
if (b + c > 1) {
|
|
348
|
+
const sum = b + c
|
|
349
|
+
b /= sum
|
|
350
|
+
c /= sum
|
|
351
|
+
}
|
|
352
|
+
const a = 1 - b - c
|
|
353
|
+
const pixel = ((tileY + y) * atlasSize + tileX + x) * 4
|
|
354
|
+
image.data[pixel] = srgbByte(colors[0]![0]! * a + colors[1]![0]! * b + colors[2]![0]! * c)
|
|
355
|
+
image.data[pixel + 1] = srgbByte(
|
|
356
|
+
colors[0]![1]! * a + colors[1]![1]! * b + colors[2]![1]! * c,
|
|
357
|
+
)
|
|
358
|
+
image.data[pixel + 2] = srgbByte(
|
|
359
|
+
colors[0]![2]! * a + colors[1]![2]! * b + colors[2]![2]! * c,
|
|
360
|
+
)
|
|
361
|
+
image.data[pixel + 3] = Math.round(
|
|
362
|
+
255 *
|
|
363
|
+
Math.max(0, Math.min(1, colors[0]![3]! * a + colors[1]![3]! * b + colors[2]![3]! * c)),
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// Dilate each row's terminal tile through all unused power-of-two cells.
|
|
369
|
+
// Importers may generate their own mip chain (USDZ in particular), so sampler
|
|
370
|
+
// flags cannot prevent transparent-black padding from bleeding into Grass.
|
|
371
|
+
for (let y = 0; y < atlasSize; y++) {
|
|
372
|
+
const tileRow = Math.floor(y / VERTEX_COLOR_TILE_SIZE)
|
|
373
|
+
for (let x = 0; x < atlasSize; x++) {
|
|
374
|
+
const tileColumn = Math.floor(x / VERTEX_COLOR_TILE_SIZE)
|
|
375
|
+
const tileIndex = tileRow * tilesPerRow + tileColumn
|
|
376
|
+
if (tileColumn < tilesPerRow && tileIndex < faceCount) continue
|
|
377
|
+
const sourceFace =
|
|
378
|
+
tileRow * tilesPerRow < faceCount
|
|
379
|
+
? Math.min((tileRow + 1) * tilesPerRow - 1, faceCount - 1)
|
|
380
|
+
: faceCount - 1
|
|
381
|
+
const sourceX =
|
|
382
|
+
(sourceFace % tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (x % VERTEX_COLOR_TILE_SIZE)
|
|
383
|
+
const sourceY =
|
|
384
|
+
Math.floor(sourceFace / tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (y % VERTEX_COLOR_TILE_SIZE)
|
|
385
|
+
const sourcePixel = (sourceY * atlasSize + sourceX) * 4
|
|
386
|
+
const targetPixel = (y * atlasSize + x) * 4
|
|
387
|
+
image.data[targetPixel] = image.data[sourcePixel]!
|
|
388
|
+
image.data[targetPixel + 1] = image.data[sourcePixel + 1]!
|
|
389
|
+
image.data[targetPixel + 2] = image.data[sourcePixel + 2]!
|
|
390
|
+
image.data[targetPixel + 3] = image.data[sourcePixel + 3]!
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
context.putImageData(image, 0, 0)
|
|
395
|
+
expanded.setAttribute(uvAttributeName(VERTEX_COLOR_UV_CHANNEL), new THREE.BufferAttribute(uv, 2))
|
|
396
|
+
expanded.deleteAttribute('color')
|
|
397
|
+
const texture = new THREE.CanvasTexture(canvas)
|
|
398
|
+
texture.name = 'pascal_vertex_color_atlas'
|
|
399
|
+
texture.channel = VERTEX_COLOR_UV_CHANNEL
|
|
400
|
+
texture.colorSpace = THREE.SRGBColorSpace
|
|
401
|
+
texture.flipY = true
|
|
402
|
+
texture.needsUpdate = true
|
|
403
|
+
return { geometry: expanded, texture, hasAlpha, clampedHdr }
|
|
404
|
+
}
|
|
405
|
+
function hasDeformedGeometry(mesh: THREE.Mesh): boolean {
|
|
406
|
+
const skinnedMesh = mesh as THREE.SkinnedMesh
|
|
407
|
+
return (
|
|
408
|
+
skinnedMesh.isSkinnedMesh ||
|
|
409
|
+
Object.values(mesh.geometry.morphAttributes).some((attributes) => attributes.length > 0)
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function bakeVertexColors(root: THREE.Object3D, preserveDeformations = false): boolean {
|
|
414
|
+
const meshes: THREE.Mesh[] = []
|
|
415
|
+
root.traverse((object) => {
|
|
416
|
+
if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
|
|
417
|
+
})
|
|
418
|
+
let clampedHdr = false
|
|
419
|
+
|
|
420
|
+
for (const mesh of meshes) {
|
|
421
|
+
if (preserveDeformations && hasDeformedGeometry(mesh)) continue
|
|
422
|
+
const color = mesh.geometry.getAttribute('color')
|
|
423
|
+
if (!color) continue
|
|
424
|
+
const materials = materialsOf(mesh)
|
|
425
|
+
if (!materials.some((material) => material.vertexColors)) {
|
|
426
|
+
const originalGeometry = mesh.geometry
|
|
427
|
+
mesh.geometry = originalGeometry.clone()
|
|
428
|
+
mesh.geometry.deleteAttribute('color')
|
|
429
|
+
rememberDiscarded(root, originalGeometry)
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
const occupiedUvChannels = new Set<number>()
|
|
433
|
+
for (let channel = 1; channel <= 3; channel++) {
|
|
434
|
+
if (mesh.geometry.getAttribute(uvAttributeName(channel))) occupiedUvChannels.add(channel)
|
|
435
|
+
}
|
|
436
|
+
const channelZeroTextures = new Set<THREE.Texture>()
|
|
437
|
+
for (const material of materials) {
|
|
438
|
+
const textured = material as TexturedMaterial
|
|
439
|
+
if (material.vertexColors && textured.map instanceof THREE.Texture) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`Material "${material.name}" combines a diffuse map with varying vertex colors; portable atlas composition is unavailable`,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
for (const slot of MATERIAL_TEXTURE_SLOTS) {
|
|
445
|
+
const texture = textured[slot]
|
|
446
|
+
if (!(texture instanceof THREE.Texture)) continue
|
|
447
|
+
if (texture.channel === 0) channelZeroTextures.add(texture)
|
|
448
|
+
else occupiedUvChannels.add(texture.channel)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const relocatedUvChannel =
|
|
453
|
+
channelZeroTextures.size === 0
|
|
454
|
+
? undefined
|
|
455
|
+
: [1, 2, 3].find((channel) => !occupiedUvChannels.has(channel))
|
|
456
|
+
if (channelZeroTextures.size > 0 && relocatedUvChannel === undefined) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Vertex-color geometry "${mesh.name}" has no free portable UV channel for its PBR maps`,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const originalGeometry = mesh.geometry
|
|
463
|
+
const baked = buildVertexColorAtlas(originalGeometry, materials, relocatedUvChannel)
|
|
464
|
+
mesh.geometry = baked.geometry
|
|
465
|
+
if (baked.clampedHdr) mesh.userData.pascalHdrVertexColor = 'clamped-to-portable-range'
|
|
466
|
+
clampedHdr ||= baked.clampedHdr
|
|
467
|
+
const relocatedTextures = new Map<THREE.Texture, THREE.Texture>()
|
|
468
|
+
const convertedMaterials = new Map<THREE.Material, THREE.Material>()
|
|
469
|
+
const converted = materials.map((material) => {
|
|
470
|
+
const cached = convertedMaterials.get(material)
|
|
471
|
+
if (cached) return cached
|
|
472
|
+
if (!material.vertexColors && relocatedUvChannel === undefined) return material
|
|
473
|
+
const clone = material.clone() as TexturedMaterial
|
|
474
|
+
if (relocatedUvChannel !== undefined) {
|
|
475
|
+
for (const slot of MATERIAL_TEXTURE_SLOTS) {
|
|
476
|
+
const sourceTexture = clone[slot]
|
|
477
|
+
if (!(sourceTexture instanceof THREE.Texture) || sourceTexture.channel !== 0) continue
|
|
478
|
+
let relocatedTexture = relocatedTextures.get(sourceTexture)
|
|
479
|
+
if (!relocatedTexture) {
|
|
480
|
+
relocatedTexture = sourceTexture.clone()
|
|
481
|
+
relocatedTexture.channel = relocatedUvChannel
|
|
482
|
+
relocatedTexture.needsUpdate = true
|
|
483
|
+
relocatedTextures.set(sourceTexture, relocatedTexture)
|
|
484
|
+
rememberDiscarded(root, sourceTexture)
|
|
485
|
+
}
|
|
486
|
+
clone[slot] = relocatedTexture
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (material.vertexColors) {
|
|
490
|
+
clone.vertexColors = false
|
|
491
|
+
clone.map = baked.texture
|
|
492
|
+
if (baked.hasAlpha) clone.transparent = true
|
|
493
|
+
}
|
|
494
|
+
convertedMaterials.set(material, clone)
|
|
495
|
+
return clone
|
|
496
|
+
})
|
|
497
|
+
mesh.material = Array.isArray(mesh.material) ? converted : converted[0]!
|
|
498
|
+
rememberDiscarded(root, originalGeometry)
|
|
499
|
+
for (const material of materials) rememberDiscarded(root, material)
|
|
500
|
+
}
|
|
501
|
+
return clampedHdr
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function sourcePixels(texture: THREE.Texture): {
|
|
505
|
+
width: number
|
|
506
|
+
height: number
|
|
507
|
+
data: Uint8ClampedArray
|
|
508
|
+
} {
|
|
509
|
+
const image = texture.image as {
|
|
510
|
+
width?: number
|
|
511
|
+
height?: number
|
|
512
|
+
data?: ArrayLike<number>
|
|
513
|
+
} | null
|
|
514
|
+
const width = image?.width
|
|
515
|
+
const height = image?.height
|
|
516
|
+
if (!(width && height)) throw new Error(`Texture "${texture.name}" has no readable dimensions`)
|
|
517
|
+
|
|
518
|
+
if (image?.data) {
|
|
519
|
+
if (
|
|
520
|
+
!(image.data instanceof Uint8Array || image.data instanceof Uint8ClampedArray) ||
|
|
521
|
+
image.data.length !== width * height * 4
|
|
522
|
+
) {
|
|
523
|
+
throw new Error(`Texture "${texture.name}" is not an 8-bit RGBA texture`)
|
|
524
|
+
}
|
|
525
|
+
return { width, height, data: Uint8ClampedArray.from(image.data) }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const { context } = canvas2d(width, height)
|
|
529
|
+
try {
|
|
530
|
+
context.drawImage(texture.image as CanvasImageSource, 0, 0, width, height)
|
|
531
|
+
} catch {
|
|
532
|
+
throw new Error(`Texture "${texture.name}" cannot be read for portable export`)
|
|
533
|
+
}
|
|
534
|
+
return { width, height, data: context.getImageData(0, 0, width, height).data }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function cloneTextureSettings(source: THREE.Texture, target: THREE.Texture): void {
|
|
538
|
+
target.name = source.name
|
|
539
|
+
target.mapping = source.mapping
|
|
540
|
+
target.channel = source.channel
|
|
541
|
+
target.wrapS = source.wrapS
|
|
542
|
+
target.wrapT = source.wrapT
|
|
543
|
+
target.magFilter = source.magFilter
|
|
544
|
+
target.minFilter = source.minFilter
|
|
545
|
+
target.anisotropy = source.anisotropy
|
|
546
|
+
target.offset.copy(source.offset)
|
|
547
|
+
target.repeat.copy(source.repeat)
|
|
548
|
+
target.center.copy(source.center)
|
|
549
|
+
target.rotation = source.rotation
|
|
550
|
+
target.matrixAutoUpdate = source.matrixAutoUpdate
|
|
551
|
+
target.matrix.copy(source.matrix)
|
|
552
|
+
target.generateMipmaps = source.generateMipmaps
|
|
553
|
+
target.premultiplyAlpha = source.premultiplyAlpha
|
|
554
|
+
target.flipY = source.flipY
|
|
555
|
+
target.unpackAlignment = source.unpackAlignment
|
|
556
|
+
target.colorSpace = THREE.NoColorSpace
|
|
557
|
+
target.userData = structuredClone(source.userData)
|
|
558
|
+
target.needsUpdate = true
|
|
559
|
+
}
|
|
560
|
+
function materializeDataTextures(root: THREE.Object3D): void {
|
|
561
|
+
const converted = new Map<THREE.Texture, THREE.CanvasTexture>()
|
|
562
|
+
root.traverse((object) => {
|
|
563
|
+
if (!(object as THREE.Mesh).isMesh) return
|
|
564
|
+
for (const material of materialsOf(object as THREE.Mesh)) {
|
|
565
|
+
const textured = material as TexturedMaterial
|
|
566
|
+
for (const slot of MATERIAL_TEXTURE_SLOTS) {
|
|
567
|
+
const source = textured[slot]
|
|
568
|
+
if (!(source instanceof THREE.Texture) || !(source as THREE.DataTexture).isDataTexture)
|
|
569
|
+
continue
|
|
570
|
+
let texture = converted.get(source)
|
|
571
|
+
if (!texture) {
|
|
572
|
+
if (source.format !== THREE.RGBAFormat || source.type !== THREE.UnsignedByteType) {
|
|
573
|
+
throw new Error(`Data texture "${source.name}" is not portable 8-bit RGBA`)
|
|
574
|
+
}
|
|
575
|
+
const pixels = sourcePixels(source)
|
|
576
|
+
const { canvas, context } = canvas2d(pixels.width, pixels.height)
|
|
577
|
+
const image = context.createImageData(pixels.width, pixels.height)
|
|
578
|
+
image.data.set(pixels.data)
|
|
579
|
+
context.putImageData(image, 0, 0)
|
|
580
|
+
texture = new THREE.CanvasTexture(canvas)
|
|
581
|
+
cloneTextureSettings(source, texture)
|
|
582
|
+
texture.colorSpace = source.colorSpace
|
|
583
|
+
converted.set(source, texture)
|
|
584
|
+
rememberDiscarded(root, source)
|
|
585
|
+
}
|
|
586
|
+
textured[slot] = texture
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function canonicalizeAlphaMaps(root: THREE.Object3D): void {
|
|
593
|
+
const materials = new Set<THREE.Material>()
|
|
594
|
+
root.traverse((object) => {
|
|
595
|
+
if (!(object as THREE.Mesh).isMesh) return
|
|
596
|
+
for (const material of materialsOf(object as THREE.Mesh)) materials.add(material)
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
for (const material of materials) {
|
|
600
|
+
const standard = material as THREE.MeshStandardMaterial
|
|
601
|
+
if (!standard.alphaMap) continue
|
|
602
|
+
const alphaMap = standard.alphaMap
|
|
603
|
+
const map = standard.map
|
|
604
|
+
alphaMap.updateMatrix()
|
|
605
|
+
map?.updateMatrix()
|
|
606
|
+
if (
|
|
607
|
+
map &&
|
|
608
|
+
(map.channel !== alphaMap.channel ||
|
|
609
|
+
map.flipY !== alphaMap.flipY ||
|
|
610
|
+
!map.matrix.equals(alphaMap.matrix))
|
|
611
|
+
) {
|
|
612
|
+
throw new Error(
|
|
613
|
+
`Material "${material.name}" uses incompatible diffuse and alpha texture coordinates`,
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const alpha = sourcePixels(alphaMap)
|
|
618
|
+
const color = map ? sourcePixels(map) : null
|
|
619
|
+
if (color && (color.width !== alpha.width || color.height !== alpha.height)) {
|
|
620
|
+
throw new Error(`Material "${material.name}" uses mismatched diffuse and alpha map sizes`)
|
|
621
|
+
}
|
|
622
|
+
const { canvas, context } = canvas2d(alpha.width, alpha.height)
|
|
623
|
+
const output = context.createImageData(alpha.width, alpha.height)
|
|
624
|
+
for (let index = 0; index < alpha.data.length; index += 4) {
|
|
625
|
+
output.data[index] = color?.data[index] ?? 255
|
|
626
|
+
output.data[index + 1] = color?.data[index + 1] ?? 255
|
|
627
|
+
output.data[index + 2] = color?.data[index + 2] ?? 255
|
|
628
|
+
output.data[index + 3] = Math.round(
|
|
629
|
+
((color?.data[index + 3] ?? 255) * alpha.data[index + 1]!) / 255,
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
context.putImageData(output, 0, 0)
|
|
633
|
+
const texture = new THREE.CanvasTexture(canvas)
|
|
634
|
+
cloneTextureSettings(map ?? alphaMap, texture)
|
|
635
|
+
texture.colorSpace = map?.colorSpace ?? THREE.SRGBColorSpace
|
|
636
|
+
texture.name = map?.name || alphaMap.name
|
|
637
|
+
standard.map = texture
|
|
638
|
+
standard.alphaMap = null
|
|
639
|
+
standard.transparent = true
|
|
640
|
+
rememberDiscarded(root, alphaMap)
|
|
641
|
+
if (map) rememberDiscarded(root, map)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function canonicalNormalTexture(source: THREE.Texture, scale: THREE.Vector2): THREE.CanvasTexture {
|
|
646
|
+
const compressed = source as THREE.CompressedTexture
|
|
647
|
+
if (compressed.isCompressedTexture) {
|
|
648
|
+
throw new Error(`Compressed normal map "${source.name}" must be baked before portable export`)
|
|
649
|
+
}
|
|
650
|
+
const pixels = sourcePixels(source)
|
|
651
|
+
const { canvas, context } = canvas2d(pixels.width, pixels.height)
|
|
652
|
+
const output = context.createImageData(pixels.width, pixels.height)
|
|
653
|
+
for (let index = 0; index < pixels.data.length; index += 4) {
|
|
654
|
+
let x = (pixels.data[index]! / 255) * 2 - 1
|
|
655
|
+
let y = (pixels.data[index + 1]! / 255) * 2 - 1
|
|
656
|
+
let z = (pixels.data[index + 2]! / 255) * 2 - 1
|
|
657
|
+
x *= scale.x
|
|
658
|
+
y *= scale.y
|
|
659
|
+
const inverseLength = 1 / Math.max(Math.hypot(x, y, z), 1e-8)
|
|
660
|
+
x *= inverseLength
|
|
661
|
+
y *= inverseLength
|
|
662
|
+
z *= inverseLength
|
|
663
|
+
output.data[index] = Math.round((x * 0.5 + 0.5) * 255)
|
|
664
|
+
output.data[index + 1] = Math.round((y * 0.5 + 0.5) * 255)
|
|
665
|
+
output.data[index + 2] = Math.round((z * 0.5 + 0.5) * 255)
|
|
666
|
+
output.data[index + 3] = pixels.data[index + 3]!
|
|
667
|
+
}
|
|
668
|
+
context.putImageData(output, 0, 0)
|
|
669
|
+
const texture = new THREE.CanvasTexture(canvas)
|
|
670
|
+
cloneTextureSettings(source, texture)
|
|
671
|
+
return texture
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function canonicalizeNormalMaps(root: THREE.Object3D): void {
|
|
675
|
+
const materials = new Set<THREE.Material>()
|
|
676
|
+
root.traverse((object) => {
|
|
677
|
+
if (!(object as THREE.Mesh).isMesh) return
|
|
678
|
+
for (const material of materialsOf(object as THREE.Mesh)) materials.add(material)
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
const cache = new Map<THREE.Texture, Map<string, THREE.CanvasTexture>>()
|
|
682
|
+
for (const material of materials) {
|
|
683
|
+
const standard = material as THREE.MeshStandardMaterial
|
|
684
|
+
if (!standard.normalMap || !standard.normalScale) continue
|
|
685
|
+
if (standard.normalScale.x === 1 && standard.normalScale.y === 1) continue
|
|
686
|
+
const source = standard.normalMap
|
|
687
|
+
const key = `${standard.normalScale.x}:${standard.normalScale.y}`
|
|
688
|
+
let variants = cache.get(source)
|
|
689
|
+
if (!variants) {
|
|
690
|
+
variants = new Map()
|
|
691
|
+
cache.set(source, variants)
|
|
692
|
+
}
|
|
693
|
+
let canonical = variants.get(key)
|
|
694
|
+
if (!canonical) {
|
|
695
|
+
canonical = canonicalNormalTexture(source, standard.normalScale)
|
|
696
|
+
variants.set(key, canonical)
|
|
697
|
+
}
|
|
698
|
+
rememberDiscarded(root, source)
|
|
699
|
+
standard.normalMap = canonical
|
|
700
|
+
standard.normalScale.set(1, 1)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function materialIndexForTriangle(geometry: THREE.BufferGeometry, triangleOffset: number): number {
|
|
705
|
+
for (const group of geometry.groups) {
|
|
706
|
+
if (triangleOffset >= group.start && triangleOffset < group.start + group.count) {
|
|
707
|
+
return group.materialIndex ?? 0
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return 0
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function expandPortableSidedGeometry(
|
|
714
|
+
geometry: THREE.BufferGeometry,
|
|
715
|
+
materials: THREE.Material[],
|
|
716
|
+
): THREE.BufferGeometry | null {
|
|
717
|
+
if (materials.every((material) => material.side === THREE.FrontSide)) return null
|
|
718
|
+
const source = geometry.index ? geometry.toNonIndexed() : geometry.clone()
|
|
719
|
+
const position = source.getAttribute('position')
|
|
720
|
+
if (!position || position.count % 3 !== 0) {
|
|
721
|
+
source.dispose()
|
|
722
|
+
throw new Error('Portable sidedness export requires triangle geometry')
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const triangles: Array<{ offset: number; materialIndex: number; reverse: boolean }> = []
|
|
726
|
+
for (let offset = 0; offset < position.count; offset += 3) {
|
|
727
|
+
const materialIndex = materialIndexForTriangle(source, offset)
|
|
728
|
+
const side = materials[materialIndex]?.side ?? THREE.FrontSide
|
|
729
|
+
if (side !== THREE.BackSide) triangles.push({ offset, materialIndex, reverse: false })
|
|
730
|
+
if (side !== THREE.FrontSide) triangles.push({ offset, materialIndex, reverse: true })
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const output = new THREE.BufferGeometry()
|
|
734
|
+
for (const [name, attribute] of Object.entries(source.attributes)) {
|
|
735
|
+
const ArrayType = attribute.array.constructor as new (length: number) => typeof attribute.array
|
|
736
|
+
const values = new ArrayType(triangles.length * 3 * attribute.itemSize)
|
|
737
|
+
const target = new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized)
|
|
738
|
+
for (let triangle = 0; triangle < triangles.length; triangle++) {
|
|
739
|
+
const entry = triangles[triangle]!
|
|
740
|
+
const order = entry.reverse ? [0, 2, 1] : [0, 1, 2]
|
|
741
|
+
for (let corner = 0; corner < 3; corner++) {
|
|
742
|
+
const sourceIndex = entry.offset + order[corner]!
|
|
743
|
+
const targetIndex = triangle * 3 + corner
|
|
744
|
+
for (let component = 0; component < attribute.itemSize; component++) {
|
|
745
|
+
let value = attribute.getComponent(sourceIndex, component)
|
|
746
|
+
if (entry.reverse && name === 'normal') value = -value
|
|
747
|
+
if (entry.reverse && name === 'tangent' && component === 3) value = -value
|
|
748
|
+
target.setComponent(targetIndex, component, value)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
output.setAttribute(name, target)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
let groupStart = 0
|
|
756
|
+
let groupMaterial = triangles[0]?.materialIndex ?? 0
|
|
757
|
+
for (let index = 1; index <= triangles.length; index++) {
|
|
758
|
+
const materialIndex = triangles[index]?.materialIndex
|
|
759
|
+
if (index < triangles.length && materialIndex === groupMaterial) continue
|
|
760
|
+
output.addGroup(groupStart * 3, (index - groupStart) * 3, groupMaterial)
|
|
761
|
+
groupStart = index
|
|
762
|
+
groupMaterial = materialIndex ?? 0
|
|
763
|
+
}
|
|
764
|
+
source.dispose()
|
|
765
|
+
return output
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function bakeDoubleSidedMeshes(root: THREE.Object3D): void {
|
|
769
|
+
root.traverse((object) => {
|
|
770
|
+
const mesh = object as THREE.Mesh
|
|
771
|
+
if (!mesh.isMesh) return
|
|
772
|
+
const materials = materialsOf(mesh)
|
|
773
|
+
const geometry = expandPortableSidedGeometry(mesh.geometry, materials)
|
|
774
|
+
if (!geometry) return
|
|
775
|
+
const original = mesh.geometry
|
|
776
|
+
mesh.geometry = geometry
|
|
777
|
+
const converted = materials.map((material) => {
|
|
778
|
+
if (material.side === THREE.FrontSide) return material
|
|
779
|
+
const clone = material.clone()
|
|
780
|
+
clone.side = THREE.FrontSide
|
|
781
|
+
rememberDiscarded(root, material)
|
|
782
|
+
return clone
|
|
783
|
+
})
|
|
784
|
+
mesh.material = Array.isArray(mesh.material) ? converted : converted[0]!
|
|
785
|
+
rememberDiscarded(root, original)
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
const PORTABLE_COLOR_PROPERTIES = [
|
|
789
|
+
'color',
|
|
790
|
+
'emissive',
|
|
791
|
+
'sheenColor',
|
|
792
|
+
'specularColor',
|
|
793
|
+
'attenuationColor',
|
|
794
|
+
] as const
|
|
795
|
+
|
|
796
|
+
function clampPortableMaterialColors(root: THREE.Object3D): boolean {
|
|
797
|
+
const materials = new Set<THREE.Material>()
|
|
798
|
+
root.traverse((object) => {
|
|
799
|
+
if (!(object as THREE.Mesh).isMesh) return
|
|
800
|
+
for (const material of materialsOf(object as THREE.Mesh)) materials.add(material)
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
let clipped = false
|
|
804
|
+
for (const material of materials) {
|
|
805
|
+
const properties = material as unknown as Record<string, unknown>
|
|
806
|
+
for (const property of PORTABLE_COLOR_PROPERTIES) {
|
|
807
|
+
const color = properties[property] as THREE.Color | undefined
|
|
808
|
+
if (!color?.isColor) continue
|
|
809
|
+
if (color.r < 0 || color.r > 1 || color.g < 0 || color.g > 1 || color.b < 0 || color.b > 1) {
|
|
810
|
+
clipped = true
|
|
811
|
+
color.r = THREE.MathUtils.clamp(color.r, 0, 1)
|
|
812
|
+
color.g = THREE.MathUtils.clamp(color.g, 0, 1)
|
|
813
|
+
color.b = THREE.MathUtils.clamp(color.b, 0, 1)
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return clipped
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function normalizePortableScene(root: THREE.Object3D): string[] {
|
|
821
|
+
expandInstancedMeshes(root)
|
|
822
|
+
root.updateMatrixWorld(true)
|
|
823
|
+
freezeDeformedMeshes(root)
|
|
824
|
+
const clampedMaterialColors = clampPortableMaterialColors(root)
|
|
825
|
+
materializeDataTextures(root)
|
|
826
|
+
const clampedVertexColors = bakeVertexColors(root)
|
|
827
|
+
const clampedHdr = clampedMaterialColors || clampedVertexColors
|
|
828
|
+
canonicalizeAlphaMaps(root)
|
|
829
|
+
canonicalizeNormalMaps(root)
|
|
830
|
+
bakeDoubleSidedMeshes(root)
|
|
831
|
+
root.updateMatrixWorld(true)
|
|
832
|
+
return clampedHdr
|
|
833
|
+
? [
|
|
834
|
+
'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.',
|
|
835
|
+
]
|
|
836
|
+
: []
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Canonicalize baked static material details without freezing authored item
|
|
840
|
+
* deformation that existing saved-viewer animation clips still target.
|
|
841
|
+
*/
|
|
842
|
+
export function normalizeViewerArtifactMaterials(root: THREE.Object3D): string[] {
|
|
843
|
+
const clampedMaterialColors = clampPortableMaterialColors(root)
|
|
844
|
+
materializeDataTextures(root)
|
|
845
|
+
const clampedVertexColors = bakeVertexColors(root, true)
|
|
846
|
+
canonicalizeAlphaMaps(root)
|
|
847
|
+
canonicalizeNormalMaps(root)
|
|
848
|
+
return clampedMaterialColors || clampedVertexColors
|
|
849
|
+
? [
|
|
850
|
+
'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.',
|
|
851
|
+
]
|
|
852
|
+
: []
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function cloneMaterialForUsdz(material: THREE.Material): THREE.Material {
|
|
856
|
+
const clone = material.clone()
|
|
857
|
+
clone.side = THREE.FrontSide
|
|
858
|
+
return clone
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function reverseWindingPreservingNormals(geometry: THREE.BufferGeometry): void {
|
|
862
|
+
const index = geometry.getIndex()
|
|
863
|
+
if (index) {
|
|
864
|
+
for (let offset = 0; offset < index.count; offset += 3) {
|
|
865
|
+
const first = index.getX(offset)
|
|
866
|
+
index.setX(offset, index.getX(offset + 2))
|
|
867
|
+
index.setX(offset + 2, first)
|
|
868
|
+
}
|
|
869
|
+
index.needsUpdate = true
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
for (const attribute of Object.values(geometry.attributes)) {
|
|
874
|
+
for (let offset = 0; offset < attribute.count; offset += 3) {
|
|
875
|
+
for (let component = 0; component < attribute.itemSize; component++) {
|
|
876
|
+
const first = attribute.getComponent(offset, component)
|
|
877
|
+
attribute.setComponent(offset, component, attribute.getComponent(offset + 2, component))
|
|
878
|
+
attribute.setComponent(offset + 2, component, first)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
attribute.needsUpdate = true
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Make reflected static geometry agree with exported normals after world transforms. */
|
|
886
|
+
export function fixReflectedMeshWinding(root: THREE.Object3D): void {
|
|
887
|
+
root.updateMatrixWorld(true)
|
|
888
|
+
root.traverse((object) => {
|
|
889
|
+
const mesh = object as THREE.Mesh
|
|
890
|
+
if (!mesh.isMesh || mesh.matrixWorld.determinant() >= 0) return
|
|
891
|
+
const original = mesh.geometry
|
|
892
|
+
mesh.geometry = original.clone()
|
|
893
|
+
reverseWindingPreservingNormals(mesh.geometry)
|
|
894
|
+
rememberDiscarded(root, original)
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Build a USDZ-only clone with world transforms baked into geometry. This
|
|
900
|
+
* removes unsupported negative-scale transforms without changing the shared
|
|
901
|
+
* prepared artifact or its identity hierarchy. Empty mesh containers become
|
|
902
|
+
* groups because USDZ requires vertex positions for every mesh primitive.
|
|
903
|
+
*/
|
|
904
|
+
export function createUsdzScene(source: THREE.Object3D): THREE.Object3D {
|
|
905
|
+
source.updateMatrixWorld(true)
|
|
906
|
+
|
|
907
|
+
const cloneObject = (object: THREE.Object3D): THREE.Object3D => {
|
|
908
|
+
let clone: THREE.Object3D
|
|
909
|
+
const mesh = object as THREE.Mesh
|
|
910
|
+
if (mesh.isMesh && (mesh.geometry.getAttribute('position')?.count ?? 0) > 0) {
|
|
911
|
+
const geometry = mesh.geometry.clone()
|
|
912
|
+
geometry.applyMatrix4(mesh.matrixWorld)
|
|
913
|
+
if (mesh.matrixWorld.determinant() < 0) reverseWindingPreservingNormals(geometry)
|
|
914
|
+
const material = Array.isArray(mesh.material)
|
|
915
|
+
? mesh.material.length === 1
|
|
916
|
+
? cloneMaterialForUsdz(mesh.material[0]!)
|
|
917
|
+
: mesh.material.map(cloneMaterialForUsdz)
|
|
918
|
+
: cloneMaterialForUsdz(mesh.material)
|
|
919
|
+
clone = new THREE.Mesh(geometry, material)
|
|
920
|
+
} else {
|
|
921
|
+
clone = new THREE.Group()
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
clone.name = object.name
|
|
925
|
+
clone.visible = object.visible
|
|
926
|
+
clone.layers.mask = object.layers.mask
|
|
927
|
+
clone.renderOrder = object.renderOrder
|
|
928
|
+
clone.userData = structuredClone(object.userData)
|
|
929
|
+
for (const child of object.children) clone.add(cloneObject(child))
|
|
930
|
+
return clone
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return cloneObject(source)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Dispose geometry, materials, and export-owned texture handles exactly once. */
|
|
937
|
+
export function disposeExportResources(
|
|
938
|
+
root: THREE.Object3D,
|
|
939
|
+
options: { textures?: boolean } = {},
|
|
940
|
+
): void {
|
|
941
|
+
const geometries = new Set<THREE.BufferGeometry>()
|
|
942
|
+
const materials = new Set<THREE.Material>()
|
|
943
|
+
const textures = new Set<THREE.Texture>()
|
|
944
|
+
root.traverse((object) => {
|
|
945
|
+
const renderable = object as THREE.Mesh
|
|
946
|
+
if (renderable.geometry) geometries.add(renderable.geometry)
|
|
947
|
+
if (!renderable.material) return
|
|
948
|
+
for (const material of materialsOf(renderable)) {
|
|
949
|
+
materials.add(material)
|
|
950
|
+
if (options.textures === false) continue
|
|
951
|
+
const textured = material as TexturedMaterial
|
|
952
|
+
for (const slot of MATERIAL_TEXTURE_SLOTS) {
|
|
953
|
+
const texture = textured[slot]
|
|
954
|
+
if (texture instanceof THREE.Texture) textures.add(texture)
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
for (const resource of discardedResources.get(root) ?? []) {
|
|
960
|
+
if (resource instanceof THREE.Texture) textures.add(resource)
|
|
961
|
+
else if (resource instanceof THREE.Material) materials.add(resource)
|
|
962
|
+
else geometries.add(resource)
|
|
963
|
+
}
|
|
964
|
+
discardedResources.delete(root)
|
|
965
|
+
|
|
966
|
+
for (const geometry of geometries) geometry.dispose()
|
|
967
|
+
for (const material of materials) material.dispose()
|
|
968
|
+
for (const texture of textures) texture.dispose()
|
|
969
|
+
}
|