three-cad-viewer 4.1.2 → 4.2.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.
Files changed (58) hide show
  1. package/Readme.md +12 -5
  2. package/dist/camera/camera.d.ts +14 -2
  3. package/dist/core/studio-manager.d.ts +91 -0
  4. package/dist/core/types.d.ts +260 -9
  5. package/dist/core/viewer-state.d.ts +28 -2
  6. package/dist/core/viewer.d.ts +200 -6
  7. package/dist/index.d.ts +7 -2
  8. package/dist/rendering/environment.d.ts +239 -0
  9. package/dist/rendering/light-detection.d.ts +44 -0
  10. package/dist/rendering/material-factory.d.ts +77 -2
  11. package/dist/rendering/material-presets.d.ts +32 -0
  12. package/dist/rendering/room-environment.d.ts +13 -0
  13. package/dist/rendering/studio-composer.d.ts +130 -0
  14. package/dist/rendering/studio-floor.d.ts +53 -0
  15. package/dist/rendering/texture-cache.d.ts +142 -0
  16. package/dist/rendering/triplanar.d.ts +37 -0
  17. package/dist/scene/animation.d.ts +1 -1
  18. package/dist/scene/clipping.d.ts +31 -0
  19. package/dist/scene/nestedgroup.d.ts +64 -27
  20. package/dist/scene/objectgroup.d.ts +47 -0
  21. package/dist/three-cad-viewer.css +339 -29
  22. package/dist/three-cad-viewer.esm.js +27567 -11874
  23. package/dist/three-cad-viewer.esm.js.map +1 -1
  24. package/dist/three-cad-viewer.esm.min.js +10 -4
  25. package/dist/three-cad-viewer.js +27486 -11787
  26. package/dist/three-cad-viewer.min.js +10 -4
  27. package/dist/ui/display.d.ts +147 -0
  28. package/dist/utils/decode-instances.d.ts +60 -0
  29. package/dist/utils/utils.d.ts +10 -0
  30. package/package.json +4 -2
  31. package/src/_version.ts +1 -1
  32. package/src/camera/camera.ts +27 -10
  33. package/src/core/studio-manager.ts +682 -0
  34. package/src/core/types.ts +328 -9
  35. package/src/core/viewer-state.ts +84 -4
  36. package/src/core/viewer.ts +453 -22
  37. package/src/index.ts +25 -1
  38. package/src/rendering/environment.ts +840 -0
  39. package/src/rendering/light-detection.ts +327 -0
  40. package/src/rendering/material-factory.ts +456 -2
  41. package/src/rendering/material-presets.ts +303 -0
  42. package/src/rendering/raycast.ts +2 -2
  43. package/src/rendering/room-environment.ts +192 -0
  44. package/src/rendering/studio-composer.ts +577 -0
  45. package/src/rendering/studio-floor.ts +108 -0
  46. package/src/rendering/texture-cache.ts +1020 -0
  47. package/src/rendering/triplanar.ts +329 -0
  48. package/src/scene/animation.ts +3 -2
  49. package/src/scene/clipping.ts +59 -0
  50. package/src/scene/nestedgroup.ts +399 -0
  51. package/src/scene/objectgroup.ts +186 -11
  52. package/src/scene/orientation.ts +12 -0
  53. package/src/scene/render-shape.ts +55 -21
  54. package/src/types/n8ao.d.ts +28 -0
  55. package/src/ui/display.ts +1032 -27
  56. package/src/ui/index.html +181 -44
  57. package/src/utils/decode-instances.ts +233 -0
  58. package/src/utils/utils.ts +33 -20
