three-cad-viewer 4.1.2 → 4.3.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 +90 -0
  4. package/dist/core/types.d.ts +239 -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 +100 -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 +63 -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 +26944 -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 +26863 -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 +652 -0
  34. package/src/core/types.ts +302 -9
  35. package/src/core/viewer-state.ts +84 -4
  36. package/src/core/viewer.ts +453 -22
  37. package/src/index.ts +24 -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 +458 -2
  41. package/src/rendering/material-presets.ts +289 -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 +319 -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 +392 -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
@@ -272,6 +272,20 @@ interface ClippingOptions {
272
272
  onNormalChange?: (index: ClipIndex, normalArray: Vector3Tuple) => void;
273
273
  }
274
274
 
275
+ /**
276
+ * Saved clipping state for mode transitions (e.g., entering/leaving Studio mode).
277
+ * Captures only Clipping-internal state; renderer flags and ViewerState keys
278
+ * are managed by the caller.
279
+ */
280
+ interface ClippingState {
281
+ /** Centered constant (position) for each of the 3 clip planes */
282
+ planeConstants: [number, number, number];
283
+ /** Whether the plane helper meshes (translucent colored rectangles) are visible */
284
+ helperVisible: boolean;
285
+ /** Whether the stencil plane meshes (solid colored caps) are visible */
286
+ planesVisible: boolean;
287
+ }
288
+
275
289
  /**
276
290
  * Manages clipping planes, stencil rendering, and plane visualization.
277
291
  */
@@ -566,6 +580,50 @@ class Clipping extends THREE.Group {
566
580
  }
567
581
  };
568
582
 
583
+ /**
584
+ * Save the current clipping state for later restoration.
585
+ * Captures plane positions, helper visibility, and stencil plane visibility.
586
+ * Used by Studio mode to snapshot clipping state before disabling clipping.
587
+ *
588
+ * Note: `renderer.localClippingEnabled` and `clipPlaneHelpers` ViewerState
589
+ * are managed by the caller (Display/Viewer layer), not captured here.
590
+ */
591
+ saveState(): ClippingState {
592
+ return {
593
+ planeConstants: [
594
+ this.clipPlanes[0].centeredConstant,
595
+ this.clipPlanes[1].centeredConstant,
596
+ this.clipPlanes[2].centeredConstant,
597
+ ],
598
+ helperVisible: this.planeHelpers?.visible ?? false,
599
+ planesVisible: this._planeMeshGroup?.children.length
600
+ ? this._planeMeshGroup.children[0].material.visible
601
+ : false,
602
+ };
603
+ }
604
+
605
+ /**
606
+ * Restore a previously saved clipping state.
607
+ * Re-applies plane positions, helper visibility, and stencil plane visibility.
608
+ * Used by Studio mode when leaving to restore the clipping configuration.
609
+ *
610
+ * @param state - The state previously captured by `saveState()`.
611
+ */
612
+ restoreState(state: ClippingState): void {
613
+ // Restore plane positions
614
+ for (const i of CLIP_INDICES) {
615
+ this.setConstant(i, state.planeConstants[i]);
616
+ }
617
+
618
+ // Restore plane helper visibility
619
+ if (this.planeHelpers) {
620
+ this.planeHelpers.visible = state.helperVisible;
621
+ }
622
+
623
+ // Restore stencil plane mesh visibility
624
+ this.setVisible(state.planesVisible);
625
+ }
626
+
569
627
  /**
570
628
  * Clean up resources.
571
629
  * Note: We don't null out arrays/references as GC handles cleanup when the Clipping object is collected.
@@ -579,3 +637,4 @@ class Clipping extends THREE.Group {
579
637
  }
580
638
 
581
639
  export { Clipping };
640
+ export type { ClippingState };
@@ -10,16 +10,26 @@ import { gpuTracker } from "../utils/gpu-tracker.js";
10
10
  import type {
11
11
  ZebraColorScheme,
12
12
  ZebraMappingMode,
13
+ StudioTextureMapping,
13
14
  Shapes,
14
15
  ColorValue,
15
16
  ColoredMaterial,
17
+ MaterialAppearance,
18
+ MaterialXMaterial,
16
19
  } from "../core/types";
20
+ import { isMaterialXMaterial } from "../core/types";
21
+ import { MATERIAL_PRESETS } from "../rendering/material-presets.js";
22
+ import { logger } from "../utils/logger.js";
23
+ import { TextureCache } from "../rendering/texture-cache.js";
24
+ import type { TextureCacheInterface } from "../rendering/material-factory.js";
25
+ import { applyTriplanarMapping } from "../rendering/triplanar.js";
17
26
 
18
27
  interface ShapeData {
19
28
  vertices: Float32Array | number[][];
20
29
  normals: Float32Array | number[][];
21
30
  triangles: Uint32Array | number[][];
22
31
  edges?: Float32Array | number[][];
32
+ uvs?: Float32Array | number[];
23
33
  }
24
34
 
25
35
  interface EdgeData {
@@ -57,6 +67,7 @@ interface ShapeEntry {
57
67
  geomtype?: number | null;
58
68
  subtype?: string | null;
59
69
  texture?: { image: TextureData; width: number; height: number };
70
+ material?: string;
60
71
  }
61
72
 
62
73
  interface ShapeTree {
@@ -113,6 +124,32 @@ class CompoundGroup extends THREE.Group {
113
124
  *
114
125
  * @internal - This is an internal class used by Viewer
115
126
  */
