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
package/src/ui/display.ts CHANGED
@@ -15,11 +15,13 @@ import type {
15
15
  import { FilterByDropDownMenu } from "../tools/cad_tools/ui.js";
16
16
  import { Info } from "./info.js";
17
17
  import type { Viewer } from "../core/viewer.js";
18
+ import type { ObjectGroup } from "../scene/objectgroup.js";
18
19
  import type { ViewerState } from "../core/viewer-state.js";
19
20
  import { isClipIndex, CollapseState } from "../core/types.js";
20
21
  import type { Vector3Tuple } from "three";
21
22
  import type { ActiveTab, ThemeInput, ClipIndex } from "../core/types.js";
22
23
  import type { CameraDirection } from "../camera/camera.js";
24
+ import { applyTriplanarMapping } from "../rendering/triplanar.js";
23
25
 
24
26
  import template from "./index.html";
25
27
 
@@ -41,6 +43,42 @@ function px(val: number): string {
41
43
  return `${val}px`;
42
44
  }
43
45
 
46
+ // ---------------------------------------------------------------------------
47
+ // Material Editor Parameter Definitions
48
+ // ---------------------------------------------------------------------------
49
+
50
+ interface MatEditorParam {
51
+ key: string;
52
+ label: string;
53
+ min: number;
54
+ max: number;
55
+ step: number;
56
+ group: string;
57
+ infinity?: boolean;
58
+ }
59
+
60
+ const MAT_EDITOR_PARAMS: MatEditorParam[] = [
61
+ { key: "metalness", label: "Metallic", min: 0, max: 1, step: 0.01, group: "PBR Core" },
62
+ { key: "roughness", label: "Roughness", min: 0, max: 1, step: 0.01, group: "PBR Core" },
63
+ { key: "clearcoat", label: "Clearcoat", min: 0, max: 1, step: 0.01, group: "Clearcoat" },
64
+ { key: "clearcoatRoughness", label: "Clearcoat Rough.", min: 0, max: 1, step: 0.01, group: "Clearcoat" },
65
+ { key: "transmission", label: "Transmission", min: 0, max: 1, step: 0.01, group: "Transmission" },
66
+ { key: "ior", label: "IOR", min: 1.0, max: 2.5, step: 0.01, group: "Transmission" },
67
+ { key: "thickness", label: "Thickness", min: 0, max: 10, step: 0.1, group: "Transmission" },
68
+ { key: "attenuationDistance", label: "Atten. Distance", min: 0, max: 100, step: 0.5, group: "Transmission", infinity: true },
69
+ { key: "sheen", label: "Sheen", min: 0, max: 1, step: 0.01, group: "Sheen" },
70
+ { key: "sheenRoughness", label: "Sheen Roughness", min: 0, max: 1, step: 0.01, group: "Sheen" },
71
+ { key: "specularIntensity", label: "Specular Intensity", min: 0, max: 2, step: 0.01, group: "Specular" },
72
+ { key: "anisotropy", label: "Anisotropy", min: 0, max: 1, step: 0.01, group: "Anisotropy" },
73
+ { key: "anisotropyRotation", label: "Anisotropy Rotation", min: 0, max: 6.28, step: 0.01, group: "Anisotropy" },
74
+ { key: "emissiveIntensity", label: "Emissive Intensity", min: 0, max: 5, step: 0.1, group: "Emissive" },
75
+ ];
76
+
77
+ function _formatMatValue(value: number, step: number): string {
78
+ const decimals = step < 0.1 ? 2 : 1;
79
+ return value.toFixed(decimals);
80
+ }
81
+
44
82
  const buttons = ["plane", "play", "pause", "stop"];
45
83
 
46
84
  const listeners = new EventListenerManager();
@@ -59,6 +97,7 @@ export interface DisplayOptions {
59
97
  explodeTool: boolean;
60
98
  zscaleTool: boolean;
61
99
  zebraTool: boolean;
100
+ studioTool: boolean;
62
101
  glass: boolean;
63
102
  tools: boolean;
64
103
  cadWidth: number;
@@ -67,6 +106,8 @@ export interface DisplayOptions {
67
106
  treeHeight?: number;
68
107
  theme: ThemeInput;
69
108
  pinning: boolean;
109
+ canvas?: HTMLCanvasElement;
110
+ gl?: WebGLRenderingContext | WebGL2RenderingContext;
70
111
  }
71
112
 
72
113
  /**
@@ -150,17 +191,34 @@ class Display {
150
191
  cadTree!: HTMLElement;
151
192
  cadTreeScrollContainer!: HTMLElement;
152
193
  cadTreeToggles!: HTMLElement;
194
+ cadClipToggles!: HTMLElement;
195
+ cadMaterialToggles!: HTMLElement;
196
+ cadZebraToggles!: HTMLElement;
197
+ cadStudioToggles!: HTMLElement;
153
198
  cadClip!: HTMLElement;
154
199
  cadMaterial!: HTMLElement;
155
200
  cadZebra!: HTMLElement;
201
+ cadStudio!: HTMLElement;
202
+ private _spinnerEl: HTMLElement | null = null;
203
+ private _warningBannerEl: HTMLElement | null = null;
204
+ private _warningBannerTimer: ReturnType<typeof setTimeout> | null = null;
205
+ private _spinnerCount: number = 0;
206
+ // Material editor state
207
+ private _matEditorPath: string | null = null;
208
+ private _matEditorClones: Map<string, { original: THREE.MeshPhysicalMaterial; clone: THREE.MeshPhysicalMaterial }> = new Map();
209
+ private _savedMatEditorChanges: Map<string, Record<string, number>> = new Map();
210
+ private _matEditorDragAbort: AbortController | null = null;
211
+ private _matEditorInputAbort: AbortController | null = null;
156
212
  cadInfo!: HTMLElement;
157
213
  cadAnim!: HTMLElement;
214
+ private _animWasVisible: boolean = false;
158
215
  cadTools!: HTMLElement;
159
216
  cadHelp!: HTMLElement;
160
217
  tabTree!: HTMLElement;
161
218
  tabClip!: HTMLElement;
162
219
  tabMaterial!: HTMLElement;
163
220
  tabZebra!: HTMLElement;
221
+ tabStudio!: HTMLElement;
164
222
  tickValueElement!: HTMLElement;
165
223
  tickInfoElement!: HTMLElement;
166
224
  distanceMeasurementPanel!: HTMLElement;
@@ -186,6 +244,12 @@ class Display {
186
244
  zebraCountSlider: Slider | undefined;
187
245
  zebraOpacitySlider: Slider | undefined;
188
246
  zebraDirectionSlider: Slider | undefined;
247
+ studioEnvIntensitySlider: Slider | undefined;
248
+ studioExposureSlider: Slider | undefined;
249
+ studioEnvRotationSlider: Slider | undefined;
250
+ studioShadowIntensitySlider: Slider | undefined;
251
+ studioShadowSoftnessSlider: Slider | undefined;
252
+ studioAOIntensitySlider: Slider | undefined;
189
253
 
190
254
  // State - set in setupUI() which is called at end of Viewer constructor
191
255
  viewer!: Viewer;
@@ -205,6 +269,7 @@ class Display {
205
269
  lastPlaneState: boolean;
206
270
  help_shown: boolean;
207
271
  info_shown: boolean;
272
+ tools_shown: boolean;
208
273
 
209
274
  // Info panel
210
275
  _info: Info;
@@ -288,14 +353,27 @@ class Display {
288
353
 
289
354
  this.cadTree = this.getElement("tcv_cad_tree_container");
290
355
  this.cadTreeScrollContainer = this.getElement("tcv_box_content");
291
- this.cadTreeToggles = this.getElement("tcv_cad_tree_toggles");
356
+ this.cadTreeToggles = this.getElement("tcv_toggles_tree");
357
+ this.cadClipToggles = this.getElement("tcv_toggles_clip");
358
+ this.cadMaterialToggles = this.getElement("tcv_toggles_material");
359
+ this.cadZebraToggles = this.getElement("tcv_toggles_zebra");
360
+ this.cadStudioToggles = this.getElement("tcv_toggles_studio");
292
361
  this.cadClip = this.getElement("tcv_cad_clip_container");
293
362
  this.cadMaterial = this.getElement("tcv_cad_material_container");
294
363
  this.cadZebra = this.getElement("tcv_cad_zebra_container");
364
+ this.cadStudio = this.getElement("tcv_cad_studio_container");
365
+ this._spinnerEl = this.container.querySelector(".tcv_studio_spinner") as HTMLElement | null;
366
+ this._warningBannerEl = this.container.querySelector(".tcv_warning_banner") as HTMLElement | null;
367
+ this.container.addEventListener("tcv-material-warnings", ((e: CustomEvent<string[]>) => {
368
+ this._showWarningBanner(
369
+ `Unresolved material tag(s): ${e.detail.map(t => `"${t}"`).join(", ")}`,
370
+ );
371
+ }) as EventListener);
295
372
  this.tabTree = this.getElement("tcv_tab_tree");
296
373
  this.tabClip = this.getElement("tcv_tab_clip");
297
- this.tabMaterial = this.getElement("tcv_tab_material");
298
374
  this.tabZebra = this.getElement("tcv_tab_zebra");
375
+ this.tabMaterial = this.getElement("tcv_tab_material");
376
+ this.tabStudio = this.getElement("tcv_tab_studio");
299
377
  this.cadInfo = this.getElement("tcv_cad_info_container");
300
378
  this._info = new Info(this.cadInfo);
301
379
  this.tickValueElement = this.getElement("tcv_tick_size_value");
@@ -305,6 +383,9 @@ class Display {
305
383
  if (!options.zebraTool) {
306
384
  this.tabZebra.style.display = "none";
307
385
  }
386
+ if (options.studioTool === false) {
387
+ this.tabStudio.style.display = "none";
388
+ }
308
389
  this.cadHelp = this.getElement("tcv_cad_help");
309
390
  listeners.add(this.cadHelp, "contextmenu", (e) => {
310
391
  e.preventDefault();
@@ -331,6 +412,7 @@ class Display {
331
412
  this.cadClip.style.display = "none";
332
413
  this.cadMaterial.style.display = "none";
333
414
  this.cadZebra.style.display = "none";
415
+ this.cadStudio.style.display = "none";
334
416
  this.clipSliders = null;
335
417
 
336
418
  // Note: activeTool is managed by ViewerState, not stored locally
@@ -338,6 +420,7 @@ class Display {
338
420
  this.lastPlaneState = false;
339
421
  this.help_shown = true;
340
422
  this.info_shown = !this.glass;
423
+ this.tools_shown = true;
341
424
 
342
425
  const theme = options.theme;
343
426
  this.theme = theme;
@@ -623,6 +706,15 @@ class Display {
623
706
  this._events.push(["change", name, fn]);
624
707
  }
625
708
 
709
+ /**
710
+ * Wire a select element's change event
711
+ */
712
+ private setupSelectEvent(name: string, fn: EventListener): void {
713
+ const el = this.getElement(name);
714
+ listeners.add(el, "change", fn);
715
+ this._events.push(["change", name, fn]);
716
+ }
717
+
626
718
  /**
627
719
  * Get a DOM element by class name (internal use only).
628
720
  * @param name - Name of the DOM element class
@@ -693,10 +785,21 @@ class Display {
693
785
  this.zebraCountSlider?.dispose();
694
786
  this.zebraOpacitySlider?.dispose();
695
787
  this.zebraDirectionSlider?.dispose();
788
+ this.studioEnvIntensitySlider?.dispose();
789
+ this.studioExposureSlider?.dispose();
790
+ this.studioEnvRotationSlider?.dispose();
791
+ this.studioAOIntensitySlider?.dispose();
792
+
793
+ // Clean up material editor
794
+ this._matEditorDragAbort?.abort();
795
+ this.disposeMatEditorClones();
696
796
 
697
797
  // Clear DOM content (elements remain valid until Display is GC'd)
698
798
  this.cadTree.innerHTML = "";
699
- this.cadView.removeChild(this.cadView.children[2]);
799
+ const attachedCanvas = this.cadView.querySelector("canvas");
800
+ if (attachedCanvas && attachedCanvas.parentElement === this.cadView) {
801
+ this.cadView.removeChild(attachedCanvas);
802
+ }
700
803
  this.container.innerHTML = "";
701
804
  }
702
805
 
@@ -790,6 +893,7 @@ class Display {
790
893
  this.height = options.height;
791
894
  this.cadView.style.height = px(options.height);
792
895
  }
896
+
793
897
  if (options.treeWidth) {
794
898
  this.treeWidth = options.treeWidth;
795
899
  this.cadTree.parentElement!.parentElement!.style.width = px(
@@ -856,9 +960,12 @@ class Display {
856
960
  this.setupClickEvent("tcv_collapse_all", this.handleCollapseNodes);
857
961
  this.setupClickEvent("tcv_expand", this.handleCollapseNodes);
858
962
 
963
+ this.setupClickEvent("tcv_clip_reset", this.handleClipReset);
859
964
  this.setupClickEvent("tcv_material_reset", this.handleMaterialReset);
965
+ this.setupClickEvent("tcv_zebra_reset", this.handleZebraReset);
860
966
 
861
967
  this.setupClickEvent("tcv_toggle_info", this.toggleInfo);
968
+ this.setupClickEvent("tcv_toggle_tools_wrapper", this.toggleToolsPanel);
862
969
 
863
970
  this.help_shown = true;
864
971
  this.info_shown = !this.glass;
@@ -866,8 +973,9 @@ class Display {
866
973
  const tabs = [
867
974
  "tcv_tab_tree",
868
975
  "tcv_tab_clip",
869
- "tcv_tab_material",
870
976
  "tcv_tab_zebra",
977
+ "tcv_tab_material",
978
+ "tcv_tab_studio",
871
979
  ];
872
980
  tabs.forEach((name) => {
873
981
  this.setupClickEvent(name, this.selectTab);
@@ -975,6 +1083,101 @@ class Display {
975
1083
  this.setupRadioEvent(`tcv_zebra_mapping${id}`, this.setZebraMappingMode);
976
1084
  });
977
1085
 
1086
+ // Studio tab controls
1087
+ this.studioEnvIntensitySlider = new Slider(
1088
+ "studio_env_intensity",
1089
+ 0,
1090
+ 300,
1091
+ this.container,
1092
+ {
1093
+ handler: this.handleStudioEnvIntensity,
1094
+ percentage: true,
1095
+ isReadyCheck: viewerReadyCheck,
1096
+ },
1097
+ );
1098
+ this.studioExposureSlider = new Slider(
1099
+ "studio_exposure",
1100
+ 0,
1101
+ 300,
1102
+ this.container,
1103
+ {
1104
+ handler: this.handleStudioExposure,
1105
+ percentage: true,
1106
+ isReadyCheck: viewerReadyCheck,
1107
+ },
1108
+ );
1109
+ this.studioEnvRotationSlider = new Slider(
1110
+ "studio_env_rotation",
1111
+ 0,
1112
+ 360,
1113
+ this.container,
1114
+ {
1115
+ handler: this.handleStudioEnvRotation,
1116
+ isReadyCheck: viewerReadyCheck,
1117
+ },
1118
+ );
1119
+
1120
+ this.studioShadowIntensitySlider = new Slider(
1121
+ "studio_shadow_intensity",
1122
+ 0,
1123
+ 100,
1124
+ this.container,
1125
+ {
1126
+ handler: this.handleStudioShadowIntensity,
1127
+ percentage: true,
1128
+ isReadyCheck: viewerReadyCheck,
1129
+ },
1130
+ );
1131
+ this.studioShadowSoftnessSlider = new Slider(
1132
+ "studio_shadow_softness",
1133
+ 0,
1134
+ 100,
1135
+ this.container,
1136
+ {
1137
+ handler: this.handleStudioShadowSoftness,
1138
+ percentage: true,
1139
+ isReadyCheck: viewerReadyCheck,
1140
+ },
1141
+ );
1142
+ this.studioAOIntensitySlider = new Slider(
1143
+ "studio_ao_intensity",
1144
+ 0,
1145
+ 30,
1146
+ this.container,
1147
+ {
1148
+ handler: this.handleStudioAOIntensity,
1149
+ isReadyCheck: viewerReadyCheck,
1150
+ },
1151
+ );
1152
+ this.setupCheckEvent(
1153
+ "tcv_studio_4k_env_maps",
1154
+ this.handleStudio4kEnvMaps,
1155
+ false,
1156
+ );
1157
+
1158
+ this.setupSelectEvent(
1159
+ "tcv_studio_environment",
1160
+ this.handleStudioEnvironment,
1161
+ );
1162
+ this.setupSelectEvent(
1163
+ "tcv_studio_background",
1164
+ this.handleStudioBackground,
1165
+ );
1166
+ this.setupSelectEvent(
1167
+ "tcv_studio_tone_mapping",
1168
+ this.handleStudioToneMapping,
1169
+ );
1170
+ this.setupSelectEvent(
1171
+ "tcv_studio_texture_mapping",
1172
+ this.handleStudioTextureMapping,
1173
+ );
1174
+
1175
+ this.setupClickEvent("tcv_studio_reset", this.handleStudioReset);
1176
+ this.setupClickEvent("tcv_mat_editor_toggle", this.handleMatEditorToggle);
1177
+ this.setupClickEvent("tcv_mat_editor_close", () => this.closeMatEditor());
1178
+ this.setupClickEvent("tcv_mat_editor_reset", this.handleMatEditorReset);
1179
+ this._initMatEditorDrag();
1180
+
978
1181
  this.setupClickEvent("tcv_play", this.controlAnimation);
979
1182
  this.setupClickEvent("tcv_pause", this.controlAnimation);
980
1183
  this.setupClickEvent("tcv_stop", this.controlAnimation);
@@ -1066,6 +1269,9 @@ class Display {
1066
1269
 
1067
1270
  sub("tools", (change) => {
1068
1271
  this.showTools(change.new);
1272
+ const animationMode = this.state.get("animationMode");
1273
+ this.cadAnim.style.display =
1274
+ change.new && animationMode !== "none" ? "block" : "none";
1069
1275
  });
1070
1276
 
1071
1277
  sub("glass", (change) => {
@@ -1160,13 +1366,78 @@ class Display {
1160
1366
  this.setZebraMappingModeSelect(change.new);
1161
1367
  });
1162
1368
 
1369
+ // Studio tab subscriptions
1370
+ sub("studioEnvironment", (change) => {
1371
+ this._syncEnvDropdown(change.new);
1372
+ this._update4kCheckboxEnabled(change.new);
1373
+ });
1374
+ sub(
1375
+ "studioEnvIntensity",
1376
+ (change) => {
1377
+ this.studioEnvIntensitySlider?.setValueFromState(change.new * 100);
1378
+ },
1379
+ { immediate: true },
1380
+ );
1381
+ sub(
1382
+ "studioEnvRotation",
1383
+ (change) => {
1384
+ this.studioEnvRotationSlider?.setValueFromState(change.new);
1385
+ },
1386
+ { immediate: true },
1387
+ );
1388
+ sub("studioBackground", (change) => {
1389
+ const el = this.container.querySelector(".tcv_studio_background");
1390
+ if (el instanceof HTMLSelectElement) el.value = change.new;
1391
+ });
1392
+ sub("studioToneMapping", (change) => {
1393
+ const el = this.container.querySelector(".tcv_studio_tone_mapping");
1394
+ if (el instanceof HTMLSelectElement) el.value = change.new;
1395
+ });
1396
+ sub("studioTextureMapping", (change) => {
1397
+ const el = this.container.querySelector(".tcv_studio_texture_mapping");
1398
+ if (el instanceof HTMLSelectElement) el.value = change.new;
1399
+ });
1400
+ sub(
1401
+ "studioExposure",
1402
+ (change) => {
1403
+ this.studioExposureSlider?.setValueFromState(change.new * 100);
1404
+ },
1405
+ { immediate: true },
1406
+ );
1407
+ sub(
1408
+ "studioShadowIntensity",
1409
+ (change) => {
1410
+ this.studioShadowIntensitySlider?.setValueFromState(change.new * 100);
1411
+ },
1412
+ { immediate: true },
1413
+ );
1414
+ sub(
1415
+ "studioShadowSoftness",
1416
+ (change) => {
1417
+ this.studioShadowSoftnessSlider?.setValueFromState(change.new * 100);
1418
+ },
1419
+ { immediate: true },
1420
+ );
1421
+ sub(
1422
+ "studioAOIntensity",
1423
+ (change) => {
1424
+ this.studioAOIntensitySlider?.setValueFromState(change.new * 10);
1425
+ },
1426
+ { immediate: true },
1427
+ );
1428
+ sub("studio4kEnvMaps", (change) => {
1429
+ this.getInputElement("tcv_studio_4k_env_maps").checked = change.new;
1430
+ });
1431
+
1163
1432
  // Animation/Explode mode subscription - controls slider visibility, label, and explode button
1164
1433
  sub(
1165
1434
  "animationMode",
1166
1435
  (change) => {
1167
1436
  const mode = change.new;
1168
- // Show/hide slider control
1169
- this.cadAnim.style.display = mode !== "none" ? "block" : "none";
1437
+ const toolsEnabled = this.state.get("tools");
1438
+ // Show/hide slider control (only when tools panel is enabled)
1439
+ this.cadAnim.style.display =
1440
+ toolsEnabled && mode !== "none" ? "block" : "none";
1170
1441
  // Set label: "A" for animation, "E" for explode
1171
1442
  this.getElement("tcv_animation_label").innerHTML =
1172
1443
  mode === "explode" ? "E" : "A";
@@ -1263,9 +1534,10 @@ class Display {
1263
1534
  this.lastPlaneState =
1264
1535
  typeof clipPlaneHelpers === "boolean" ? clipPlaneHelpers : false;
1265
1536
 
1266
- // Sync material and zebra sliders with current state values
1537
+ // Sync material, zebra, and studio sliders with current state values
1267
1538
  this.syncMaterialSlidersFromState();
1268
1539
  this.syncZebraSlidersFromState();
1540
+ this.syncStudioSlidersFromState();
1269
1541
  }
1270
1542
 
1271
1543
  // ---------------------------------------------------------------------------
@@ -1286,6 +1558,17 @@ class Display {
1286
1558
  * @param canvasElement - The canvas to attach.
1287
1559
  */
1288
1560
  private attachCanvas(canvasElement: HTMLCanvasElement): void {
1561
+ // If the canvas is already attached elsewhere
1562
+ // do not re-parent it into this display.
1563
+ if (canvasElement.parentElement && canvasElement.parentElement !== this.cadView) {
1564
+ listeners.add(canvasElement, "click", () => {
1565
+ if (this.help_shown) {
1566
+ this.showHelp(false);
1567
+ }
1568
+ });
1569
+ return;
1570
+ }
1571
+
1289
1572
  const existingCanvas = this.cadView.querySelector("canvas");
1290
1573
  if (existingCanvas) {
1291
1574
  this.cadView.replaceChild(canvasElement, existingCanvas);
@@ -1303,7 +1586,9 @@ class Display {
1303
1586
  * Get the DOM canvas element
1304
1587
  */
1305
1588
  getCanvas(): Element {
1306
- return this.cadView.children[this.cadView.children.length - 1];
1589
+ const localCanvas = this.cadView.querySelector("canvas");
1590
+ if (localCanvas) return localCanvas;
1591
+ return this.viewer.renderer.domElement;
1307
1592
  }
1308
1593
 
1309
1594
  /**
@@ -1409,6 +1694,10 @@ class Display {
1409
1694
  * Delegates state mutations to Viewer.activateTool() to maintain unidirectional data flow.
1410
1695
  */
1411
1696
  setTool = (name: string, flag: boolean): void => {
1697
+ // Block tool activation while Studio mode is active
1698
+ if (flag && this.viewer.isStudioActive) {
1699
+ return;
1700
+ }
1412
1701
  this.viewer.toggleAnimationLoop(flag);
1413
1702
  const activeTool = this.state.get("activeTool");
1414
1703
  const currentTool = typeof activeTool === "string" ? activeTool : "";
@@ -1469,16 +1758,32 @@ class Display {
1469
1758
  this.tools = flag;
1470
1759
  const tb = this.getElement("tcv_cad_toolbar");
1471
1760
  const cn = this.getElement("tcv_cad_navigation");
1761
+ const tickInfo = this.tickInfoElement;
1472
1762
  if (flag) {
1473
1763
  tb.style.height = "38px";
1474
1764
  tb.style.display = "flex";
1475
1765
  cn.style.height = "38px";
1476
1766
  cn.style.display = "block";
1767
+
1768
+ // Tick size badge belongs to the tools UI. Restore it only when tools are visible and at least one grid is visible.
1769
+ if (tickInfo) {
1770
+ if (this.viewer?.ready) {
1771
+ tickInfo.style.display = this.viewer.rendered.gridHelper.getVisible()
1772
+ ? "block"
1773
+ : "none";
1774
+ } else {
1775
+ tickInfo.style.display = "none";
1776
+ }
1777
+ }
1477
1778
  } else {
1478
1779
  tb.style.height = "0px";
1479
1780
  tb.style.display = "none";
1480
1781
  cn.style.height = "0px";
1481
1782
  cn.style.display = "none";
1783
+
1784
+ if (tickInfo) {
1785
+ tickInfo.style.display = "none";
1786
+ }
1482
1787
  }
1483
1788
  };
1484
1789
 
@@ -1514,6 +1819,38 @@ class Display {
1514
1819
  }
1515
1820
  };
1516
1821
 
1822
+ /**
1823
+ * Deactivate any running tool and hide tool buttons for Studio mode.
1824
+ * Called when entering Studio mode.
1825
+ * @internal
1826
+ */
1827
+ private _deactivateToolsForStudio(): void {
1828
+ // If a tool is currently active, deactivate it cleanly
1829
+ const activeTool = this.state.get("activeTool");
1830
+ if (activeTool && ["distance", "properties", "angle", "select"].includes(activeTool)) {
1831
+ this.clickButtons[activeTool]?.set(false);
1832
+ this.setTool(activeTool, false);
1833
+ // setTool→toggleTab(false) silently sets activeTab to "tree" (no notification).
1834
+ // Restore to "studio" so the next tab click correctly detects Studio as oldTab.
1835
+ this.state.set("activeTab", "studio", false);
1836
+ }
1837
+
1838
+ // Hide tool buttons
1839
+ this.showMeasureTools(false);
1840
+ this.showSelectTool(false);
1841
+ }
1842
+
1843
+ /**
1844
+ * Restore tool button visibility after leaving Studio mode.
1845
+ * Respects original feature flags (measureTools, selectTool).
1846
+ * Does not auto-activate any tool.
1847
+ * @internal
1848
+ */
1849
+ private _restoreToolsAfterStudio(): void {
1850
+ this.showMeasureTools(this.measureTools);
1851
+ this.showSelectTool(this.selectTool);
1852
+ }
1853
+
1517
1854
  // ---------------------------------------------------------------------------
1518
1855
  // Clipping Handlers
1519
1856
  // ---------------------------------------------------------------------------
@@ -1629,8 +1966,9 @@ class Display {
1629
1966
  if (
1630
1967
  tabName === "clip" ||
1631
1968
  tabName === "tree" ||
1969
+ tabName === "zebra" ||
1632
1970
  tabName === "material" ||
1633
- tabName === "zebra"
1971
+ tabName === "studio"
1634
1972
  ) {
1635
1973
  this.viewer.setActiveTab(tabName);
1636
1974
  }
@@ -1640,41 +1978,62 @@ class Display {
1640
1978
  * Switch to a tab (internal, called by activeTab subscription).
1641
1979
  */
1642
1980
  private switchToTab(newTab: ActiveTab, oldTab?: ActiveTab): void {
1643
- if (!["clip", "tree", "material", "zebra"].includes(newTab)) {
1981
+ if (!["clip", "tree", "zebra", "material", "studio"].includes(newTab)) {
1644
1982
  return;
1645
1983
  }
1646
1984
 
1985
+ // --- Leave the OLD mode FIRST (before _updateVisibility runs) ---
1986
+ // This ensures all state (clipping, materials, etc.) is restored
1987
+ // before the new tab's controls are activated.
1988
+ if (oldTab === "zebra" && newTab !== "zebra") {
1989
+ this.viewer.enableZebraTool(false);
1990
+ }
1991
+ if (oldTab === "studio" && newTab !== "studio") {
1992
+ this.closeMatEditor();
1993
+ this._saveMatEditorChanges();
1994
+ this.disposeMatEditorClones();
1995
+ this.viewer.leaveStudioMode();
1996
+ this._hideWarningBanner();
1997
+ // Restore tool button visibility based on feature flags
1998
+ this._restoreToolsAfterStudio();
1999
+ }
2000
+
1647
2001
  const _updateVisibility = (
1648
2002
  showTree: boolean,
1649
2003
  showClip: boolean,
1650
- showMaterial: boolean,
1651
2004
  showZebra: boolean,
2005
+ showMaterial: boolean,
2006
+ showStudio: boolean,
1652
2007
  ) => {
1653
2008
  this.cadTree.style.display = showTree ? "block" : "none";
1654
2009
  this.cadTreeToggles.style.display = showTree ? "block" : "none";
2010
+ this.cadClipToggles.style.display = showClip ? "block" : "none";
1655
2011
  this.cadClip.style.display = showClip ? "block" : "none";
1656
- this.cadMaterial.style.display = showMaterial ? "block" : "none";
2012
+ this.cadZebraToggles.style.display = showZebra ? "block" : "none";
1657
2013
  this.cadZebra.style.display = showZebra ? "block" : "none";
2014
+ this.cadMaterialToggles.style.display = showMaterial ? "block" : "none";
2015
+ this.cadMaterial.style.display = showMaterial ? "block" : "none";
2016
+ this.cadStudioToggles.style.display = showStudio ? "block" : "none";
2017
+ this.cadStudio.style.display = showStudio ? "block" : "none";
1658
2018
 
1659
2019
  this.viewer.clipping.setVisible(showClip);
1660
2020
  this.viewer.setLocalClipping(showClip);
1661
2021
  if (!showClip) {
1662
2022
  this.viewer.setClipPlaneHelpers(false);
1663
2023
  }
1664
- if (newTab !== "zebra" && oldTab === "zebra") {
1665
- this.viewer.enableZebraTool(false);
1666
- }
2024
+ // NOTE: zebra and studio leave calls removed from here --
2025
+ // they now run above, before _updateVisibility is called.
1667
2026
  };
1668
2027
 
1669
2028
  if (newTab === "tree") {
1670
- _updateVisibility(true, false, false, false);
2029
+ _updateVisibility(true, false, false, false, false);
1671
2030
  this.viewer.nestedGroup.setBackVisible(false);
1672
2031
  // Lazy-rendered tree nodes may be stale if the tree was rebuilt
1673
2032
  // while this tab was hidden (display:none → getBoundingClientRect
1674
2033
  // returns zero, so update() rendered nothing). Kick it now.
1675
2034
  this.viewer.treeview?.update();
1676
2035
  } else if (newTab === "clip") {
1677
- _updateVisibility(false, true, false, false);
2036
+ _updateVisibility(false, true, false, false, false);
1678
2037
  this.viewer.nestedGroup.setBackVisible(true);
1679
2038
  const clipIntersection = this.viewer.state.get("clipIntersection");
1680
2039
  if (typeof clipIntersection === "boolean") {
@@ -1682,16 +2041,30 @@ class Display {
1682
2041
  }
1683
2042
  this.viewer.setClipPlaneHelpers(this.lastPlaneState);
1684
2043
  this.viewer.update(true, false);
1685
- } else if (newTab === "material") {
1686
- _updateVisibility(false, false, true, false);
1687
- this.viewer.nestedGroup.setBackVisible(false);
1688
2044
  } else if (newTab === "zebra") {
1689
- _updateVisibility(false, false, false, true);
2045
+ _updateVisibility(false, false, true, false, false);
1690
2046
  this.viewer.enableZebraTool(true);
2047
+ } else if (newTab === "material") {
2048
+ _updateVisibility(false, false, false, true, false);
2049
+ this.viewer.nestedGroup.setBackVisible(false);
2050
+ } else if (newTab === "studio") {
2051
+ _updateVisibility(false, false, false, false, true);
2052
+ this.viewer.nestedGroup.setBackVisible(false);
2053
+
2054
+ // Disable any active tool before entering Studio mode
2055
+ this._deactivateToolsForStudio();
2056
+
2057
+ this._showSpinner();
2058
+ this.viewer.enterStudioMode().finally(() => {
2059
+ this._hideSpinner();
2060
+ this._reapplyMatEditorChanges();
2061
+ this.syncStudioSlidersFromState();
2062
+ this.viewer.update(true, false);
2063
+ });
1691
2064
  }
1692
2065
 
1693
2066
  // Update tab styling
1694
- [this.tabTree, this.tabClip, this.tabMaterial, this.tabZebra].forEach(
2067
+ [this.tabTree, this.tabClip, this.tabZebra, this.tabMaterial, this.tabStudio].forEach(
1695
2068
  (tabEl) => {
1696
2069
  tabEl.classList.add("tcv_tab-unselected");
1697
2070
  tabEl.classList.remove("tcv_tab-selected");
@@ -1705,12 +2078,47 @@ class Display {
1705
2078
  } else if (newTab === "clip") {
1706
2079
  this.tabClip.classList.add("tcv_tab-selected");
1707
2080
  this.tabClip.classList.remove("tcv_tab-unselected");
2081
+ } else if (newTab === "zebra") {
2082
+ this.tabZebra.classList.add("tcv_tab-selected");
2083
+ this.tabZebra.classList.remove("tcv_tab-unselected");
1708
2084
  } else if (newTab === "material") {
1709
2085
  this.tabMaterial.classList.add("tcv_tab-selected");
1710
2086
  this.tabMaterial.classList.remove("tcv_tab-unselected");
1711
- } else if (newTab === "zebra") {
1712
- this.tabZebra.classList.remove("tcv_tab-unselected");
1713
- this.tabZebra.classList.add("tcv_tab-selected");
2087
+ } else if (newTab === "studio") {
2088
+ this.tabStudio.classList.add("tcv_tab-selected");
2089
+ this.tabStudio.classList.remove("tcv_tab-unselected");
2090
+ }
2091
+ }
2092
+
2093
+ /** Show the toolbar spinner (ref-counted for overlapping async ops). */
2094
+ private _showSpinner(): void {
2095
+ this._spinnerCount++;
2096
+ if (this._spinnerEl) this._spinnerEl.style.display = "block";
2097
+ }
2098
+
2099
+ /** Hide the toolbar spinner (only when all pending ops complete). */
2100
+ private _hideSpinner(): void {
2101
+ this._spinnerCount = Math.max(0, this._spinnerCount - 1);
2102
+ if (this._spinnerCount === 0 && this._spinnerEl) {
2103
+ this._spinnerEl.style.display = "none";
2104
+ }
2105
+ }
2106
+
2107
+ /** Show a warning banner in the viewport. Auto-hides after 8 seconds. */
2108
+ private _showWarningBanner(message: string): void {
2109
+ if (!this._warningBannerEl) return;
2110
+ this._warningBannerEl.textContent = message;
2111
+ this._warningBannerEl.style.display = "block";
2112
+ if (this._warningBannerTimer) clearTimeout(this._warningBannerTimer);
2113
+ this._warningBannerTimer = setTimeout(() => this._hideWarningBanner(), 8000);
2114
+ }
2115
+
2116
+ /** Hide the warning banner. */
2117
+ private _hideWarningBanner(): void {
2118
+ if (this._warningBannerEl) this._warningBannerEl.style.display = "none";
2119
+ if (this._warningBannerTimer) {
2120
+ clearTimeout(this._warningBannerTimer);
2121
+ this._warningBannerTimer = null;
1714
2122
  }
1715
2123
  }
1716
2124
 
@@ -1745,6 +2153,17 @@ class Display {
1745
2153
  }
1746
2154
  };
1747
2155
 
2156
+ // ---------------------------------------------------------------------------
2157
+ // Clip Reset Handler
2158
+ // ---------------------------------------------------------------------------
2159
+
2160
+ /**
2161
+ * Reset clip planes to default normals and slider positions
2162
+ */
2163
+ handleClipReset = (_e: Event): void => {
2164
+ this.viewer.resetClip();
2165
+ };
2166
+
1748
2167
  // ---------------------------------------------------------------------------
1749
2168
  // Material Handlers
1750
2169
  // ---------------------------------------------------------------------------
@@ -1756,6 +2175,468 @@ class Display {
1756
2175
  this.viewer.resetMaterial();
1757
2176
  };
1758
2177
 
2178
+ /**
2179
+ * Reset zebra tool to default settings
2180
+ */
2181
+ handleZebraReset = (_e: Event): void => {
2182
+ this.viewer.resetZebra();
2183
+ };
2184
+
2185
+ // ---------------------------------------------------------------------------
2186
+ // Studio Tab Handlers
2187
+ // ---------------------------------------------------------------------------
2188
+
2189
+ /**
2190
+ * Handler for Studio environment dropdown change
2191
+ */
2192
+ handleStudioEnvironment = (e: Event): void => {
2193
+ if (!(e.target instanceof HTMLSelectElement)) return;
2194
+ this._showSpinner();
2195
+ this.state.set("studioEnvironment", e.target.value);
2196
+ this.container.addEventListener("tcv-studio-ready", () => this._hideSpinner(), { once: true });
2197
+ };
2198
+
2199
+ /**
2200
+ * Handler for Studio env intensity slider change.
2201
+ * Slider range 0-200 with percentage=true, so value arrives as 0-2.
2202
+ */
2203
+ handleStudioEnvIntensity = (value: number): void => {
2204
+ this.state.set("studioEnvIntensity", value);
2205
+ };
2206
+
2207
+ handleStudioEnvRotation = (value: number): void => {
2208
+ this.state.set("studioEnvRotation", value);
2209
+ };
2210
+
2211
+ /**
2212
+ * Handler for Studio background dropdown change.
2213
+ * Validates against the StudioBackground union before setting state.
2214
+ */
2215
+ handleStudioBackground = (e: Event): void => {
2216
+ if (!(e.target instanceof HTMLSelectElement)) return;
2217
+ const value = e.target.value;
2218
+ if (value === "grey" || value === "darkgrey" || value === "white" || value === "gradient" || value === "gradient-dark" || value === "environment" || value === "transparent") {
2219
+ this.state.set("studioBackground", value);
2220
+ }
2221
+ };
2222
+
2223
+ /**
2224
+ * Handler for Studio tone mapping dropdown change.
2225
+ * Validates against the StudioToneMapping union before setting state.
2226
+ */
2227
+ handleStudioToneMapping = (e: Event): void => {
2228
+ if (!(e.target instanceof HTMLSelectElement)) return;
2229
+ const value = e.target.value;
2230
+ if (value === "neutral" || value === "ACES" || value === "none") {
2231
+ this.state.set("studioToneMapping", value);
2232
+ }
2233
+ };
2234
+
2235
+ /**
2236
+ * Handler for Studio exposure slider change.
2237
+ * Slider range 0-200 with percentage=true, so value arrives as 0-2.
2238
+ */
2239
+ handleStudioTextureMapping = (e: Event): void => {
2240
+ if (!(e.target instanceof HTMLSelectElement)) return;
2241
+ const value = e.target.value;
2242
+ if (value === "triplanar" || value === "parametric") {
2243
+ this._showSpinner();
2244
+ this.state.set("studioTextureMapping", value);
2245
+ this.container.addEventListener("tcv-studio-ready", () => this._hideSpinner(), { once: true });
2246
+ }
2247
+ };
2248
+
2249
+ handleStudioExposure = (value: number): void => {
2250
+ this.state.set("studioExposure", value);
2251
+ };
2252
+
2253
+ handleStudioShadowIntensity = (value: number): void => {
2254
+ this.state.set("studioShadowIntensity", value);
2255
+ };
2256
+
2257
+ handleStudioShadowSoftness = (value: number): void => {
2258
+ this.state.set("studioShadowSoftness", value);
2259
+ };
2260
+
2261
+ /**
2262
+ * Handler for Studio AO intensity slider change.
2263
+ * Slider range 0-30, divided by 10 → state gets 0-3.0.
2264
+ * A value of 0 disables AO.
2265
+ */
2266
+ handleStudioAOIntensity = (value: number): void => {
2267
+ this.state.set("studioAOIntensity", value / 10);
2268
+ };
2269
+
2270
+ /**
2271
+ * Handler for Studio 4K env maps checkbox change.
2272
+ * Shows a "Loading…" indicator while the new resolution downloads.
2273
+ */
2274
+ handleStudio4kEnvMaps = (e: Event): void => {
2275
+ if (!(e.target instanceof HTMLInputElement)) return;
2276
+ this._showSpinner();
2277
+ this.state.set("studio4kEnvMaps", e.target.checked);
2278
+ this.container.addEventListener("tcv-studio-ready", () => this._hideSpinner(), { once: true });
2279
+ };
2280
+
2281
+ /**
2282
+ * Reset Studio tab values to defaults.
2283
+ * Delegates to viewer.resetStudio() (same pattern as handleMaterialReset -> resetMaterial()).
2284
+ */
2285
+ handleStudioReset = (_e: Event): void => {
2286
+ this.viewer.resetStudio();
2287
+ };
2288
+
2289
+ // ---------------------------------------------------------------------------
2290
+ // Material Editor
2291
+ // ---------------------------------------------------------------------------
2292
+
2293
+ handleMatEditorToggle = (_e: Event): void => {
2294
+ const dialog = this.container.querySelector(".tcv_mat_editor") as HTMLElement;
2295
+ if (!dialog) return;
2296
+
2297
+ // Toggle off if visible
2298
+ if (dialog.style.display !== "none") {
2299
+ this.closeMatEditor();
2300
+ return;
2301
+ }
2302
+
2303
+ const result = this.viewer.getSelectedObjectGroup();
2304
+ if (!result || !result.object.front) {
2305
+ this._showMatEditorHint(dialog);
2306
+ return;
2307
+ }
2308
+
2309
+ this.openMatEditor(result.object, result.path);
2310
+ };
2311
+
2312
+ private _showMatEditorHint(dialog: HTMLElement): void {
2313
+ const pathEl = dialog.querySelector(".tcv_mat_editor_path") as HTMLElement;
2314
+ if (pathEl) pathEl.style.display = "none";
2315
+ const resetBtn = dialog.querySelector(".tcv_mat_editor_reset") as HTMLElement;
2316
+ if (resetBtn) resetBtn.style.display = "none";
2317
+ const content = dialog.querySelector(".tcv_mat_editor_content") as HTMLElement;
2318
+ if (content) {
2319
+ content.innerHTML = "";
2320
+ const hint = document.createElement("div");
2321
+ hint.style.padding = "12px 6px";
2322
+ hint.style.textAlign = "center";
2323
+ hint.style.opacity = "0.7";
2324
+ hint.textContent = "Select object by double-click first";
2325
+ content.appendChild(hint);
2326
+ }
2327
+ dialog.style.display = "";
2328
+ }
2329
+
2330
+ private openMatEditor(object: ObjectGroup, objectPath: string): void {
2331
+ const dialog = this.container.querySelector(".tcv_mat_editor") as HTMLElement;
2332
+ if (!dialog || !object.front) return;
2333
+
2334
+ this._matEditorPath = objectPath;
2335
+
2336
+ // Clone material on first edit so changes are per-object (reuse existing clone)
2337
+ let mat: THREE.MeshPhysicalMaterial;
2338
+ let originalMat: THREE.MeshPhysicalMaterial;
2339
+ const existing = this._matEditorClones.get(objectPath);
2340
+ if (existing && object.front.material === existing.clone) {
2341
+ mat = existing.clone;
2342
+ originalMat = existing.original;
2343
+ } else {
2344
+ const currentMat = object.front.material;
2345
+ if (!(currentMat instanceof THREE.MeshPhysicalMaterial)) return;
2346
+ originalMat = currentMat;
2347
+ mat = currentMat.clone();
2348
+ // Preserve triplanar mapping if the original material uses it
2349
+ if (currentMat.customProgramCacheKey() === "triplanar" && object.shapeGeometry) {
2350
+ applyTriplanarMapping(mat, object.shapeGeometry);
2351
+ }
2352
+ object.front.material = mat;
2353
+ this._matEditorClones.set(objectPath, { original: originalMat, clone: mat });
2354
+ }
2355
+
2356
+ // Restore elements that _showMatEditorHint may have hidden
2357
+ const resetBtn = dialog.querySelector(".tcv_mat_editor_reset") as HTMLElement;
2358
+ if (resetBtn) resetBtn.style.display = "";
2359
+
2360
+ // Update path display
2361
+ const pathEl = dialog.querySelector(".tcv_mat_editor_path") as HTMLElement;
2362
+ if (pathEl) {
2363
+ pathEl.style.display = "";
2364
+ const shortName = objectPath.split("/").pop() || objectPath;
2365
+ pathEl.textContent = `${shortName} — ${object.materialTag || "(default)"}`;
2366
+ pathEl.title = objectPath;
2367
+ }
2368
+
2369
+ // Mark toggle active
2370
+ const toggleBtn = this.container.querySelector(".tcv_mat_editor_toggle") as HTMLElement;
2371
+ if (toggleBtn) toggleBtn.classList.add("tcv_active");
2372
+
2373
+ // Populate content — abort previous input listeners before rebuilding
2374
+ this._matEditorInputAbort?.abort();
2375
+ this._matEditorInputAbort = new AbortController();
2376
+
2377
+ const content = dialog.querySelector(".tcv_mat_editor_content") as HTMLElement;
2378
+ if (!content) return;
2379
+ content.innerHTML = "";
2380
+ this._buildMatEditorContent(content, mat, originalMat);
2381
+
2382
+ dialog.style.display = "";
2383
+ }
2384
+
2385
+ closeMatEditor(): void {
2386
+ this._matEditorInputAbort?.abort();
2387
+ this._matEditorInputAbort = null;
2388
+
2389
+ const dialog = this.container.querySelector(".tcv_mat_editor") as HTMLElement;
2390
+ if (dialog) {
2391
+ const content = dialog.querySelector(".tcv_mat_editor_content") as HTMLElement;
2392
+ if (content) content.innerHTML = "";
2393
+ dialog.style.display = "none";
2394
+ }
2395
+ const toggleBtn = this.container.querySelector(".tcv_mat_editor_toggle") as HTMLElement;
2396
+ if (toggleBtn) toggleBtn.classList.remove("tcv_active");
2397
+ this._matEditorPath = null;
2398
+ }
2399
+
2400
+ /** Dispose all cloned materials, restoring originals first (call on Studio mode exit) */
2401
+ disposeMatEditorClones(): void {
2402
+ this._matEditorInputAbort?.abort();
2403
+ this._matEditorInputAbort = null;
2404
+
2405
+ let groups: Record<string, THREE.Object3D> | null = null;
2406
+ try { groups = this.viewer.rendered.nestedGroup.groups; } catch { /* not rendered */ }
2407
+
2408
+ for (const [path, { original, clone }] of this._matEditorClones.entries()) {
2409
+ // Restore original material on mesh before disposing the clone
2410
+ if (groups) {
2411
+ const group = groups[path] as ObjectGroup | undefined;
2412
+ if (group?.front?.material === clone) {
2413
+ group.front.material = original;
2414
+ }
2415
+ }
2416
+ clone.dispose();
2417
+ }
2418
+ this._matEditorClones.clear();
2419
+ }
2420
+
2421
+ /** Save material editor property deltas so they survive a Studio mode leave/enter cycle. */
2422
+ private _saveMatEditorChanges(): void {
2423
+ this._savedMatEditorChanges.clear();
2424
+ for (const [path, { original, clone }] of this._matEditorClones.entries()) {
2425
+ const changes: Record<string, number> = {};
2426
+ for (const param of MAT_EDITOR_PARAMS) {
2427
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2428
+ const origVal = (original as any)[param.key] as number;
2429
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2430
+ const cloneVal = (clone as any)[param.key] as number;
2431
+ if (origVal !== cloneVal) {
2432
+ changes[param.key] = cloneVal;
2433
+ }
2434
+ }
2435
+ if (Object.keys(changes).length > 0) {
2436
+ this._savedMatEditorChanges.set(path, changes);
2437
+ }
2438
+ }
2439
+ }
2440
+
2441
+ /** Reapply saved material editor changes after re-entering Studio mode. */
2442
+ private _reapplyMatEditorChanges(): void {
2443
+ if (this._savedMatEditorChanges.size === 0) return;
2444
+ let groups: Record<string, THREE.Object3D>;
2445
+ try { groups = this.viewer.rendered.nestedGroup.groups; } catch { return; }
2446
+
2447
+ for (const [path, changes] of this._savedMatEditorChanges.entries()) {
2448
+ const group = groups[path] as ObjectGroup | undefined;
2449
+ if (!group?.front?.material) continue;
2450
+ const currentMat = group.front.material;
2451
+ if (!(currentMat instanceof THREE.MeshPhysicalMaterial)) continue;
2452
+
2453
+ const clone = currentMat.clone();
2454
+ if (currentMat.customProgramCacheKey() === "triplanar" && group.shapeGeometry) {
2455
+ applyTriplanarMapping(clone, group.shapeGeometry);
2456
+ }
2457
+ for (const [key, value] of Object.entries(changes)) {
2458
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2459
+ (clone as any)[key] = value;
2460
+ }
2461
+ group.front.material = clone;
2462
+ this._matEditorClones.set(path, { original: currentMat as THREE.MeshPhysicalMaterial, clone });
2463
+ }
2464
+ this._savedMatEditorChanges.clear();
2465
+ }
2466
+
2467
+ /**
2468
+ * Called by viewer.ts when the selected object changes.
2469
+ * Updates or closes the material editor if it's open.
2470
+ */
2471
+ onSelectionChanged(newObjectId: string | null): void {
2472
+ if (!this._matEditorPath) return; // Editor not open
2473
+ if (newObjectId === this._matEditorPath) return; // Same object
2474
+ this.closeMatEditor();
2475
+ // Immediately reopen for the new object if one was selected
2476
+ if (newObjectId) {
2477
+ const result = this.viewer.getSelectedObjectGroup();
2478
+ if (result?.object.front) {
2479
+ this.openMatEditor(result.object, result.path);
2480
+ }
2481
+ }
2482
+ }
2483
+
2484
+ handleMatEditorReset = (_e: Event): void => {
2485
+ if (!this._matEditorPath) return;
2486
+ const result = this.viewer.getSelectedObjectGroup();
2487
+ if (!result?.object.front) return;
2488
+
2489
+ const entry = this._matEditorClones.get(this._matEditorPath);
2490
+ if (entry) {
2491
+ // Restore original material and dispose the clone
2492
+ result.object.front.material = entry.original;
2493
+ entry.clone.dispose();
2494
+ this._matEditorClones.delete(this._matEditorPath);
2495
+ }
2496
+
2497
+ // Reopen to rebuild UI with fresh clone from original
2498
+ this.closeMatEditor();
2499
+ this.openMatEditor(result.object, result.path);
2500
+ this.viewer.update(true, false);
2501
+ };
2502
+
2503
+ /** Make the material editor dialog draggable by its titlebar */
2504
+ private _initMatEditorDrag(): void {
2505
+ const dialog = this.container.querySelector(".tcv_mat_editor") as HTMLElement;
2506
+ const titlebar = this.container.querySelector(".tcv_mat_editor_titlebar") as HTMLElement;
2507
+ if (!dialog || !titlebar) return;
2508
+
2509
+ this._matEditorDragAbort = new AbortController();
2510
+ const { signal } = this._matEditorDragAbort;
2511
+
2512
+ let dragging = false;
2513
+ let startX = 0;
2514
+ let startY = 0;
2515
+ let origLeft = 0;
2516
+ let origTop = 0;
2517
+
2518
+ titlebar.addEventListener("mousedown", (e: MouseEvent) => {
2519
+ // Don't drag if clicking buttons
2520
+ if ((e.target as HTMLElement).tagName === "INPUT") return;
2521
+ dragging = true;
2522
+ startX = e.clientX;
2523
+ startY = e.clientY;
2524
+ const rect = dialog.getBoundingClientRect();
2525
+ const parentRect = dialog.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 };
2526
+ origLeft = rect.left - parentRect.left;
2527
+ origTop = rect.top - parentRect.top;
2528
+ // Switch from right-positioning to left-positioning for drag
2529
+ dialog.style.right = "auto";
2530
+ dialog.style.left = origLeft + "px";
2531
+ dialog.style.top = origTop + "px";
2532
+ e.preventDefault();
2533
+ }, { signal });
2534
+
2535
+ document.addEventListener("mousemove", (e: MouseEvent) => {
2536
+ if (!dragging) return;
2537
+ const dx = e.clientX - startX;
2538
+ const dy = e.clientY - startY;
2539
+ dialog.style.left = (origLeft + dx) + "px";
2540
+ dialog.style.top = (origTop + dy) + "px";
2541
+ }, { signal });
2542
+
2543
+ document.addEventListener("mouseup", () => {
2544
+ dragging = false;
2545
+ }, { signal });
2546
+ }
2547
+
2548
+ private _buildMatEditorContent(
2549
+ content: HTMLElement,
2550
+ material: THREE.MeshPhysicalMaterial,
2551
+ originalMat: THREE.MeshPhysicalMaterial,
2552
+ ): void {
2553
+ let currentGroup = "";
2554
+ for (const param of MAT_EDITOR_PARAMS) {
2555
+ if (param.group !== currentGroup) {
2556
+ currentGroup = param.group;
2557
+ const section = document.createElement("div");
2558
+ section.className = "tcv_mat_editor_section";
2559
+ section.textContent = currentGroup;
2560
+ content.appendChild(section);
2561
+ }
2562
+
2563
+ let currentValue = // eslint-disable-next-line @typescript-eslint/no-explicit-any
2564
+ (material as any)[param.key] as number;
2565
+ const isInfinity = param.infinity === true && (currentValue === Infinity || currentValue == null);
2566
+ if (isInfinity) currentValue = param.max;
2567
+
2568
+ this._buildMatEditorRow(content, param, currentValue ?? 0, isInfinity, originalMat);
2569
+ }
2570
+ }
2571
+
2572
+ private _buildMatEditorRow(
2573
+ container: HTMLElement,
2574
+ param: MatEditorParam,
2575
+ value: number,
2576
+ isInfinity: boolean,
2577
+ originalMat: THREE.MeshPhysicalMaterial,
2578
+ ): void {
2579
+ const row = document.createElement("div");
2580
+ row.className = "tcv_mat_editor_row";
2581
+
2582
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2583
+ const origValue = (originalMat as any)[param.key] as number;
2584
+
2585
+ const isChanged = (v: number) => param.infinity
2586
+ ? (v >= param.max) !== (origValue === Infinity || origValue == null)
2587
+ || (v < param.max && Math.abs(v - origValue) > param.step * 0.5)
2588
+ : Math.abs(v - origValue) > param.step * 0.5;
2589
+
2590
+ const label = document.createElement("label");
2591
+ label.className = "tcv_mat_editor_label";
2592
+ if (isChanged(value)) label.classList.add("tcv_mat_editor_changed");
2593
+ label.textContent = param.label;
2594
+ label.title = `${param.key} (${param.min}–${param.max})`;
2595
+
2596
+ const sliderGroup = document.createElement("div");
2597
+ sliderGroup.className = "tcv_mat_editor_slider_group";
2598
+
2599
+ const slider = document.createElement("input");
2600
+ slider.type = "range";
2601
+ slider.className = "tcv_clip_slider";
2602
+ slider.min = String(param.min);
2603
+ slider.max = String(param.max);
2604
+ slider.step = String(param.step);
2605
+ slider.value = String(value);
2606
+
2607
+ const valueDisplay = document.createElement("input");
2608
+ valueDisplay.className = "tcv_clip_input";
2609
+ valueDisplay.readOnly = true;
2610
+ valueDisplay.value = isInfinity ? "\u221E" : _formatMatValue(value, param.step);
2611
+
2612
+ slider.addEventListener("input", () => {
2613
+ const newValue = parseFloat(slider.value);
2614
+ const result = this.viewer.getSelectedObjectGroup();
2615
+ if (!result?.object.front) return;
2616
+ const mat = result.object.front.material;
2617
+
2618
+ if (param.infinity && newValue >= param.max) {
2619
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2620
+ (mat as any)[param.key] = Infinity;
2621
+ valueDisplay.value = "\u221E";
2622
+ } else {
2623
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2624
+ (mat as any)[param.key] = newValue;
2625
+ valueDisplay.value = _formatMatValue(newValue, param.step);
2626
+ }
2627
+
2628
+ label.classList.toggle("tcv_mat_editor_changed", isChanged(newValue));
2629
+
2630
+ this.viewer.update(true, false);
2631
+ }, { signal: this._matEditorInputAbort!.signal });
2632
+
2633
+ sliderGroup.appendChild(slider);
2634
+ sliderGroup.appendChild(valueDisplay);
2635
+ row.appendChild(label);
2636
+ row.appendChild(sliderGroup);
2637
+ container.appendChild(row);
2638
+ }
2639
+
1759
2640
  // ---------------------------------------------------------------------------
1760
2641
  // Zebra Tool Handlers
1761
2642
  // ---------------------------------------------------------------------------
@@ -1886,6 +2767,82 @@ class Display {
1886
2767
  this.setZebraMappingModeSelect(state.get("zebraMappingMode"));
1887
2768
  }
1888
2769
 
2770
+ /**
2771
+ * Sync Studio slider/control UI from current state values.
2772
+ */
2773
+ syncStudioSlidersFromState(): void {
2774
+ const state = this.viewer.state;
2775
+ this.studioEnvIntensitySlider?.setValueFromState(state.get("studioEnvIntensity") * 100);
2776
+ this.studioExposureSlider?.setValueFromState(state.get("studioExposure") * 100);
2777
+ this.studioEnvRotationSlider?.setValueFromState(state.get("studioEnvRotation"));
2778
+ this.studioShadowIntensitySlider?.setValueFromState(state.get("studioShadowIntensity") * 100);
2779
+ this.studioShadowSoftnessSlider?.setValueFromState(state.get("studioShadowSoftness") * 100);
2780
+ this.studioAOIntensitySlider?.setValueFromState(state.get("studioAOIntensity") * 10);
2781
+ this.getInputElement("tcv_studio_4k_env_maps").checked = state.get("studio4kEnvMaps");
2782
+ this._syncEnvDropdown(state.get("studioEnvironment"));
2783
+ const bgEl = this.container.querySelector(".tcv_studio_background");
2784
+ if (bgEl instanceof HTMLSelectElement) bgEl.value = state.get("studioBackground");
2785
+ const tmEl = this.container.querySelector(".tcv_studio_tone_mapping");
2786
+ if (tmEl instanceof HTMLSelectElement) tmEl.value = state.get("studioToneMapping");
2787
+ const txmEl = this.container.querySelector(".tcv_studio_texture_mapping");
2788
+ if (txmEl instanceof HTMLSelectElement) txmEl.value = state.get("studioTextureMapping");
2789
+ this._update4kCheckboxEnabled(state.get("studioEnvironment"));
2790
+ }
2791
+
2792
+ /**
2793
+ * Ensure the environment dropdown can display a custom HDR URL.
2794
+ * If envName isn't already an option, adds a "Custom HDR" entry.
2795
+ * Removes stale custom entries when switching back to a built-in preset.
2796
+ */
2797
+ private _syncEnvDropdown(envName: string): void {
2798
+ const el = this.container.querySelector(".tcv_studio_environment");
2799
+ if (!(el instanceof HTMLSelectElement)) return;
2800
+
2801
+ // Check if the value matches a built-in option
2802
+ const isBuiltin = Array.from(el.options).some(
2803
+ (opt) => !opt.hasAttribute("data-custom") && opt.value === envName,
2804
+ );
2805
+
2806
+ if (isBuiltin) {
2807
+ el.value = envName;
2808
+ } else {
2809
+ // Add or update a "Custom" optgroup with the custom HDR entry
2810
+ const label = envName.split("/").pop()?.replace(/\.hdr$/i, "") || "Custom HDR";
2811
+ let customGroup = el.querySelector("optgroup[data-custom]") as HTMLOptGroupElement | null;
2812
+ if (customGroup) {
2813
+ const opt = customGroup.querySelector("option")!;
2814
+ opt.value = envName;
2815
+ opt.textContent = label;
2816
+ } else {
2817
+ customGroup = document.createElement("optgroup");
2818
+ customGroup.label = "Custom";
2819
+ customGroup.setAttribute("data-custom", "true");
2820
+ const opt = document.createElement("option");
2821
+ opt.value = envName;
2822
+ opt.textContent = label;
2823
+ customGroup.appendChild(opt);
2824
+ el.appendChild(customGroup);
2825
+ }
2826
+ el.value = envName;
2827
+ }
2828
+ }
2829
+
2830
+ /**
2831
+ * Enable/disable the 4K checkbox based on whether the current environment
2832
+ * is a Poly Haven preset (resolution-switchable) or a custom URL / "studio".
2833
+ */
2834
+ private _update4kCheckboxEnabled(envName: string): void {
2835
+ const cb = this.container.querySelector(".tcv_studio_4k_env_maps") as HTMLInputElement | null;
2836
+ if (!cb) return;
2837
+ const isPreset = this.viewer.envManager.isPreset(envName);
2838
+ cb.disabled = !isPreset;
2839
+ if (!isPreset) {
2840
+ cb.title = "4K switching is only available for built-in Poly Haven presets";
2841
+ } else {
2842
+ cb.title = "";
2843
+ }
2844
+ }
2845
+
1889
2846
  /**
1890
2847
  * Refresh clipping plane position
1891
2848
  */
@@ -1945,6 +2902,16 @@ class Display {
1945
2902
  private _handleKeyboardShortcut = (e: Event): void => {
1946
2903
  if (!(e instanceof KeyboardEvent)) return;
1947
2904
 
2905
+ // ESC closes the material editor (works regardless of focus target)
2906
+ if (e.key === "Escape") {
2907
+ const matDialog = this.container.querySelector(".tcv_mat_editor") as HTMLElement;
2908
+ if (matDialog && matDialog.style.display !== "none") {
2909
+ e.preventDefault();
2910
+ this.closeMatEditor();
2911
+ return;
2912
+ }
2913
+ }
2914
+
1948
2915
  // Skip if modifier keys are held (avoid conflicts with modifier-based mouse actions)
1949
2916
  if (e.ctrlKey || e.altKey || e.metaKey) return;
1950
2917
 
@@ -2046,8 +3013,9 @@ class Display {
2046
3013
  }
2047
3014
  case "tree":
2048
3015
  case "clip":
2049
- case "material":
2050
3016
  case "zebra":
3017
+ case "material":
3018
+ case "studio":
2051
3019
  this.viewer.setActiveTab(action);
2052
3020
  return;
2053
3021
  }
@@ -2091,8 +3059,9 @@ class Display {
2091
3059
  const tabMap: Record<string, { el: HTMLElement | undefined; label: string }> = {
2092
3060
  tree: { el: this.tabTree, label: "Navigation Tree" },
2093
3061
  clip: { el: this.tabClip, label: "Clipping Tool" },
2094
- material: { el: this.tabMaterial, label: "Material Selection" },
2095
3062
  zebra: { el: this.tabZebra, label: "Zebra Tool" },
3063
+ material: { el: this.tabMaterial, label: "Material Selection" },
3064
+ studio: { el: this.tabStudio, label: "Studio Mode" },
2096
3065
  };
2097
3066
  for (const [action, key] of Object.entries(shortcuts)) {
2098
3067
  const entry = tabMap[action];
@@ -2164,6 +3133,38 @@ class Display {
2164
3133
  this.showInfo(!this.info_shown);
2165
3134
  };
2166
3135
 
3136
+ /**
3137
+ * Show or hide tools panel (tabs + content) in glass mode.
3138
+ * Also toggles the orientation marker and animation/explode slider.
3139
+ */
3140
+ showToolsPanel = (flag: boolean): void => {
3141
+ const cadTree = this.getElement("tcv_cad_tree");
3142
+ cadTree.style.display = flag ? "" : "none";
3143
+ this.getElement("tcv_toggle_tools").innerHTML = flag ? "\u25BE" : "\u25B8";
3144
+ this.tools_shown = flag;
3145
+
3146
+ // Toggle orientation marker
3147
+ if (this.viewer.ready) {
3148
+ this.viewer.rendered.orientationMarker.setVisible(flag);
3149
+ this.viewer.update(true, false);
3150
+ }
3151
+
3152
+ // Toggle animation/explode slider (only if it was visible before hiding)
3153
+ if (!flag) {
3154
+ this._animWasVisible = this.cadAnim.style.display !== "none";
3155
+ this.cadAnim.style.display = "none";
3156
+ } else if (this._animWasVisible) {
3157
+ this.cadAnim.style.display = "block";
3158
+ }
3159
+ };
3160
+
3161
+ /**
3162
+ * Toggle tools panel visibility
3163
+ */
3164
+ toggleToolsPanel = (): void => {
3165
+ this.showToolsPanel(!this.tools_shown);
3166
+ };
3167
+
2167
3168
  // ---------------------------------------------------------------------------
2168
3169
  // Theme & Glass Mode
2169
3170
  // ---------------------------------------------------------------------------
@@ -2197,8 +3198,10 @@ class Display {
2197
3198
  this.getElement("tcv_cad_view").classList.add("tcv_cad_view_glass");
2198
3199
 
2199
3200
  this.getElement("tcv_toggle_info_wrapper").style.display = "block";
3201
+ this.getElement("tcv_toggle_tools_wrapper").style.display = "block";
2200
3202
 
2201
3203
  this.showInfo(false);
3204
+ this.showToolsPanel(true);
2202
3205
  this.glass = true;
2203
3206
  this.autoCollapse();
2204
3207
  } else {
@@ -2209,8 +3212,10 @@ class Display {
2209
3212
  this.getElement("tcv_cad_view").classList.remove("tcv_cad_view_glass");
2210
3213
 
2211
3214
  this.getElement("tcv_toggle_info_wrapper").style.display = "none";
3215
+ this.getElement("tcv_toggle_tools_wrapper").style.display = "none";
2212
3216
 
2213
3217
  this.showInfo(true);
3218
+ this.showToolsPanel(true);
2214
3219
  this.glass = false;
2215
3220
  }
2216
3221
  const options: SizeOptions = {