@@ -0,0 +1,682 @@
1
+ /**
2
+ * StudioManager — orchestrates Studio mode rendering.
3
+ *
4
+ * Extracted from viewer.ts to reduce its complexity. Owns the Studio-specific
5
+ * resources (composer, floor, shadow lights, environment manager) and handles
6
+ * all Studio subscriptions and mode enter/leave logic.
7
+ *
8
+ * Communicates with the Viewer via the StudioManagerContext interface to avoid
9
+ * a circular dependency.
10
+ */
11
+ import * as THREE from "three";
12
+ import type { ClippingState } from "../scene/clipping.js";
13
+ import { EnvironmentManager } from "../rendering/environment.js";
14
+ import { StudioFloor } from "../rendering/studio-floor.js";
15
+ import { StudioComposer } from "../rendering/studio-composer.js";
16
+ import { ViewerState } from "./viewer-state.js";
17
+ import { logger } from "../utils/logger.js";
18
+ import { scaleLight } from "../utils/utils.js";
19
+ import type { NestedGroup, ObjectGroup } from "../scene/nestedgroup.js";
20
+ import { isObjectGroup } from "../scene/nestedgroup.js";
21
+ import type { Camera } from "../camera/camera.js";
22
+ import type { Clipping } from "../scene/clipping.js";
23
+ import type { BoundingBox } from "../scene/bbox.js";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Context interface — what StudioManager needs from Viewer
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Abstraction over Viewer that StudioManager uses. Keeps the dependency
31
+ * one-directional (StudioManager → context, never StudioManager → Viewer).
32
+ */
33
+ export interface StudioManagerContext {
34
+ renderer: THREE.WebGLRenderer;
35
+ state: ViewerState;
36
+
37
+ /** Whether viewer has rendered content. */
38
+ isRendered(): boolean;
39
+
40
+ // Scene graph access (throw if not rendered)
41
+ getScene(): THREE.Scene;
42
+ getCamera(): Camera;
43
+ getAmbientLight(): THREE.AmbientLight;
44
+ getDirectLight(): THREE.DirectionalLight;
45
+ getNestedGroup(): NestedGroup;
46
+ getClipping(): Clipping;
47
+ getBbox(): BoundingBox | null;
48
+ getLastBboxId(): string | null;
49
+
50
+ // Viewer actions
51
+ setAxes(flag: boolean, notify?: boolean): void;
52
+ setGrids(grids: [boolean, boolean, boolean], notify?: boolean): void;
53
+ setOrtho(flag: boolean, notify?: boolean): void;
54
+
55
+ // Callbacks
56
+ update(updateMarker: boolean, notify?: boolean): void;
57
+ dispatchEvent(event: Event): void;
58
+ onSelectionChanged(id: string | null): void;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // StudioManager
63
+ // ---------------------------------------------------------------------------
64
+
65
+ class StudioManager {
66
+ readonly envManager: EnvironmentManager;
67
+ readonly floor: StudioFloor;
68
+
69
+ private _composer: StudioComposer | null = null;
70
+ private _active: boolean = false;
71
+ private _savedClippingState: ClippingState | null = null;
72
+ private _savedViewState: {
73
+ ortho: boolean;
74
+ axes: boolean;
75
+ grid: [boolean, boolean, boolean];
76
+ } | null = null;
77
+ private _shadowLights: THREE.DirectionalLight[] = [];
78
+ private _ctx: StudioManagerContext;
79
+
80
+ constructor(ctx: StudioManagerContext) {
81
+ this._ctx = ctx;
82
+ this.envManager = new EnvironmentManager();
83
+ this.floor = new StudioFloor();
84
+ this._setupSubscriptions();
85
+ }
86
+
87
+ // -------------------------------------------------------------------------
88
+ // Public API
89
+ // -------------------------------------------------------------------------
90
+
91
+ get isActive(): boolean {
92
+ return this._active && this._ctx.isRendered();
93
+ }
94
+
95
+ get hasComposer(): boolean {
96
+ return this._composer !== null;
97
+ }
98
+
99
+ /** Render via composer (call from Viewer.update / _animate). */
100
+ render(): void {
101
+ this._composer?.render();
102
+ }
103
+
104
+ /** Update composer camera (call from Viewer.switchCamera). */
105
+ setCamera(camera: THREE.Camera): void {
106
+ this._composer?.setCamera(camera);
107
+ }
108
+
109
+ /** Resize composer (call from Viewer.resize). */
110
+ setSize(width: number, height: number): void {
111
+ this._composer?.setSize(width, height);
112
+ }
113
+
114
+ /** Whether env background needs per-frame update. */
115
+ get isEnvBackgroundActive(): boolean {
116
+ return this.envManager.isEnvBackgroundActive;
117
+ }
118
+
119
+ /** Update env background (ortho workaround, call per frame). */
120
+ updateEnvBackground(renderer: THREE.WebGLRenderer, camera: THREE.Camera): void {
121
+ this.envManager.updateEnvBackground(renderer, camera);
122
+ }
123
+
124
+ /** Check if an environment name is a Poly Haven preset. */
125
+ isEnvPreset(name: string): boolean {
126
+ return this.envManager.isPreset(name);
127
+ }
128
+
129
+ /**
130
+ * Get the ObjectGroup and path for the currently selected object.
131
+ * Returns null if nothing selected or Studio mode inactive.
132
+ */
133
+ getSelectedObjectGroup(): { object: ObjectGroup; path: string } | null {
134
+ if (!this._active || this._ctx.getLastBboxId() == null) {
135
+ return null;
136
+ }
137
+ const id = this._ctx.getLastBboxId()!;
138
+ const entry = this._ctx.getNestedGroup().groups[id];
139
+ if (!isObjectGroup(entry)) {
140
+ return null;
141
+ }
142
+ return { object: entry, path: id };
143
+ }
144
+
145
+ // -------------------------------------------------------------------------
146
+ // Mode enter/leave
147
+ // -------------------------------------------------------------------------
148
+
149
+ enterStudioMode = async (): Promise<void> => {
150
+ if (!this._ctx.isRendered()) return;
151
+ this._active = true;
152
+
153
+ const { renderer, state } = this._ctx;
154
+
155
+ try {
156
+ // 1. Save and disable clipping
157
+ const clipping = this._ctx.getClipping();
158
+ this._savedClippingState = clipping.saveState();
159
+ renderer.localClippingEnabled = false;
160
+ clipping.setVisible(false);
161
+ if (clipping.planeHelpers) {
162
+ clipping.planeHelpers.visible = false;
163
+ }
164
+
165
+ // 2. Save view state (applied after env is loaded to avoid
166
+ // subscriber side-effects before the texture is ready)
167
+ this._savedViewState = {
168
+ ortho: state.get("ortho"),
169
+ axes: state.get("axes"),
170
+ grid: [...state.get("grid")] as [boolean, boolean, boolean],
171
+ };
172
+
173
+ // 3. Build/swap studio materials (async due to textures)
174
+ const nestedGroup = this._ctx.getNestedGroup();
175
+ const unresolvedTags = await nestedGroup.enterStudioMode(state.get("studioTextureMapping"));
176
+ if (!this._active) return;
177
+
178
+ // Surface unresolved material tags to the UI
179
+ if (unresolvedTags.length > 0) {
180
+ this._ctx.dispatchEvent(
181
+ new CustomEvent("tcv-material-warnings", { detail: unresolvedTags }),
182
+ );
183
+ }
184
+
185
+ // 4. Load environment map
186
+ const envName = state.get("studioEnvironment");
187
+ await this.envManager.loadEnvironment(envName, renderer);
188
+ if (!this._active) return;
189
+
190
+ // 5. Apply ALL rendering changes atomically
191
+ const scene = this._ctx.getScene();
192
+ const camera = this._ctx.getCamera();
193
+ this.envManager.apply(
194
+ scene,
195
+ state.get("studioEnvIntensity"),
196
+ state.get("studioBackground"),
197
+ state.get("up") === "Z",
198
+ camera.ortho,
199
+ state.get("studioEnvRotation"),
200
+ );
201
+
202
+ // 6. Override camera, axes, grid (after env is loaded so
203
+ // ortho subscriber doesn't trigger reapplyEnv with null texture)
204
+ if (this._savedViewState?.ortho) this._ctx.setOrtho(false);
205
+ if (this._savedViewState?.axes) this._ctx.setAxes(false);
206
+ if (state.get("grid").some(Boolean)) this._ctx.setGrids([false, false, false]);
207
+
208
+ // Lighting: disable CAD lights; environment IBL provides all illumination
209
+ this._ctx.getAmbientLight().intensity = 0;
210
+ this._ctx.getDirectLight().intensity = 0;
211
+
212
+ // Floor
213
+ this._configureFloor();
214
+
215
+ // Create composer (must be before shadows)
216
+ if (!this._composer) {
217
+ this._composer = new StudioComposer(
218
+ renderer,
219
+ scene,
220
+ camera.getCamera(),
221
+ state.get("cadWidth"),
222
+ state.get("height"),
223
+ );
224
+ }
225
+
226
+ // Shadows (requires composer)
227
+ if (state.get("studioShadowIntensity") > 0) {
228
+ this._setShadowsEnabled(true);
229
+ }
230
+
231
+ // Tone mapping
232
+ this._composer.setToneMapping(
233
+ state.get("studioToneMapping"),
234
+ state.get("studioExposure"),
235
+ );
236
+
237
+ // Background protection
238
+ const bg = scene.background;
239
+ this._composer.setBackgroundProtect(
240
+ bg instanceof THREE.Color ? bg : null,
241
+ );
242
+
243
+ // Ambient Occlusion
244
+ const aoIntensity = state.get("studioAOIntensity");
245
+ this._composer.setAOIntensity(aoIntensity);
246
+ this._composer.setAOEnabled(aoIntensity > 0);
247
+
248
+ // Edges are always hidden in Studio mode
249
+ nestedGroup.setStudioShowEdges(false);
250
+
251
+ this._ctx.update(true, false);
252
+ } catch (err) {
253
+ if (this._composer) {
254
+ this._composer.dispose();
255
+ this._composer = null;
256
+ }
257
+ this._active = false;
258
+ logger.error("Unexpected error entering studio mode", err);
259
+ }
260
+ };
261
+
262
+ leaveStudioMode = (): void => {
263
+ if (!this._ctx.isRendered()) return;
264
+
265
+ const { renderer, state } = this._ctx;
266
+ const nestedGroup = this._ctx.getNestedGroup();
267
+
268
+ // 1. Restore materials
269
+ nestedGroup.leaveStudioMode();
270
+
271
+ // 2. Tear down composer
272
+ if (this._composer) {
273
+ this._composer.dispose();
274
+ this._composer = null;
275
+ }
276
+
277
+ // 3. Remove environment, disable shadows
278
+ this.envManager.remove(this._ctx.getScene());
279
+ this._setShadowsEnabled(false);
280
+
281
+ // 4. Restore lighting
282
+ this._ctx.getAmbientLight().intensity = scaleLight(state.get("ambientIntensity"));
283
+ this._ctx.getDirectLight().intensity = scaleLight(state.get("directIntensity"));
284
+
285
+ // 5. Disable tone mapping
286
+ renderer.toneMapping = THREE.NoToneMapping;
287
+ renderer.toneMappingExposure = 1.0;
288
+
289
+ // 6. Restore clipping state
290
+ if (this._savedClippingState) {
291
+ this._ctx.getClipping().restoreState(this._savedClippingState);
292
+ this._savedClippingState = null;
293
+ }
294
+
295
+ // 7. Clear active flag (before restoring view state, so subscribers don't re-apply studio)
296
+ this._active = false;
297
+
298
+ // 8. Restore camera, axes, grid
299
+ if (this._savedViewState) {
300
+ const { ortho, axes, grid } = this._savedViewState;
301
+ if (ortho) this._ctx.setOrtho(true);
302
+ if (axes) this._ctx.setAxes(true);
303
+ if (grid.some(Boolean)) this._ctx.setGrids(grid);
304
+ this._savedViewState = null;
305
+ }
306
+
307
+ // 9. Edges restored by ObjectGroup.leaveStudioMode()
308
+
309
+ this._ctx.update(true, false);
310
+ };
311
+
312
+ resetStudio = (): void => {
313
+ const defaults = ViewerState.STUDIO_MODE_DEFAULTS;
314
+ const state = this._ctx.state;
315
+ state.set("studioEnvironment", defaults.studioEnvironment);
316
+ state.set("studioEnvIntensity", defaults.studioEnvIntensity);
317
+ state.set("studioBackground", defaults.studioBackground);
318
+ state.set("studioToneMapping", defaults.studioToneMapping);
319
+ state.set("studioExposure", defaults.studioExposure);
320
+ state.set("studio4kEnvMaps", defaults.studio4kEnvMaps);
321
+ state.set("studioTextureMapping", defaults.studioTextureMapping);
322
+ state.set("studioEnvRotation", defaults.studioEnvRotation);
323
+ state.set("studioShadowIntensity", defaults.studioShadowIntensity);
324
+ state.set("studioShadowSoftness", defaults.studioShadowSoftness);
325
+ state.set("studioAOIntensity", defaults.studioAOIntensity);
326
+ };
327
+
328
+ /**
329
+ * Dispose all Studio resources. Called from Viewer.dispose().
330
+ * Must be called BEFORE renderer.dispose().
331
+ */
332
+ dispose(): void {
333
+ if (this._composer) {
334
+ this._composer.dispose();
335
+ this._composer = null;
336
+ }
337
+ this._removeShadowLights();
338
+ this.envManager.dispose();
339
+ this.floor.dispose();
340
+ this._active = false;
341
+ this._savedClippingState = null;
342
+ }
343
+
344
+ // -------------------------------------------------------------------------
345
+ // Private — subscriptions
346
+ // -------------------------------------------------------------------------
347
+
348
+ private _setupSubscriptions(): void {
349
+ const isActive = (): boolean => {
350
+ return this._active && this._ctx.isRendered();
351
+ };
352
+
353
+ const reapplyEnv = (orthoOverride?: boolean): void => {
354
+ this.envManager.apply(
355
+ this._ctx.getScene(),
356
+ this._ctx.state.get("studioEnvIntensity"),
357
+ this._ctx.state.get("studioBackground"),
358
+ this._ctx.state.get("up") === "Z",
359
+ orthoOverride ?? this._ctx.getCamera().ortho,
360
+ this._ctx.state.get("studioEnvRotation"),
361
+ );
362
+ if (this._composer) {
363
+ const bg = this._ctx.getScene().background;
364
+ this._composer.setBackgroundProtect(
365
+ bg instanceof THREE.Color ? bg : null,
366
+ );
367
+ }
368
+ };
369
+
370
+ const state = this._ctx.state;
371
+
372
+ state.subscribe("studioEnvironment", (change) => {
373
+ if (!isActive()) return;
374
+ this.envManager.loadEnvironment(change.new, this._ctx.renderer).then(() => {
375
+ if (!isActive()) return;
376
+ reapplyEnv();
377
+ if (state.get("studioShadowIntensity") > 0) {
378
+ this._configureShadowLights();
379
+ }
380
+ this._ctx.update(true, false);
381
+ this._ctx.dispatchEvent(new Event("tcv-studio-ready"));
382
+ }).catch((err) => {
383
+ logger.error("Unexpected error loading studio environment", err);
384
+ this._ctx.dispatchEvent(new Event("tcv-studio-ready"));
385
+ });
386
+ });
387
+
388
+ state.subscribe("studioEnvIntensity", () => {
389
+ if (!isActive()) return;
390
+ reapplyEnv();
391
+ this._ctx.update(true, false);
392
+ });
393
+
394
+ state.subscribe("studioEnvRotation", () => {
395
+ if (!isActive()) return;
396
+ reapplyEnv();
397
+ if (state.get("studioShadowIntensity") > 0) {
398
+ this._configureShadowLights();
399
+ }
400
+ this._ctx.update(true, false);
401
+ });
402
+
403
+ state.subscribe("studioShadowIntensity", (change) => {
404
+ if (!isActive()) return;
405
+ const intensity = change.new;
406
+ const wasEnabled = change.old != null && change.old > 0;
407
+ const nowEnabled = intensity > 0;
408
+
409
+ if (nowEnabled && !wasEnabled) {
410
+ this._setShadowsEnabled(true);
411
+ } else if (!nowEnabled && wasEnabled) {
412
+ this._setShadowsEnabled(false);
413
+ }
414
+
415
+ if (nowEnabled) {
416
+ for (const light of this._shadowLights) {
417
+ light.shadow.intensity = intensity;
418
+ }
419
+ this.floor.setShadowIntensity(intensity);
420
+ if (this._composer) {
421
+ this._composer.setShadowMaskIntensity(intensity * 0.75);
422
+ }
423
+ }
424
+ this._ctx.update(true, false);
425
+ });
426
+
427
+ state.subscribe("studioShadowSoftness", (change) => {
428
+ if (!isActive()) return;
429
+ if (state.get("studioShadowIntensity") <= 0) return;
430
+ if (this._composer) {
431
+ this._composer.setShadowSoftness(change.new);
432
+ }
433
+ this._ctx.update(true, false);
434
+ });
435
+
436
+ state.subscribe("studioAOIntensity", (change) => {
437
+ if (!isActive()) return;
438
+ if (this._composer) {
439
+ const intensity = change.new;
440
+ this._composer.setAOEnabled(intensity > 0);
441
+ this._composer.setAOIntensity(intensity);
442
+ }
443
+ this._ctx.update(true, false);
444
+ });
445
+
446
+ state.subscribe("studioBackground", () => {
447
+ if (!isActive()) return;
448
+ reapplyEnv();
449
+ this._ctx.update(true, false);
450
+ });
451
+
452
+ state.subscribe("ortho", (change) => {
453
+ if (!isActive()) return;
454
+ reapplyEnv(change.new as boolean);
455
+ this._ctx.update(true, false);
456
+ });
457
+
458
+ state.subscribe("studioToneMapping", () => {
459
+ if (!isActive()) return;
460
+ this._applyToneMapping();
461
+ this._ctx.update(true, false);
462
+ });
463
+
464
+ state.subscribe("studioExposure", () => {
465
+ if (!isActive()) return;
466
+ this._applyToneMapping();
467
+ this._ctx.update(true, false);
468
+ });
469
+
470
+ state.subscribe("studio4kEnvMaps", (change) => {
471
+ if (!isActive()) return;
472
+ const envName = state.get("studioEnvironment");
473
+ this.envManager.setUse4kEnvMaps(change.new, envName, this._ctx.renderer).then(() => {
474
+ if (!isActive()) return;
475
+ reapplyEnv();
476
+ if (state.get("studioShadowIntensity") > 0) {
477
+ this._configureShadowLights();
478
+ }
479
+ this._ctx.update(true, false);
480
+ this._ctx.dispatchEvent(new Event("tcv-studio-ready"));
481
+ });
482
+ });
483
+
484
+ state.subscribe("studioTextureMapping", () => {
485
+ if (!isActive()) return;
486
+ this._rebuildMaterials().finally(() => {
487
+ this._ctx.dispatchEvent(new Event("tcv-studio-ready"));
488
+ });
489
+ });
490
+ }
491
+
492
+ // -------------------------------------------------------------------------
493
+ // Private — floor, shadows, tone mapping, material rebuild
494
+ // -------------------------------------------------------------------------
495
+
496
+ private _configureFloor(): void {
497
+ const bbox = this._ctx.getBbox();
498
+ if (!bbox) return;
499
+ const maxExtent = Math.max(
500
+ bbox.max.x - bbox.min.x,
501
+ bbox.max.y - bbox.min.y,
502
+ bbox.max.z - bbox.min.z,
503
+ );
504
+ const zPosition = bbox.min.z - maxExtent * 0.001;
505
+ this.floor.configure(zPosition, maxExtent);
506
+ }
507
+
508
+ private _configureShadowLights(): void {
509
+ this._removeShadowLights();
510
+
511
+ const bbox = this._ctx.getBbox();
512
+ if (!bbox || !this._ctx.isRendered()) return;
513
+
514
+ const state = this._ctx.state;
515
+ const envName = state.get("studioEnvironment");
516
+ const detection = this.envManager.getLightDetection(envName);
517
+ if (!detection || detection.lights.length === 0) return;
518
+
519
+ const bboxCenter = new THREE.Vector3(
520
+ (bbox.min.x + bbox.max.x) / 2,
521
+ (bbox.min.y + bbox.max.y) / 2,
522
+ (bbox.min.z + bbox.max.z) / 2,
523
+ );
524
+ const maxExtent = Math.max(
525
+ bbox.max.x - bbox.min.x,
526
+ bbox.max.y - bbox.min.y,
527
+ bbox.max.z - bbox.min.z,
528
+ );
529
+
530
+ const isZUp = state.get("up") === "Z";
531
+ const envRotationRad = (state.get("studioEnvRotation") * Math.PI) / 180;
532
+
533
+ this._ctx.renderer.shadowMap.enabled = true;
534
+ this._ctx.renderer.shadowMap.type = THREE.PCFShadowMap;
535
+
536
+ const scene = this._ctx.getScene();
537
+
538
+ for (const detected of detection.lights) {
539
+ let [dx, dy, dz] = detected.direction;
540
+
541
+ if (isZUp) {
542
+ const oy = dy;
543
+ const oz = dz;
544
+ dy = -oz;
545
+ dz = oy;
546
+ }
547
+
548
+ if (envRotationRad !== 0) {
549
+ if (isZUp) {
550
+ const cosR = Math.cos(envRotationRad);
551
+ const sinR = Math.sin(envRotationRad);
552
+ const nx = dx * cosR - dy * sinR;
553
+ const ny = dx * sinR + dy * cosR;
554
+ dx = nx;
555
+ dy = ny;
556
+ } else {
557
+ const cosR = Math.cos(envRotationRad);
558
+ const sinR = Math.sin(envRotationRad);
559
+ const nx = dx * cosR + dz * sinR;
560
+ const nz = -dx * sinR + dz * cosR;
561
+ dx = nx;
562
+ dz = nz;
563
+ }
564
+ }
565
+
566
+ const dir = new THREE.Vector3(dx, dy, dz).normalize();
567
+ const light = new THREE.DirectionalLight(0xffffff, 0.01);
568
+
569
+ light.position.copy(bboxCenter).addScaledVector(dir, maxExtent * 3);
570
+ light.target.position.copy(bboxCenter);
571
+
572
+ light.castShadow = true;
573
+ const frustumSize = maxExtent * 4.0;
574
+ light.shadow.camera.left = -frustumSize;
575
+ light.shadow.camera.right = frustumSize;
576
+ light.shadow.camera.top = frustumSize;
577
+ light.shadow.camera.bottom = -frustumSize;
578
+ light.shadow.camera.near = maxExtent * 0.1;
579
+ light.shadow.camera.far = maxExtent * 7;
580
+ light.shadow.mapSize.set(4096, 4096);
581
+ light.shadow.bias = -0.001;
582
+ light.shadow.intensity = state.get("studioShadowIntensity");
583
+
584
+ scene.add(light);
585
+ scene.add(light.target);
586
+ this._shadowLights.push(light);
587
+ }
588
+
589
+ if (this._composer) {
590
+ this._composer.setShadowMaskEnabled(true);
591
+ this._composer.setShadowSoftness(state.get("studioShadowSoftness"));
592
+ this._composer.setShadowMaskIntensity(state.get("studioShadowIntensity") * 0.75);
593
+ }
594
+ }
595
+
596
+ private _removeShadowLights(): void {
597
+ if (this._ctx.isRendered()) {
598
+ const scene = this._ctx.getScene();
599
+ for (const light of this._shadowLights) {
600
+ scene.remove(light);
601
+ scene.remove(light.target);
602
+ light.shadow.map?.dispose();
603
+ light.dispose();
604
+ }
605
+ } else {
606
+ for (const light of this._shadowLights) {
607
+ light.shadow.map?.dispose();
608
+ light.dispose();
609
+ }
610
+ }
611
+ this._shadowLights = [];
612
+
613
+ if (this._composer) {
614
+ this._composer.setShadowMaskEnabled(false);
615
+ }
616
+ }
617
+
618
+ private _setShadowsEnabled(enabled: boolean): void {
619
+ if (!this._ctx.isRendered()) return;
620
+
621
+ const nestedGroup = this._ctx.getNestedGroup();
622
+
623
+ if (enabled) {
624
+ this._configureShadowLights();
625
+
626
+ nestedGroup.rootGroup?.traverse((obj) => {
627
+ if (obj instanceof THREE.Mesh) {
628
+ obj.castShadow = true;
629
+ }
630
+ });
631
+
632
+ this.floor.setShadowsEnabled(true);
633
+
634
+ this._ctx.getScene().traverse((obj) => {
635
+ if (obj instanceof THREE.Mesh && obj.material) {
636
+ const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
637
+ for (const m of mats) {
638
+ m.needsUpdate = true;
639
+ }
640
+ }
641
+ });
642
+ } else {
643
+ this._removeShadowLights();
644
+ this._ctx.renderer.shadowMap.enabled = false;
645
+
646
+ nestedGroup.rootGroup?.traverse((obj) => {
647
+ if (obj instanceof THREE.Mesh) {
648
+ obj.castShadow = false;
649
+ }
650
+ });
651
+
652
+ this.floor.setShadowsEnabled(false);
653
+ }
654
+ }
655
+
656
+ private _applyToneMapping(): void {
657
+ if (this._composer) {
658
+ this._composer.setToneMapping(
659
+ this._ctx.state.get("studioToneMapping"),
660
+ this._ctx.state.get("studioExposure"),
661
+ );
662
+ }
663
+ }
664
+
665
+ private _rebuildMaterials = async (): Promise<void> => {
666
+ const nestedGroup = this._ctx.getNestedGroup();
667
+ nestedGroup.leaveStudioMode();
668
+ nestedGroup.clearStudioMaterialCache();
669
+ const unresolvedTags = await nestedGroup.enterStudioMode(this._ctx.state.get("studioTextureMapping"));
670
+ if (this._active) {
671
+ nestedGroup.setStudioShowEdges(false);
672
+ if (unresolvedTags.length > 0) {
673
+ this._ctx.dispatchEvent(
674
+ new CustomEvent("tcv-material-warnings", { detail: unresolvedTags }),
675
+ );
676
+ }
677
+ this._ctx.update(true, false);
678
+ }
679
+ };
680
+ }
681
+
682
+ export { StudioManager };