127
+
128
+ /** Texture field names on MaterialAppearance that require UV coordinates. */
129
+ const TEXTURE_FIELDS = [
130
+ "map", "normalMap", "aoMap",
131
+ "metalnessMap", "roughnessMap", "emissiveMap", "transmissionMap",
132
+ "clearcoatMap", "clearcoatRoughnessMap", "clearcoatNormalMap",
133
+ "thicknessMap", "specularIntensityMap", "specularColorMap",
134
+ "sheenColorMap", "sheenRoughnessMap", "anisotropyMap",
135
+ ] as const;
136
+
137
+ /** Check whether a resolved MaterialAppearance references any texture. */
138
+ function materialHasTexture(def: MaterialAppearance): boolean {
139
+ for (const f of TEXTURE_FIELDS) {
140
+ if ((def as Record<string, unknown>)[f]) return true;
141
+ }
142
+ return false;
143
+ }
144
+
145
+ /** Check whether a threejs-materials entry has texture references in its properties. */
146
+ function materialXHasTextures(entry: MaterialXMaterial): boolean {
147
+ for (const [, prop] of Object.entries(entry.properties)) {
148
+ if (prop.texture) return true;
149
+ }
150
+ return false;
151
+ }
152
+
116
153
  class NestedGroup {
117
154
  shapes!: Shapes;
118
155
  width: number;
@@ -134,6 +171,15 @@ class NestedGroup {
134
171
  groups!: GroupsMap; // Initialized to {} in constructor
135
172
  clipPlanes: THREE.Plane[] | null;
136
173
  materialFactory: MaterialFactory;
174
+ materialsTable: Record<string, string | MaterialXMaterial | MaterialAppearance> | null;
175
+ resolvedMaterials: Map<string, MaterialAppearance>;
176
+ /** Cache for threejs-materials entries resolved from the materials table */
177
+ resolvedMaterialX: Map<string, MaterialXMaterial>;
178
+ private _textureCache: TextureCache | null;
179
+ private _studioMaterialCache: Map<string, THREE.MeshPhysicalMaterial | THREE.MeshBasicMaterial>;
180
+ /** Sharing keys of materials that have textures (for UV generation on cache hits) */
181
+ private _texturedMaterialKeys: Set<string>;
182
+ private _isStudioMode: boolean;
137
183
 
138
184
  /**
139
185
  * Create a NestedGroup for rendering CAD geometry.
@@ -181,6 +227,14 @@ class NestedGroup {
181
227
 
182
228
  this.clipPlanes = null;
183
229
 
230
+ this.materialsTable = null;
231
+ this.resolvedMaterials = new Map();
232
+ this.resolvedMaterialX = new Map();
233
+ this._textureCache = null;
234
+ this._studioMaterialCache = new Map();
235
+ this._texturedMaterialKeys = new Set();
236
+ this._isStudioMode = false;
237
+
184
238
  this.materialFactory = new MaterialFactory({
185
239
  defaultOpacity: opacity,
186
240
  metalness: metalness,
@@ -202,6 +256,110 @@ class NestedGroup {
202
256
  deepDispose(this.rootGroup);
203
257
  this.rootGroup = null;
204
258
  }
259
+ this._disposeStudioResources();
260
+ this.resolvedMaterials.clear();
261
+ this.resolvedMaterialX.clear();
262
+ this.materialsTable = null;
263
+ }
264
+
265
+ /**
266
+ * Resolve a material tag to its definition.
267
+ *
268
+ * Returns either a MaterialAppearance (for builtin presets) or a
269
+ * MaterialXMaterial (for threejs-materials entries). The caller must check the
270
+ * return type to determine which factory method to use.
271
+ *
272
+ * Resolution order:
273
+ * 1. Check caches (resolvedMaterials / resolvedMaterialX)
274
+ * 2. Look up in root-level `materials` table:
275
+ * - string starting with "builtin:" → MATERIAL_PRESETS lookup
276
+ * - object with `properties` key → threejs-materials entry
277
+ * 3. Direct lookup in MATERIAL_PRESETS by tag name
278
+ * 4. No match → warning, return null
279
+ *
280
+ * @param tag - The material tag from a leaf node
281
+ * @param objectPath - The object path (for warning messages)
282
+ * @returns Resolved material definition or null if not found
283
+ */
284
+ resolveMaterialTag(
285
+ tag: string,
286
+ objectPath: string,
287
+ ): MaterialAppearance | MaterialXMaterial | null {
288
+ // Empty string is equivalent to no tag -- skip silently
289
+ if (tag === "") {
290
+ return null;
291
+ }
292
+
293
+ // Check caches
294
+ const cachedPreset = this.resolvedMaterials.get(tag);
295
+ if (cachedPreset !== undefined) return cachedPreset;
296
+
297
+ const cachedMX = this.resolvedMaterialX.get(tag);
298
+ if (cachedMX !== undefined) return cachedMX;
299
+
300
+ // 1. Look up in user-defined materials table
301
+ if (this.materialsTable && tag in this.materialsTable) {
302
+ const entry = this.materialsTable[tag];
303
+
304
+ // String entry: "builtin:<preset-name>"
305
+ if (typeof entry === "string") {
306
+ if (entry.startsWith("builtin:")) {
307
+ const presetName = entry.slice(8);
308
+ const preset = MATERIAL_PRESETS[presetName];
309
+ if (preset) {
310
+ const resolved = { ...preset };
311
+ this.resolvedMaterials.set(tag, resolved);
312
+ return resolved;
313
+ }
314
+ logger.warn(
315
+ `Unknown builtin preset '${presetName}' referenced by '${tag}' on '${objectPath}'`,
316
+ );
317
+ return null;
318
+ }
319
+ logger.warn(
320
+ `Invalid material string '${entry}' for tag '${tag}' (expected "builtin:" prefix)`,
321
+ );
322
+ return null;
323
+ }
324
+
325
+ // MaterialXMaterial entry: object with `properties` key
326
+ if (isMaterialXMaterial(entry)) {
327
+ this.resolvedMaterialX.set(tag, entry);
328
+ return entry;
329
+ }
330
+
331
+ // MaterialAppearance entry: object with `builtin` key (preset + overrides)
332
+ if (typeof entry === "object" && "builtin" in entry) {
333
+ const appearance = entry as MaterialAppearance;
334
+ const presetName = appearance.builtin!;
335
+ const preset = MATERIAL_PRESETS[presetName];
336
+ if (!preset) {
337
+ logger.warn(
338
+ `Unknown builtin preset '${presetName}' referenced by '${tag}' on '${objectPath}'`,
339
+ );
340
+ return null;
341
+ }
342
+ const resolved: MaterialAppearance = { ...preset, ...appearance };
343
+ this.resolvedMaterials.set(tag, resolved);
344
+ return resolved;
345
+ }
346
+
347
+ // Should not happen with current type, but guard anyway
348
+ logger.warn(`Unrecognised material entry for tag '${tag}' on '${objectPath}'`);
349
+ return null;
350
+ }
351
+
352
+ // 2. Direct lookup in built-in presets (leaf tag matches preset name)
353
+ const preset = MATERIAL_PRESETS[tag];
354
+ if (preset) {
355
+ const resolved = { ...preset };
356
+ this.resolvedMaterials.set(tag, resolved);
357
+ return resolved;
358
+ }
359
+
360
+ // 3. No match
361
+ logger.warn(`Unknown material tag '${tag}' on object '${objectPath}'`);
362
+ return null;
205
363
  }
206
364
 
207
365
  /**
@@ -469,6 +627,15 @@ class NestedGroup {
469
627
  new THREE.BufferAttribute(normals, 3),
470
628
  );
471
629
  shapeGeometry.setIndex(new THREE.BufferAttribute(triangles, 1));
630
+ if (shape.uvs && shape.uvs.length > 0) {
631
+ const uvArray = shape.uvs instanceof Float32Array
632
+ ? shape.uvs
633
+ : new Float32Array(shape.uvs);
634
+ shapeGeometry.setAttribute(
635
+ "uv",
636
+ new THREE.BufferAttribute(uvArray, 2),
637
+ );
638
+ }
472
639
  group.shapeGeometry = shapeGeometry;
473
640
 
474
641
  frontMaterial = this.materialFactory.createFrontFaceMaterial(
@@ -798,12 +965,21 @@ class NestedGroup {
798
965
  group.add(this.renderLoop(shape));
799
966
  } else {
800
967
  const entry = shape as ShapeEntry;
968
+ // Propagate material tag from shapes data to local ShapeEntry
969
+ const materialTag = (shape as Shapes).material;
970
+ if (materialTag != null) {
971
+ entry.material = materialTag;
972
+ }
801
973
  const has_texture = entry.texture != null;
802
974
  const texture = has_texture ? entry.texture!.image : null;
803
975
  const width = has_texture ? entry.texture!.width : null;
804
976
  const height = has_texture ? entry.texture!.height : null;
805
977
  const objectGroup = _render(entry, texture, width, height);
806
978
  this.groups[entry.id] = objectGroup;
979
+ // Store material tag on ObjectGroup for Studio mode lookup
980
+ if (entry.material !== undefined && entry.material !== null) {
981
+ objectGroup.materialTag = entry.material;
982
+ }
807
983
  group.add(objectGroup);
808
984
  }
809
985
  }
@@ -817,6 +993,9 @@ class NestedGroup {
817
993
  if (this.shapes.format == "GDS") {
818
994
  this.instances = this.shapes.instances || null;
819
995
  }
996
+ this.materialsTable = this.shapes.materials || null;
997
+ this.resolvedMaterials.clear();
998
+ this.resolvedMaterialX.clear();
820
999
  this.rootGroup = this.renderLoop(this.shapes);
821
1000
  return this.rootGroup;
822
1001
  }
@@ -1018,6 +1197,219 @@ class NestedGroup {
1018
1197
  setZebraMappingMode(flag: ZebraMappingMode): void {
1019
1198
  this._traverse("setZebraMappingMode", flag);
1020
1199
  }
1200
+
1201
+ // ===========================================================================
1202
+ // Studio Mode
1203
+ // ===========================================================================
1204
+
1205
+ /**
1206
+ * Enter Studio mode: build and apply studio materials to all ObjectGroups.
1207
+ *
1208
+ * Material resolution per ObjectGroup:
1209
+ * 1. Resolve the material tag via `resolveMaterialTag()`
1210
+ * - MaterialXMaterial → `createStudioMaterialFromMaterialX`
1211
+ * - MaterialAppearance → `createStudioMaterial` (builtin presets)
1212
+ * - null (no tag) → fallback plastic-glossy preset tinted with CAD color
1213
+ * 2. Cache by sharing key for reuse across objects with the same tag+color
1214
+ * 3. Clone BackSide variant for renderback objects
1215
+ * 4. Auto-generate box-projected UVs when textured but geometry has no UVs
1216
+ */
1217
+ async enterStudioMode(textureMapping: StudioTextureMapping = "triplanar"): Promise<string[]> {
1218
+ // Create TextureCache lazily
1219
+ if (!this._textureCache) {
1220
+ this._textureCache = new TextureCache();
1221
+ }
1222
+ // Track material tags that failed to resolve
1223
+ const unresolvedTags = new Set<string>();
1224
+
1225
+ // Iterate all ObjectGroups with front meshes
1226
+ for (const path in this.groups) {
1227
+ const obj = this.groups[path];
1228
+ if (!(obj instanceof ObjectGroup)) continue;
1229
+ if (!obj.front) continue;
1230
+
1231
+ // Determine material tag, leaf color, and leaf alpha
1232
+ const tag = obj.materialTag || "";
1233
+ const leafColor = obj.originalColor
1234
+ ? "#" + obj.originalColor.getHexString()
1235
+ : "#707070";
1236
+ const leafAlpha = obj.alpha;
1237
+
1238
+ // Compute sharing key
1239
+ const sharingKey = `${tag}:${leafColor}:${leafAlpha}`;
1240
+
1241
+ // Check cached material for this key
1242
+ let studioMaterial = this._studioMaterialCache.get(sharingKey);
1243
+
1244
+ if (!studioMaterial) {
1245
+ // Resolve the tag
1246
+ const resolved = tag ? this.resolveMaterialTag(tag, path) : null;
1247
+ if (tag && !resolved) {
1248
+ unresolvedTags.add(tag);
1249
+ }
1250
+
1251
+ // Per-object try/catch: a single failure should not abort the rest
1252
+ try {
1253
+ if (resolved && isMaterialXMaterial(resolved)) {
1254
+ // --- threejs-materials path ---
1255
+ studioMaterial = await this.materialFactory.createStudioMaterialFromMaterialX(
1256
+ resolved.properties,
1257
+ resolved.textureRepeat,
1258
+ this._textureCache as TextureCacheInterface,
1259
+ );
1260
+ if (materialXHasTextures(resolved)) {
1261
+ this._texturedMaterialKeys.add(sharingKey);
1262
+ }
1263
+ } else {
1264
+ // --- Builtin preset path (or fallback) ---
1265
+ let materialDef: MaterialAppearance;
1266
+ if (resolved) {
1267
+ materialDef = resolved;
1268
+ } else if (leafAlpha < 1) {
1269
+ // Fallback for transparent objects: acrylic-clear with
1270
+ // transmission matching the CAD alpha, tinted with CAD color
1271
+ const { color: _, ...acrylicClear } = MATERIAL_PRESETS["acrylic-clear"];
1272
+ materialDef = { ...acrylicClear, transmission: 1 - leafAlpha };
1273
+ } else {
1274
+ // Fallback: plastic-glossy tinted with CAD color
1275
+ const { color: _, ...plasticGlossy } = MATERIAL_PRESETS["plastic-glossy"];
1276
+ materialDef = plasticGlossy;
1277
+ }
1278
+ studioMaterial = await this.materialFactory.createStudioMaterial({
1279
+ materialDef,
1280
+ fallbackColor: leafColor,
1281
+ fallbackAlpha: leafAlpha,
1282
+ textureCache: this._textureCache as TextureCacheInterface,
1283
+ });
1284
+ if (materialHasTexture(materialDef)) {
1285
+ this._texturedMaterialKeys.add(sharingKey);
1286
+ }
1287
+ }
1288
+ } catch (err) {
1289
+ logger.warn(
1290
+ `Studio material creation failed for "${path}" (tag="${tag}"), skipping`,
1291
+ err,
1292
+ );
1293
+ continue;
1294
+ }
1295
+
1296
+ this._studioMaterialCache.set(sharingKey, studioMaterial);
1297
+ }
1298
+
1299
+ // Triplanar mapping for textured materials.
1300
+ // "triplanar" mode: always use triplanar for textured materials
1301
+ // "parametric" mode: triplanar only when geometry has no UVs (fallback)
1302
+ const textured = this._texturedMaterialKeys.has(sharingKey);
1303
+ const hasUVs = obj.shapeGeometry?.getAttribute("uv") != null;
1304
+ const needsTriplanar =
1305
+ textured &&
1306
+ obj.shapeGeometry != null &&
1307
+ (textureMapping === "triplanar" || !hasUVs);
1308
+
1309
+ if (textured) {
1310
+ logger.debug(`Studio "${path}": ${needsTriplanar ? "using triplanar" : "using parametric UVs"}`);
1311
+ }
1312
+
1313
+ if (needsTriplanar && studioMaterial instanceof THREE.MeshPhysicalMaterial) {
1314
+ const triKey = `${sharingKey}:tri:${path}`;
1315
+ let triMat = this._studioMaterialCache.get(triKey);
1316
+ if (!triMat) {
1317
+ triMat = studioMaterial.clone();
1318
+ applyTriplanarMapping(triMat as THREE.MeshPhysicalMaterial, obj.shapeGeometry!);
1319
+ this._studioMaterialCache.set(triKey, triMat);
1320
+ }
1321
+ studioMaterial = triMat;
1322
+ }
1323
+
1324
+ // Build back-face variant if needed
1325
+ let studioBack: THREE.MeshPhysicalMaterial | null = null;
1326
+ if (obj.renderback && studioMaterial instanceof THREE.MeshPhysicalMaterial) {
1327
+ const backKey = needsTriplanar
1328
+ ? `${sharingKey}:tri:${path}:back`
1329
+ : `${sharingKey}:back`;
1330
+ let cachedBack = this._studioMaterialCache.get(backKey);
1331
+ if (!cachedBack) {
1332
+ cachedBack = studioMaterial.clone();
1333
+ cachedBack.side = THREE.BackSide;
1334
+ if (needsTriplanar && obj.shapeGeometry) {
1335
+ applyTriplanarMapping(cachedBack as THREE.MeshPhysicalMaterial, obj.shapeGeometry);
1336
+ }
1337
+ this._studioMaterialCache.set(backKey, cachedBack);
1338
+ }
1339
+ studioBack = cachedBack as THREE.MeshPhysicalMaterial;
1340
+ }
1341
+
1342
+ // Apply to ObjectGroup
1343
+ obj.enterStudioMode(
1344
+ studioMaterial instanceof THREE.MeshPhysicalMaterial ? studioMaterial : null,
1345
+ studioBack,
1346
+ );
1347
+ }
1348
+
1349
+ this._isStudioMode = true;
1350
+ return [...unresolvedTags];
1351
+ }
1352
+
1353
+ /**
1354
+ * Leave Studio mode: restore CAD materials on all ObjectGroups.
1355
+ * Does NOT clear the material cache (allows fast re-entry).
1356
+ */
1357
+ leaveStudioMode(): void {
1358
+ for (const path in this.groups) {
1359
+ const obj = this.groups[path];
1360
+ if (!(obj instanceof ObjectGroup)) continue;
1361
+ obj.leaveStudioMode();
1362
+ }
1363
+ this._isStudioMode = false;
1364
+ }
1365
+
1366
+ /**
1367
+ * Clear cached Studio materials so they are rebuilt on next enterStudioMode.
1368
+ */
1369
+ clearStudioMaterialCache(): void {
1370
+ for (const [, material] of this._studioMaterialCache) {
1371
+ material.dispose();
1372
+ }
1373
+ this._studioMaterialCache.clear();
1374
+ this._texturedMaterialKeys.clear();
1375
+ }
1376
+
1377
+ /**
1378
+ * Set edge visibility across all ObjectGroups while in Studio mode.
1379
+ * @param visible - Whether edges should be visible
1380
+ */
1381
+ setStudioShowEdges(visible: boolean): void {
1382
+ for (const path in this.groups) {
1383
+ const obj = this.groups[path];
1384
+ if (!(obj instanceof ObjectGroup)) continue;
1385
+ obj.setStudioShowEdges(visible);
1386
+ }
1387
+ }
1388
+
1389
+ /**
1390
+ * Dispose all Studio mode resources (material cache + texture cache).
1391
+ */
1392
+ private _disposeStudioResources(): void {
1393
+ // Leave studio mode if still active
1394
+ if (this._isStudioMode) {
1395
+ this.leaveStudioMode();
1396
+ }
1397
+
1398
+ // Dispose cached studio materials
1399
+ for (const [, material] of this._studioMaterialCache) {
1400
+ material.dispose();
1401
+ }
1402
+ this._studioMaterialCache.clear();
1403
+ this._texturedMaterialKeys.clear();
1404
+
1405
+ // Dispose texture cache
1406
+ if (this._textureCache) {
1407
+ this._textureCache.disposeFull();
1408
+ this._textureCache = null;
1409
+ }
1410
+
1411
+ this._isStudioMode = false;
1412
+ }
1021
1413
  }
1022
1414
 
1023
1415
  /**