@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
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'
|
|
@@ -27,6 +36,11 @@ import {
|
|
|
27
36
|
type GLTFWriter,
|
|
28
37
|
} from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
29
38
|
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
|
|
39
|
+
import {
|
|
40
|
+
disposeExportResources,
|
|
41
|
+
normalizePortableScene,
|
|
42
|
+
normalizeViewerArtifactMaterials,
|
|
43
|
+
} from './portable-export'
|
|
30
44
|
|
|
31
45
|
/**
|
|
32
46
|
* Two TRS samples (closed vs open) differing by less than this are treated as
|
|
@@ -44,10 +58,23 @@ type SwingLeafMarker = { axis: 'y'; openRotationY: number }
|
|
|
44
58
|
export type GlbExport = {
|
|
45
59
|
scene: THREE.Object3D
|
|
46
60
|
animations: THREE.AnimationClip[]
|
|
61
|
+
warnings: string[]
|
|
62
|
+
dispose: () => void
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
export type GlbExportOptions = {
|
|
50
66
|
textures?: 'embed' | 'reference'
|
|
67
|
+
onlyVisible?: boolean
|
|
68
|
+
/** Omit these node kinds and their rendered subtrees before baking. */
|
|
69
|
+
excludedNodeTypes?: readonly string[]
|
|
70
|
+
/** Selected static viewer-presentation contributions; omitted means none. */
|
|
71
|
+
includedPresentationIds?: readonly string[]
|
|
72
|
+
/** Portable downloads are static; the baked viewer retains internal clips. */
|
|
73
|
+
purpose?: 'portable' | 'viewer'
|
|
74
|
+
/** Called for actual lossy portable conversions discovered during preparation. */
|
|
75
|
+
onWarning?: (warning: string) => void
|
|
76
|
+
/** Reject retained node kinds whose export geometry can only be baked asynchronously. */
|
|
77
|
+
requireSynchronousBake?: boolean
|
|
51
78
|
}
|
|
52
79
|
|
|
53
80
|
/** Resolve after the next couple of animation frames, giving React/R3F time to
|
|
@@ -117,127 +144,606 @@ export async function exportSceneToGlb(
|
|
|
117
144
|
options: GlbExportOptions = {},
|
|
118
145
|
): Promise<ArrayBuffer> {
|
|
119
146
|
const textureMode = options.textures ?? 'embed'
|
|
147
|
+
const prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, options)
|
|
148
|
+
for (const warning of prepared.warnings) options.onWarning?.(warning)
|
|
149
|
+
try {
|
|
150
|
+
return await serializePreparedSceneToGlb(prepared, {
|
|
151
|
+
textures: textureMode,
|
|
152
|
+
onlyVisible: options.onlyVisible,
|
|
153
|
+
})
|
|
154
|
+
} finally {
|
|
155
|
+
prepared.dispose()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Capture the live renderer synchronously, restore editor presentation, then do
|
|
160
|
+
* async offscreen material/presentation work against only the owned clone.
|
|
161
|
+
*/
|
|
162
|
+
export async function preparePortableSceneFromViewer(
|
|
163
|
+
sceneGroup: Object3D,
|
|
164
|
+
nodes: Record<string, AnyNode>,
|
|
165
|
+
options: GlbExportOptions = {},
|
|
166
|
+
): Promise<GlbExport> {
|
|
120
167
|
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
168
|
const restoreLevels = snapLevelsToTruePositions()
|
|
126
|
-
let
|
|
169
|
+
let preparation: SceneExportPreparation
|
|
127
170
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
171
|
+
preparation = startSceneExportPreparation(sceneGroup, nodes, {
|
|
172
|
+
...options,
|
|
173
|
+
purpose: options.purpose ?? 'portable',
|
|
174
|
+
})
|
|
132
175
|
} finally {
|
|
133
176
|
restoreLevels()
|
|
134
177
|
emitter.emit('thumbnail:after-capture', undefined)
|
|
135
178
|
}
|
|
136
|
-
|
|
179
|
+
return completeSceneExportPreparation(preparation)
|
|
180
|
+
}
|
|
137
181
|
|
|
182
|
+
export function serializePreparedSceneToGlb(
|
|
183
|
+
prepared: GlbExport,
|
|
184
|
+
options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> = {},
|
|
185
|
+
): Promise<ArrayBuffer> {
|
|
138
186
|
const exporter = new GLTFExporter()
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// offscreen renderer (passing the live renderer would resize/draw over the
|
|
143
|
-
// editor canvas), letting the exporter embed standard textures.
|
|
187
|
+
if ((options.textures ?? 'embed') === 'reference') {
|
|
188
|
+
exporter.register(textureReferencePlugin)
|
|
189
|
+
}
|
|
144
190
|
exporter.setTextureUtils(WebGPUTextureUtils)
|
|
145
191
|
|
|
146
192
|
return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
147
193
|
exporter.parse(
|
|
148
|
-
|
|
194
|
+
prepared.scene,
|
|
149
195
|
(gltf) => {
|
|
150
196
|
resolve(gltf as ArrayBuffer)
|
|
151
197
|
},
|
|
152
198
|
(error) => {
|
|
153
199
|
reject(error)
|
|
154
200
|
},
|
|
155
|
-
{
|
|
201
|
+
{
|
|
202
|
+
binary: true,
|
|
203
|
+
animations: prepared.animations,
|
|
204
|
+
onlyVisible: options.onlyVisible ?? true,
|
|
205
|
+
},
|
|
156
206
|
)
|
|
157
207
|
})
|
|
158
208
|
}
|
|
159
209
|
|
|
210
|
+
type RegistryEntry = readonly [id: string, original: THREE.Object3D]
|
|
211
|
+
|
|
212
|
+
type SelectedPresentation = {
|
|
213
|
+
contribution: ViewerPresentationContribution
|
|
214
|
+
configuration: unknown
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
type SceneExportPreparation = {
|
|
218
|
+
scene: THREE.Object3D
|
|
219
|
+
nodes: Record<string, AnyNode>
|
|
220
|
+
options: GlbExportOptions
|
|
221
|
+
registryEntries: RegistryEntry[]
|
|
222
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>
|
|
223
|
+
builders: Map<
|
|
224
|
+
string,
|
|
225
|
+
{
|
|
226
|
+
sync: ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D) | undefined
|
|
227
|
+
async: ((node: AnyNode, ctx: GeometryContext) => Promise<THREE.Object3D>) | undefined
|
|
228
|
+
}
|
|
229
|
+
>
|
|
230
|
+
geometryContext: Pick<GeometryContext, 'materials' | 'levelData'>
|
|
231
|
+
presentations: SelectedPresentation[]
|
|
232
|
+
}
|
|
233
|
+
|
|
160
234
|
/**
|
|
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.
|
|
235
|
+
* Build the legacy synchronous artifact used by geometry-only/print exports.
|
|
236
|
+
* The default remains viewer-purpose so existing internal baked-viewer clips
|
|
237
|
+
* are unchanged; portable downloads use the async entry point below.
|
|
177
238
|
*/
|
|
178
239
|
export function prepareSceneForExport(
|
|
179
240
|
source: THREE.Object3D,
|
|
180
241
|
nodes: Record<string, AnyNode>,
|
|
181
242
|
options: GlbExportOptions = {},
|
|
182
243
|
): GlbExport {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
244
|
+
const preparation = startSceneExportPreparation(source, nodes, {
|
|
245
|
+
...options,
|
|
246
|
+
purpose: options.purpose ?? 'viewer',
|
|
247
|
+
})
|
|
248
|
+
try {
|
|
249
|
+
replaceBakeGeometrySync(preparation)
|
|
250
|
+
return finishSceneExportPreparation(preparation)
|
|
251
|
+
} catch (error) {
|
|
252
|
+
disposeExportResources(preparation.scene)
|
|
253
|
+
throw error
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Capture once, await async bake hooks, then normalize a static portable tree. */
|
|
258
|
+
export async function prepareSceneForExportAsync(
|
|
259
|
+
source: THREE.Object3D,
|
|
260
|
+
nodes: Record<string, AnyNode>,
|
|
261
|
+
options: GlbExportOptions = {},
|
|
262
|
+
): Promise<GlbExport> {
|
|
263
|
+
const preparation = startSceneExportPreparation(source, nodes, {
|
|
264
|
+
...options,
|
|
265
|
+
purpose: options.purpose ?? 'portable',
|
|
266
|
+
})
|
|
267
|
+
return completeSceneExportPreparation(preparation)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function startSceneExportPreparation(
|
|
271
|
+
source: THREE.Object3D,
|
|
272
|
+
inputNodes: Record<string, AnyNode>,
|
|
273
|
+
options: GlbExportOptions,
|
|
274
|
+
): SceneExportPreparation {
|
|
275
|
+
const nodes = structuredClone(inputNodes)
|
|
276
|
+
const registryEntries = Array.from(sceneRegistry.nodes.entries())
|
|
277
|
+
const excludedNodeTypes = new Set(options.excludedNodeTypes)
|
|
278
|
+
const excludedObjects = new Set<THREE.Object3D>()
|
|
279
|
+
const builders: SceneExportPreparation['builders'] = new Map()
|
|
280
|
+
const sceneState = useScene.getState()
|
|
281
|
+
|
|
282
|
+
for (const [id, original] of registryEntries) {
|
|
194
283
|
const node = nodes[id]
|
|
195
|
-
if (node
|
|
196
|
-
|
|
284
|
+
if (!node) continue
|
|
285
|
+
const installedPlugins = sceneState.hasExplicitPluginInstallState
|
|
286
|
+
? sceneState.installedPlugins
|
|
287
|
+
: undefined
|
|
288
|
+
if (!isNodeKindEnabled(node.type, installedPlugins)) {
|
|
289
|
+
excludedObjects.add(original)
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
const definition = nodeRegistry.get(node.type)
|
|
293
|
+
builders.set(id, {
|
|
294
|
+
sync: definition?.bakeGeometry as
|
|
295
|
+
| ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D)
|
|
296
|
+
| undefined,
|
|
297
|
+
async: definition?.bakeGeometryAsync as
|
|
298
|
+
| ((node: AnyNode, ctx: GeometryContext) => Promise<THREE.Object3D>)
|
|
299
|
+
| undefined,
|
|
300
|
+
})
|
|
301
|
+
if (bakePolicyOf(node.type) === 'strip' || excludedNodeTypes.has(node.type)) {
|
|
302
|
+
excludedObjects.add(original)
|
|
197
303
|
}
|
|
198
304
|
}
|
|
199
305
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
306
|
+
const selectedIds = new Set(options.includedPresentationIds ?? [])
|
|
307
|
+
const registeredPresentations = viewerPresentationRegistry.getSnapshot()
|
|
308
|
+
const presentations: SelectedPresentation[] = []
|
|
309
|
+
for (const id of selectedIds) {
|
|
310
|
+
const contribution = registeredPresentations.find((entry) => entry.id === id)
|
|
311
|
+
if (!contribution?.staticExport) {
|
|
312
|
+
throw new Error(`Static viewer presentation "${id}" is not registered`)
|
|
313
|
+
}
|
|
314
|
+
if (contribution.pluginId && !sceneState.installedPlugins.includes(contribution.pluginId)) {
|
|
315
|
+
throw new Error(`Static viewer presentation "${id}" belongs to an uninstalled plugin`)
|
|
316
|
+
}
|
|
317
|
+
presentations.push({
|
|
318
|
+
contribution,
|
|
319
|
+
configuration: contribution.configuration?.getSnapshot(),
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const cloneByOriginal = new Map<THREE.Object3D, THREE.Object3D>()
|
|
324
|
+
const scene = cloneSceneForExport(source, excludedObjects, cloneByOriginal)
|
|
325
|
+
cloneSkinnedSkeletons(cloneByOriginal)
|
|
326
|
+
if (options.onlyVisible ?? true) {
|
|
327
|
+
pruneHiddenSceneNodes(cloneByOriginal, nodes, registryEntries)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
scene,
|
|
332
|
+
nodes,
|
|
333
|
+
options,
|
|
334
|
+
registryEntries,
|
|
335
|
+
cloneByOriginal,
|
|
336
|
+
builders,
|
|
337
|
+
geometryContext: {
|
|
338
|
+
levelData: undefined,
|
|
339
|
+
materials: structuredClone(sceneState.materials),
|
|
340
|
+
},
|
|
341
|
+
presentations,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function completeSceneExportPreparation(
|
|
346
|
+
preparation: SceneExportPreparation,
|
|
347
|
+
): Promise<GlbExport> {
|
|
348
|
+
try {
|
|
349
|
+
await replaceBakeGeometryAsync(preparation)
|
|
350
|
+
await appendSelectedPresentations(preparation)
|
|
351
|
+
const prepared = finishSceneExportPreparation(preparation)
|
|
352
|
+
prepared.warnings.push(
|
|
353
|
+
...(preparation.options.purpose === 'viewer'
|
|
354
|
+
? normalizeViewerArtifactMaterials(prepared.scene)
|
|
355
|
+
: normalizePortableScene(prepared.scene)),
|
|
356
|
+
)
|
|
357
|
+
return prepared
|
|
358
|
+
} catch (error) {
|
|
359
|
+
disposeExportResources(preparation.scene)
|
|
360
|
+
throw error
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbExport {
|
|
365
|
+
const { scene, cloneByOriginal, nodes, options, registryEntries } = preparation
|
|
204
366
|
const identityNodes = new Set<THREE.Object3D>()
|
|
205
|
-
for (const original of
|
|
367
|
+
for (const [, original] of registryEntries) {
|
|
206
368
|
const clone = cloneByOriginal.get(original)
|
|
207
369
|
if (clone) identityNodes.add(clone)
|
|
208
370
|
}
|
|
209
371
|
|
|
210
372
|
pruneNonRenderableMeshes(scene, identityNodes)
|
|
211
373
|
sanitizeMaterialGroups(scene, identityNodes)
|
|
212
|
-
convertMaterials(scene, options.textures ?? 'embed')
|
|
374
|
+
convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer')
|
|
213
375
|
|
|
214
|
-
const
|
|
376
|
+
const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
|
|
377
|
+
const animation =
|
|
378
|
+
options.purpose === 'viewer'
|
|
379
|
+
? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
|
|
380
|
+
: { clips: [], clipNamesByNode: new Map<string, string[]>() }
|
|
381
|
+
stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries)
|
|
382
|
+
|
|
383
|
+
let disposed = false
|
|
384
|
+
return {
|
|
385
|
+
scene,
|
|
386
|
+
animations: animation.clips,
|
|
387
|
+
warnings: [],
|
|
388
|
+
dispose: () => {
|
|
389
|
+
if (disposed) return
|
|
390
|
+
disposed = true
|
|
391
|
+
disposeExportResources(scene)
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
}
|
|
215
395
|
|
|
216
|
-
|
|
396
|
+
function replaceBakeGeometrySync(preparation: SceneExportPreparation): void {
|
|
397
|
+
for (const [id, original] of preparation.registryEntries) {
|
|
398
|
+
const node = preparation.nodes[id]
|
|
399
|
+
const builders = preparation.builders.get(id)
|
|
400
|
+
if (!node || !builders) continue
|
|
401
|
+
const cloned = preparation.cloneByOriginal.get(original)
|
|
402
|
+
if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue
|
|
403
|
+
if (preparation.options.requireSynchronousBake && builders.async && !builders.sync) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Node kind "${node.type}" can only bake geometry asynchronously. Choose GLB/USDZ or exclude it from the export.`,
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
if (!builders.sync) continue
|
|
409
|
+
replaceBakedNode(
|
|
410
|
+
preparation,
|
|
411
|
+
id,
|
|
412
|
+
original,
|
|
413
|
+
builders.sync(
|
|
414
|
+
node,
|
|
415
|
+
buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext),
|
|
416
|
+
),
|
|
417
|
+
'bakeGeometry',
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
217
421
|
|
|
218
|
-
|
|
422
|
+
async function replaceBakeGeometryAsync(preparation: SceneExportPreparation): Promise<void> {
|
|
423
|
+
for (const [id, original] of preparation.registryEntries) {
|
|
424
|
+
const node = preparation.nodes[id]
|
|
425
|
+
const builders = preparation.builders.get(id)
|
|
426
|
+
if (!node || (!builders?.async && !builders?.sync)) continue
|
|
427
|
+
const cloned = preparation.cloneByOriginal.get(original)
|
|
428
|
+
if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue
|
|
429
|
+
const context = buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext)
|
|
430
|
+
const replacement = builders.async
|
|
431
|
+
? await builders.async(node, context)
|
|
432
|
+
: builders.sync!(node, context)
|
|
433
|
+
replaceBakedNode(
|
|
434
|
+
preparation,
|
|
435
|
+
id,
|
|
436
|
+
original,
|
|
437
|
+
replacement,
|
|
438
|
+
builders.async ? 'bakeGeometryAsync' : 'bakeGeometry',
|
|
439
|
+
)
|
|
440
|
+
}
|
|
219
441
|
}
|
|
220
442
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
443
|
+
function replaceBakedNode(
|
|
444
|
+
preparation: SceneExportPreparation,
|
|
445
|
+
id: string,
|
|
446
|
+
original: THREE.Object3D,
|
|
447
|
+
replacement: THREE.Object3D,
|
|
448
|
+
hook: 'bakeGeometry' | 'bakeGeometryAsync',
|
|
449
|
+
): void {
|
|
450
|
+
const cloned = preparation.cloneByOriginal.get(original)
|
|
451
|
+
if (!cloned || !isDescendantOf(cloned, preparation.scene)) return
|
|
452
|
+
const parent = cloned.parent
|
|
453
|
+
if (!parent) throw new Error(`Cannot replace root export geometry for node ${id}`)
|
|
454
|
+
if (replacement === cloned || replacement.parent) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`${hook} for ${preparation.nodes[id]?.type} must return a new detached Object3D`,
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const siblingIndex = parent.children.indexOf(cloned)
|
|
461
|
+
replacement.position.copy(cloned.position)
|
|
462
|
+
replacement.quaternion.copy(cloned.quaternion)
|
|
463
|
+
replacement.scale.copy(cloned.scale)
|
|
464
|
+
replacement.matrix.copy(cloned.matrix)
|
|
465
|
+
replacement.matrixAutoUpdate = cloned.matrixAutoUpdate
|
|
466
|
+
replacement.visible = cloned.visible
|
|
467
|
+
replacement.layers.mask = cloned.layers.mask
|
|
468
|
+
replacement.renderOrder = cloned.renderOrder
|
|
469
|
+
parent.remove(cloned)
|
|
470
|
+
parent.add(replacement)
|
|
471
|
+
const appendedIndex = parent.children.indexOf(replacement)
|
|
472
|
+
|
|
473
|
+
parent.children.splice(appendedIndex, 1)
|
|
474
|
+
parent.children.splice(siblingIndex, 0, replacement)
|
|
475
|
+
preparation.cloneByOriginal.set(original, replacement)
|
|
476
|
+
}
|
|
477
|
+
function ownBorrowedPresentationTextures(root: THREE.Object3D): void {
|
|
478
|
+
const ownedTextures = new Map<THREE.Texture, THREE.Texture>()
|
|
479
|
+
root.traverse((object) => {
|
|
480
|
+
const renderable = object as THREE.Mesh
|
|
481
|
+
if (!renderable.material) return
|
|
482
|
+
const materials = Array.isArray(renderable.material)
|
|
483
|
+
? renderable.material
|
|
484
|
+
: [renderable.material]
|
|
485
|
+
for (const material of materials) {
|
|
486
|
+
const textured = material as THREE.Material & Record<string, unknown>
|
|
487
|
+
for (const slot of REFERENCE_MAP_SLOTS) {
|
|
488
|
+
const sourceTexture = textured[slot]
|
|
489
|
+
if (
|
|
490
|
+
!(sourceTexture instanceof THREE.Texture) ||
|
|
491
|
+
!isViewerPresentationTextureBorrowed(sourceTexture)
|
|
492
|
+
) {
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
let ownedTexture = ownedTextures.get(sourceTexture)
|
|
496
|
+
if (!ownedTexture) {
|
|
497
|
+
ownedTexture = sourceTexture.clone()
|
|
498
|
+
ownedTexture.userData = structuredClone(sourceTexture.userData)
|
|
499
|
+
ownedTexture.needsUpdate = true
|
|
500
|
+
ownedTextures.set(sourceTexture, ownedTexture)
|
|
501
|
+
}
|
|
502
|
+
textured[slot] = ownedTexture
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function appendSelectedPresentations(preparation: SceneExportPreparation): Promise<void> {
|
|
509
|
+
for (const { contribution, configuration } of preparation.presentations) {
|
|
510
|
+
const staticExport = contribution.staticExport!
|
|
511
|
+
const built = await staticExport.build({
|
|
512
|
+
nodes: preparation.nodes,
|
|
513
|
+
configuration,
|
|
514
|
+
onlyVisible: preparation.options.onlyVisible ?? true,
|
|
515
|
+
excludedNodeTypes: preparation.options.excludedNodeTypes ?? [],
|
|
516
|
+
})
|
|
517
|
+
if (!built) continue
|
|
518
|
+
if (built.parent) {
|
|
519
|
+
throw new Error(`Static viewer presentation "${contribution.id}" returned an attached root`)
|
|
520
|
+
}
|
|
521
|
+
ownBorrowedPresentationTextures(built)
|
|
522
|
+
const wrapper = new THREE.Group()
|
|
523
|
+
wrapper.name = contribution.id
|
|
524
|
+
wrapper.userData = {
|
|
525
|
+
pascalPresentationId: contribution.id,
|
|
526
|
+
label: staticExport.label,
|
|
527
|
+
}
|
|
528
|
+
wrapper.add(built)
|
|
529
|
+
preparation.scene.add(wrapper)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function buildBakeGeometryContext(
|
|
534
|
+
node: AnyNode,
|
|
535
|
+
nodes: Record<string, AnyNode>,
|
|
536
|
+
base: Pick<GeometryContext, 'materials' | 'levelData'>,
|
|
537
|
+
): GeometryContext {
|
|
538
|
+
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
|
539
|
+
const childIds = Array.isArray((node as { children?: AnyNodeId[] }).children)
|
|
540
|
+
? (node as { children: AnyNodeId[] }).children
|
|
541
|
+
: []
|
|
542
|
+
const children = childIds
|
|
543
|
+
.map((id) => nodes[id])
|
|
544
|
+
.filter((child): child is AnyNode => child !== undefined)
|
|
545
|
+
const allNodes = node.parentId ? undefined : Object.values(nodes)
|
|
546
|
+
const parent = node.parentId
|
|
547
|
+
? (nodes[node.parentId] ?? null)
|
|
548
|
+
: (allNodes?.find(
|
|
549
|
+
(candidate) =>
|
|
550
|
+
candidate.type === 'site' &&
|
|
551
|
+
'children' in candidate &&
|
|
552
|
+
Array.isArray(candidate.children) &&
|
|
553
|
+
candidate.children.includes(node.id),
|
|
554
|
+
) ?? null)
|
|
555
|
+
const siblingIds =
|
|
556
|
+
parent && Array.isArray((parent as { children?: AnyNodeId[] }).children)
|
|
557
|
+
? (parent as { children: AnyNodeId[] }).children
|
|
558
|
+
: []
|
|
559
|
+
let siblings: AnyNode[]
|
|
560
|
+
if (parent?.type === 'site') {
|
|
561
|
+
const declaredChildren = new Set(siblingIds)
|
|
562
|
+
siblings = (allNodes ?? Object.values(nodes)).filter(
|
|
563
|
+
(candidate) =>
|
|
564
|
+
candidate.id !== node.id &&
|
|
565
|
+
candidate.type === node.type &&
|
|
566
|
+
(candidate.parentId === parent.id || declaredChildren.has(candidate.id)),
|
|
567
|
+
)
|
|
568
|
+
} else {
|
|
569
|
+
siblings = siblingIds
|
|
570
|
+
.filter((id) => id !== node.id)
|
|
571
|
+
.map((id) => nodes[id])
|
|
572
|
+
.filter((sibling): sibling is AnyNode => sibling?.type === node.type)
|
|
573
|
+
}
|
|
574
|
+
const levelId = findLevelAncestorId(node.id, nodes)
|
|
575
|
+
const levelBaseAt = (x: number, z: number) =>
|
|
576
|
+
levelId ? levelBaseElevationAt(nodes, levelId, x, z) : 0
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
resolve,
|
|
580
|
+
children,
|
|
581
|
+
siblings,
|
|
582
|
+
parent,
|
|
583
|
+
levelBaseAt,
|
|
584
|
+
levelData: base.levelData,
|
|
585
|
+
materials: base.materials,
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function isDescendantOf(object: THREE.Object3D, ancestor: THREE.Object3D): boolean {
|
|
590
|
+
let current: THREE.Object3D | null = object
|
|
591
|
+
while (current) {
|
|
592
|
+
if (current === ancestor) return true
|
|
593
|
+
current = current.parent
|
|
594
|
+
}
|
|
595
|
+
return false
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function pruneHiddenSceneNodes(
|
|
599
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
600
|
+
nodes: Record<string, AnyNode>,
|
|
601
|
+
registryEntries: readonly RegistryEntry[],
|
|
602
|
+
) {
|
|
603
|
+
const visibility = new Map<string, boolean>()
|
|
604
|
+
const declaredSiteParents = new Map<string, string>()
|
|
605
|
+
for (const node of Object.values(nodes)) {
|
|
606
|
+
if (node.type !== 'site' || !('children' in node) || !Array.isArray(node.children)) continue
|
|
607
|
+
for (const childId of node.children) {
|
|
608
|
+
const child = nodes[childId]
|
|
609
|
+
if (child && !child.parentId && !declaredSiteParents.has(childId)) {
|
|
610
|
+
declaredSiteParents.set(childId, node.id)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const isVisible = (id: string, path: Set<string>): boolean => {
|
|
616
|
+
const cached = visibility.get(id)
|
|
617
|
+
if (cached !== undefined) return cached
|
|
618
|
+
|
|
619
|
+
const node = nodes[id]
|
|
620
|
+
if (!node) return true
|
|
621
|
+
if (node.visible === false) {
|
|
622
|
+
visibility.set(id, false)
|
|
623
|
+
return false
|
|
624
|
+
}
|
|
625
|
+
const parentId = node.parentId || declaredSiteParents.get(id)
|
|
626
|
+
if (!parentId || path.has(id)) {
|
|
627
|
+
visibility.set(id, true)
|
|
628
|
+
return true
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
path.add(id)
|
|
632
|
+
const visible = isVisible(parentId, path)
|
|
633
|
+
path.delete(id)
|
|
634
|
+
visibility.set(id, visible)
|
|
635
|
+
return visible
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
for (const [id, original] of registryEntries) {
|
|
639
|
+
if (isVisible(id, new Set())) continue
|
|
640
|
+
cloneByOriginal.get(original)?.removeFromParent()
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function retainedClones(
|
|
645
|
+
root: THREE.Object3D,
|
|
646
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
229
647
|
): Map<THREE.Object3D, THREE.Object3D> {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
648
|
+
const retained = new Set<THREE.Object3D>()
|
|
649
|
+
root.traverse((object) => retained.add(object))
|
|
650
|
+
return new Map(Array.from(cloneByOriginal.entries()).filter(([, clone]) => retained.has(clone)))
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
type ResourceCloneCache = {
|
|
654
|
+
geometries: Map<THREE.BufferGeometry, THREE.BufferGeometry>
|
|
655
|
+
materials: Map<THREE.Material, THREE.Material>
|
|
656
|
+
textures: Map<THREE.Texture, THREE.Texture>
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Skip excluded subtrees before cloning large procedural instance buffers. */
|
|
660
|
+
function cloneSceneForExport(
|
|
661
|
+
source: THREE.Object3D,
|
|
662
|
+
excludedObjects: Set<THREE.Object3D>,
|
|
663
|
+
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
664
|
+
cache: ResourceCloneCache = {
|
|
665
|
+
geometries: new Map(),
|
|
666
|
+
materials: new Map(),
|
|
667
|
+
textures: new Map(),
|
|
668
|
+
},
|
|
669
|
+
): THREE.Object3D {
|
|
670
|
+
if (excludedObjects.has(source)) return new THREE.Group()
|
|
671
|
+
|
|
672
|
+
const clone = source.clone(false)
|
|
673
|
+
clone.userData = structuredClone(source.userData)
|
|
674
|
+
const renderable = source as THREE.Mesh
|
|
675
|
+
const renderableClone = clone as THREE.Mesh
|
|
676
|
+
if (renderable.geometry) {
|
|
677
|
+
let geometry = cache.geometries.get(renderable.geometry)
|
|
678
|
+
if (!geometry) {
|
|
679
|
+
geometry = renderable.geometry.clone()
|
|
680
|
+
cache.geometries.set(renderable.geometry, geometry)
|
|
681
|
+
}
|
|
682
|
+
renderableClone.geometry = geometry
|
|
683
|
+
}
|
|
684
|
+
if (renderable.material) {
|
|
685
|
+
const cloneMaterial = (material: THREE.Material): THREE.Material => {
|
|
686
|
+
let result = cache.materials.get(material)
|
|
687
|
+
if (result) return result
|
|
688
|
+
result = material.clone()
|
|
689
|
+
const textured = result as THREE.Material & Record<string, unknown>
|
|
690
|
+
for (const slot of REFERENCE_MAP_SLOTS) {
|
|
691
|
+
const texture = textured[slot]
|
|
692
|
+
if (!(texture instanceof THREE.Texture)) continue
|
|
693
|
+
let textureClone = cache.textures.get(texture)
|
|
694
|
+
if (!textureClone) {
|
|
695
|
+
textureClone = texture.clone()
|
|
696
|
+
textureClone.userData = structuredClone(texture.userData)
|
|
697
|
+
textureClone.needsUpdate = true
|
|
698
|
+
cache.textures.set(texture, textureClone)
|
|
699
|
+
}
|
|
700
|
+
textured[slot] = textureClone
|
|
701
|
+
}
|
|
702
|
+
cache.materials.set(material, result)
|
|
703
|
+
return result
|
|
704
|
+
}
|
|
705
|
+
if (Array.isArray(renderable.material)) {
|
|
706
|
+
const materialSlots = renderable.material as Array<THREE.Material | null | undefined>
|
|
707
|
+
renderableClone.material = materialSlots.map((material) =>
|
|
708
|
+
material ? cloneMaterial(material) : material,
|
|
709
|
+
) as THREE.Material[]
|
|
710
|
+
} else {
|
|
711
|
+
renderableClone.material = cloneMaterial(renderable.material)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
cloneByOriginal.set(source, clone)
|
|
716
|
+
for (const child of source.children) {
|
|
717
|
+
if (!excludedObjects.has(child)) {
|
|
718
|
+
clone.add(cloneSceneForExport(child, excludedObjects, cloneByOriginal, cache))
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return clone
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function cloneSkinnedSkeletons(cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>): void {
|
|
725
|
+
for (const [original, cloned] of cloneByOriginal) {
|
|
726
|
+
const source = original as THREE.SkinnedMesh
|
|
727
|
+
const target = cloned as THREE.SkinnedMesh
|
|
728
|
+
if (!source.isSkinnedMesh) continue
|
|
729
|
+
const bones = source.skeleton.bones.map((bone) => {
|
|
730
|
+
const clonedBone = cloneByOriginal.get(bone)
|
|
731
|
+
if (!(clonedBone as THREE.Bone | undefined)?.isBone) {
|
|
732
|
+
throw new Error(
|
|
733
|
+
`Skinned mesh "${source.name}" references a bone outside its export subtree`,
|
|
734
|
+
)
|
|
735
|
+
}
|
|
736
|
+
return clonedBone as THREE.Bone
|
|
737
|
+
})
|
|
738
|
+
target.bindMode = source.bindMode
|
|
739
|
+
target.bind(
|
|
740
|
+
new THREE.Skeleton(
|
|
741
|
+
bones,
|
|
742
|
+
source.skeleton.boneInverses.map((inverse) => inverse.clone()),
|
|
743
|
+
),
|
|
744
|
+
source.bindMatrix.clone(),
|
|
745
|
+
)
|
|
239
746
|
}
|
|
240
|
-
return map
|
|
241
747
|
}
|
|
242
748
|
|
|
243
749
|
// A single empty geometry shared by every container mesh we neutralise below —
|
|
@@ -296,8 +802,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
|
|
|
296
802
|
renderable.material == null
|
|
297
803
|
) {
|
|
298
804
|
if (object.children.length > 0) {
|
|
299
|
-
renderable.geometry = EMPTY_GEOMETRY
|
|
300
|
-
renderable.material = PLACEHOLDER_MATERIAL
|
|
805
|
+
renderable.geometry = EMPTY_GEOMETRY.clone()
|
|
806
|
+
renderable.material = PLACEHOLDER_MATERIAL.clone()
|
|
301
807
|
} else {
|
|
302
808
|
toRemove.push(object)
|
|
303
809
|
}
|
|
@@ -315,12 +821,12 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
|
|
|
315
821
|
Array.isArray(renderable.material) &&
|
|
316
822
|
renderable.material.some((m) => m == null)
|
|
317
823
|
) {
|
|
318
|
-
renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL)
|
|
824
|
+
renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL.clone())
|
|
319
825
|
}
|
|
320
826
|
const mesh = object as THREE.Mesh
|
|
321
827
|
if (!mesh.isMesh || isRenderableMesh(mesh)) return
|
|
322
828
|
if (mesh.children.length > 0) {
|
|
323
|
-
mesh.geometry = EMPTY_GEOMETRY
|
|
829
|
+
mesh.geometry = EMPTY_GEOMETRY.clone()
|
|
324
830
|
} else {
|
|
325
831
|
toRemove.push(mesh)
|
|
326
832
|
}
|
|
@@ -343,8 +849,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
|
|
|
343
849
|
* system's degenerate placeholder) — is neutralised like other
|
|
344
850
|
* non-renderables (kept as a bare transform node, or removed if a leaf
|
|
345
851
|
* that carries no node identity).
|
|
346
|
-
*
|
|
347
|
-
*
|
|
852
|
+
* The export tree owns its resources; repairs still avoid copying vertex
|
|
853
|
+
* buffers when replacing only group metadata.
|
|
348
854
|
*/
|
|
349
855
|
function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
|
|
350
856
|
const toRemove: THREE.Object3D[] = []
|
|
@@ -364,15 +870,15 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.O
|
|
|
364
870
|
)
|
|
365
871
|
if (validGroups.length === 0) {
|
|
366
872
|
if (mesh.children.length > 0 || identityNodes.has(mesh)) {
|
|
367
|
-
mesh.geometry = EMPTY_GEOMETRY
|
|
368
|
-
mesh.material = PLACEHOLDER_MATERIAL
|
|
873
|
+
mesh.geometry = EMPTY_GEOMETRY.clone()
|
|
874
|
+
mesh.material = PLACEHOLDER_MATERIAL.clone()
|
|
369
875
|
} else {
|
|
370
876
|
toRemove.push(mesh)
|
|
371
877
|
}
|
|
372
878
|
return
|
|
373
879
|
}
|
|
374
|
-
// Only the group list needs repair
|
|
375
|
-
//
|
|
880
|
+
// Only the group list needs repair; reuse export-owned attributes rather
|
|
881
|
+
// than duplicating every vertex buffer.
|
|
376
882
|
if (validGroups.length !== groups.length) {
|
|
377
883
|
const geometry = new THREE.BufferGeometry()
|
|
378
884
|
geometry.index = mesh.geometry.index
|
|
@@ -385,7 +891,7 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.O
|
|
|
385
891
|
geometry.groups = validGroups.map((g) => ({ ...g }))
|
|
386
892
|
mesh.geometry = geometry
|
|
387
893
|
}
|
|
388
|
-
mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL)
|
|
894
|
+
mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL.clone())
|
|
389
895
|
})
|
|
390
896
|
for (const object of toRemove) {
|
|
391
897
|
object.removeFromParent()
|
|
@@ -435,29 +941,44 @@ const REFERENCE_MAP_SLOTS = [
|
|
|
435
941
|
'anisotropyMap',
|
|
436
942
|
] as const
|
|
437
943
|
|
|
438
|
-
function convertMaterials(
|
|
944
|
+
function convertMaterials(
|
|
945
|
+
root: THREE.Object3D,
|
|
946
|
+
textureMode: 'embed' | 'reference',
|
|
947
|
+
purpose: 'portable' | 'viewer',
|
|
948
|
+
) {
|
|
439
949
|
const cache = new Map<THREE.Material, THREE.Material>()
|
|
440
950
|
const placeholderCache = new Map<THREE.Texture, THREE.Texture>()
|
|
441
951
|
root.traverse((object) => {
|
|
442
952
|
const mesh = object as THREE.Mesh
|
|
443
953
|
if (!mesh.isMesh) return
|
|
444
954
|
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.
|
|
955
|
+
const materialArray = Array.isArray(material) ? material : [material]
|
|
454
956
|
if (
|
|
455
|
-
|
|
456
|
-
|
|
957
|
+
purpose === 'viewer' &&
|
|
958
|
+
materialArray.length === 1 &&
|
|
959
|
+
(materialArray[0] as { isNodeMaterial?: boolean }).isNodeMaterial &&
|
|
960
|
+
materialArray[0]!.side === THREE.BackSide
|
|
457
961
|
) {
|
|
458
962
|
mesh.geometry = flipGeometryWinding(mesh.geometry)
|
|
459
963
|
}
|
|
460
|
-
|
|
964
|
+
const converted = materialArray.map((entry) =>
|
|
965
|
+
convertMaterial(entry, cache, textureMode, placeholderCache, purpose),
|
|
966
|
+
)
|
|
967
|
+
const color = mesh.geometry.getAttribute('color')
|
|
968
|
+
let hasVertexAlpha = false
|
|
969
|
+
if (color?.itemSize === 4) {
|
|
970
|
+
for (let index = 0; index < color.count; index++) {
|
|
971
|
+
if (color.getW(index) < 1) {
|
|
972
|
+
hasVertexAlpha = true
|
|
973
|
+
break
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
for (const entry of converted) {
|
|
978
|
+
const textured = entry as THREE.Material & { alphaMap?: THREE.Texture | null }
|
|
979
|
+
if (hasVertexAlpha || textured.alphaMap) entry.transparent = true
|
|
980
|
+
}
|
|
981
|
+
mesh.material = Array.isArray(material) ? converted : converted[0]!
|
|
461
982
|
})
|
|
462
983
|
}
|
|
463
984
|
|
|
@@ -509,6 +1030,7 @@ function convertMaterial(
|
|
|
509
1030
|
cache: Map<THREE.Material, THREE.Material>,
|
|
510
1031
|
textureMode: 'embed' | 'reference',
|
|
511
1032
|
placeholderCache: Map<THREE.Texture, THREE.Texture>,
|
|
1033
|
+
purpose: 'portable' | 'viewer',
|
|
512
1034
|
): THREE.Material {
|
|
513
1035
|
const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true
|
|
514
1036
|
if (!isNodeMaterial) {
|
|
@@ -543,7 +1065,8 @@ function convertMaterial(
|
|
|
543
1065
|
target.opacity = material.opacity
|
|
544
1066
|
// BackSide is flipped to FrontSide (with the mesh winding reversed in
|
|
545
1067
|
// convertMaterials) because glTF has no back-face-only mode.
|
|
546
|
-
target.side =
|
|
1068
|
+
target.side =
|
|
1069
|
+
purpose === 'viewer' && material.side === THREE.BackSide ? THREE.FrontSide : material.side
|
|
547
1070
|
target.alphaTest = material.alphaTest
|
|
548
1071
|
target.depthWrite = material.depthWrite
|
|
549
1072
|
target.depthTest = material.depthTest
|
|
@@ -649,11 +1172,12 @@ function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture {
|
|
|
649
1172
|
function bakeAnimationClips(
|
|
650
1173
|
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
651
1174
|
nodes: Record<string, AnyNode>,
|
|
1175
|
+
registryEntries: readonly RegistryEntry[],
|
|
652
1176
|
): { clips: THREE.AnimationClip[]; clipNamesByNode: Map<string, string[]> } {
|
|
653
1177
|
const clips: THREE.AnimationClip[] = []
|
|
654
1178
|
const clipNamesByNode = new Map<string, string[]>()
|
|
655
1179
|
|
|
656
|
-
for (const [id, original] of
|
|
1180
|
+
for (const [id, original] of registryEntries) {
|
|
657
1181
|
const node = nodes[id]
|
|
658
1182
|
const target = cloneByOriginal.get(original)
|
|
659
1183
|
if (!node || !target) continue
|
|
@@ -1006,12 +1530,16 @@ function stampIdentity(
|
|
|
1006
1530
|
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
1007
1531
|
nodes: Record<string, AnyNode>,
|
|
1008
1532
|
clipNamesByNode: Map<string, string[]>,
|
|
1533
|
+
registryEntries: readonly RegistryEntry[],
|
|
1009
1534
|
) {
|
|
1010
1535
|
scene.traverse((object) => {
|
|
1011
|
-
object.userData
|
|
1536
|
+
const presentationId = object.userData.pascalPresentationId
|
|
1537
|
+
const label = object.userData.label
|
|
1538
|
+
object.userData =
|
|
1539
|
+
typeof presentationId === 'string' ? { pascalPresentationId: presentationId, label } : {}
|
|
1012
1540
|
})
|
|
1013
1541
|
|
|
1014
|
-
for (const [id, original] of
|
|
1542
|
+
for (const [id, original] of registryEntries) {
|
|
1015
1543
|
const node = nodes[id]
|
|
1016
1544
|
const target = cloneByOriginal.get(original)
|
|
1017
1545
|
if (!node || !target) continue
|