@chocozhang/three-model-render 1.0.4 → 1.0.6
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/CHANGELOG.md +39 -0
- package/README.md +46 -6
- package/dist/camera/index.js +6 -10
- package/dist/camera/index.js.map +1 -1
- package/dist/camera/index.mjs +6 -10
- package/dist/camera/index.mjs.map +1 -1
- package/dist/core/index.d.ts +21 -1
- package/dist/core/index.js +70 -9
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +70 -10
- package/dist/core/index.mjs.map +1 -1
- package/dist/effect/index.js +185 -230
- package/dist/effect/index.js.map +1 -1
- package/dist/effect/index.mjs +185 -230
- package/dist/effect/index.mjs.map +1 -1
- package/dist/index.d.ts +61 -28
- package/dist/index.js +812 -806
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +808 -807
- package/dist/index.mjs.map +1 -1
- package/dist/interaction/index.d.ts +12 -6
- package/dist/interaction/index.js +26 -14
- package/dist/interaction/index.js.map +1 -1
- package/dist/interaction/index.mjs +26 -14
- package/dist/interaction/index.mjs.map +1 -1
- package/dist/loader/index.d.ts +17 -2
- package/dist/loader/index.js +385 -386
- package/dist/loader/index.js.map +1 -1
- package/dist/loader/index.mjs +384 -387
- package/dist/loader/index.mjs.map +1 -1
- package/dist/setup/index.d.ts +13 -21
- package/dist/setup/index.js +120 -167
- package/dist/setup/index.js.map +1 -1
- package/dist/setup/index.mjs +119 -168
- package/dist/setup/index.mjs.map +1 -1
- package/dist/ui/index.js +15 -17
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/index.mjs +15 -17
- package/dist/ui/index.mjs.map +1 -1
- package/package.json +49 -21
package/dist/index.mjs
CHANGED
|
@@ -45,8 +45,8 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
// Configuration
|
|
48
|
-
const enableCache =
|
|
49
|
-
const updateInterval =
|
|
48
|
+
const enableCache = options?.enableCache !== false;
|
|
49
|
+
const updateInterval = options?.updateInterval || 0;
|
|
50
50
|
// Create label container, absolute positioning, attached to body
|
|
51
51
|
const container = document.createElement('div');
|
|
52
52
|
container.style.position = 'absolute';
|
|
@@ -62,11 +62,10 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
62
62
|
let lastUpdateTime = 0;
|
|
63
63
|
// Traverse all child models
|
|
64
64
|
parentModel.traverse((child) => {
|
|
65
|
-
var _a;
|
|
66
65
|
// Only process Mesh or Group
|
|
67
66
|
if ((child.isMesh || child.type === 'Group')) {
|
|
68
67
|
// Dynamic matching of name to prevent undefined
|
|
69
|
-
const labelText =
|
|
68
|
+
const labelText = Object.entries(modelLabelsMap).find(([key]) => child.name.includes(key))?.[1];
|
|
70
69
|
if (!labelText)
|
|
71
70
|
return; // Skip if no matching label
|
|
72
71
|
// Create DOM label
|
|
@@ -74,11 +73,11 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
74
73
|
el.innerText = labelText;
|
|
75
74
|
// Styles defined in JS, can be overridden via options
|
|
76
75
|
el.style.position = 'absolute';
|
|
77
|
-
el.style.color =
|
|
78
|
-
el.style.background =
|
|
79
|
-
el.style.padding =
|
|
80
|
-
el.style.borderRadius =
|
|
81
|
-
el.style.fontSize =
|
|
76
|
+
el.style.color = options?.color || '#fff';
|
|
77
|
+
el.style.background = options?.background || 'rgba(0,0,0,0.6)';
|
|
78
|
+
el.style.padding = options?.padding || '4px 8px';
|
|
79
|
+
el.style.borderRadius = options?.borderRadius || '4px';
|
|
80
|
+
el.style.fontSize = options?.fontSize || '14px';
|
|
82
81
|
el.style.transform = 'translate(-50%, -100%)'; // Position label directly above the model
|
|
83
82
|
el.style.whiteSpace = 'nowrap';
|
|
84
83
|
el.style.pointerEvents = 'none';
|
|
@@ -459,6 +458,67 @@ function initPostProcessing(renderer, scene, camera, options = {}) {
|
|
|
459
458
|
};
|
|
460
459
|
}
|
|
461
460
|
|
|
461
|
+
/**
|
|
462
|
+
* ResourceManager
|
|
463
|
+
* Handles tracking and disposal of Three.js objects to prevent memory leaks.
|
|
464
|
+
*/
|
|
465
|
+
class ResourceManager {
|
|
466
|
+
constructor() {
|
|
467
|
+
this.geometries = new Set();
|
|
468
|
+
this.materials = new Set();
|
|
469
|
+
this.textures = new Set();
|
|
470
|
+
this.objects = new Set();
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Track an object and its resources recursively
|
|
474
|
+
*/
|
|
475
|
+
track(object) {
|
|
476
|
+
this.objects.add(object);
|
|
477
|
+
object.traverse((child) => {
|
|
478
|
+
if (child.isMesh) {
|
|
479
|
+
const mesh = child;
|
|
480
|
+
if (mesh.geometry)
|
|
481
|
+
this.geometries.add(mesh.geometry);
|
|
482
|
+
if (mesh.material) {
|
|
483
|
+
if (Array.isArray(mesh.material)) {
|
|
484
|
+
mesh.material.forEach(m => this.trackMaterial(m));
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
this.trackMaterial(mesh.material);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
return object;
|
|
493
|
+
}
|
|
494
|
+
trackMaterial(material) {
|
|
495
|
+
this.materials.add(material);
|
|
496
|
+
// Track textures in material
|
|
497
|
+
for (const value of Object.values(material)) {
|
|
498
|
+
if (value instanceof THREE.Texture) {
|
|
499
|
+
this.textures.add(value);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Dispose all tracked resources
|
|
505
|
+
*/
|
|
506
|
+
dispose() {
|
|
507
|
+
this.geometries.forEach(g => g.dispose());
|
|
508
|
+
this.materials.forEach(m => m.dispose());
|
|
509
|
+
this.textures.forEach(t => t.dispose());
|
|
510
|
+
this.objects.forEach(obj => {
|
|
511
|
+
if (obj.parent) {
|
|
512
|
+
obj.parent.remove(obj);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
this.geometries.clear();
|
|
516
|
+
this.materials.clear();
|
|
517
|
+
this.textures.clear();
|
|
518
|
+
this.objects.clear();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
462
522
|
/**
|
|
463
523
|
* @file clickHandler.ts
|
|
464
524
|
* @description
|
|
@@ -610,7 +670,6 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
610
670
|
*/
|
|
611
671
|
class ArrowGuide {
|
|
612
672
|
constructor(renderer, camera, scene, options) {
|
|
613
|
-
var _a, _b, _c;
|
|
614
673
|
this.renderer = renderer;
|
|
615
674
|
this.camera = camera;
|
|
616
675
|
this.scene = scene;
|
|
@@ -629,10 +688,10 @@ class ArrowGuide {
|
|
|
629
688
|
// Config: Non-highlight opacity and brightness
|
|
630
689
|
this.fadeOpacity = 0.5;
|
|
631
690
|
this.fadeBrightness = 0.1;
|
|
632
|
-
this.clickThreshold =
|
|
633
|
-
this.ignoreRaycastNames = new Set(
|
|
634
|
-
this.fadeOpacity =
|
|
635
|
-
this.fadeBrightness =
|
|
691
|
+
this.clickThreshold = options?.clickThreshold ?? 10;
|
|
692
|
+
this.ignoreRaycastNames = new Set(options?.ignoreRaycastNames || []);
|
|
693
|
+
this.fadeOpacity = options?.fadeOpacity ?? 0.5;
|
|
694
|
+
this.fadeBrightness = options?.fadeBrightness ?? 0.1;
|
|
636
695
|
this.abortController = new AbortController();
|
|
637
696
|
this.initEvents();
|
|
638
697
|
}
|
|
@@ -870,7 +929,8 @@ class ArrowGuide {
|
|
|
870
929
|
* LiquidFillerGroup - Optimized
|
|
871
930
|
* Supports single or multi-model liquid level animation with independent color control.
|
|
872
931
|
*
|
|
873
|
-
*
|
|
932
|
+
* Capabilities:
|
|
933
|
+
* - Supports THREE.Object3D, Array<THREE.Object3D>, Set<THREE.Object3D> etc.
|
|
874
934
|
* - Uses renderer.domElement instead of window events
|
|
875
935
|
* - Uses AbortController to manage event lifecycle
|
|
876
936
|
* - Adds error handling and boundary checks
|
|
@@ -880,7 +940,7 @@ class ArrowGuide {
|
|
|
880
940
|
class LiquidFillerGroup {
|
|
881
941
|
/**
|
|
882
942
|
* Constructor
|
|
883
|
-
* @param models Single or multiple THREE.Object3D
|
|
943
|
+
* @param models Single or multiple THREE.Object3D (Array, Set, etc.)
|
|
884
944
|
* @param scene Scene
|
|
885
945
|
* @param camera Camera
|
|
886
946
|
* @param renderer Renderer
|
|
@@ -920,14 +980,13 @@ class LiquidFillerGroup {
|
|
|
920
980
|
this.clickThreshold = clickThreshold;
|
|
921
981
|
// Create AbortController for event management
|
|
922
982
|
this.abortController = new AbortController();
|
|
923
|
-
const modelArray =
|
|
983
|
+
const modelArray = this.normalizeInput(models);
|
|
924
984
|
modelArray.forEach(model => {
|
|
925
|
-
var _a, _b, _c;
|
|
926
985
|
try {
|
|
927
986
|
const options = {
|
|
928
|
-
color:
|
|
929
|
-
opacity:
|
|
930
|
-
speed:
|
|
987
|
+
color: defaultOptions?.color ?? 0x00ff00,
|
|
988
|
+
opacity: defaultOptions?.opacity ?? 0.6,
|
|
989
|
+
speed: defaultOptions?.speed ?? 0.05,
|
|
931
990
|
};
|
|
932
991
|
// Save original materials
|
|
933
992
|
const originalMaterials = new Map();
|
|
@@ -1004,9 +1063,22 @@ class LiquidFillerGroup {
|
|
|
1004
1063
|
this.renderer.domElement.addEventListener('pointerdown', this.handlePointerDown, { signal });
|
|
1005
1064
|
this.renderer.domElement.addEventListener('pointerup', this.handlePointerUp, { signal });
|
|
1006
1065
|
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Helper to normalize input to Array<THREE.Object3D>
|
|
1068
|
+
*/
|
|
1069
|
+
normalizeInput(models) {
|
|
1070
|
+
if (models instanceof THREE.Object3D) {
|
|
1071
|
+
return [models];
|
|
1072
|
+
}
|
|
1073
|
+
if (Array.isArray(models)) {
|
|
1074
|
+
return models;
|
|
1075
|
+
}
|
|
1076
|
+
// Handle Iterable (Set, etc.)
|
|
1077
|
+
return Array.from(models);
|
|
1078
|
+
}
|
|
1007
1079
|
/**
|
|
1008
1080
|
* Set liquid level
|
|
1009
|
-
* @param models Single model or array of models
|
|
1081
|
+
* @param models Single model or array/iterable of models
|
|
1010
1082
|
* @param percent Liquid level percentage 0~1
|
|
1011
1083
|
*/
|
|
1012
1084
|
fillTo(models, percent) {
|
|
@@ -1015,7 +1087,7 @@ class LiquidFillerGroup {
|
|
|
1015
1087
|
console.warn('LiquidFillerGroup: percent must be between 0 and 1', percent);
|
|
1016
1088
|
percent = Math.max(0, Math.min(1, percent));
|
|
1017
1089
|
}
|
|
1018
|
-
const modelArray =
|
|
1090
|
+
const modelArray = this.normalizeInput(models);
|
|
1019
1091
|
modelArray.forEach(model => {
|
|
1020
1092
|
const item = this.items.find(i => i.model === model);
|
|
1021
1093
|
if (!item) {
|
|
@@ -1174,7 +1246,6 @@ const EASING_FUNCTIONS = {
|
|
|
1174
1246
|
* - Robust error handling
|
|
1175
1247
|
*/
|
|
1176
1248
|
function followModels(camera, targets, options = {}) {
|
|
1177
|
-
var _a, _b, _c, _d, _e, _f;
|
|
1178
1249
|
// Cancel previous animation
|
|
1179
1250
|
cancelFollow(camera);
|
|
1180
1251
|
// Boundary check
|
|
@@ -1201,14 +1272,14 @@ function followModels(camera, targets, options = {}) {
|
|
|
1201
1272
|
box.getBoundingSphere(sphere);
|
|
1202
1273
|
const center = sphere.center.clone();
|
|
1203
1274
|
const radiusBase = Math.max(0.001, sphere.radius);
|
|
1204
|
-
const duration =
|
|
1205
|
-
const padding =
|
|
1275
|
+
const duration = options.duration ?? 700;
|
|
1276
|
+
const padding = options.padding ?? 1.0;
|
|
1206
1277
|
const minDistance = options.minDistance;
|
|
1207
1278
|
const maxDistance = options.maxDistance;
|
|
1208
|
-
const controls =
|
|
1209
|
-
const azimuth =
|
|
1210
|
-
const elevation =
|
|
1211
|
-
const easing =
|
|
1279
|
+
const controls = options.controls ?? null;
|
|
1280
|
+
const azimuth = options.azimuth ?? Math.PI / 4;
|
|
1281
|
+
const elevation = options.elevation ?? Math.PI / 4;
|
|
1282
|
+
const easing = options.easing ?? 'easeOut';
|
|
1212
1283
|
const onProgress = options.onProgress;
|
|
1213
1284
|
// Get easing function
|
|
1214
1285
|
const easingFn = EASING_FUNCTIONS[easing] || EASING_FUNCTIONS.easeOut;
|
|
@@ -1346,9 +1417,6 @@ function setView(camera, controls, targetObj, position = 'front', options = {})
|
|
|
1346
1417
|
console.warn('setView: Failed to calculate bounding box');
|
|
1347
1418
|
return Promise.reject(new Error('Invalid bounding box'));
|
|
1348
1419
|
}
|
|
1349
|
-
const center = box.getCenter(new THREE.Vector3());
|
|
1350
|
-
const size = box.getSize(new THREE.Vector3());
|
|
1351
|
-
const maxSize = Math.max(size.x, size.y, size.z);
|
|
1352
1420
|
// Use mapping table for creating view angles
|
|
1353
1421
|
const viewAngles = {
|
|
1354
1422
|
'front': { azimuth: 0, elevation: 0 },
|
|
@@ -1400,37 +1468,22 @@ const ViewPresets = {
|
|
|
1400
1468
|
top: (camera, controls, target, options) => setView(camera, controls, target, 'top', options)
|
|
1401
1469
|
};
|
|
1402
1470
|
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
Permission to use, copy, modify, and/or distribute this software for any
|
|
1407
|
-
purpose with or without fee is hereby granted.
|
|
1408
|
-
|
|
1409
|
-
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
1410
|
-
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
1411
|
-
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
1412
|
-
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
1413
|
-
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
1414
|
-
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
1415
|
-
PERFORMANCE OF THIS SOFTWARE.
|
|
1416
|
-
***************************************************************************** */
|
|
1417
|
-
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
function __awaiter(thisArg, _arguments, P, generator) {
|
|
1421
|
-
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
1422
|
-
return new (P || (P = Promise))(function (resolve, reject) {
|
|
1423
|
-
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
1424
|
-
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
1425
|
-
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
1426
|
-
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
1427
|
-
});
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
1431
|
-
var e = new Error(message);
|
|
1432
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
1471
|
+
let globalConfig = {
|
|
1472
|
+
dracoDecoderPath: '/draco/',
|
|
1473
|
+
ktx2TranscoderPath: '/basis/',
|
|
1433
1474
|
};
|
|
1475
|
+
/**
|
|
1476
|
+
* Update global loader configuration (e.g., set path to CDN)
|
|
1477
|
+
*/
|
|
1478
|
+
function setLoaderConfig(config) {
|
|
1479
|
+
globalConfig = { ...globalConfig, ...config };
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Get current global loader configuration
|
|
1483
|
+
*/
|
|
1484
|
+
function getLoaderConfig() {
|
|
1485
|
+
return globalConfig;
|
|
1486
|
+
}
|
|
1434
1487
|
|
|
1435
1488
|
/**
|
|
1436
1489
|
* @file modelLoader.ts
|
|
@@ -1448,19 +1501,22 @@ const DEFAULT_OPTIONS$1 = {
|
|
|
1448
1501
|
maxTextureSize: null,
|
|
1449
1502
|
useSimpleMaterials: false,
|
|
1450
1503
|
skipSkinned: true,
|
|
1504
|
+
useCache: true,
|
|
1451
1505
|
};
|
|
1506
|
+
const modelCache = new Map();
|
|
1452
1507
|
/** Automatically determine which options to enable based on extension (smart judgment) */
|
|
1453
1508
|
function normalizeOptions(url, opts) {
|
|
1454
1509
|
const ext = (url.split('.').pop() || '').toLowerCase();
|
|
1455
|
-
const merged =
|
|
1510
|
+
const merged = { ...DEFAULT_OPTIONS$1, ...opts };
|
|
1456
1511
|
if (ext === 'gltf' || ext === 'glb') {
|
|
1512
|
+
const globalConfig = getLoaderConfig();
|
|
1457
1513
|
// gltf/glb defaults to trying draco/ktx2 if user didn't specify
|
|
1458
1514
|
if (merged.dracoDecoderPath === undefined)
|
|
1459
|
-
merged.dracoDecoderPath =
|
|
1515
|
+
merged.dracoDecoderPath = globalConfig.dracoDecoderPath;
|
|
1460
1516
|
if (merged.useKTX2 === undefined)
|
|
1461
1517
|
merged.useKTX2 = true;
|
|
1462
1518
|
if (merged.ktx2TranscoderPath === undefined)
|
|
1463
|
-
merged.ktx2TranscoderPath =
|
|
1519
|
+
merged.ktx2TranscoderPath = globalConfig.ktx2TranscoderPath;
|
|
1464
1520
|
}
|
|
1465
1521
|
else {
|
|
1466
1522
|
// fbx/obj/ply/stl etc. do not need draco/ktx2
|
|
@@ -1470,103 +1526,108 @@ function normalizeOptions(url, opts) {
|
|
|
1470
1526
|
}
|
|
1471
1527
|
return merged;
|
|
1472
1528
|
}
|
|
1473
|
-
function loadModelByUrl(
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
const ktx2Loader = new KTX2Loader().setTranscoderPath(opts.ktx2TranscoderPath);
|
|
1494
|
-
gltfLoader.__ktx2Loader = ktx2Loader;
|
|
1495
|
-
}
|
|
1496
|
-
loader = gltfLoader;
|
|
1497
|
-
}
|
|
1498
|
-
else if (ext === 'fbx') {
|
|
1499
|
-
const { FBXLoader } = yield import('three/examples/jsm/loaders/FBXLoader.js');
|
|
1500
|
-
loader = new FBXLoader(manager);
|
|
1501
|
-
}
|
|
1502
|
-
else if (ext === 'obj') {
|
|
1503
|
-
const { OBJLoader } = yield import('three/examples/jsm/loaders/OBJLoader.js');
|
|
1504
|
-
loader = new OBJLoader(manager);
|
|
1505
|
-
}
|
|
1506
|
-
else if (ext === 'ply') {
|
|
1507
|
-
const { PLYLoader } = yield import('three/examples/jsm/loaders/PLYLoader.js');
|
|
1508
|
-
loader = new PLYLoader(manager);
|
|
1509
|
-
}
|
|
1510
|
-
else if (ext === 'stl') {
|
|
1511
|
-
const { STLLoader } = yield import('three/examples/jsm/loaders/STLLoader.js');
|
|
1512
|
-
loader = new STLLoader(manager);
|
|
1529
|
+
async function loadModelByUrl(url, options = {}) {
|
|
1530
|
+
if (!url)
|
|
1531
|
+
throw new Error('url required');
|
|
1532
|
+
const ext = (url.split('.').pop() || '').toLowerCase();
|
|
1533
|
+
const opts = normalizeOptions(url, options);
|
|
1534
|
+
const manager = opts.manager ?? new THREE.LoadingManager();
|
|
1535
|
+
// Cache key includes URL and relevant optimization options
|
|
1536
|
+
const cacheKey = `${url}_${opts.mergeGeometries}_${opts.maxTextureSize}_${opts.useSimpleMaterials}`;
|
|
1537
|
+
if (opts.useCache && modelCache.has(cacheKey)) {
|
|
1538
|
+
return modelCache.get(cacheKey).clone();
|
|
1539
|
+
}
|
|
1540
|
+
let loader;
|
|
1541
|
+
if (ext === 'gltf' || ext === 'glb') {
|
|
1542
|
+
const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
|
1543
|
+
const gltfLoader = new GLTFLoader(manager);
|
|
1544
|
+
if (opts.dracoDecoderPath) {
|
|
1545
|
+
const { DRACOLoader } = await import('three/examples/jsm/loaders/DRACOLoader.js');
|
|
1546
|
+
const draco = new DRACOLoader();
|
|
1547
|
+
draco.setDecoderPath(opts.dracoDecoderPath);
|
|
1548
|
+
gltfLoader.setDRACOLoader(draco);
|
|
1513
1549
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
loader.load(url, (res) => {
|
|
1519
|
-
var _a;
|
|
1520
|
-
if (ext === 'gltf' || ext === 'glb') {
|
|
1521
|
-
const sceneObj = res.scene || res;
|
|
1522
|
-
// --- Critical: Expose animations to scene.userData (or scene.animations) ---
|
|
1523
|
-
// So the caller can access clips simply by getting sceneObj.userData.animations
|
|
1524
|
-
sceneObj.userData = (sceneObj === null || sceneObj === void 0 ? void 0 : sceneObj.userData) || {};
|
|
1525
|
-
sceneObj.userData.animations = (_a = res.animations) !== null && _a !== void 0 ? _a : [];
|
|
1526
|
-
resolve(sceneObj);
|
|
1527
|
-
}
|
|
1528
|
-
else {
|
|
1529
|
-
resolve(res);
|
|
1530
|
-
}
|
|
1531
|
-
}, undefined, (err) => reject(err));
|
|
1532
|
-
});
|
|
1533
|
-
// Optimize
|
|
1534
|
-
object.traverse((child) => {
|
|
1535
|
-
var _a, _b, _c;
|
|
1536
|
-
const mesh = child;
|
|
1537
|
-
if (mesh.isMesh && mesh.geometry && !mesh.geometry.isBufferGeometry) {
|
|
1538
|
-
try {
|
|
1539
|
-
mesh.geometry = (_c = (_b = (_a = new THREE.BufferGeometry()).fromGeometry) === null || _b === void 0 ? void 0 : _b.call(_a, mesh.geometry)) !== null && _c !== void 0 ? _c : mesh.geometry;
|
|
1540
|
-
}
|
|
1541
|
-
catch (_d) { }
|
|
1542
|
-
}
|
|
1543
|
-
});
|
|
1544
|
-
if (opts.maxTextureSize && opts.maxTextureSize > 0)
|
|
1545
|
-
downscaleTexturesInObject(object, opts.maxTextureSize);
|
|
1546
|
-
if (opts.useSimpleMaterials) {
|
|
1547
|
-
object.traverse((child) => {
|
|
1548
|
-
const m = child.material;
|
|
1549
|
-
if (!m)
|
|
1550
|
-
return;
|
|
1551
|
-
if (Array.isArray(m))
|
|
1552
|
-
child.material = m.map((mat) => toSimpleMaterial(mat));
|
|
1553
|
-
else
|
|
1554
|
-
child.material = toSimpleMaterial(m);
|
|
1555
|
-
});
|
|
1550
|
+
if (opts.useKTX2 && opts.ktx2TranscoderPath) {
|
|
1551
|
+
const { KTX2Loader } = await import('three/examples/jsm/loaders/KTX2Loader.js');
|
|
1552
|
+
const ktx2Loader = new KTX2Loader().setTranscoderPath(opts.ktx2TranscoderPath);
|
|
1553
|
+
gltfLoader.__ktx2Loader = ktx2Loader;
|
|
1556
1554
|
}
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1555
|
+
loader = gltfLoader;
|
|
1556
|
+
}
|
|
1557
|
+
else if (ext === 'fbx') {
|
|
1558
|
+
const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
|
|
1559
|
+
loader = new FBXLoader(manager);
|
|
1560
|
+
}
|
|
1561
|
+
else if (ext === 'obj') {
|
|
1562
|
+
const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js');
|
|
1563
|
+
loader = new OBJLoader(manager);
|
|
1564
|
+
}
|
|
1565
|
+
else if (ext === 'ply') {
|
|
1566
|
+
const { PLYLoader } = await import('three/examples/jsm/loaders/PLYLoader.js');
|
|
1567
|
+
loader = new PLYLoader(manager);
|
|
1568
|
+
}
|
|
1569
|
+
else if (ext === 'stl') {
|
|
1570
|
+
const { STLLoader } = await import('three/examples/jsm/loaders/STLLoader.js');
|
|
1571
|
+
loader = new STLLoader(manager);
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
throw new Error(`Unsupported model extension: .${ext}`);
|
|
1575
|
+
}
|
|
1576
|
+
const object = await new Promise((resolve, reject) => {
|
|
1577
|
+
loader.load(url, (res) => {
|
|
1578
|
+
if (ext === 'gltf' || ext === 'glb') {
|
|
1579
|
+
const sceneObj = res.scene || res;
|
|
1580
|
+
// --- Critical: Expose animations to scene.userData (or scene.animations) ---
|
|
1581
|
+
// So the caller can access clips simply by getting sceneObj.userData.animations
|
|
1582
|
+
sceneObj.userData = sceneObj?.userData || {};
|
|
1583
|
+
sceneObj.userData.animations = res.animations ?? [];
|
|
1584
|
+
resolve(sceneObj);
|
|
1560
1585
|
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1586
|
+
else {
|
|
1587
|
+
resolve(res);
|
|
1588
|
+
}
|
|
1589
|
+
}, undefined, (err) => reject(err));
|
|
1590
|
+
});
|
|
1591
|
+
// Optimize
|
|
1592
|
+
object.traverse((child) => {
|
|
1593
|
+
const mesh = child;
|
|
1594
|
+
if (mesh.isMesh && mesh.geometry && !mesh.geometry.isBufferGeometry) {
|
|
1595
|
+
try {
|
|
1596
|
+
mesh.geometry = new THREE.BufferGeometry().fromGeometry?.(mesh.geometry) ?? mesh.geometry;
|
|
1563
1597
|
}
|
|
1598
|
+
catch { }
|
|
1564
1599
|
}
|
|
1565
|
-
return object;
|
|
1566
1600
|
});
|
|
1601
|
+
if (opts.maxTextureSize && opts.maxTextureSize > 0)
|
|
1602
|
+
await downscaleTexturesInObject(object, opts.maxTextureSize);
|
|
1603
|
+
if (opts.useSimpleMaterials) {
|
|
1604
|
+
object.traverse((child) => {
|
|
1605
|
+
const m = child.material;
|
|
1606
|
+
if (!m)
|
|
1607
|
+
return;
|
|
1608
|
+
if (Array.isArray(m))
|
|
1609
|
+
child.material = m.map((mat) => toSimpleMaterial(mat));
|
|
1610
|
+
else
|
|
1611
|
+
child.material = toSimpleMaterial(m);
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
if (opts.mergeGeometries) {
|
|
1615
|
+
try {
|
|
1616
|
+
await tryMergeGeometries(object, { skipSkinned: opts.skipSkinned ?? true });
|
|
1617
|
+
}
|
|
1618
|
+
catch (e) {
|
|
1619
|
+
console.warn('mergeGeometries failed', e);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
if (opts.useCache) {
|
|
1623
|
+
modelCache.set(cacheKey, object);
|
|
1624
|
+
return object.clone();
|
|
1625
|
+
}
|
|
1626
|
+
return object;
|
|
1567
1627
|
}
|
|
1568
|
-
/** Runtime downscale textures in mesh to maxSize (canvas
|
|
1569
|
-
function downscaleTexturesInObject(obj, maxSize) {
|
|
1628
|
+
/** Runtime downscale textures in mesh to maxSize (createImageBitmap or canvas) to save GPU memory */
|
|
1629
|
+
async function downscaleTexturesInObject(obj, maxSize) {
|
|
1630
|
+
const tasks = [];
|
|
1570
1631
|
obj.traverse((ch) => {
|
|
1571
1632
|
if (!ch.isMesh)
|
|
1572
1633
|
return;
|
|
@@ -1585,27 +1646,44 @@ function downscaleTexturesInObject(obj, maxSize) {
|
|
|
1585
1646
|
const max = maxSize;
|
|
1586
1647
|
if (image.width <= max && image.height <= max)
|
|
1587
1648
|
return;
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1649
|
+
tasks.push((async () => {
|
|
1650
|
+
try {
|
|
1651
|
+
const scale = Math.min(max / image.width, max / image.height);
|
|
1652
|
+
const newWidth = Math.floor(image.width * scale);
|
|
1653
|
+
const newHeight = Math.floor(image.height * scale);
|
|
1654
|
+
let newSource;
|
|
1655
|
+
if (typeof createImageBitmap !== 'undefined') {
|
|
1656
|
+
newSource = await createImageBitmap(image, {
|
|
1657
|
+
resizeWidth: newWidth,
|
|
1658
|
+
resizeHeight: newHeight,
|
|
1659
|
+
resizeQuality: 'high'
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
else {
|
|
1663
|
+
// Fallback for environments without createImageBitmap
|
|
1664
|
+
const canvas = document.createElement('canvas');
|
|
1665
|
+
canvas.width = newWidth;
|
|
1666
|
+
canvas.height = newHeight;
|
|
1667
|
+
const ctx = canvas.getContext('2d');
|
|
1668
|
+
if (ctx) {
|
|
1669
|
+
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
|
1670
|
+
newSource = canvas;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
if (newSource) {
|
|
1674
|
+
const newTex = new THREE.Texture(newSource);
|
|
1675
|
+
newTex.needsUpdate = true;
|
|
1676
|
+
newTex.encoding = tex.encoding;
|
|
1677
|
+
mat[p] = newTex;
|
|
1678
|
+
}
|
|
1602
1679
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
}
|
|
1680
|
+
catch (e) {
|
|
1681
|
+
console.warn('downscale texture failed', e);
|
|
1682
|
+
}
|
|
1683
|
+
})());
|
|
1607
1684
|
});
|
|
1608
1685
|
});
|
|
1686
|
+
await Promise.all(tasks);
|
|
1609
1687
|
}
|
|
1610
1688
|
/**
|
|
1611
1689
|
* Try to merge geometries in object (Only merge: non-transparent, non-SkinnedMesh, attribute compatible BufferGeometry)
|
|
@@ -1613,81 +1691,77 @@ function downscaleTexturesInObject(obj, maxSize) {
|
|
|
1613
1691
|
* - Merging will group by material UUID (different materials cannot be merged)
|
|
1614
1692
|
* - Merge function is compatible with common export names of BufferGeometryUtils
|
|
1615
1693
|
*/
|
|
1616
|
-
function tryMergeGeometries(root, opts) {
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
var _a;
|
|
1622
|
-
if (!ch.isMesh)
|
|
1623
|
-
return;
|
|
1624
|
-
const mesh = ch;
|
|
1625
|
-
if (opts.skipSkinned && mesh.isSkinnedMesh)
|
|
1626
|
-
return;
|
|
1627
|
-
const mat = mesh.material;
|
|
1628
|
-
// don't merge transparent or morph-enabled or skinned meshes
|
|
1629
|
-
if (!mesh.geometry || mesh.visible === false)
|
|
1630
|
-
return;
|
|
1631
|
-
if (mat && mat.transparent)
|
|
1632
|
-
return;
|
|
1633
|
-
const geom = mesh.geometry.clone();
|
|
1634
|
-
mesh.updateWorldMatrix(true, false);
|
|
1635
|
-
geom.applyMatrix4(mesh.matrixWorld);
|
|
1636
|
-
// ensure attributes compatible? we'll rely on merge function to return null if incompatible
|
|
1637
|
-
const key = (mat && mat.uuid) || 'default';
|
|
1638
|
-
const bucket = (_a = groups.get(key)) !== null && _a !== void 0 ? _a : { material: mat !== null && mat !== void 0 ? mat : new THREE.MeshStandardMaterial(), geoms: [] };
|
|
1639
|
-
bucket.geoms.push(geom);
|
|
1640
|
-
groups.set(key, bucket);
|
|
1641
|
-
// mark for removal (we'll remove meshes after)
|
|
1642
|
-
mesh.userData.__toRemoveForMerge = true;
|
|
1643
|
-
});
|
|
1644
|
-
if (groups.size === 0)
|
|
1694
|
+
async function tryMergeGeometries(root, opts) {
|
|
1695
|
+
// collect meshes by material uuid
|
|
1696
|
+
const groups = new Map();
|
|
1697
|
+
root.traverse((ch) => {
|
|
1698
|
+
if (!ch.isMesh)
|
|
1645
1699
|
return;
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
if (
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1700
|
+
const mesh = ch;
|
|
1701
|
+
if (opts.skipSkinned && mesh.isSkinnedMesh)
|
|
1702
|
+
return;
|
|
1703
|
+
const mat = mesh.material;
|
|
1704
|
+
// don't merge transparent or morph-enabled or skinned meshes
|
|
1705
|
+
if (!mesh.geometry || mesh.visible === false)
|
|
1706
|
+
return;
|
|
1707
|
+
if (mat && mat.transparent)
|
|
1708
|
+
return;
|
|
1709
|
+
const geom = mesh.geometry.clone();
|
|
1710
|
+
mesh.updateWorldMatrix(true, false);
|
|
1711
|
+
geom.applyMatrix4(mesh.matrixWorld);
|
|
1712
|
+
// ensure attributes compatible? we'll rely on merge function to return null if incompatible
|
|
1713
|
+
const key = (mat && mat.uuid) || 'default';
|
|
1714
|
+
const bucket = groups.get(key) ?? { material: mat ?? new THREE.MeshStandardMaterial(), geoms: [] };
|
|
1715
|
+
bucket.geoms.push(geom);
|
|
1716
|
+
groups.set(key, bucket);
|
|
1717
|
+
// mark for removal (we'll remove meshes after)
|
|
1718
|
+
mesh.userData.__toRemoveForMerge = true;
|
|
1719
|
+
});
|
|
1720
|
+
if (groups.size === 0)
|
|
1721
|
+
return;
|
|
1722
|
+
// dynamic import BufferGeometryUtils and find merge function name
|
|
1723
|
+
const bufUtilsMod = await import('three/examples/jsm/utils/BufferGeometryUtils.js');
|
|
1724
|
+
// use || chain (avoid mixing ?? with || without parentheses)
|
|
1725
|
+
const mergeFn = bufUtilsMod.mergeBufferGeometries ||
|
|
1726
|
+
bufUtilsMod.mergeGeometries ||
|
|
1727
|
+
bufUtilsMod.mergeBufferGeometries || // defensive duplicate
|
|
1728
|
+
bufUtilsMod.mergeGeometries;
|
|
1729
|
+
if (!mergeFn)
|
|
1730
|
+
throw new Error('No merge function found in BufferGeometryUtils');
|
|
1731
|
+
// for each group, try merge
|
|
1732
|
+
for (const [key, { material, geoms }] of groups) {
|
|
1733
|
+
if (geoms.length <= 1) {
|
|
1734
|
+
// nothing to merge
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
// call merge function - signature typically mergeBufferGeometries(array, useGroups)
|
|
1738
|
+
const merged = mergeFn(geoms, false);
|
|
1739
|
+
if (!merged) {
|
|
1740
|
+
console.warn('merge returned null for group', key);
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
// create merged mesh at root (world-space geometry already applied)
|
|
1744
|
+
const mergedMesh = new THREE.Mesh(merged, material);
|
|
1745
|
+
root.add(mergedMesh);
|
|
1746
|
+
}
|
|
1747
|
+
// now remove original meshes flagged for removal
|
|
1748
|
+
const toRemove = [];
|
|
1749
|
+
root.traverse((ch) => {
|
|
1750
|
+
if (ch.userData?.__toRemoveForMerge)
|
|
1751
|
+
toRemove.push(ch);
|
|
1752
|
+
});
|
|
1753
|
+
toRemove.forEach((m) => {
|
|
1754
|
+
if (m.parent)
|
|
1755
|
+
m.parent.remove(m);
|
|
1756
|
+
// free original resources (geometries already cloned/applied), but careful with shared materials
|
|
1757
|
+
if (m.isMesh) {
|
|
1758
|
+
const mm = m;
|
|
1759
|
+
try {
|
|
1760
|
+
mm.geometry.dispose();
|
|
1666
1761
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
root.add(mergedMesh);
|
|
1762
|
+
catch { }
|
|
1763
|
+
// we do NOT dispose material because it may be reused by mergedMesh
|
|
1670
1764
|
}
|
|
1671
|
-
// now remove original meshes flagged for removal
|
|
1672
|
-
const toRemove = [];
|
|
1673
|
-
root.traverse((ch) => {
|
|
1674
|
-
var _a;
|
|
1675
|
-
if ((_a = ch.userData) === null || _a === void 0 ? void 0 : _a.__toRemoveForMerge)
|
|
1676
|
-
toRemove.push(ch);
|
|
1677
|
-
});
|
|
1678
|
-
toRemove.forEach((m) => {
|
|
1679
|
-
if (m.parent)
|
|
1680
|
-
m.parent.remove(m);
|
|
1681
|
-
// free original resources (geometries already cloned/applied), but careful with shared materials
|
|
1682
|
-
if (m.isMesh) {
|
|
1683
|
-
const mm = m;
|
|
1684
|
-
try {
|
|
1685
|
-
mm.geometry.dispose();
|
|
1686
|
-
}
|
|
1687
|
-
catch (_a) { }
|
|
1688
|
-
// we do NOT dispose material because it may be reused by mergedMesh
|
|
1689
|
-
}
|
|
1690
|
-
});
|
|
1691
1765
|
});
|
|
1692
1766
|
}
|
|
1693
1767
|
/* ---------------------
|
|
@@ -1704,7 +1778,7 @@ function disposeObject(obj) {
|
|
|
1704
1778
|
try {
|
|
1705
1779
|
m.geometry.dispose();
|
|
1706
1780
|
}
|
|
1707
|
-
catch
|
|
1781
|
+
catch { }
|
|
1708
1782
|
}
|
|
1709
1783
|
const mat = m.material;
|
|
1710
1784
|
if (mat) {
|
|
@@ -1726,14 +1800,14 @@ function disposeMaterial(mat) {
|
|
|
1726
1800
|
try {
|
|
1727
1801
|
mat[k].dispose();
|
|
1728
1802
|
}
|
|
1729
|
-
catch
|
|
1803
|
+
catch { }
|
|
1730
1804
|
}
|
|
1731
1805
|
});
|
|
1732
1806
|
try {
|
|
1733
1807
|
if (typeof mat.dispose === 'function')
|
|
1734
1808
|
mat.dispose();
|
|
1735
1809
|
}
|
|
1736
|
-
catch
|
|
1810
|
+
catch { }
|
|
1737
1811
|
}
|
|
1738
1812
|
// Helper to convert to simple material (stub)
|
|
1739
1813
|
function toSimpleMaterial(mat) {
|
|
@@ -1776,100 +1850,97 @@ const equirectCache = new Map();
|
|
|
1776
1850
|
* @param paths string[] 6 image paths, order: [px, nx, py, ny, pz, nz]
|
|
1777
1851
|
* @param opts SkyboxOptions
|
|
1778
1852
|
*/
|
|
1779
|
-
function loadCubeSkybox(
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
rec.refCount += 1;
|
|
1790
|
-
// reapply to scene (in case it was removed)
|
|
1791
|
-
if (options.setAsBackground)
|
|
1792
|
-
scene.background = rec.handle.backgroundTexture;
|
|
1793
|
-
if (options.setAsEnvironment && rec.handle.envRenderTarget)
|
|
1794
|
-
scene.environment = rec.handle.envRenderTarget.texture;
|
|
1795
|
-
return rec.handle;
|
|
1796
|
-
}
|
|
1797
|
-
// Load cube texture
|
|
1798
|
-
const loader = new THREE.CubeTextureLoader();
|
|
1799
|
-
const texture = yield new Promise((resolve, reject) => {
|
|
1800
|
-
loader.load(paths, (tex) => resolve(tex), undefined, (err) => reject(err));
|
|
1801
|
-
});
|
|
1802
|
-
// Set encoding and mapping
|
|
1803
|
-
if (options.useSRGBEncoding)
|
|
1804
|
-
texture.encoding = THREE.sRGBEncoding;
|
|
1805
|
-
texture.mapping = THREE.CubeReflectionMapping;
|
|
1806
|
-
// apply as background if required
|
|
1853
|
+
async function loadCubeSkybox(renderer, scene, paths, opts = {}) {
|
|
1854
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
1855
|
+
if (!Array.isArray(paths) || paths.length !== 6)
|
|
1856
|
+
throw new Error('cube skybox requires 6 image paths');
|
|
1857
|
+
const key = paths.join('|');
|
|
1858
|
+
// Cache handling
|
|
1859
|
+
if (options.cache && cubeCache.has(key)) {
|
|
1860
|
+
const rec = cubeCache.get(key);
|
|
1861
|
+
rec.refCount += 1;
|
|
1862
|
+
// reapply to scene (in case it was removed)
|
|
1807
1863
|
if (options.setAsBackground)
|
|
1808
|
-
scene.background =
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1864
|
+
scene.background = rec.handle.backgroundTexture;
|
|
1865
|
+
if (options.setAsEnvironment && rec.handle.envRenderTarget)
|
|
1866
|
+
scene.environment = rec.handle.envRenderTarget.texture;
|
|
1867
|
+
return rec.handle;
|
|
1868
|
+
}
|
|
1869
|
+
// Load cube texture
|
|
1870
|
+
const loader = new THREE.CubeTextureLoader();
|
|
1871
|
+
const texture = await new Promise((resolve, reject) => {
|
|
1872
|
+
loader.load(paths, (tex) => resolve(tex), undefined, (err) => reject(err));
|
|
1873
|
+
});
|
|
1874
|
+
// Set encoding and mapping
|
|
1875
|
+
if (options.useSRGBEncoding)
|
|
1876
|
+
texture.encoding = THREE.sRGBEncoding;
|
|
1877
|
+
texture.mapping = THREE.CubeReflectionMapping;
|
|
1878
|
+
// apply as background if required
|
|
1879
|
+
if (options.setAsBackground)
|
|
1880
|
+
scene.background = texture;
|
|
1881
|
+
// environment: use PMREM to produce a proper prefiltered env map for PBR
|
|
1882
|
+
let pmremGenerator = options.pmremGenerator ?? new THREE.PMREMGenerator(renderer);
|
|
1883
|
+
pmremGenerator.compileCubemapShader?.( /* optional */);
|
|
1884
|
+
// fromCubemap might be available in your three.js; fallback to fromEquirectangular approach if not
|
|
1885
|
+
let envRenderTarget = null;
|
|
1886
|
+
if (pmremGenerator.fromCubemap) {
|
|
1887
|
+
envRenderTarget = pmremGenerator.fromCubemap(texture);
|
|
1888
|
+
}
|
|
1889
|
+
else {
|
|
1890
|
+
// Fallback: render cube to env map by using generator.fromEquirectangular with a converted equirect if needed.
|
|
1891
|
+
// Simpler fallback: use the cube texture directly as environment (less correct for reflections).
|
|
1892
|
+
envRenderTarget = null;
|
|
1893
|
+
}
|
|
1894
|
+
if (options.setAsEnvironment) {
|
|
1895
|
+
if (envRenderTarget) {
|
|
1896
|
+
scene.environment = envRenderTarget.texture;
|
|
1816
1897
|
}
|
|
1817
1898
|
else {
|
|
1818
|
-
//
|
|
1819
|
-
|
|
1820
|
-
envRenderTarget = null;
|
|
1899
|
+
// fallback: use cube texture directly (works but not prefiltered)
|
|
1900
|
+
scene.environment = texture;
|
|
1821
1901
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1902
|
+
}
|
|
1903
|
+
const handle = {
|
|
1904
|
+
key,
|
|
1905
|
+
backgroundTexture: options.setAsBackground ? texture : null,
|
|
1906
|
+
envRenderTarget: envRenderTarget,
|
|
1907
|
+
pmremGenerator: options.pmremGenerator ? null : pmremGenerator, // only dispose if we created it
|
|
1908
|
+
setAsBackground: !!options.setAsBackground,
|
|
1909
|
+
setAsEnvironment: !!options.setAsEnvironment,
|
|
1910
|
+
dispose() {
|
|
1911
|
+
// remove from scene
|
|
1912
|
+
if (options.setAsBackground && scene.background === texture)
|
|
1913
|
+
scene.background = null;
|
|
1914
|
+
if (options.setAsEnvironment && scene.environment) {
|
|
1915
|
+
// only clear if it's the same texture we set
|
|
1916
|
+
if (envRenderTarget && scene.environment === envRenderTarget.texture)
|
|
1917
|
+
scene.environment = null;
|
|
1918
|
+
else if (scene.environment === texture)
|
|
1919
|
+
scene.environment = null;
|
|
1825
1920
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
scene.environment = texture;
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
const handle = {
|
|
1832
|
-
key,
|
|
1833
|
-
backgroundTexture: options.setAsBackground ? texture : null,
|
|
1834
|
-
envRenderTarget: envRenderTarget,
|
|
1835
|
-
pmremGenerator: options.pmremGenerator ? null : pmremGenerator, // only dispose if we created it
|
|
1836
|
-
setAsBackground: !!options.setAsBackground,
|
|
1837
|
-
setAsEnvironment: !!options.setAsEnvironment,
|
|
1838
|
-
dispose() {
|
|
1839
|
-
// remove from scene
|
|
1840
|
-
if (options.setAsBackground && scene.background === texture)
|
|
1841
|
-
scene.background = null;
|
|
1842
|
-
if (options.setAsEnvironment && scene.environment) {
|
|
1843
|
-
// only clear if it's the same texture we set
|
|
1844
|
-
if (envRenderTarget && scene.environment === envRenderTarget.texture)
|
|
1845
|
-
scene.environment = null;
|
|
1846
|
-
else if (scene.environment === texture)
|
|
1847
|
-
scene.environment = null;
|
|
1848
|
-
}
|
|
1849
|
-
// dispose resources only if not cached/shared
|
|
1850
|
-
if (envRenderTarget) {
|
|
1851
|
-
try {
|
|
1852
|
-
envRenderTarget.dispose();
|
|
1853
|
-
}
|
|
1854
|
-
catch (_a) { }
|
|
1855
|
-
}
|
|
1921
|
+
// dispose resources only if not cached/shared
|
|
1922
|
+
if (envRenderTarget) {
|
|
1856
1923
|
try {
|
|
1857
|
-
|
|
1924
|
+
envRenderTarget.dispose();
|
|
1858
1925
|
}
|
|
1859
|
-
catch
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1926
|
+
catch { }
|
|
1927
|
+
}
|
|
1928
|
+
try {
|
|
1929
|
+
texture.dispose();
|
|
1930
|
+
}
|
|
1931
|
+
catch { }
|
|
1932
|
+
// dispose pmremGenerator we created
|
|
1933
|
+
if (!options.pmremGenerator && pmremGenerator) {
|
|
1934
|
+
try {
|
|
1935
|
+
pmremGenerator.dispose();
|
|
1866
1936
|
}
|
|
1937
|
+
catch { }
|
|
1867
1938
|
}
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
if (options.cache)
|
|
1942
|
+
cubeCache.set(key, { handle, refCount: 1 });
|
|
1943
|
+
return handle;
|
|
1873
1944
|
}
|
|
1874
1945
|
/**
|
|
1875
1946
|
* Load Equirectangular/Single Image (Supports HDR via RGBELoader)
|
|
@@ -1878,95 +1949,90 @@ function loadCubeSkybox(renderer_1, scene_1, paths_1) {
|
|
|
1878
1949
|
* @param url string - *.hdr, *.exr, *.jpg, *.png
|
|
1879
1950
|
* @param opts SkyboxOptions
|
|
1880
1951
|
*/
|
|
1881
|
-
function loadEquirectSkybox(
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
const
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
//
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1952
|
+
async function loadEquirectSkybox(renderer, scene, url, opts = {}) {
|
|
1953
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
1954
|
+
const key = url;
|
|
1955
|
+
if (options.cache && equirectCache.has(key)) {
|
|
1956
|
+
const rec = equirectCache.get(key);
|
|
1957
|
+
rec.refCount += 1;
|
|
1958
|
+
if (options.setAsBackground)
|
|
1959
|
+
scene.background = rec.handle.backgroundTexture;
|
|
1960
|
+
if (options.setAsEnvironment && rec.handle.envRenderTarget)
|
|
1961
|
+
scene.environment = rec.handle.envRenderTarget.texture;
|
|
1962
|
+
return rec.handle;
|
|
1963
|
+
}
|
|
1964
|
+
// Dynamically import RGBELoader (for .hdr/.exr), if loading normal jpg/png directly use TextureLoader
|
|
1965
|
+
const isHDR = /\.hdr$|\.exr$/i.test(url);
|
|
1966
|
+
let hdrTexture;
|
|
1967
|
+
if (isHDR) {
|
|
1968
|
+
const { RGBELoader } = await import('three/examples/jsm/loaders/RGBELoader.js');
|
|
1969
|
+
hdrTexture = await new Promise((resolve, reject) => {
|
|
1970
|
+
new RGBELoader().load(url, (tex) => resolve(tex), undefined, (err) => reject(err));
|
|
1971
|
+
});
|
|
1972
|
+
// RGBE textures typically use LinearEncoding
|
|
1973
|
+
hdrTexture.encoding = THREE.LinearEncoding;
|
|
1974
|
+
}
|
|
1975
|
+
else {
|
|
1976
|
+
// ordinary image - use TextureLoader
|
|
1977
|
+
const loader = new THREE.TextureLoader();
|
|
1978
|
+
hdrTexture = await new Promise((resolve, reject) => {
|
|
1979
|
+
loader.load(url, (t) => resolve(t), undefined, (err) => reject(err));
|
|
1980
|
+
});
|
|
1981
|
+
if (options.useSRGBEncoding)
|
|
1982
|
+
hdrTexture.encoding = THREE.sRGBEncoding;
|
|
1983
|
+
}
|
|
1984
|
+
// PMREMGenerator to convert equirectangular to prefiltered cubemap (good for PBR)
|
|
1985
|
+
const pmremGenerator = options.pmremGenerator ?? new THREE.PMREMGenerator(renderer);
|
|
1986
|
+
pmremGenerator.compileEquirectangularShader?.();
|
|
1987
|
+
const envRenderTarget = pmremGenerator.fromEquirectangular(hdrTexture);
|
|
1988
|
+
// envTexture to use for scene.environment
|
|
1989
|
+
const envTexture = envRenderTarget.texture;
|
|
1990
|
+
// set background and/or environment
|
|
1991
|
+
if (options.setAsBackground) {
|
|
1992
|
+
// for background it's ok to use the equirect texture directly or the envTexture
|
|
1993
|
+
// envTexture is cubemap-like and usually better for reflections; using it as background creates cube-projected look
|
|
1994
|
+
scene.background = envTexture;
|
|
1995
|
+
}
|
|
1996
|
+
if (options.setAsEnvironment) {
|
|
1997
|
+
scene.environment = envTexture;
|
|
1998
|
+
}
|
|
1999
|
+
// We can dispose the original hdrTexture (the PMREM target contains the needed data)
|
|
2000
|
+
try {
|
|
2001
|
+
hdrTexture.dispose();
|
|
2002
|
+
}
|
|
2003
|
+
catch { }
|
|
2004
|
+
const handle = {
|
|
2005
|
+
key,
|
|
2006
|
+
backgroundTexture: options.setAsBackground ? envTexture : null,
|
|
2007
|
+
envRenderTarget,
|
|
2008
|
+
pmremGenerator: options.pmremGenerator ? null : pmremGenerator,
|
|
2009
|
+
setAsBackground: !!options.setAsBackground,
|
|
2010
|
+
setAsEnvironment: !!options.setAsEnvironment,
|
|
2011
|
+
dispose() {
|
|
2012
|
+
if (options.setAsBackground && scene.background === envTexture)
|
|
2013
|
+
scene.background = null;
|
|
2014
|
+
if (options.setAsEnvironment && scene.environment === envTexture)
|
|
2015
|
+
scene.environment = null;
|
|
2016
|
+
try {
|
|
2017
|
+
envRenderTarget.dispose();
|
|
2018
|
+
}
|
|
2019
|
+
catch { }
|
|
2020
|
+
if (!options.pmremGenerator && pmremGenerator) {
|
|
1947
2021
|
try {
|
|
1948
|
-
|
|
1949
|
-
}
|
|
1950
|
-
catch (_a) { }
|
|
1951
|
-
if (!options.pmremGenerator && pmremGenerator) {
|
|
1952
|
-
try {
|
|
1953
|
-
pmremGenerator.dispose();
|
|
1954
|
-
}
|
|
1955
|
-
catch (_b) { }
|
|
2022
|
+
pmremGenerator.dispose();
|
|
1956
2023
|
}
|
|
2024
|
+
catch { }
|
|
1957
2025
|
}
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
2026
|
+
}
|
|
2027
|
+
};
|
|
2028
|
+
if (options.cache)
|
|
2029
|
+
equirectCache.set(key, { handle, refCount: 1 });
|
|
2030
|
+
return handle;
|
|
1963
2031
|
}
|
|
1964
|
-
function loadSkybox(
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
return loadEquirectSkybox(renderer, scene, params.url, opts);
|
|
1969
|
-
});
|
|
2032
|
+
async function loadSkybox(renderer, scene, params, opts = {}) {
|
|
2033
|
+
if (params.type === 'cube')
|
|
2034
|
+
return loadCubeSkybox(renderer, scene, params.paths, opts);
|
|
2035
|
+
return loadEquirectSkybox(renderer, scene, params.url, opts);
|
|
1970
2036
|
}
|
|
1971
2037
|
/* -------------------------
|
|
1972
2038
|
Cache / Reference Counting Helper Methods
|
|
@@ -2176,10 +2242,9 @@ class BlueSkyManager {
|
|
|
2176
2242
|
* Usually called when the scene is completely destroyed or the application exits
|
|
2177
2243
|
*/
|
|
2178
2244
|
destroy() {
|
|
2179
|
-
var _a;
|
|
2180
2245
|
this.cancelLoad();
|
|
2181
2246
|
this.dispose();
|
|
2182
|
-
|
|
2247
|
+
this.pmremGen?.dispose();
|
|
2183
2248
|
this.isInitialized = false;
|
|
2184
2249
|
this.loadingState = 'idle';
|
|
2185
2250
|
}
|
|
@@ -2213,20 +2278,19 @@ const BlueSky = new BlueSkyManager();
|
|
|
2213
2278
|
* - RAF management optimization
|
|
2214
2279
|
*/
|
|
2215
2280
|
function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, options) {
|
|
2216
|
-
var _a, _b, _c, _d, _e, _f;
|
|
2217
2281
|
const cfg = {
|
|
2218
|
-
fontSize:
|
|
2219
|
-
color:
|
|
2220
|
-
background:
|
|
2221
|
-
padding:
|
|
2222
|
-
borderRadius:
|
|
2223
|
-
lift:
|
|
2224
|
-
dotSize:
|
|
2225
|
-
dotSpacing:
|
|
2226
|
-
lineColor:
|
|
2227
|
-
lineWidth:
|
|
2228
|
-
updateInterval:
|
|
2229
|
-
fadeInDuration:
|
|
2282
|
+
fontSize: options?.fontSize || '12px',
|
|
2283
|
+
color: options?.color || '#ffffff',
|
|
2284
|
+
background: options?.background || '#1890ff',
|
|
2285
|
+
padding: options?.padding || '6px 10px',
|
|
2286
|
+
borderRadius: options?.borderRadius || '6px',
|
|
2287
|
+
lift: options?.lift ?? 100,
|
|
2288
|
+
dotSize: options?.dotSize ?? 6,
|
|
2289
|
+
dotSpacing: options?.dotSpacing ?? 2,
|
|
2290
|
+
lineColor: options?.lineColor || 'rgba(200,200,200,0.7)',
|
|
2291
|
+
lineWidth: options?.lineWidth ?? 1,
|
|
2292
|
+
updateInterval: options?.updateInterval ?? 0, // Default update every frame
|
|
2293
|
+
fadeInDuration: options?.fadeInDuration ?? 300, // Fade-in duration
|
|
2230
2294
|
};
|
|
2231
2295
|
const container = document.createElement('div');
|
|
2232
2296
|
container.style.position = 'absolute';
|
|
@@ -2249,7 +2313,7 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2249
2313
|
svg.style.zIndex = '1';
|
|
2250
2314
|
container.appendChild(svg);
|
|
2251
2315
|
let currentModel = parentModel;
|
|
2252
|
-
let currentLabelsMap =
|
|
2316
|
+
let currentLabelsMap = { ...modelLabelsMap };
|
|
2253
2317
|
let labels = [];
|
|
2254
2318
|
let isActive = true;
|
|
2255
2319
|
let isPaused = false;
|
|
@@ -2330,9 +2394,8 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2330
2394
|
if (!currentModel)
|
|
2331
2395
|
return;
|
|
2332
2396
|
currentModel.traverse((child) => {
|
|
2333
|
-
var _a;
|
|
2334
2397
|
if (child.isMesh || child.type === 'Group') {
|
|
2335
|
-
const labelText =
|
|
2398
|
+
const labelText = Object.entries(currentLabelsMap).find(([key]) => child.name.includes(key))?.[1];
|
|
2336
2399
|
if (!labelText)
|
|
2337
2400
|
return;
|
|
2338
2401
|
const wrapper = document.createElement('div');
|
|
@@ -2437,7 +2500,7 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2437
2500
|
rebuildLabels();
|
|
2438
2501
|
},
|
|
2439
2502
|
updateLabelsMap(newMap) {
|
|
2440
|
-
currentLabelsMap =
|
|
2503
|
+
currentLabelsMap = { ...newMap };
|
|
2441
2504
|
rebuildLabels();
|
|
2442
2505
|
},
|
|
2443
2506
|
// Pause update
|
|
@@ -2535,205 +2598,197 @@ class GroupExploder {
|
|
|
2535
2598
|
* @param newSet The new set of meshes
|
|
2536
2599
|
* @param contextId Optional context ID to distinguish business scenarios
|
|
2537
2600
|
*/
|
|
2538
|
-
setMeshes(newSet, options) {
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2601
|
+
async setMeshes(newSet, options) {
|
|
2602
|
+
const autoRestorePrev = options?.autoRestorePrev ?? true;
|
|
2603
|
+
const restoreDuration = options?.restoreDuration ?? 300;
|
|
2604
|
+
this.log(`setMeshes called. newSetSize=${newSet ? newSet.size : 0}, autoRestorePrev=${autoRestorePrev}`);
|
|
2605
|
+
// If the newSet is null and currentSet is null -> nothing
|
|
2606
|
+
if (!newSet && !this.currentSet) {
|
|
2607
|
+
this.log('setMeshes: both newSet and currentSet are null, nothing to do');
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
// If both exist and are the same reference, we still must detect content changes.
|
|
2611
|
+
const sameReference = this.currentSet === newSet;
|
|
2612
|
+
// Prepare prevSet snapshot (we copy current to prev)
|
|
2613
|
+
if (this.currentSet) {
|
|
2614
|
+
this.prevSet = this.currentSet;
|
|
2615
|
+
this.prevStateMap = new Map(this.stateMap);
|
|
2616
|
+
this.log(`setMeshes: backed up current->prev prevSetSize=${this.prevSet.size}`);
|
|
2617
|
+
}
|
|
2618
|
+
else {
|
|
2619
|
+
this.prevSet = null;
|
|
2620
|
+
this.prevStateMap = new Map();
|
|
2621
|
+
}
|
|
2622
|
+
// If we used to be exploded and need to restore prevSet, do that first (await)
|
|
2623
|
+
if (this.prevSet && autoRestorePrev && this.isExploded) {
|
|
2624
|
+
this.log('setMeshes: need to restore prevSet before applying newSet');
|
|
2625
|
+
await this.restoreSet(this.prevSet, this.prevStateMap, restoreDuration, { debug: true });
|
|
2626
|
+
this.log('setMeshes: prevSet restore done');
|
|
2627
|
+
this.prevStateMap.clear();
|
|
2628
|
+
this.prevSet = null;
|
|
2629
|
+
}
|
|
2630
|
+
// Now register newSet: we clear and rebuild stateMap carefully.
|
|
2631
|
+
// But we must handle the case where caller reuses same Set object and just mutated elements.
|
|
2632
|
+
// We will compute additions and removals.
|
|
2633
|
+
const oldSet = this.currentSet;
|
|
2634
|
+
this.currentSet = newSet;
|
|
2635
|
+
// If newSet is null -> simply clear stateMap
|
|
2636
|
+
if (!this.currentSet) {
|
|
2637
|
+
this.stateMap.clear();
|
|
2638
|
+
this.log('setMeshes: newSet is null -> cleared stateMap');
|
|
2639
|
+
this.isExploded = false;
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
// If we have oldSet (could be same reference) then compute diffs
|
|
2643
|
+
if (oldSet) {
|
|
2644
|
+
// If same reference but size or content differs -> handle diffs
|
|
2645
|
+
const wasSameRef = sameReference;
|
|
2646
|
+
let added = [];
|
|
2647
|
+
let removed = [];
|
|
2648
|
+
// Build maps of membership
|
|
2649
|
+
const oldMembers = new Set(Array.from(oldSet));
|
|
2650
|
+
const newMembers = new Set(Array.from(this.currentSet));
|
|
2651
|
+
// find removals
|
|
2652
|
+
oldMembers.forEach((m) => {
|
|
2653
|
+
if (!newMembers.has(m))
|
|
2654
|
+
removed.push(m);
|
|
2655
|
+
});
|
|
2656
|
+
// find additions
|
|
2657
|
+
newMembers.forEach((m) => {
|
|
2658
|
+
if (!oldMembers.has(m))
|
|
2659
|
+
added.push(m);
|
|
2660
|
+
});
|
|
2661
|
+
if (wasSameRef && added.length === 0 && removed.length === 0) {
|
|
2662
|
+
// truly identical (no content changes)
|
|
2663
|
+
this.log('setMeshes: same reference and identical contents -> nothing to update');
|
|
2579
2664
|
return;
|
|
2580
2665
|
}
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
let removed = [];
|
|
2587
|
-
// Build maps of membership
|
|
2588
|
-
const oldMembers = new Set(Array.from(oldSet));
|
|
2589
|
-
const newMembers = new Set(Array.from(this.currentSet));
|
|
2590
|
-
// find removals
|
|
2591
|
-
oldMembers.forEach((m) => {
|
|
2592
|
-
if (!newMembers.has(m))
|
|
2593
|
-
removed.push(m);
|
|
2594
|
-
});
|
|
2595
|
-
// find additions
|
|
2596
|
-
newMembers.forEach((m) => {
|
|
2597
|
-
if (!oldMembers.has(m))
|
|
2598
|
-
added.push(m);
|
|
2599
|
-
});
|
|
2600
|
-
if (wasSameRef && added.length === 0 && removed.length === 0) {
|
|
2601
|
-
// truly identical (no content changes)
|
|
2602
|
-
this.log('setMeshes: same reference and identical contents -> nothing to update');
|
|
2603
|
-
return;
|
|
2666
|
+
this.log(`setMeshes: diff detected -> added=${added.length}, removed=${removed.length}`);
|
|
2667
|
+
// Remove snapshots for removed meshes
|
|
2668
|
+
removed.forEach((m) => {
|
|
2669
|
+
if (this.stateMap.has(m)) {
|
|
2670
|
+
this.stateMap.delete(m);
|
|
2604
2671
|
}
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
this.stateMap.clear();
|
|
2621
|
-
yield this.ensureSnapshotsForSet(this.currentSet);
|
|
2622
|
-
this.log(`setMeshes: recorded stateMap entries for newSet size=${this.stateMap.size}`);
|
|
2623
|
-
this.isExploded = false;
|
|
2624
|
-
return;
|
|
2625
|
-
}
|
|
2626
|
-
});
|
|
2672
|
+
});
|
|
2673
|
+
// Ensure snapshots exist for current set members (create for newly added meshes)
|
|
2674
|
+
await this.ensureSnapshotsForSet(this.currentSet);
|
|
2675
|
+
this.log(`setMeshes: after diff handling, stateMap size=${this.stateMap.size}`);
|
|
2676
|
+
this.isExploded = false;
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
else {
|
|
2680
|
+
// no oldSet -> brand new registration
|
|
2681
|
+
this.stateMap.clear();
|
|
2682
|
+
await this.ensureSnapshotsForSet(this.currentSet);
|
|
2683
|
+
this.log(`setMeshes: recorded stateMap entries for newSet size=${this.stateMap.size}`);
|
|
2684
|
+
this.isExploded = false;
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2627
2687
|
}
|
|
2628
2688
|
/**
|
|
2629
2689
|
* ensureSnapshotsForSet: for each mesh in set, ensure stateMap has an entry.
|
|
2630
2690
|
* If missing, record current matrixWorld as originalMatrixWorld (best-effort).
|
|
2631
2691
|
*/
|
|
2632
|
-
ensureSnapshotsForSet(set) {
|
|
2633
|
-
|
|
2634
|
-
|
|
2692
|
+
async ensureSnapshotsForSet(set) {
|
|
2693
|
+
set.forEach((m) => {
|
|
2694
|
+
try {
|
|
2695
|
+
m.updateMatrixWorld(true);
|
|
2696
|
+
}
|
|
2697
|
+
catch { }
|
|
2698
|
+
if (!this.stateMap.has(m)) {
|
|
2635
2699
|
try {
|
|
2636
|
-
|
|
2700
|
+
this.stateMap.set(m, {
|
|
2701
|
+
originalParent: m.parent || null,
|
|
2702
|
+
originalMatrixWorld: (m.matrixWorld && m.matrixWorld.clone()) || new THREE.Matrix4().copy(m.matrix),
|
|
2703
|
+
});
|
|
2704
|
+
// Also store in userData for extra resilience
|
|
2705
|
+
m.userData.__originalMatrixWorld = this.stateMap.get(m).originalMatrixWorld.clone();
|
|
2637
2706
|
}
|
|
2638
|
-
catch (
|
|
2639
|
-
|
|
2640
|
-
try {
|
|
2641
|
-
this.stateMap.set(m, {
|
|
2642
|
-
originalParent: m.parent || null,
|
|
2643
|
-
originalMatrixWorld: (m.matrixWorld && m.matrixWorld.clone()) || new THREE.Matrix4().copy(m.matrix),
|
|
2644
|
-
});
|
|
2645
|
-
// Also store in userData for extra resilience
|
|
2646
|
-
m.userData.__originalMatrixWorld = this.stateMap.get(m).originalMatrixWorld.clone();
|
|
2647
|
-
}
|
|
2648
|
-
catch (e) {
|
|
2649
|
-
this.log(`ensureSnapshotsForSet: failed to snapshot mesh ${m.name || m.id}: ${e.message}`);
|
|
2650
|
-
}
|
|
2707
|
+
catch (e) {
|
|
2708
|
+
this.log(`ensureSnapshotsForSet: failed to snapshot mesh ${m.name || m.id}: ${e.message}`);
|
|
2651
2709
|
}
|
|
2652
|
-
}
|
|
2710
|
+
}
|
|
2653
2711
|
});
|
|
2654
2712
|
}
|
|
2655
2713
|
/**
|
|
2656
2714
|
* explode: compute targets first, compute targetBound using targets + mesh radii,
|
|
2657
2715
|
* animate camera to that targetBound, then animate meshes to targets.
|
|
2658
2716
|
*/
|
|
2659
|
-
explode(opts) {
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2717
|
+
async explode(opts) {
|
|
2718
|
+
if (!this.currentSet || this.currentSet.size === 0) {
|
|
2719
|
+
this.log('explode: empty currentSet, nothing to do');
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
const { spacing = 2, duration = 1000, lift = 0.5, cameraPadding = 1.5, mode = 'spiral', dimOthers = { enabled: true, opacity: 0.25 }, debug = false, } = opts || {};
|
|
2723
|
+
this.log(`explode called. setSize=${this.currentSet.size}, mode=${mode}, spacing=${spacing}, duration=${duration}, lift=${lift}, dim=${dimOthers.enabled}`);
|
|
2724
|
+
this.cancelAnimations();
|
|
2725
|
+
const meshes = Array.from(this.currentSet);
|
|
2726
|
+
// ensure snapshots exist for any meshes that may have been added after initial registration
|
|
2727
|
+
await this.ensureSnapshotsForSet(this.currentSet);
|
|
2728
|
+
// compute center/radius from current meshes (fallback)
|
|
2729
|
+
const initial = this.computeBoundingSphereForMeshes(meshes);
|
|
2730
|
+
const center = initial.center;
|
|
2731
|
+
const baseRadius = Math.max(1, initial.radius);
|
|
2732
|
+
this.log(`explode: initial center=${center.toArray().map((n) => n.toFixed(3))}, baseRadius=${baseRadius.toFixed(3)}`);
|
|
2733
|
+
// compute targets (pure calculation)
|
|
2734
|
+
const targets = this.computeTargetsByMode(meshes, center, baseRadius + spacing, { lift, mode });
|
|
2735
|
+
this.log(`explode: computed ${targets.length} target positions`);
|
|
2736
|
+
// compute target-based bounding sphere (targets + per-mesh radius)
|
|
2737
|
+
const targetBound = this.computeBoundingSphereForPositionsAndMeshes(targets, meshes);
|
|
2738
|
+
this.log(`explode: targetBound center=${targetBound.center.toArray().map((n) => n.toFixed(3))}, radius=${targetBound.radius.toFixed(3)}`);
|
|
2739
|
+
await this.animateCameraToFit(targetBound.center, targetBound.radius, { duration: Math.min(600, duration), padding: cameraPadding });
|
|
2740
|
+
this.log('explode: camera animation to target bound completed');
|
|
2741
|
+
// apply dim if needed with context id
|
|
2742
|
+
const contextId = dimOthers?.enabled ? this.applyDimToOthers(meshes, dimOthers.opacity ?? 0.25, { debug }) : null;
|
|
2743
|
+
if (contextId)
|
|
2744
|
+
this.log(`explode: applied dim for context ${contextId}`);
|
|
2745
|
+
// capture starts after camera move
|
|
2746
|
+
const starts = meshes.map((m) => {
|
|
2747
|
+
const v = new THREE.Vector3();
|
|
2748
|
+
try {
|
|
2749
|
+
m.getWorldPosition(v);
|
|
2665
2750
|
}
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
const
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
this.log(`explode: applied dim for context ${contextId}`);
|
|
2689
|
-
// capture starts after camera move
|
|
2690
|
-
const starts = meshes.map((m) => {
|
|
2691
|
-
const v = new THREE.Vector3();
|
|
2692
|
-
try {
|
|
2693
|
-
m.getWorldPosition(v);
|
|
2694
|
-
}
|
|
2695
|
-
catch (_a) {
|
|
2696
|
-
// fallback to originalMatrixWorld if available
|
|
2697
|
-
const st = this.stateMap.get(m);
|
|
2698
|
-
if (st)
|
|
2699
|
-
v.setFromMatrixPosition(st.originalMatrixWorld);
|
|
2700
|
-
}
|
|
2701
|
-
return v;
|
|
2702
|
-
});
|
|
2703
|
-
const startTime = performance.now();
|
|
2704
|
-
const total = Math.max(1, duration);
|
|
2705
|
-
const tick = (now) => {
|
|
2706
|
-
const t = Math.min(1, (now - startTime) / total);
|
|
2707
|
-
const eased = easeInOutQuad(t);
|
|
2708
|
-
for (let i = 0; i < meshes.length; i++) {
|
|
2709
|
-
const m = meshes[i];
|
|
2710
|
-
const s = starts[i];
|
|
2711
|
-
const tar = targets[i];
|
|
2712
|
-
const cur = s.clone().lerp(tar, eased);
|
|
2713
|
-
if (m.parent) {
|
|
2714
|
-
const local = cur.clone();
|
|
2715
|
-
m.parent.worldToLocal(local);
|
|
2716
|
-
m.position.copy(local);
|
|
2717
|
-
}
|
|
2718
|
-
else {
|
|
2719
|
-
m.position.copy(cur);
|
|
2720
|
-
}
|
|
2721
|
-
m.updateMatrix();
|
|
2722
|
-
}
|
|
2723
|
-
if (this.controls && typeof this.controls.update === 'function')
|
|
2724
|
-
this.controls.update();
|
|
2725
|
-
if (t < 1) {
|
|
2726
|
-
this.animId = requestAnimationFrame(tick);
|
|
2751
|
+
catch {
|
|
2752
|
+
// fallback to originalMatrixWorld if available
|
|
2753
|
+
const st = this.stateMap.get(m);
|
|
2754
|
+
if (st)
|
|
2755
|
+
v.setFromMatrixPosition(st.originalMatrixWorld);
|
|
2756
|
+
}
|
|
2757
|
+
return v;
|
|
2758
|
+
});
|
|
2759
|
+
const startTime = performance.now();
|
|
2760
|
+
const total = Math.max(1, duration);
|
|
2761
|
+
const tick = (now) => {
|
|
2762
|
+
const t = Math.min(1, (now - startTime) / total);
|
|
2763
|
+
const eased = easeInOutQuad(t);
|
|
2764
|
+
for (let i = 0; i < meshes.length; i++) {
|
|
2765
|
+
const m = meshes[i];
|
|
2766
|
+
const s = starts[i];
|
|
2767
|
+
const tar = targets[i];
|
|
2768
|
+
const cur = s.clone().lerp(tar, eased);
|
|
2769
|
+
if (m.parent) {
|
|
2770
|
+
const local = cur.clone();
|
|
2771
|
+
m.parent.worldToLocal(local);
|
|
2772
|
+
m.position.copy(local);
|
|
2727
2773
|
}
|
|
2728
2774
|
else {
|
|
2729
|
-
|
|
2730
|
-
this.isExploded = true;
|
|
2731
|
-
this.log(`explode: completed. contextId=${contextId !== null && contextId !== void 0 ? contextId : 'none'}`);
|
|
2775
|
+
m.position.copy(cur);
|
|
2732
2776
|
}
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2777
|
+
m.updateMatrix();
|
|
2778
|
+
}
|
|
2779
|
+
if (this.controls && typeof this.controls.update === 'function')
|
|
2780
|
+
this.controls.update();
|
|
2781
|
+
if (t < 1) {
|
|
2782
|
+
this.animId = requestAnimationFrame(tick);
|
|
2783
|
+
}
|
|
2784
|
+
else {
|
|
2785
|
+
this.animId = null;
|
|
2786
|
+
this.isExploded = true;
|
|
2787
|
+
this.log(`explode: completed. contextId=${contextId ?? 'none'}`);
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
this.animId = requestAnimationFrame(tick);
|
|
2791
|
+
return;
|
|
2737
2792
|
}
|
|
2738
2793
|
/**
|
|
2739
2794
|
* Restore all exploded meshes to their original transform:
|
|
@@ -2756,7 +2811,7 @@ class GroupExploder {
|
|
|
2756
2811
|
*/
|
|
2757
2812
|
restoreSet(set, stateMap, duration = 400, opts) {
|
|
2758
2813
|
if (!set || set.size === 0) {
|
|
2759
|
-
if (opts
|
|
2814
|
+
if (opts?.debug)
|
|
2760
2815
|
this.log('restoreSet: empty set, nothing to restore');
|
|
2761
2816
|
return Promise.resolve();
|
|
2762
2817
|
}
|
|
@@ -2769,12 +2824,12 @@ class GroupExploder {
|
|
|
2769
2824
|
try {
|
|
2770
2825
|
m.updateMatrixWorld(true);
|
|
2771
2826
|
}
|
|
2772
|
-
catch
|
|
2827
|
+
catch { }
|
|
2773
2828
|
const s = new THREE.Vector3();
|
|
2774
2829
|
try {
|
|
2775
2830
|
m.getWorldPosition(s);
|
|
2776
2831
|
}
|
|
2777
|
-
catch
|
|
2832
|
+
catch {
|
|
2778
2833
|
s.set(0, 0, 0);
|
|
2779
2834
|
}
|
|
2780
2835
|
starts.push(s);
|
|
@@ -2872,7 +2927,7 @@ class GroupExploder {
|
|
|
2872
2927
|
});
|
|
2873
2928
|
}
|
|
2874
2929
|
// material dim with context id
|
|
2875
|
-
applyDimToOthers(explodingMeshes, opacity = 0.25,
|
|
2930
|
+
applyDimToOthers(explodingMeshes, opacity = 0.25, _opts) {
|
|
2876
2931
|
const contextId = `ctx_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
|
2877
2932
|
const explodingSet = new Set(explodingMeshes);
|
|
2878
2933
|
const touched = new Set();
|
|
@@ -2883,11 +2938,10 @@ class GroupExploder {
|
|
|
2883
2938
|
if (explodingSet.has(mesh))
|
|
2884
2939
|
return;
|
|
2885
2940
|
const applyMat = (mat) => {
|
|
2886
|
-
var _a;
|
|
2887
2941
|
if (!this.materialSnaps.has(mat)) {
|
|
2888
2942
|
this.materialSnaps.set(mat, {
|
|
2889
2943
|
transparent: !!mat.transparent,
|
|
2890
|
-
opacity:
|
|
2944
|
+
opacity: mat.opacity ?? 1,
|
|
2891
2945
|
depthWrite: mat.depthWrite,
|
|
2892
2946
|
});
|
|
2893
2947
|
}
|
|
@@ -2914,7 +2968,7 @@ class GroupExploder {
|
|
|
2914
2968
|
return contextId;
|
|
2915
2969
|
}
|
|
2916
2970
|
// clean contexts for meshes (restore materials whose contexts are removed)
|
|
2917
|
-
cleanContextsForMeshes(
|
|
2971
|
+
cleanContextsForMeshes(_meshes) {
|
|
2918
2972
|
// conservative strategy: for each context we created, delete it and restore materials accordingly
|
|
2919
2973
|
for (const [contextId, mats] of Array.from(this.contextMaterials.entries())) {
|
|
2920
2974
|
mats.forEach((mat) => {
|
|
@@ -3028,7 +3082,7 @@ class GroupExploder {
|
|
|
3028
3082
|
}
|
|
3029
3083
|
}
|
|
3030
3084
|
}
|
|
3031
|
-
catch
|
|
3085
|
+
catch {
|
|
3032
3086
|
radius = 0;
|
|
3033
3087
|
}
|
|
3034
3088
|
if (!isFinite(radius) || radius < 0 || radius > 1e8)
|
|
@@ -3051,10 +3105,9 @@ class GroupExploder {
|
|
|
3051
3105
|
}
|
|
3052
3106
|
// computeTargetsByMode (unchanged logic but pure function)
|
|
3053
3107
|
computeTargetsByMode(meshes, center, baseRadius, opts) {
|
|
3054
|
-
var _a, _b;
|
|
3055
3108
|
const n = meshes.length;
|
|
3056
|
-
const lift =
|
|
3057
|
-
const mode =
|
|
3109
|
+
const lift = opts.lift ?? 0.5;
|
|
3110
|
+
const mode = opts.mode ?? 'ring';
|
|
3058
3111
|
const targets = [];
|
|
3059
3112
|
if (mode === 'ring') {
|
|
3060
3113
|
for (let i = 0; i < n; i++) {
|
|
@@ -3096,9 +3149,8 @@ class GroupExploder {
|
|
|
3096
3149
|
return targets;
|
|
3097
3150
|
}
|
|
3098
3151
|
animateCameraToFit(targetCenter, targetRadius, opts) {
|
|
3099
|
-
|
|
3100
|
-
const
|
|
3101
|
-
const padding = (_b = opts === null || opts === void 0 ? void 0 : opts.padding) !== null && _b !== void 0 ? _b : 1.5;
|
|
3152
|
+
const duration = opts?.duration ?? 600;
|
|
3153
|
+
const padding = opts?.padding ?? 1.5;
|
|
3102
3154
|
if (!(this.camera instanceof THREE.PerspectiveCamera)) {
|
|
3103
3155
|
if (this.controls && this.controls.target) {
|
|
3104
3156
|
// Fallback for non-PerspectiveCamera
|
|
@@ -3110,14 +3162,13 @@ class GroupExploder {
|
|
|
3110
3162
|
const endPos = endTarget.clone().add(dir.multiplyScalar(dist));
|
|
3111
3163
|
const startTime = performance.now();
|
|
3112
3164
|
const tick = (now) => {
|
|
3113
|
-
var _a;
|
|
3114
3165
|
const t = Math.min(1, (now - startTime) / duration);
|
|
3115
3166
|
const k = easeInOutQuad(t);
|
|
3116
3167
|
if (this.controls && this.controls.target) {
|
|
3117
3168
|
this.controls.target.lerpVectors(startTarget, endTarget, k);
|
|
3118
3169
|
}
|
|
3119
3170
|
this.camera.position.lerpVectors(startPos, endPos, k);
|
|
3120
|
-
if (
|
|
3171
|
+
if (this.controls?.update)
|
|
3121
3172
|
this.controls.update();
|
|
3122
3173
|
if (t < 1) {
|
|
3123
3174
|
this.cameraAnimId = requestAnimationFrame(tick);
|
|
@@ -3140,8 +3191,8 @@ class GroupExploder {
|
|
|
3140
3191
|
const distH = targetRadius / Math.sin(Math.min(fov, fov * aspect) / 2); // approximate
|
|
3141
3192
|
const dist = Math.max(distV, distH) * padding;
|
|
3142
3193
|
const startPos = this.camera.position.clone();
|
|
3143
|
-
const startTarget =
|
|
3144
|
-
if (!
|
|
3194
|
+
const startTarget = this.controls?.target ? this.controls.target.clone() : new THREE.Vector3(); // assumption
|
|
3195
|
+
if (!this.controls?.target) {
|
|
3145
3196
|
this.camera.getWorldDirection(startTarget);
|
|
3146
3197
|
startTarget.add(startPos);
|
|
3147
3198
|
}
|
|
@@ -3154,13 +3205,12 @@ class GroupExploder {
|
|
|
3154
3205
|
return new Promise((resolve) => {
|
|
3155
3206
|
const startTime = performance.now();
|
|
3156
3207
|
const tick = (now) => {
|
|
3157
|
-
var _a, _b;
|
|
3158
3208
|
const t = Math.min(1, (now - startTime) / duration);
|
|
3159
3209
|
const k = easeInOutQuad(t);
|
|
3160
3210
|
this.camera.position.lerpVectors(startPos, endPos, k);
|
|
3161
3211
|
if (this.controls && this.controls.target) {
|
|
3162
3212
|
this.controls.target.lerpVectors(startTarget, endTarget, k);
|
|
3163
|
-
|
|
3213
|
+
this.controls.update?.();
|
|
3164
3214
|
}
|
|
3165
3215
|
else {
|
|
3166
3216
|
this.camera.lookAt(endTarget); // simple lookAt if no controls
|
|
@@ -3209,183 +3259,134 @@ class GroupExploder {
|
|
|
3209
3259
|
* @file autoSetup.ts
|
|
3210
3260
|
* @description
|
|
3211
3261
|
* Automatically sets up the camera and basic lighting scene based on the model's bounding box.
|
|
3212
|
-
*
|
|
3213
|
-
* @best-practice
|
|
3214
|
-
* - Call `autoSetupCameraAndLight` after loading a model to get a quick "good looking" scene.
|
|
3215
|
-
* - Returns a handle to dispose lights or update intensity later.
|
|
3216
3262
|
*/
|
|
3217
3263
|
/**
|
|
3218
|
-
*
|
|
3219
|
-
*
|
|
3220
|
-
* Features:
|
|
3221
|
-
* - Adds light intensity adjustment method
|
|
3222
|
-
* - Improved error handling
|
|
3223
|
-
* - Optimized dispose logic
|
|
3224
|
-
*
|
|
3225
|
-
* - camera: THREE.PerspectiveCamera (will be moved and pointed at model center)
|
|
3226
|
-
* - scene: THREE.Scene (newly created light group will be added to the scene)
|
|
3227
|
-
* - model: THREE.Object3D loaded model (arbitrary transform/coordinates)
|
|
3228
|
-
* - options: Optional configuration (see AutoSetupOptions)
|
|
3229
|
-
*
|
|
3230
|
-
* Returns AutoSetupHandle, caller should call handle.dispose() when component unmounts/switches
|
|
3264
|
+
* Fit camera to object bounding box
|
|
3231
3265
|
*/
|
|
3232
|
-
function
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3266
|
+
function fitCameraToObject(camera, object, padding = 1.2, elevation = 0.2) {
|
|
3267
|
+
const box = new THREE.Box3().setFromObject(object);
|
|
3268
|
+
if (!isFinite(box.min.x))
|
|
3269
|
+
return { center: new THREE.Vector3(), radius: 0 };
|
|
3270
|
+
const sphere = new THREE.Sphere();
|
|
3271
|
+
box.getBoundingSphere(sphere);
|
|
3272
|
+
const center = sphere.center.clone();
|
|
3273
|
+
const radius = Math.max(0.001, sphere.radius);
|
|
3274
|
+
const fov = (camera.fov * Math.PI) / 180;
|
|
3275
|
+
const halfFov = fov / 2;
|
|
3276
|
+
const sinHalfFov = Math.max(Math.sin(halfFov), 0.001);
|
|
3277
|
+
const distance = (radius * padding) / sinHalfFov;
|
|
3278
|
+
const dir = new THREE.Vector3(0, Math.sin(elevation), Math.cos(elevation)).normalize();
|
|
3279
|
+
const desiredPos = center.clone().add(dir.multiplyScalar(distance));
|
|
3280
|
+
camera.position.copy(desiredPos);
|
|
3281
|
+
camera.lookAt(center);
|
|
3282
|
+
camera.near = Math.max(0.001, radius / 1000);
|
|
3283
|
+
camera.far = Math.max(1000, radius * 50);
|
|
3284
|
+
camera.updateProjectionMatrix();
|
|
3285
|
+
return { center, radius };
|
|
3286
|
+
}
|
|
3287
|
+
/**
|
|
3288
|
+
* Setup default lighting for a model
|
|
3289
|
+
*/
|
|
3290
|
+
function setupDefaultLights(scene, model, options = {}) {
|
|
3291
|
+
const box = new THREE.Box3().setFromObject(model);
|
|
3292
|
+
const sphere = new THREE.Sphere();
|
|
3293
|
+
box.getBoundingSphere(sphere);
|
|
3294
|
+
const center = sphere.center.clone();
|
|
3295
|
+
const radius = Math.max(0.001, sphere.radius);
|
|
3238
3296
|
const opts = {
|
|
3239
|
-
padding:
|
|
3240
|
-
elevation:
|
|
3241
|
-
enableShadows:
|
|
3242
|
-
shadowMapSize:
|
|
3243
|
-
directionalCount:
|
|
3244
|
-
setMeshShadowProps:
|
|
3245
|
-
renderer:
|
|
3297
|
+
padding: options.padding ?? 1.2,
|
|
3298
|
+
elevation: options.elevation ?? 0.2,
|
|
3299
|
+
enableShadows: options.enableShadows ?? false,
|
|
3300
|
+
shadowMapSize: options.shadowMapSize ?? 1024,
|
|
3301
|
+
directionalCount: options.directionalCount ?? 4,
|
|
3302
|
+
setMeshShadowProps: options.setMeshShadowProps ?? true,
|
|
3303
|
+
renderer: options.renderer ?? null,
|
|
3246
3304
|
};
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3305
|
+
if (opts.renderer && opts.enableShadows) {
|
|
3306
|
+
opts.renderer.shadowMap.enabled = true;
|
|
3307
|
+
opts.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
3308
|
+
}
|
|
3309
|
+
const lightsGroup = new THREE.Group();
|
|
3310
|
+
lightsGroup.name = 'autoSetupLightsGroup';
|
|
3311
|
+
lightsGroup.position.copy(center);
|
|
3312
|
+
scene.add(lightsGroup);
|
|
3313
|
+
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6);
|
|
3314
|
+
hemi.position.set(0, radius * 2.0, 0);
|
|
3315
|
+
lightsGroup.add(hemi);
|
|
3316
|
+
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
|
|
3317
|
+
lightsGroup.add(ambient);
|
|
3318
|
+
const dirCount = Math.max(1, Math.floor(opts.directionalCount));
|
|
3319
|
+
const dirs = [new THREE.Vector3(0, 1, 0)];
|
|
3320
|
+
for (let i = 0; i < dirCount; i++) {
|
|
3321
|
+
const angle = (i / dirCount) * Math.PI * 2;
|
|
3322
|
+
const v = new THREE.Vector3(Math.cos(angle), 0.3, Math.sin(angle)).normalize();
|
|
3323
|
+
dirs.push(v);
|
|
3324
|
+
}
|
|
3325
|
+
const shadowCamSize = Math.max(1, radius * 1.5);
|
|
3326
|
+
dirs.forEach((d, i) => {
|
|
3327
|
+
const light = new THREE.DirectionalLight(0xffffff, i === 0 ? 1.5 : 1.2);
|
|
3328
|
+
light.position.copy(d.clone().multiplyScalar(radius * 2.5));
|
|
3329
|
+
light.target.position.copy(center);
|
|
3330
|
+
light.name = `auto_dir_${i}`;
|
|
3331
|
+
lightsGroup.add(light);
|
|
3332
|
+
lightsGroup.add(light.target);
|
|
3333
|
+
if (opts.enableShadows) {
|
|
3334
|
+
light.castShadow = true;
|
|
3335
|
+
light.shadow.mapSize.width = opts.shadowMapSize;
|
|
3336
|
+
light.shadow.mapSize.height = opts.shadowMapSize;
|
|
3337
|
+
const cam = light.shadow.camera;
|
|
3338
|
+
const s = shadowCamSize;
|
|
3339
|
+
cam.left = -s;
|
|
3340
|
+
cam.right = s;
|
|
3341
|
+
cam.top = s;
|
|
3342
|
+
cam.bottom = -s;
|
|
3343
|
+
cam.near = 0.1;
|
|
3344
|
+
cam.far = radius * 10 + 50;
|
|
3345
|
+
light.shadow.bias = -5e-4;
|
|
3253
3346
|
}
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
}
|
|
3275
|
-
// --- 4) Create Lights Group
|
|
3276
|
-
const lightsGroup = new THREE.Group();
|
|
3277
|
-
lightsGroup.name = 'autoSetupLightsGroup';
|
|
3278
|
-
lightsGroup.position.copy(center);
|
|
3279
|
-
scene.add(lightsGroup);
|
|
3280
|
-
// 4.1 Basic Light
|
|
3281
|
-
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6);
|
|
3282
|
-
hemi.name = 'auto_hemi';
|
|
3283
|
-
hemi.position.set(0, radius * 2.0, 0);
|
|
3284
|
-
lightsGroup.add(hemi);
|
|
3285
|
-
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
|
|
3286
|
-
ambient.name = 'auto_ambient';
|
|
3287
|
-
lightsGroup.add(ambient);
|
|
3288
|
-
// 4.2 Directional Lights
|
|
3289
|
-
const dirCount = Math.max(1, Math.floor(opts.directionalCount));
|
|
3290
|
-
const directionalLights = [];
|
|
3291
|
-
const dirs = [];
|
|
3292
|
-
dirs.push(new THREE.Vector3(0, 1, 0));
|
|
3293
|
-
for (let i = 0; i < Math.max(1, dirCount); i++) {
|
|
3294
|
-
const angle = (i / Math.max(1, dirCount)) * Math.PI * 2;
|
|
3295
|
-
const v = new THREE.Vector3(Math.cos(angle), 0.3, Math.sin(angle)).normalize();
|
|
3296
|
-
dirs.push(v);
|
|
3297
|
-
}
|
|
3298
|
-
const shadowCamSize = Math.max(1, radius * 1.5);
|
|
3299
|
-
for (let i = 0; i < dirs.length; i++) {
|
|
3300
|
-
const d = dirs[i];
|
|
3301
|
-
const light = new THREE.DirectionalLight(0xffffff, i === 0 ? 1.5 : 1.2);
|
|
3302
|
-
light.position.copy(d.clone().multiplyScalar(radius * 2.5));
|
|
3303
|
-
light.target.position.copy(center);
|
|
3304
|
-
light.name = `auto_dir_${i}`;
|
|
3305
|
-
lightsGroup.add(light);
|
|
3306
|
-
lightsGroup.add(light.target);
|
|
3307
|
-
if (opts.enableShadows) {
|
|
3308
|
-
light.castShadow = true;
|
|
3309
|
-
light.shadow.mapSize.width = opts.shadowMapSize;
|
|
3310
|
-
light.shadow.mapSize.height = opts.shadowMapSize;
|
|
3311
|
-
const cam = light.shadow.camera;
|
|
3312
|
-
const s = shadowCamSize;
|
|
3313
|
-
cam.left = -s;
|
|
3314
|
-
cam.right = s;
|
|
3315
|
-
cam.top = s;
|
|
3316
|
-
cam.bottom = -s;
|
|
3317
|
-
cam.near = 0.1;
|
|
3318
|
-
cam.far = radius * 10 + 50;
|
|
3319
|
-
light.shadow.bias = -0.0005;
|
|
3320
|
-
}
|
|
3321
|
-
directionalLights.push(light);
|
|
3322
|
-
}
|
|
3323
|
-
// 4.3 Point Light Fill
|
|
3324
|
-
const fill1 = new THREE.PointLight(0xffffff, 0.5, radius * 4);
|
|
3325
|
-
fill1.position.copy(center).add(new THREE.Vector3(radius * 0.5, 0.2 * radius, 0));
|
|
3326
|
-
fill1.name = 'auto_fill1';
|
|
3327
|
-
lightsGroup.add(fill1);
|
|
3328
|
-
const fill2 = new THREE.PointLight(0xffffff, 0.3, radius * 3);
|
|
3329
|
-
fill2.position.copy(center).add(new THREE.Vector3(-radius * 0.5, -0.2 * radius, 0));
|
|
3330
|
-
fill2.name = 'auto_fill2';
|
|
3331
|
-
lightsGroup.add(fill2);
|
|
3332
|
-
// --- 5) Set Mesh Shadow Props
|
|
3333
|
-
if (opts.setMeshShadowProps) {
|
|
3334
|
-
model.traverse((ch) => {
|
|
3335
|
-
if (ch.isMesh) {
|
|
3336
|
-
const mesh = ch;
|
|
3337
|
-
const isSkinned = mesh.isSkinnedMesh;
|
|
3338
|
-
mesh.castShadow = opts.enableShadows && !isSkinned ? true : mesh.castShadow;
|
|
3339
|
-
mesh.receiveShadow = opts.enableShadows ? true : mesh.receiveShadow;
|
|
3347
|
+
});
|
|
3348
|
+
if (opts.setMeshShadowProps) {
|
|
3349
|
+
model.traverse((ch) => {
|
|
3350
|
+
if (ch.isMesh) {
|
|
3351
|
+
const mesh = ch;
|
|
3352
|
+
const isSkinned = mesh.isSkinnedMesh;
|
|
3353
|
+
mesh.castShadow = opts.enableShadows && !isSkinned ? true : mesh.castShadow;
|
|
3354
|
+
mesh.receiveShadow = opts.enableShadows ? true : mesh.receiveShadow;
|
|
3355
|
+
}
|
|
3356
|
+
});
|
|
3357
|
+
}
|
|
3358
|
+
const handle = {
|
|
3359
|
+
lightsGroup,
|
|
3360
|
+
center,
|
|
3361
|
+
radius,
|
|
3362
|
+
updateLightIntensity(factor) {
|
|
3363
|
+
lightsGroup.traverse((node) => {
|
|
3364
|
+
if (node.isLight) {
|
|
3365
|
+
const light = node;
|
|
3366
|
+
light.intensity *= factor; // Simple implementation
|
|
3340
3367
|
}
|
|
3341
3368
|
});
|
|
3342
|
-
}
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
if (node.isLight) {
|
|
3352
|
-
const light = node;
|
|
3353
|
-
const originalIntensity = parseFloat(light.name.split('_').pop() || '1');
|
|
3354
|
-
light.intensity = originalIntensity * Math.max(0, factor);
|
|
3355
|
-
}
|
|
3356
|
-
});
|
|
3357
|
-
},
|
|
3358
|
-
dispose: () => {
|
|
3359
|
-
try {
|
|
3360
|
-
// Remove lights group
|
|
3361
|
-
if (lightsGroup.parent)
|
|
3362
|
-
lightsGroup.parent.remove(lightsGroup);
|
|
3363
|
-
// Dispose shadow resources
|
|
3364
|
-
lightsGroup.traverse((node) => {
|
|
3365
|
-
if (node.isLight) {
|
|
3366
|
-
const l = node;
|
|
3367
|
-
if (l.shadow && l.shadow.map) {
|
|
3368
|
-
try {
|
|
3369
|
-
l.shadow.map.dispose();
|
|
3370
|
-
}
|
|
3371
|
-
catch (err) {
|
|
3372
|
-
console.warn('Failed to dispose shadow map:', err);
|
|
3373
|
-
}
|
|
3374
|
-
}
|
|
3375
|
-
}
|
|
3376
|
-
});
|
|
3377
|
-
}
|
|
3378
|
-
catch (error) {
|
|
3379
|
-
console.error('autoSetupCameraAndLight: dispose failed', error);
|
|
3369
|
+
},
|
|
3370
|
+
dispose: () => {
|
|
3371
|
+
if (lightsGroup.parent)
|
|
3372
|
+
lightsGroup.parent.remove(lightsGroup);
|
|
3373
|
+
lightsGroup.traverse((node) => {
|
|
3374
|
+
if (node.isLight) {
|
|
3375
|
+
const l = node;
|
|
3376
|
+
if (l.shadow && l.shadow.map)
|
|
3377
|
+
l.shadow.map.dispose();
|
|
3380
3378
|
}
|
|
3381
|
-
}
|
|
3382
|
-
}
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
};
|
|
3382
|
+
return handle;
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* Automatically setup camera and basic lighting (Combine fitCameraToObject and setupDefaultLights)
|
|
3386
|
+
*/
|
|
3387
|
+
function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
3388
|
+
fitCameraToObject(camera, model, options.padding, options.elevation);
|
|
3389
|
+
return setupDefaultLights(scene, model, options);
|
|
3389
3390
|
}
|
|
3390
3391
|
|
|
3391
3392
|
/**
|
|
@@ -3395,8 +3396,8 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3395
3396
|
* @packageDocumentation
|
|
3396
3397
|
*/
|
|
3397
3398
|
// Core utilities
|
|
3398
|
-
// Version
|
|
3399
|
-
const VERSION = '1.0.
|
|
3399
|
+
// Version (keep in sync with package.json)
|
|
3400
|
+
const VERSION = '1.0.4';
|
|
3400
3401
|
|
|
3401
|
-
export { ArrowGuide, BlueSky, FOLLOW_ANGLES, GroupExploder, LiquidFillerGroup, VERSION, ViewPresets, addChildModelLabels, autoSetupCameraAndLight, cancelFollow, cancelSetView, createModelClickHandler, createModelsLabel, disposeMaterial, disposeObject, enableHoverBreath, followModels, initPostProcessing, loadCubeSkybox, loadEquirectSkybox, loadModelByUrl, loadSkybox, releaseSkybox, setView };
|
|
3402
|
+
export { ArrowGuide, BlueSky, FOLLOW_ANGLES, GroupExploder, LiquidFillerGroup, ResourceManager, VERSION, ViewPresets, addChildModelLabels, autoSetupCameraAndLight, cancelFollow, cancelSetView, createModelClickHandler, createModelsLabel, disposeMaterial, disposeObject, enableHoverBreath, fitCameraToObject, followModels, getLoaderConfig, initPostProcessing, loadCubeSkybox, loadEquirectSkybox, loadModelByUrl, loadSkybox, releaseSkybox, setLoaderConfig, setView, setupDefaultLights };
|
|
3402
3403
|
//# sourceMappingURL=index.mjs.map
|