three-cad-viewer 4.3.8 → 4.3.9

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.
@@ -81590,9 +81590,13 @@ function toQuaternionTuple(arr) {
81590
81590
  }
81591
81591
  function isEqual(obj1, obj2, tol = 1e-9) {
81592
81592
  if (Array.isArray(obj1) && Array.isArray(obj2)) {
81593
- return (obj1.length === obj2.length && obj1.every((v, i) => isEqual(v, obj2[i], tol)));
81593
+ return (obj1.length === obj2.length &&
81594
+ obj1.every((v, i) => isEqual(v, obj2[i], tol)));
81594
81595
  }
81595
- else if (obj1 !== null && obj2 !== null && typeof obj1 === "object" && typeof obj2 === "object") {
81596
+ else if (obj1 !== null &&
81597
+ obj2 !== null &&
81598
+ typeof obj1 === "object" &&
81599
+ typeof obj2 === "object") {
81596
81600
  const rec1 = obj1;
81597
81601
  const rec2 = obj2;
81598
81602
  const keys1 = Object.keys(rec1);
@@ -81627,7 +81631,10 @@ function disposeGeometry(geometry) {
81627
81631
  gpuTracker.untrack("geometry", geometry);
81628
81632
  geometry.dispose();
81629
81633
  for (const attr of Object.values(geometry.attributes)) {
81630
- if (attr && typeof attr === "object" && "dispose" in attr && typeof attr.dispose === "function") {
81634
+ if (attr &&
81635
+ typeof attr === "object" &&
81636
+ "dispose" in attr &&
81637
+ typeof attr.dispose === "function") {
81631
81638
  attr.dispose();
81632
81639
  }
81633
81640
  }
@@ -81636,12 +81643,25 @@ function disposeGeometry(geometry) {
81636
81643
  /** All texture map property names on MaterialLike (for iteration) */
81637
81644
  const MATERIAL_TEXTURE_KEYS = [
81638
81645
  // MeshStandardMaterial
81639
- "map", "normalMap", "roughnessMap", "metalnessMap",
81640
- "aoMap", "emissiveMap", "alphaMap", "bumpMap",
81646
+ "map",
81647
+ "normalMap",
81648
+ "roughnessMap",
81649
+ "metalnessMap",
81650
+ "aoMap",
81651
+ "emissiveMap",
81652
+ "alphaMap",
81653
+ "bumpMap",
81641
81654
  // MeshPhysicalMaterial
81642
- "transmissionMap", "clearcoatMap", "clearcoatRoughnessMap", "clearcoatNormalMap",
81643
- "thicknessMap", "specularIntensityMap", "specularColorMap",
81644
- "sheenColorMap", "sheenRoughnessMap", "anisotropyMap",
81655
+ "transmissionMap",
81656
+ "clearcoatMap",
81657
+ "clearcoatRoughnessMap",
81658
+ "clearcoatNormalMap",
81659
+ "thicknessMap",
81660
+ "specularIntensityMap",
81661
+ "specularColorMap",
81662
+ "sheenColorMap",
81663
+ "sheenRoughnessMap",
81664
+ "anisotropyMap",
81645
81665
  ];
81646
81666
  /**
81647
81667
  * Dispose a material and detach its texture references.
@@ -81770,14 +81790,16 @@ function isPoints(obj) {
81770
81790
  * Accepts Object3D to allow use in controls where camera type is broader.
81771
81791
  */
81772
81792
  function isOrthographicCamera(obj) {
81773
- return "isOrthographicCamera" in obj && obj.isOrthographicCamera === true;
81793
+ return ("isOrthographicCamera" in obj &&
81794
+ obj.isOrthographicCamera === true);
81774
81795
  }
81775
81796
  /**
81776
81797
  * Type guard to check if an object is a PerspectiveCamera.
81777
81798
  * Accepts Object3D to allow use in controls where camera type is broader.
81778
81799
  */
81779
81800
  function isPerspectiveCamera(obj) {
81780
- return "isPerspectiveCamera" in obj && obj.isPerspectiveCamera === true;
81801
+ return ("isPerspectiveCamera" in obj &&
81802
+ obj.isPerspectiveCamera === true);
81781
81803
  }
81782
81804
  /**
81783
81805
  * Type guard to check if an Object3D is a LineSegments2 (fat line).
@@ -81795,7 +81817,8 @@ function hasColor(material) {
81795
81817
  * Type guard to check if a material is a MeshStandardMaterial.
81796
81818
  */
81797
81819
  function isMeshStandardMaterial(material) {
81798
- return "isMeshStandardMaterial" in material && material.isMeshStandardMaterial === true;
81820
+ return ("isMeshStandardMaterial" in material &&
81821
+ material.isMeshStandardMaterial === true);
81799
81822
  }
81800
81823
  const KeyMapper = new _KeyMapper();
81801
81824
  class EventListenerManager {
@@ -82021,7 +82044,9 @@ class ZebraTool {
82021
82044
  if (mesh.userData.excludeFromZebra)
82022
82045
  return;
82023
82046
  // Store original material (handle array case by taking first)
82024
- const currentMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
82047
+ const currentMaterial = Array.isArray(mesh.material)
82048
+ ? mesh.material[0]
82049
+ : mesh.material;
82025
82050
  if (!this.originalMaterials.has(mesh.uuid)) {
82026
82051
  this.originalMaterials.set(mesh.uuid, currentMaterial);
82027
82052
  }
@@ -82033,7 +82058,8 @@ class ZebraTool {
82033
82058
  if (hasColor(currentMaterial)) {
82034
82059
  baseColor = currentMaterial.color.clone();
82035
82060
  }
82036
- else if (isMeshStandardMaterial(currentMaterial) && currentMaterial.map) {
82061
+ else if (isMeshStandardMaterial(currentMaterial) &&
82062
+ currentMaterial.map) {
82037
82063
  // If there's a texture but no color, use white as base
82038
82064
  baseColor = new Color(1, 1, 1);
82039
82065
  }
@@ -82386,7 +82412,8 @@ class ObjectGroup extends Group {
82386
82412
  * Skips MeshBasicMaterial and other non-PBR materials.
82387
82413
  */
82388
82414
  _forEachStandardMaterial(callback) {
82389
- if (this.front && this.front.material instanceof MeshStandardMaterial) {
82415
+ if (this.front &&
82416
+ this.front.material instanceof MeshStandardMaterial) {
82390
82417
  callback(this.front.material);
82391
82418
  }
82392
82419
  // back can also be MeshStandardMaterial (e.g., for polygon rendering)
@@ -82401,9 +82428,7 @@ class ObjectGroup extends Group {
82401
82428
  highlight(flag) {
82402
82429
  const hColor = this._getHighlightColor();
82403
82430
  // Find primary material (front face, vertices, or edges)
82404
- const primaryMaterial = this.front?.material ||
82405
- this.vertices?.material ||
82406
- this.edgeMaterial;
82431
+ const primaryMaterial = this.front?.material || this.vertices?.material || this.edgeMaterial;
82407
82432
  if (primaryMaterial) {
82408
82433
  this.widen(flag);
82409
82434
  this._applyColorToMaterial(primaryMaterial, flag ? hColor : this.originalColor);
@@ -82549,12 +82574,29 @@ class ObjectGroup extends Group {
82549
82574
  child1.material.visible = flag;
82550
82575
  }
82551
82576
  }
82552
- if (this.back && this.renderback) {
82553
- if (this._isStudioMode) {
82554
- this.back.visible = flag;
82577
+ if (this.back) {
82578
+ // Hide-path: always hide the back when the shape is hidden, even
82579
+ // when !renderback. Otherwise a back face left visible by a prior
82580
+ // setBackVisible(true) (clip tab) would remain visible after the
82581
+ // front goes hidden, appearing as a ghost.
82582
+ // Show-path: only flip back to visible when renderback is set;
82583
+ // when !renderback, leave back.visible alone — the clip-tab's
82584
+ // setBackVisible() owns it.
82585
+ if (!flag) {
82586
+ if (this._isStudioMode) {
82587
+ this.back.visible = false;
82588
+ }
82589
+ else {
82590
+ this.back.material.visible = false;
82591
+ }
82555
82592
  }
82556
- else {
82557
- this.back.material.visible = flag;
82593
+ else if (this.renderback) {
82594
+ if (this._isStudioMode) {
82595
+ this.back.visible = true;
82596
+ }
82597
+ else {
82598
+ this.back.material.visible = true;
82599
+ }
82558
82600
  }
82559
82601
  }
82560
82602
  }
@@ -82768,10 +82810,16 @@ class ObjectGroup extends Group {
82768
82810
  this._cadBackMaterial = this.back.material;
82769
82811
  }
82770
82812
  // Save original colors used by highlight/unhighlight
82771
- this._cadOriginalColor = this.originalColor ? this.originalColor.clone() : null;
82772
- this._cadOriginalBackColor = this.originalBackColor ? this.originalBackColor.clone() : null;
82813
+ this._cadOriginalColor = this.originalColor
82814
+ ? this.originalColor.clone()
82815
+ : null;
82816
+ this._cadOriginalBackColor = this.originalBackColor
82817
+ ? this.originalBackColor.clone()
82818
+ : null;
82773
82819
  // Save edge visibility state
82774
- this._cadEdgesVisible = this.edgeMaterial ? this.edgeMaterial.visible : null;
82820
+ this._cadEdgesVisible = this.edgeMaterial
82821
+ ? this.edgeMaterial.visible
82822
+ : null;
82775
82823
  // --- Swap front material ---
82776
82824
  if (this.front && studioFront) {
82777
82825
  // Transfer per-object visibility to mesh.visible (NOT material.visible)
@@ -83288,12 +83336,18 @@ class TextureCache {
83288
83336
  * @returns THREE.SRGBColorSpace or THREE.LinearSRGBColorSpace
83289
83337
  */
83290
83338
  function getColorSpaceForMap(mapName) {
83291
- return THREEJS_SRGB_MAPS.has(mapName) ? SRGBColorSpace : LinearSRGBColorSpace;
83339
+ return THREEJS_SRGB_MAPS.has(mapName)
83340
+ ? SRGBColorSpace
83341
+ : LinearSRGBColorSpace;
83292
83342
  }
83293
83343
 
83294
83344
  /** threejs-materials property keys that hold [r,g,b] color arrays (linear RGB). */
83295
83345
  const COLOR_ARRAY_KEYS = new Set([
83296
- "color", "specularColor", "sheenColor", "emissive", "attenuationColor",
83346
+ "color",
83347
+ "specularColor",
83348
+ "sheenColor",
83349
+ "emissive",
83350
+ "attenuationColor",
83297
83351
  ]);
83298
83352
  /** Map from threejs-materials property names to Three.js texture map property names. */
83299
83353
  const PROPERTY_TO_MAP = {
@@ -83368,7 +83422,7 @@ class MaterialFactory {
83368
83422
  * Create a standard material for back faces with PBR properties.
83369
83423
  * Used for polygon rendering where back faces need full shading.
83370
83424
  */
83371
- createBackFaceStandardMaterial({ color, alpha, polygonOffsetUnits = 2.0, visible = true }, label) {
83425
+ createBackFaceStandardMaterial({ color, alpha, polygonOffsetUnits = 2.0, visible = true, }, label) {
83372
83426
  const material = new MeshStandardMaterial({
83373
83427
  ...this._createBaseProps(alpha),
83374
83428
  color: color,
@@ -83386,7 +83440,7 @@ class MaterialFactory {
83386
83440
  * Create a basic material for back faces (no lighting/PBR).
83387
83441
  * Used for shape rendering where back faces are simple fills.
83388
83442
  */
83389
- createBackFaceBasicMaterial({ color, alpha, polygonOffsetUnits = 2.0, visible = true }, label) {
83443
+ createBackFaceBasicMaterial({ color, alpha, polygonOffsetUnits = 2.0, visible = true, }, label) {
83390
83444
  const material = new MeshBasicMaterial({
83391
83445
  ...this._createBaseProps(alpha),
83392
83446
  color: color,
@@ -83413,7 +83467,7 @@ class MaterialFactory {
83413
83467
  /**
83414
83468
  * Create a fat line material (LineMaterial from Three.js examples).
83415
83469
  */
83416
- createEdgeMaterial({ lineWidth, color, vertexColors = false, visible = true, resolution }, label) {
83470
+ createEdgeMaterial({ lineWidth, color, vertexColors = false, visible = true, resolution, }, label) {
83417
83471
  const material = new LineMaterial({
83418
83472
  linewidth: lineWidth,
83419
83473
  transparent: true,
@@ -83487,7 +83541,7 @@ class MaterialFactory {
83487
83541
  * @param label - Optional label for GPU tracking
83488
83542
  * @returns Configured MeshPhysicalMaterial (or MeshBasicMaterial if unlit)
83489
83543
  */
83490
- async createStudioMaterial({ materialDef, fallbackColor, fallbackAlpha, textureCache }, label) {
83544
+ async createStudioMaterial({ materialDef, fallbackColor, fallbackAlpha, textureCache, }, label) {
83491
83545
  const def = materialDef;
83492
83546
  const side = def.doubleSided ? DoubleSide : FrontSide;
83493
83547
  // --- Resolve base color and opacity ---
@@ -83635,11 +83689,13 @@ class MaterialFactory {
83635
83689
  * @param values - Scalar PBR values from threejs-materials
83636
83690
  * @param textures - Texture map references from threejs-materials
83637
83691
  * @param textureRepeat - Optional [u, v] texture tiling applied to all loaded textures
83692
+ * @param textureRotation - Optional texture rotation in radians (counterclockwise),
83693
+ * pivoting around the texture center (0.5, 0.5)
83638
83694
  * @param textureCache - TextureCache for resolving data URI textures
83639
83695
  * @param label - Optional label for GPU tracking
83640
83696
  * @returns Configured MeshPhysicalMaterial
83641
83697
  */
83642
- async createStudioMaterialFromMaterialX(values, textures, textureRepeat, textureCache, label) {
83698
+ async createStudioMaterialFromMaterialX(values, textures, textureRepeat, textureRotation, textureCache, label) {
83643
83699
  // --- Build material options from scalar values ---
83644
83700
  const matOptions = {
83645
83701
  flatShading: false,
@@ -83655,14 +83711,17 @@ class MaterialFactory {
83655
83711
  }
83656
83712
  for (const [key, value] of Object.entries(values)) {
83657
83713
  // Skip displacement properties (not supported, would waste GPU memory)
83658
- if (key === "displacement" || key === "displacementScale" || key === "displacementBias")
83714
+ if (key === "displacement" ||
83715
+ key === "displacementScale" ||
83716
+ key === "displacementBias")
83659
83717
  continue;
83660
83718
  // Color arrays → THREE.Color (already linear, no sRGB conversion)
83661
83719
  if (COLOR_ARRAY_KEYS.has(key) && Array.isArray(value)) {
83662
83720
  const [r, g, b] = value;
83663
83721
  matOptions[key] = new Color(r, g, b);
83664
83722
  }
83665
- else if ((key === "normalScale" || key === "clearcoatNormalScale") && Array.isArray(value)) {
83723
+ else if ((key === "normalScale" || key === "clearcoatNormalScale") &&
83724
+ Array.isArray(value)) {
83666
83725
  matOptions[key] = new Vector2(value[0], value[1]);
83667
83726
  }
83668
83727
  else if (key === "iridescenceThicknessRange" && Array.isArray(value)) {
@@ -83681,7 +83740,8 @@ class MaterialFactory {
83681
83740
  matOptions.opacity = 1.0;
83682
83741
  matOptions.depthWrite = true;
83683
83742
  }
83684
- else if (transparentVal === true || (typeof opacityVal === "number" && opacityVal < 1.0)) {
83743
+ else if (transparentVal === true ||
83744
+ (typeof opacityVal === "number" && opacityVal < 1.0)) {
83685
83745
  matOptions.transparent = true;
83686
83746
  matOptions.depthWrite = false;
83687
83747
  }
@@ -83706,10 +83766,19 @@ class MaterialFactory {
83706
83766
  : "normalTexture";
83707
83767
  let tex = await textureCache.get(textureRef, roleForCache);
83708
83768
  if (tex) {
83709
- if (textureRepeat && (textureRepeat[0] !== 1 || textureRepeat[1] !== 1)) {
83769
+ const needsRepeat = textureRepeat &&
83770
+ (textureRepeat[0] !== 1 || textureRepeat[1] !== 1);
83771
+ const needsRotation = !!textureRotation;
83772
+ if (needsRepeat || needsRotation) {
83710
83773
  // Clone to avoid mutating shared cached texture
83711
83774
  tex = tex.clone();
83712
- tex.repeat.set(textureRepeat[0], textureRepeat[1]);
83775
+ if (needsRepeat) {
83776
+ tex.repeat.set(textureRepeat[0], textureRepeat[1]);
83777
+ }
83778
+ if (needsRotation) {
83779
+ tex.center.set(0.5, 0.5); // pivot around texture center
83780
+ tex.rotation = textureRotation;
83781
+ }
83713
83782
  }
83714
83783
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
83715
83784
  material[mapName] = tex;
@@ -83921,7 +83990,7 @@ const MATERIAL_PRESETS = {
83921
83990
  // ---------------------------------------------------------------------------
83922
83991
  // Metals -- Polished
83923
83992
  // ---------------------------------------------------------------------------
83924
- "chrome": {
83993
+ chrome: {
83925
83994
  name: "Chrome",
83926
83995
  color: [0.98, 0.98, 0.98, 1],
83927
83996
  metalness: 1.0,
@@ -83939,19 +84008,19 @@ const MATERIAL_PRESETS = {
83939
84008
  metalness: 1.0,
83940
84009
  roughness: 0.1,
83941
84010
  },
83942
- "gold": {
84011
+ gold: {
83943
84012
  name: "Gold",
83944
84013
  color: [1, 0.93, 0, 1],
83945
84014
  metalness: 1.0,
83946
84015
  roughness: 0.1,
83947
84016
  },
83948
- "copper": {
84017
+ copper: {
83949
84018
  name: "Copper",
83950
84019
  color: [0.98, 0.82, 0.76, 1],
83951
84020
  metalness: 1.0,
83952
84021
  roughness: 0.15,
83953
84022
  },
83954
- "brass": {
84023
+ brass: {
83955
84024
  name: "Brass",
83956
84025
  color: [0.95, 0.9, 0.7, 1],
83957
84026
  metalness: 1.0,
@@ -83979,13 +84048,13 @@ const MATERIAL_PRESETS = {
83979
84048
  metalness: 0.9,
83980
84049
  roughness: 0.7,
83981
84050
  },
83982
- "titanium": {
84051
+ titanium: {
83983
84052
  name: "Titanium",
83984
84053
  color: [0.81, 0.79, 0.77, 1],
83985
84054
  metalness: 1.0,
83986
84055
  roughness: 0.45,
83987
84056
  },
83988
- "galvanized": {
84057
+ galvanized: {
83989
84058
  name: "Galvanized",
83990
84059
  color: [0.88, 0.88, 0.9, 1],
83991
84060
  metalness: 0.8,
@@ -84012,7 +84081,7 @@ const MATERIAL_PRESETS = {
84012
84081
  metalness: 0.0,
84013
84082
  roughness: 0.4,
84014
84083
  },
84015
- "nylon": {
84084
+ nylon: {
84016
84085
  name: "Nylon",
84017
84086
  color: [0.95, 0.94, 0.92, 1],
84018
84087
  metalness: 0.0,
@@ -84122,7 +84191,7 @@ const MATERIAL_PRESETS = {
84122
84191
  roughness: 0.35,
84123
84192
  anisotropy: 0.3,
84124
84193
  },
84125
- "concrete": {
84194
+ concrete: {
84126
84195
  name: "Concrete",
84127
84196
  color: [0.83, 0.82, 0.8, 1],
84128
84197
  metalness: 0.0,
@@ -84175,6 +84244,7 @@ varying vec3 vTriplanarNormal;
84175
84244
  uniform vec3 triplanarOffset;
84176
84245
  uniform float triplanarScale;
84177
84246
  uniform vec2 triplanarRepeat;
84247
+ uniform float triplanarRotation;
84178
84248
 
84179
84249
  // normalMatrix is only declared in the fragment shader for object-space
84180
84250
  // normal maps. We need it for triplanar tangent-space normal mapping too.
@@ -84197,6 +84267,20 @@ void initTriplanarUVs() {
84197
84267
  tri_uvX = p.yz * r;
84198
84268
  tri_uvY = p.xz * r;
84199
84269
  tri_uvZ = p.xy * r;
84270
+
84271
+ // Apply rotation (counterclockwise, radians) if non-zero. The texture
84272
+ // coords are unbounded in triplanar (derived from world-space position),
84273
+ // so the pivot for rotation is the UV origin — for a tiled/repeating
84274
+ // texture this is visually equivalent to any other pivot modulo tile
84275
+ // wrapping, and is the simplest choice.
84276
+ if (triplanarRotation != 0.0) {
84277
+ float c = cos(triplanarRotation);
84278
+ float s = sin(triplanarRotation);
84279
+ mat2 rot = mat2(c, -s, s, c);
84280
+ tri_uvX = rot * tri_uvX;
84281
+ tri_uvY = rot * tri_uvY;
84282
+ tri_uvZ = rot * tri_uvZ;
84283
+ }
84200
84284
  }
84201
84285
 
84202
84286
  // Sample a texture using the global triplanar UVs and blend weights.
@@ -84397,14 +84481,24 @@ function applyTriplanarMapping(material, geometry) {
84397
84481
  // Uniform values (captured by closure, per-object)
84398
84482
  const offset = bb.min.clone();
84399
84483
  const scale = 1.0 / maxDim;
84400
- // Read texture repeat from the first available texture map
84401
- const repeat = (material.map ?? material.roughnessMap ?? material.normalMap ??
84402
- material.metalnessMap ?? material.emissiveMap ?? material.aoMap)?.repeat?.clone() ?? new Vector2(1, 1);
84484
+ // Read texture repeat and rotation from the first available texture map.
84485
+ // texture.rotation is set by the MaterialX path when textureRotation is
84486
+ // provided; for triplanar sampling we read it back and apply via uniform
84487
+ // since the triplanar shader bypasses three.js's texture-matrix transform.
84488
+ const firstMap = material.map ??
84489
+ material.roughnessMap ??
84490
+ material.normalMap ??
84491
+ material.metalnessMap ??
84492
+ material.emissiveMap ??
84493
+ material.aoMap;
84494
+ const repeat = firstMap?.repeat?.clone() ?? new Vector2(1, 1);
84495
+ const rotation = firstMap?.rotation ?? 0;
84403
84496
  material.onBeforeCompile = (shader) => {
84404
84497
  // Custom uniforms
84405
84498
  shader.uniforms.triplanarOffset = { value: offset };
84406
84499
  shader.uniforms.triplanarScale = { value: scale };
84407
84500
  shader.uniforms.triplanarRepeat = { value: repeat };
84501
+ shader.uniforms.triplanarRotation = { value: rotation };
84408
84502
  // --- Vertex shader ---
84409
84503
  // Declare varyings
84410
84504
  shader.vertexShader = shader.vertexShader.replace("#include <common>", `#include <common>\n${TRIPLANAR_VARYINGS}`);
@@ -84475,11 +84569,22 @@ class CompoundGroup extends Group {
84475
84569
  */
84476
84570
  /** Texture field names on MaterialAppearance that require UV coordinates. */
84477
84571
  const TEXTURE_FIELDS = [
84478
- "map", "normalMap", "aoMap",
84479
- "metalnessMap", "roughnessMap", "emissiveMap", "transmissionMap",
84480
- "clearcoatMap", "clearcoatRoughnessMap", "clearcoatNormalMap",
84481
- "thicknessMap", "specularIntensityMap", "specularColorMap",
84482
- "sheenColorMap", "sheenRoughnessMap", "anisotropyMap",
84572
+ "map",
84573
+ "normalMap",
84574
+ "aoMap",
84575
+ "metalnessMap",
84576
+ "roughnessMap",
84577
+ "emissiveMap",
84578
+ "transmissionMap",
84579
+ "clearcoatMap",
84580
+ "clearcoatRoughnessMap",
84581
+ "clearcoatNormalMap",
84582
+ "thicknessMap",
84583
+ "specularIntensityMap",
84584
+ "specularColorMap",
84585
+ "sheenColorMap",
84586
+ "sheenRoughnessMap",
84587
+ "anisotropyMap",
84483
84588
  ];
84484
84589
  /** Check whether a resolved MaterialAppearance references any texture. */
84485
84590
  function materialHasTexture(def) {
@@ -85211,7 +85316,7 @@ class NestedGroup {
85211
85316
  * 3. Clone BackSide variant for renderback objects
85212
85317
  * 4. Auto-generate box-projected UVs when textured but geometry has no UVs
85213
85318
  */
85214
- async enterStudioMode(textureMapping = "triplanar") {
85319
+ async enterStudioMode(textureMapping = "parametric") {
85215
85320
  // Create TextureCache lazily
85216
85321
  if (!this._textureCache) {
85217
85322
  this._textureCache = new TextureCache();
@@ -85245,7 +85350,8 @@ class NestedGroup {
85245
85350
  try {
85246
85351
  if (resolved && isMaterialXMaterial(resolved)) {
85247
85352
  // --- threejs-materials path ---
85248
- studioMaterial = await this.materialFactory.createStudioMaterialFromMaterialX(resolved.values, resolved.textures, resolved.textureRepeat, this._textureCache);
85353
+ studioMaterial =
85354
+ await this.materialFactory.createStudioMaterialFromMaterialX(resolved.values, resolved.textures, resolved.textureRepeat, resolved.textureRotation, this._textureCache);
85249
85355
  if (materialXHasTextures(resolved)) {
85250
85356
  this._texturedMaterialKeys.add(sharingKey);
85251
85357
  }
@@ -85295,7 +85401,8 @@ class NestedGroup {
85295
85401
  if (textured) {
85296
85402
  logger.debug(`Studio "${path}": ${needsTriplanar ? "using triplanar" : "using parametric UVs"}`);
85297
85403
  }
85298
- if (needsTriplanar && studioMaterial instanceof MeshPhysicalMaterial) {
85404
+ if (needsTriplanar &&
85405
+ studioMaterial instanceof MeshPhysicalMaterial) {
85299
85406
  const triKey = `${sharingKey}:tri:${path}`;
85300
85407
  let triMat = this._studioMaterialCache.get(triKey);
85301
85408
  if (!triMat) {
@@ -85307,7 +85414,8 @@ class NestedGroup {
85307
85414
  }
85308
85415
  // Build back-face variant if needed
85309
85416
  let studioBack = null;
85310
- if (obj.renderback && studioMaterial instanceof MeshPhysicalMaterial) {
85417
+ if (obj.renderback &&
85418
+ studioMaterial instanceof MeshPhysicalMaterial) {
85311
85419
  const backKey = needsTriplanar
85312
85420
  ? `${sharingKey}:tri:${path}:back`
85313
85421
  : `${sharingKey}:back`;
@@ -85335,7 +85443,9 @@ class NestedGroup {
85335
85443
  }
85336
85444
  }
85337
85445
  // Apply to ObjectGroup
85338
- obj.enterStudioMode(studioMaterial instanceof MeshPhysicalMaterial ? studioMaterial : null, studioBack);
85446
+ obj.enterStudioMode(studioMaterial instanceof MeshPhysicalMaterial
85447
+ ? studioMaterial
85448
+ : null, studioBack);
85339
85449
  }
85340
85450
  this._isStudioMode = true;
85341
85451
  return [...unresolvedTags];
@@ -86598,7 +86708,10 @@ class TreeModel {
86598
86708
  */
86599
86709
  _buildTreeStructure(data) {
86600
86710
  const build = (data, path, level) => {
86601
- const result = [States.unselected, States.unselected];
86711
+ const result = [
86712
+ States.unselected,
86713
+ States.unselected,
86714
+ ];
86602
86715
  const calcState = (states) => {
86603
86716
  for (const s of [0, 1]) {
86604
86717
  if (states[States.mixed][s] ||
@@ -87087,7 +87200,8 @@ class TreeView {
87087
87200
  this.update = (prefix = null) => {
87088
87201
  if (!this.container || !this.model)
87089
87202
  return;
87090
- const visibleElements = this.getVisibleElements().filter((p) => p instanceof HTMLElement && (prefix == null || (p.dataset.path || "").startsWith(prefix)));
87203
+ const visibleElements = this.getVisibleElements().filter((p) => p instanceof HTMLElement &&
87204
+ (prefix == null || (p.dataset.path || "").startsWith(prefix)));
87091
87205
  for (const el of visibleElements) {
87092
87206
  const path = el.dataset.path || "";
87093
87207
  const node = this.findNodeByPath(path);
@@ -87723,7 +87837,8 @@ class TreeView {
87723
87837
  this.showChildContainer(node);
87724
87838
  const el = this.getDomNode(path);
87725
87839
  if (el != null) {
87726
- this.scrollContainer.scrollTop = el.offsetTop - this.scrollContainer.offsetTop;
87840
+ this.scrollContainer.scrollTop =
87841
+ el.offsetTop - this.scrollContainer.offsetTop;
87727
87842
  }
87728
87843
  if (this.debug) {
87729
87844
  logger.debug("update => collapsePath");
@@ -87744,7 +87859,8 @@ class TreeView {
87744
87859
  this.model.setExpandedLevel(level);
87745
87860
  const el = this.getDomNode(this.getNodePath(this.root));
87746
87861
  if (el != null) {
87747
- this.scrollContainer.scrollTop = el.offsetTop - this.scrollContainer.offsetTop;
87862
+ this.scrollContainer.scrollTop =
87863
+ el.offsetTop - this.scrollContainer.offsetTop;
87748
87864
  }
87749
87865
  // Multiple updates to ensure all levels are rendered
87750
87866
  const maxIterations = level === -1 ? this.maxLevel : level;
@@ -87977,7 +88093,9 @@ class CenteredPlane extends Plane {
87977
88093
  */
87978
88094
  // @ts-expect-error -- THREE.Plane.clone() returns `this`, but we need a concrete CenteredPlane
87979
88095
  clone() {
87980
- return new CenteredPlane(this.normal.clone(), this.centeredConstant, [...this.center]);
88096
+ return new CenteredPlane(this.normal.clone(), this.centeredConstant, [
88097
+ ...this.center,
88098
+ ]);
87981
88099
  }
87982
88100
  }
87983
88101
  // ============================================================================
@@ -88187,7 +88305,9 @@ class Clipping extends Group {
88187
88305
  let j = 0;
88188
88306
  for (const path in this.nestedGroup.groups) {
88189
88307
  const group = this.nestedGroup.groups[path];
88190
- if (group instanceof ObjectGroup && group.subtype === "solid" && group.front) {
88308
+ if (group instanceof ObjectGroup &&
88309
+ group.subtype === "solid" &&
88310
+ group.front) {
88191
88311
  // Store color for each plane-solid combination (mirrors _planeMeshGroup order)
88192
88312
  const frontMesh = group.front;
88193
88313
  const material = frontMesh.material;
@@ -88707,7 +88827,8 @@ class ShapeRenderer {
88707
88827
  }
88708
88828
  else {
88709
88829
  // Non-binary format: nested number[][] arrays
88710
- if (!Array.isArray(shape.triangles) || !Array.isArray(shape.triangles[0])) {
88830
+ if (!Array.isArray(shape.triangles) ||
88831
+ !Array.isArray(shape.triangles[0])) {
88711
88832
  throw new Error("Expected nested array for triangles in non-binary format");
88712
88833
  }
88713
88834
  // After validation, we know shape.triangles is number[][] (TypeScript can't infer this)
@@ -88835,7 +88956,8 @@ class ShapeRenderer {
88835
88956
  else {
88836
88957
  // Non-binary format: nested number[][] arrays
88837
88958
  const edgesRaw = shape.edges;
88838
- if (!Array.isArray(edgesRaw) || (edgesRaw.length > 0 && !Array.isArray(edgesRaw[0]))) {
88959
+ if (!Array.isArray(edgesRaw) ||
88960
+ (edgesRaw.length > 0 && !Array.isArray(edgesRaw[0]))) {
88839
88961
  throw new Error("Expected nested array for edges in non-binary format");
88840
88962
  }
88841
88963
  // After validation, we know this is number[][] (TypeScript can't infer from the check)
@@ -92980,8 +93102,14 @@ const defaultDirections = {
92980
93102
  rear: { pos: new Vector3(0, 1, 0), quat: null },
92981
93103
  left: { pos: new Vector3(-1, 0, 0), quat: null },
92982
93104
  right: { pos: new Vector3(1, 0, 0), quat: null },
92983
- top: { pos: new Vector3(0, 0, 1), quat: new Quaternion(0, 0, 0, 1) },
92984
- bottom: { pos: new Vector3(0, 0, -1), quat: new Quaternion(1, 0, 0, 0) },
93105
+ top: {
93106
+ pos: new Vector3(0, 0, 1),
93107
+ quat: new Quaternion(0, 0, 0, 1),
93108
+ },
93109
+ bottom: {
93110
+ pos: new Vector3(0, 0, -1),
93111
+ quat: new Quaternion(1, 0, 0, 0),
93112
+ },
92985
93113
  },
92986
93114
  legacy: {
92987
93115
  // legacy Z up
@@ -93505,7 +93633,10 @@ class Raycaster {
93505
93633
  const object = obj.object;
93506
93634
  // Accept Mesh (faces), Points (vertices), and Line (edges)
93507
93635
  const isValidType = isMesh(object) || isPoints(object) || isLine(object);
93508
- if (isValidType && object.visible && !Array.isArray(object.material) && object.material.visible) {
93636
+ if (isValidType &&
93637
+ object.visible &&
93638
+ !Array.isArray(object.material) &&
93639
+ object.material.visible) {
93509
93640
  validObjs.push(obj);
93510
93641
  }
93511
93642
  }
@@ -93525,7 +93656,9 @@ class Raycaster {
93525
93656
  const isValidType = isMesh(obj) || isPoints(obj) || isLine(obj);
93526
93657
  if (!isValidType)
93527
93658
  continue;
93528
- if (!obj.visible || Array.isArray(obj.material) || !obj.material.visible)
93659
+ if (!obj.visible ||
93660
+ Array.isArray(obj.material) ||
93661
+ !obj.material.visible)
93529
93662
  continue;
93530
93663
  const objectGroup = object.object.parent;
93531
93664
  if (!isObjectGroup(objectGroup))
@@ -93554,7 +93687,9 @@ class Raycaster {
93554
93687
  * Type guard to check if a value is a number array of specific length.
93555
93688
  */
93556
93689
  function isNumberArray(value, length) {
93557
- return Array.isArray(value) && value.length === length && value.every((v) => typeof v === "number");
93690
+ return (Array.isArray(value) &&
93691
+ value.length === length &&
93692
+ value.every((v) => typeof v === "number"));
93558
93693
  }
93559
93694
  /**
93560
93695
  * Type guard to check if a value is a Record<string, number[]> (bounding box data).
@@ -93705,9 +93840,17 @@ class Panel {
93705
93840
  * Skip list for technical fields that should not be rendered in panels.
93706
93841
  */
93707
93842
  const SKIP_KEYS = [
93708
- "type", "tool_type", "subtype", "info",
93709
- "refpoint", "refpoint1", "refpoint2",
93710
- "shape_type", "geom_type", "groups", "result",
93843
+ "type",
93844
+ "tool_type",
93845
+ "subtype",
93846
+ "info",
93847
+ "refpoint",
93848
+ "refpoint1",
93849
+ "refpoint2",
93850
+ "shape_type",
93851
+ "geom_type",
93852
+ "groups",
93853
+ "result",
93711
93854
  ];
93712
93855
  /**
93713
93856
  * Render entries from a group object into a tbody.
@@ -94125,7 +94268,10 @@ class Measurement {
94125
94268
  e.stopPropagation();
94126
94269
  };
94127
94270
  this._movePanel = () => {
94128
- if (!this.panel || !this.viewer || !this.viewer.camera || !this.panel.isVisible())
94271
+ if (!this.panel ||
94272
+ !this.viewer ||
94273
+ !this.viewer.camera ||
94274
+ !this.panel.isVisible())
94129
94275
  return;
94130
94276
  const canvasRect = this.viewer.renderer.domElement.getBoundingClientRect();
94131
94277
  const panelRect = this.panel.html.getBoundingClientRect();
@@ -94325,8 +94471,15 @@ class Measurement {
94325
94471
  responseData = {
94326
94472
  groups: [
94327
94473
  { distance: 2.345, info: "center" },
94328
- { "point 1": this.point1.toArray(), "point 2": this.point2.toArray() },
94329
- { angle: 43.21, "reference 1": "Plane (Face)", "reference 2": "Plane (Face)" },
94474
+ {
94475
+ "point 1": this.point1.toArray(),
94476
+ "point 2": this.point2.toArray(),
94477
+ },
94478
+ {
94479
+ angle: 43.21,
94480
+ "reference 1": "Plane (Face)",
94481
+ "reference 2": "Plane (Face)",
94482
+ },
94330
94483
  ],
94331
94484
  type: "backend_response",
94332
94485
  refpoint1: this.point1.toArray(),
@@ -94345,10 +94498,21 @@ class Measurement {
94345
94498
  geom_type: "EllipseArc",
94346
94499
  refpoint: this.point1.toArray(),
94347
94500
  groups: [
94348
- { center: this.point1.toArray(), "major radius": 0.4, "minor radius": 0.2 },
94501
+ {
94502
+ center: this.point1.toArray(),
94503
+ "major radius": 0.4,
94504
+ "minor radius": 0.2,
94505
+ },
94349
94506
  { start: [2.4, -1, 0.0], end: [1.8, -0.8267949192431111, 0.0] },
94350
94507
  { length: 0.6868592404716374 },
94351
- { bb: { min: [1.8, -1, 0.0], center: [2.1, -0.9, 0.0], max: [2.4, -0.8, 0.0], size: [0.56, 0.2, 0.0] } },
94508
+ {
94509
+ bb: {
94510
+ min: [1.8, -1, 0.0],
94511
+ center: [2.1, -0.9, 0.0],
94512
+ max: [2.4, -0.8, 0.0],
94513
+ size: [0.56, 0.2, 0.0],
94514
+ },
94515
+ },
94352
94516
  ],
94353
94517
  };
94354
94518
  }
@@ -94460,7 +94624,8 @@ class DistanceMeasurement extends Measurement {
94460
94624
  this.debug = debug;
94461
94625
  }
94462
94626
  _createPanel() {
94463
- if (isDistancePanel(this.panel) && isDistanceResponseData(this.responseData)) {
94627
+ if (isDistancePanel(this.panel) &&
94628
+ isDistanceResponseData(this.responseData)) {
94464
94629
  this.panel.createTable(this.responseData);
94465
94630
  }
94466
94631
  }
@@ -94520,7 +94685,8 @@ class PropertiesMeasurement extends Measurement {
94520
94685
  this.debug = debug;
94521
94686
  }
94522
94687
  _createPanel() {
94523
- if (isPropertiesPanel(this.panel) && isPropertiesResponseData(this.responseData)) {
94688
+ if (isPropertiesPanel(this.panel) &&
94689
+ isPropertiesResponseData(this.responseData)) {
94524
94690
  this.panel.createTable(this.responseData);
94525
94691
  }
94526
94692
  }
@@ -94528,7 +94694,8 @@ class PropertiesMeasurement extends Measurement {
94528
94694
  return 1;
94529
94695
  }
94530
94696
  _getPoint() {
94531
- if (isPropertiesResponseData(this.responseData) && this.responseData.refpoint) {
94697
+ if (isPropertiesResponseData(this.responseData) &&
94698
+ this.responseData.refpoint) {
94532
94699
  this.point1 = new Vector3(...this.responseData.refpoint);
94533
94700
  }
94534
94701
  }
@@ -94795,7 +94962,7 @@ class Tools {
94795
94962
  }
94796
94963
  }
94797
94964
 
94798
- const version = "4.3.8";
94965
+ const version = "4.3.9";
94799
94966
 
94800
94967
  /**
94801
94968
  * Clean room environment for Studio mode PMREM generation.
@@ -94900,7 +95067,7 @@ function createCove(length, radius, segments, wall) {
94900
95067
  const indices = [];
94901
95068
  const sign = -1 ;
94902
95069
  for (let i = 0; i <= segments; i++) {
94903
- const angle = (i / segments) * Math.PI / 2;
95070
+ const angle = ((i / segments) * Math.PI) / 2;
94904
95071
  const h = sign * radius * Math.sin(angle); // horizontal offset toward wall
94905
95072
  const y = radius * (1 - Math.cos(angle)); // vertical offset above floor
94906
95073
  const nh = sign * Math.sin(angle); // normal toward wall
@@ -95449,7 +95616,7 @@ function halfToFloat(h) {
95449
95616
  }
95450
95617
  if (exponent === 31) {
95451
95618
  // Infinity or NaN
95452
- return mantissa ? NaN : (sign ? -Infinity : Infinity);
95619
+ return mantissa ? NaN : sign ? -Infinity : Infinity;
95453
95620
  }
95454
95621
  return (sign ? -1 : 1) * Math.pow(2, exponent - 15) * (1 + mantissa / 1024);
95455
95622
  }
@@ -95480,7 +95647,7 @@ function detectDominantLights(data, width, height) {
95480
95647
  for (let sy = 0; sy < height; sy++) {
95481
95648
  const gy = Math.min(Math.floor((sy / height) * GRID_H), GRID_H - 1);
95482
95649
  // cos(latitude) weighting: equator has more area than poles
95483
- const phi = ((0.5 - sy / height)) * Math.PI;
95650
+ const phi = (0.5 - sy / height) * Math.PI;
95484
95651
  const cosWeight = Math.cos(phi);
95485
95652
  for (let sx = 0; sx < width; sx++) {
95486
95653
  const gx = Math.min(Math.floor((sx / width) * GRID_W), GRID_W - 1);
@@ -95519,7 +95686,9 @@ function detectDominantLights(data, width, height) {
95519
95686
  }
95520
95687
  }
95521
95688
  // 2. Compute median luminance and threshold
95522
- const sorted = Array.from(grid).filter(v => v > 0).sort((a, b) => a - b);
95689
+ const sorted = Array.from(grid)
95690
+ .filter((v) => v > 0)
95691
+ .sort((a, b) => a - b);
95523
95692
  if (sorted.length === 0) {
95524
95693
  return { lights: [], wasAnalyzed: true };
95525
95694
  }
@@ -95615,9 +95784,9 @@ function detectDominantLights(data, width, height) {
95615
95784
  let color;
95616
95785
  if (colorTotal > 0) {
95617
95786
  color = [
95618
- c.totalR / colorTotal * 3,
95619
- c.totalG / colorTotal * 3,
95620
- c.totalB / colorTotal * 3,
95787
+ (c.totalR / colorTotal) * 3,
95788
+ (c.totalG / colorTotal) * 3,
95789
+ (c.totalB / colorTotal) * 3,
95621
95790
  ];
95622
95791
  // Clamp to 0-1
95623
95792
  color = [
@@ -95778,14 +95947,6 @@ class EnvironmentManager {
95778
95947
  constructor(options = {}) {
95779
95948
  /** Cached PMREM render targets keyed by environment name or URL */
95780
95949
  this._cache = new Map();
95781
- /**
95782
- * Cached raw equirectangular HDR textures keyed by the same name/URL.
95783
- * Preserved (not disposed after PMREM generation) so `scene.background`
95784
- * can sample the original HDR at full source resolution instead of the
95785
- * 256² PMREM cubemap. Only populated by `_loadHdr` — procedural
95786
- * environments have no source HDR.
95787
- */
95788
- this._hdrCache = new Map();
95789
95950
  /** Cached light detection results keyed by environment name or URL */
95790
95951
  this._lightDetectionCache = new Map();
95791
95952
  /** In-flight load promises keyed by environment name or URL */
@@ -95800,13 +95961,6 @@ class EnvironmentManager {
95800
95961
  this._hdrLoader = null;
95801
95962
  /** The last loaded PMREM texture (stateful — used by apply() for IBL) */
95802
95963
  this._currentTexture = null;
95803
- /**
95804
- * Raw HDR texture corresponding to `_currentTexture`, used for
95805
- * `scene.background` to keep the backdrop at source resolution. Null when
95806
- * the current environment is procedural ("studio" RoomEnvironment) — in
95807
- * that case the background falls back to `_currentTexture` (the PMREM).
95808
- */
95809
- this._currentBackgroundTexture = null;
95810
95964
  /** Whether this manager has been disposed */
95811
95965
  this._disposed = false;
95812
95966
  /**
@@ -95831,7 +95985,8 @@ class EnvironmentManager {
95831
95985
  * re-apply once the texture is ready.
95832
95986
  */
95833
95987
  this._deferredApply = null;
95834
- this._userOverrides = options.presetUrls ?? {};
95988
+ this._userOverrides =
95989
+ options.presetUrls ?? {};
95835
95990
  this._presetUrls = {
95836
95991
  ..._buildPresetUrls(false),
95837
95992
  ...this._userOverrides,
@@ -95860,7 +96015,6 @@ class EnvironmentManager {
95860
96015
  }
95861
96016
  if (name === "none") {
95862
96017
  this._currentTexture = null;
95863
- this._currentBackgroundTexture = null;
95864
96018
  return null;
95865
96019
  }
95866
96020
  // Check cache first (name is the cache key for presets; URL string for custom)
@@ -95869,7 +96023,6 @@ class EnvironmentManager {
95869
96023
  if (cached) {
95870
96024
  logger.debug(`Environment "${cacheKey}" loaded from cache`);
95871
96025
  this._currentTexture = cached.texture;
95872
- this._currentBackgroundTexture = this._hdrCache.get(cacheKey) ?? null;
95873
96026
  return cached.texture;
95874
96027
  }
95875
96028
  // Check in-flight promise — await and set _currentTexture
@@ -95878,7 +96031,6 @@ class EnvironmentManager {
95878
96031
  logger.debug(`Environment "${cacheKey}" already loading, reusing promise`);
95879
96032
  const texture = await inflight;
95880
96033
  this._currentTexture = texture;
95881
- this._currentBackgroundTexture = this._hdrCache.get(cacheKey) ?? null;
95882
96034
  return texture;
95883
96035
  }
95884
96036
  // Start new load
@@ -95887,7 +96039,6 @@ class EnvironmentManager {
95887
96039
  try {
95888
96040
  const texture = await promise;
95889
96041
  this._currentTexture = texture;
95890
- this._currentBackgroundTexture = this._hdrCache.get(cacheKey) ?? null;
95891
96042
  // Self-healing: if apply() was called with "environment" background
95892
96043
  // while texture was null, re-apply now that the texture is ready.
95893
96044
  if (this._deferredApply) {
@@ -95927,8 +96078,13 @@ class EnvironmentManager {
95927
96078
  scene.environmentIntensity = envIntensity;
95928
96079
  // HDR maps assume Y-up; rotate 90° around X to align with Z-up scenes.
95929
96080
  // Additional rotation for user-controlled azimuthal rotation.
96081
+ // Note: Z-up branch uses "ZYX" Euler order so the matrix is
96082
+ // Rz(rotY) · Rx(PI/2). With the default "XYZ" order, the rotation
96083
+ // would land on the wrong axis (around World -Y, the depth axis,
96084
+ // instead of World Z, the vertical axis) because of how matrix
96085
+ // multiplication composes the X-tilt with the user rotation.
95930
96086
  if (upIsZ) {
95931
- scene.environmentRotation.set(Math.PI / 2, 0, rotY);
96087
+ scene.environmentRotation.set(Math.PI / 2, 0, rotY, "ZYX");
95932
96088
  }
95933
96089
  else {
95934
96090
  scene.environmentRotation.set(0, rotY, 0);
@@ -95965,13 +96121,14 @@ class EnvironmentManager {
95965
96121
  break;
95966
96122
  case "environment":
95967
96123
  if (this._currentTexture) {
95968
- // Prefer the raw HDR for the background so the backdrop samples
95969
- // at source resolution (2K/4K) rather than the 256² PMREM cubemap.
95970
- // Falls back to PMREM for procedural "studio" (no source HDR).
95971
- const bgTex = this._currentBackgroundTexture ?? this._currentTexture;
96124
+ // Use PMREM (CubeUVReflectionMapping) for the background. Raw
96125
+ // equirectangular HDR can't be used here: three.js's WebGLBackground
96126
+ // routes non-cubemap textures through a flat planeMesh path that
96127
+ // ignores scene.backgroundRotation, so env-rotation breaks. PMREM
96128
+ // takes the cubemap path and rotation works correctly.
95972
96129
  // Always use render-to-texture with a fixed-FOV bgCamera so the
95973
96130
  // background zoom level is identical in perspective and ortho modes.
95974
- this._setupEnvBackground(scene, bgTex, upIsZ, rotY);
96131
+ this._setupEnvBackground(scene, this._currentTexture, upIsZ, rotY);
95975
96132
  this._deferredApply = null;
95976
96133
  }
95977
96134
  else {
@@ -96056,15 +96213,11 @@ class EnvironmentManager {
96056
96213
  this._lightDetectionCache.delete(slug);
96057
96214
  logger.debug(`Evicted cached environment "${slug}" for resolution switch`);
96058
96215
  }
96059
- const cachedHdr = this._hdrCache.get(slug);
96060
- if (cachedHdr) {
96061
- gpuTracker.untrack("texture", cachedHdr);
96062
- cachedHdr.dispose();
96063
- this._hdrCache.delete(slug);
96064
- }
96065
96216
  }
96066
96217
  // Reload the current environment at the new resolution
96067
- if (currentEnvName && currentEnvName !== "none" && currentEnvName !== "studio") {
96218
+ if (currentEnvName &&
96219
+ currentEnvName !== "none" &&
96220
+ currentEnvName !== "studio") {
96068
96221
  return this.loadEnvironment(currentEnvName, renderer);
96069
96222
  }
96070
96223
  return this._currentTexture;
@@ -96122,7 +96275,9 @@ class EnvironmentManager {
96122
96275
  const size = renderer.getDrawingBufferSize(_bgSizeVec);
96123
96276
  const w = size.x;
96124
96277
  const h = size.y;
96125
- if (!this._bgRenderTarget || this._bgRenderTarget.width !== w || this._bgRenderTarget.height !== h) {
96278
+ if (!this._bgRenderTarget ||
96279
+ this._bgRenderTarget.width !== w ||
96280
+ this._bgRenderTarget.height !== h) {
96126
96281
  this._bgRenderTarget?.dispose();
96127
96282
  this._bgRenderTarget = new WebGLRenderTarget(w, h);
96128
96283
  }
@@ -96160,7 +96315,6 @@ class EnvironmentManager {
96160
96315
  dispose() {
96161
96316
  this._disposed = true;
96162
96317
  this._currentTexture = null;
96163
- this._currentBackgroundTexture = null;
96164
96318
  this._deferredApply = null;
96165
96319
  this._teardownEnvBackground();
96166
96320
  this._bgScene = null;
@@ -96176,13 +96330,6 @@ class EnvironmentManager {
96176
96330
  logger.debug(`Disposed cached environment render target: ${key}`);
96177
96331
  }
96178
96332
  this._cache.clear();
96179
- // Dispose all cached raw HDR textures
96180
- for (const [key, hdrTexture] of this._hdrCache) {
96181
- gpuTracker.untrack("texture", hdrTexture);
96182
- hdrTexture.dispose();
96183
- logger.debug(`Disposed cached HDR background: ${key}`);
96184
- }
96185
- this._hdrCache.clear();
96186
96333
  this._lightDetectionCache.clear();
96187
96334
  // Clear in-flight promises (they'll resolve but won't be cached)
96188
96335
  this._inflight.clear();
@@ -96218,8 +96365,9 @@ class EnvironmentManager {
96218
96365
  this._bgScene.background = texture;
96219
96366
  this._bgScene.backgroundIntensity = 1.0;
96220
96367
  this._bgScene.backgroundBlurriness = 0;
96368
+ // See apply() for the "ZYX" rationale.
96221
96369
  if (upIsZ) {
96222
- this._bgScene.backgroundRotation.set(Math.PI / 2, 0, rotY);
96370
+ this._bgScene.backgroundRotation.set(Math.PI / 2, 0, rotY, "ZYX");
96223
96371
  }
96224
96372
  else {
96225
96373
  this._bgScene.backgroundRotation.set(0, rotY, 0);
@@ -96337,10 +96485,9 @@ class EnvironmentManager {
96337
96485
  * Load an HDR file and generate a PMREM texture from it.
96338
96486
  *
96339
96487
  * Uses HDRLoader to fetch the .hdr file, then PMREMGenerator.fromEquirectangular()
96340
- * to create the PMREM cubemap for IBL. The source equirectangular HDR is
96341
- * preserved and cached separately (in `_hdrCache`) so that "environment"
96342
- * background mode can sample the full-resolution equirectangular texture
96343
- * instead of the 256² PMREM cubemap.
96488
+ * to create the PMREM cubemap. The source equirectangular HDR is disposed
96489
+ * after PMREM generation. The PMREM texture itself serves as both the IBL
96490
+ * environment and the background (in "environment" mode).
96344
96491
  *
96345
96492
  * @param url - URL of the .hdr file
96346
96493
  * @param cacheKey - Cache key for the resulting PMREM render target
@@ -96366,19 +96513,21 @@ class EnvironmentManager {
96366
96513
  const renderTarget = pmremGenerator.fromEquirectangular(hdrTexture);
96367
96514
  // Analyze HDR pixel data for dominant light sources BEFORE disposing.
96368
96515
  // hdrTexture.image.data is Uint16Array (HalfFloatType) from HDRLoader.
96369
- if (hdrTexture.image?.data && hdrTexture.image.width && hdrTexture.image.height) {
96516
+ if (hdrTexture.image?.data &&
96517
+ hdrTexture.image.width &&
96518
+ hdrTexture.image.height) {
96370
96519
  const result = detectDominantLights(hdrTexture.image.data, hdrTexture.image.width, hdrTexture.image.height);
96371
96520
  this._lightDetectionCache.set(cacheKey, result);
96372
96521
  }
96373
- // Preserve the equirectangular HDR for use as `scene.background` at
96374
- // source resolution. PMREM's base mip is a 256² cubemap good for IBL
96375
- // (roughness-weighted prefilter) but visibly soft as a backdrop.
96376
- hdrTexture.mapping = EquirectangularReflectionMapping;
96377
- // Cache render target and HDR; track both.
96522
+ // Dispose the source equirectangular texture (PMREM is now in GPU memory).
96523
+ // Note: we cannot use the raw HDR for scene.background because three.js's
96524
+ // WebGLBackground routes non-cubemap textures through a flat planeMesh
96525
+ // path that ignores backgroundRotation; PMREM (CubeUVReflectionMapping)
96526
+ // takes the cubemap path which respects rotation.
96527
+ hdrTexture.dispose();
96528
+ // Cache render target and track its texture
96378
96529
  this._cache.set(cacheKey, renderTarget);
96379
- this._hdrCache.set(cacheKey, hdrTexture);
96380
96530
  gpuTracker.trackTexture(renderTarget.texture, `PMREM environment: ${cacheKey}`);
96381
- gpuTracker.trackTexture(hdrTexture, `HDR background: ${cacheKey}`);
96382
96531
  logger.debug(`Loaded HDR environment from "${url}", cached as "${cacheKey}"`);
96383
96532
  return renderTarget.texture;
96384
96533
  }
@@ -96448,7 +96597,8 @@ class StudioFloor {
96448
96597
  */
96449
96598
  setShadowIntensity(intensity) {
96450
96599
  if (this._shadowPlane) {
96451
- this._shadowPlane.material.opacity = intensity * 1.0;
96600
+ this._shadowPlane.material.opacity =
96601
+ intensity * 1.0;
96452
96602
  }
96453
96603
  }
96454
96604
  /** Dispose all GPU resources. */
@@ -96464,7 +96614,10 @@ class StudioFloor {
96464
96614
  _createShadowPlane(zPosition, sceneSize) {
96465
96615
  const floorSize = sceneSize * 6;
96466
96616
  const geometry = new PlaneGeometry(floorSize, floorSize);
96467
- const material = new ShadowMaterial({ opacity: 0.5, depthWrite: false });
96617
+ const material = new ShadowMaterial({
96618
+ opacity: 0.5,
96619
+ depthWrite: false,
96620
+ });
96468
96621
  const plane = new Mesh(geometry, material);
96469
96622
  plane.position.z = zPosition;
96470
96623
  plane.receiveShadow = true;
@@ -103925,13 +104078,17 @@ const $05f6997e4b65da14$export$ed4ee5d1e55474a5 = {
103925
104078
  * Only instantiated when Studio mode is active. Non-Studio rendering
103926
104079
  * bypasses this entirely and uses direct `renderer.render()`.
103927
104080
  */
103928
- // ---------------------------------------------------------------------------
103929
- // Tone-mapping: maps viewer strings to postprocessing ToneMappingMode
103930
- // ---------------------------------------------------------------------------
104081
+ // Tone mapping runs as a post-process effect in the EffectPass, before SMAA,
104082
+ // so SMAA's edge detection sees LDR luma in its calibrated [0,1] range.
104083
+ // Per-fragment tone mapping in the main RenderPass would be a no-op:
104084
+ // three.js forces NoToneMapping when rendering to a non-canvas render target
104085
+ // (see WebGLPrograms.getParameters), and the composer's input is a HalfFloat
104086
+ // HDR FBO. Exposure is read from renderer.toneMappingExposure by three.js's
104087
+ // shared <tonemapping_pars_fragment> chunk that ToneMappingEffect includes.
103931
104088
  const TONE_MAP_MODE = {
103932
- "neutral": ToneMappingMode.NEUTRAL,
103933
- "ACES": ToneMappingMode.ACES_FILMIC,
103934
- "none": ToneMappingMode.LINEAR,
104089
+ neutral: ToneMappingMode.NEUTRAL,
104090
+ ACES: ToneMappingMode.ACES_FILMIC,
104091
+ none: ToneMappingMode.LINEAR,
103935
104092
  };
103936
104093
  // Scratch color to avoid per-frame allocation
103937
104094
  const _savedClearColor = new Color();
@@ -103989,7 +104146,7 @@ class StudioComposer {
103989
104146
  * @param width - Canvas width in pixels
103990
104147
  * @param height - Canvas height in pixels
103991
104148
  */
103992
- constructor(renderer, scene, camera, width, height) {
104149
+ constructor(renderer, scene, camera, width, height, onSmaaReady) {
103993
104150
  /** Solid background color to protect from tone mapping, or null. */
103994
104151
  this._bgProtectColor = null;
103995
104152
  // Shadow mask pipeline — two separate masks to avoid depth-discontinuity halos
@@ -104010,15 +104167,17 @@ class StudioComposer {
104010
104167
  this._camera = camera;
104011
104168
  this._width = width;
104012
104169
  this._height = height;
104013
- // Postprocessing library requires renderer.toneMapping = NoToneMapping.
104014
- // Tone mapping is handled by ToneMappingEffect in the pipeline.
104170
+ // ToneMappingEffect handles the tone curve in the EffectPass. The
104171
+ // postprocessing library requires renderer.toneMapping = NoToneMapping
104172
+ // so the renderer doesn't try to apply it as well.
104015
104173
  this._renderer.toneMapping = NoToneMapping;
104016
104174
  // HDR pipeline with HalfFloat framebuffer.
104017
- // multisampling = 0: MSAA conflicts with depth-based AO passes;
104018
- // antialiasing is handled by SMAAEffect instead.
104175
+ // multisampling = 4: WebGL2 MSAA on the composer's input RT. Most GPUs
104176
+ // clamp half-float MSAA to 4 samples anyway, and Studio mode applies
104177
+ // additional supersampling via renderer.setPixelRatio to compensate.
104019
104178
  this._composer = new EffectComposer(renderer, {
104020
104179
  frameBufferType: HalfFloatType,
104021
- multisampling: 0,
104180
+ multisampling: 4,
104022
104181
  });
104023
104182
  // --- Pass 1: scene render ---
104024
104183
  this._renderPass = new RenderPass(scene, camera);
@@ -104038,11 +104197,23 @@ class StudioComposer {
104038
104197
  this._n8aoPass.enabled = false; // off by default
104039
104198
  this._composer.addPass(this._n8aoPass);
104040
104199
  // --- Pass 3: shadow mask compositing + tone mapping + SMAA ---
104200
+ // ShadowMask runs first in linear HDR (where shadow math is correct).
104201
+ // ToneMapping next so SMAA sees LDR luma in its calibrated [0,1] range.
104041
104202
  this._toneMappingEffect = new ToneMappingEffect({
104042
104203
  mode: ToneMappingMode.NEUTRAL,
104043
104204
  });
104044
- const smaaEffect = new SMAAEffect({ preset: SMAAPreset.HIGH });
104045
- // ShadowMaskEffect is first so it runs in linear space before tone mapping
104205
+ const smaaEffect = new SMAAEffect({ preset: SMAAPreset.ULTRA });
104206
+ // SMAA loads its lookup textures (search/area) asynchronously via
104207
+ // `new Image(); image.src = "data:..."`. Even though the source is a
104208
+ // data URL, the `load` event fires in a microtask — so the very first
104209
+ // render after composer construction has no SMAA textures attached and
104210
+ // produces aliased edges. We notify on `load` so the caller can trigger
104211
+ // another render and the user sees AA without having to interact.
104212
+ if (onSmaaReady) {
104213
+ // postprocessing's TS types only declare "change"; SMAA dispatches
104214
+ // "load" at runtime when the lookup textures finish decoding.
104215
+ smaaEffect.addEventListener("load", () => onSmaaReady());
104216
+ }
104046
104217
  this._shadowMaskEffect = new ShadowMaskEffect();
104047
104218
  this._effectPass = new EffectPass(camera, this._shadowMaskEffect, this._toneMappingEffect, smaaEffect);
104048
104219
  this._composer.addPass(this._effectPass);
@@ -104110,6 +104281,9 @@ class StudioComposer {
104110
104281
  else {
104111
104282
  this._toneMappingEffect.mode = mapped;
104112
104283
  }
104284
+ // ToneMappingEffect's GLSL reads toneMappingExposure from the
104285
+ // <tonemapping_pars_fragment> chunk, which three.js auto-populates
104286
+ // from this renderer property each frame.
104113
104287
  this._renderer.toneMappingExposure = exposure;
104114
104288
  }
104115
104289
  // -----------------------------------------------------------------------
@@ -104209,7 +104383,9 @@ class StudioComposer {
104209
104383
  this._composer.setSize(width, height, false);
104210
104384
  this._n8aoPass.setSize(width, height);
104211
104385
  // Resize shadow mask RTs at half resolution
104212
- if (this._shadowMaskRT && this._blurredObjectMaskRT && this._blurredFloorMaskRT) {
104386
+ if (this._shadowMaskRT &&
104387
+ this._blurredObjectMaskRT &&
104388
+ this._blurredFloorMaskRT) {
104213
104389
  const halfW = Math.max(1, Math.floor(width / 2));
104214
104390
  const halfH = Math.max(1, Math.floor(height / 2));
104215
104391
  this._shadowMaskRT.setSize(halfW, halfH);
@@ -104236,8 +104412,11 @@ class StudioComposer {
104236
104412
  render(deltaTime) {
104237
104413
  // Two-pass shadow mask: objects and floor are blurred separately to
104238
104414
  // avoid depth-discontinuity halos at their boundary.
104239
- if (this._shadowMaskEnabled && this._shadowMaskRT && this._blurPass
104240
- && this._blurredObjectMaskRT && this._blurredFloorMaskRT) {
104415
+ if (this._shadowMaskEnabled &&
104416
+ this._shadowMaskRT &&
104417
+ this._blurPass &&
104418
+ this._blurredObjectMaskRT &&
104419
+ this._blurredFloorMaskRT) {
104241
104420
  this._renderer.shadowMap.autoUpdate = false;
104242
104421
  this._renderer.shadowMap.needsUpdate = true;
104243
104422
  // Pass 1: object shadow mask (floor hidden, generates shadow map)
@@ -104248,8 +104427,10 @@ class StudioComposer {
104248
104427
  this._renderShadowMask("floor");
104249
104428
  this._blurPass.render(this._renderer, this._shadowMaskRT, this._blurredFloorMaskRT);
104250
104429
  // Feed both blurred masks to the compositing effect
104251
- this._shadowMaskEffect.uniforms.get("shadowMaskObjects").value = this._blurredObjectMaskRT.texture;
104252
- this._shadowMaskEffect.uniforms.get("shadowMaskFloor").value = this._blurredFloorMaskRT.texture;
104430
+ this._shadowMaskEffect.uniforms.get("shadowMaskObjects").value =
104431
+ this._blurredObjectMaskRT.texture;
104432
+ this._shadowMaskEffect.uniforms.get("shadowMaskFloor").value =
104433
+ this._blurredFloorMaskRT.texture;
104253
104434
  this._renderer.shadowMap.autoUpdate = true;
104254
104435
  }
104255
104436
  // Hide floor during main render — blurred shadow mask provides floor shadow
@@ -104382,26 +104563,86 @@ class StudioComposer {
104382
104563
  */
104383
104564
  const STATE_KEYS = new Set([
104384
104565
  // Display
104385
- "theme", "cadWidth", "treeWidth", "treeHeight", "height", "pinning", "glass", "tools",
104386
- "keymap", "newTreeBehavior", "measureTools", "selectTool", "explodeTool", "zscaleTool",
104387
- "zebraTool", "studioTool", "measurementDebug",
104566
+ "theme",
104567
+ "cadWidth",
104568
+ "treeWidth",
104569
+ "treeHeight",
104570
+ "height",
104571
+ "pinning",
104572
+ "glass",
104573
+ "tools",
104574
+ "keymap",
104575
+ "newTreeBehavior",
104576
+ "measureTools",
104577
+ "selectTool",
104578
+ "explodeTool",
104579
+ "zscaleTool",
104580
+ "zebraTool",
104581
+ "studioTool",
104582
+ "measurementDebug",
104388
104583
  // Render
104389
- "ambientIntensity", "directIntensity", "metalness", "roughness", "defaultOpacity",
104390
- "edgeColor", "normalLen",
104584
+ "ambientIntensity",
104585
+ "directIntensity",
104586
+ "metalness",
104587
+ "roughness",
104588
+ "defaultOpacity",
104589
+ "edgeColor",
104590
+ "normalLen",
104391
104591
  // Viewer
104392
- "axes", "axes0", "grid", "ortho", "transparent", "blackEdges", "collapse",
104393
- "clipIntersection", "clipPlaneHelpers", "clipObjectColors", "clipNormal0", "clipNormal1",
104394
- "clipNormal2", "clipSlider0", "clipSlider1", "clipSlider2", "control", "holroyd", "up",
104395
- "ticks", "gridFontSize", "centerGrid", "position", "quaternion", "target", "zoom",
104396
- "panSpeed", "rotateSpeed", "zoomSpeed", "timeit",
104592
+ "axes",
104593
+ "axes0",
104594
+ "grid",
104595
+ "ortho",
104596
+ "transparent",
104597
+ "blackEdges",
104598
+ "collapse",
104599
+ "clipIntersection",
104600
+ "clipPlaneHelpers",
104601
+ "clipObjectColors",
104602
+ "clipNormal0",
104603
+ "clipNormal1",
104604
+ "clipNormal2",
104605
+ "clipSlider0",
104606
+ "clipSlider1",
104607
+ "clipSlider2",
104608
+ "control",
104609
+ "holroyd",
104610
+ "up",
104611
+ "ticks",
104612
+ "gridFontSize",
104613
+ "centerGrid",
104614
+ "position",
104615
+ "quaternion",
104616
+ "target",
104617
+ "zoom",
104618
+ "panSpeed",
104619
+ "rotateSpeed",
104620
+ "zoomSpeed",
104621
+ "timeit",
104397
104622
  // Zebra
104398
- "zebraCount", "zebraOpacity", "zebraDirection", "zebraColorScheme", "zebraMappingMode",
104623
+ "zebraCount",
104624
+ "zebraOpacity",
104625
+ "zebraDirection",
104626
+ "zebraColorScheme",
104627
+ "zebraMappingMode",
104399
104628
  // Studio
104400
- "studioEnvironment", "studioEnvIntensity", "studioBackground",
104401
- "studioToneMapping", "studioExposure", "studio4kEnvMaps", "studioTextureMapping",
104402
- "studioEnvRotation", "studioShadowIntensity", "studioShadowSoftness", "studioAOIntensity",
104629
+ "studioEnvironment",
104630
+ "studioEnvIntensity",
104631
+ "studioBackground",
104632
+ "studioToneMapping",
104633
+ "studioExposure",
104634
+ "studio4kEnvMaps",
104635
+ "studioTextureMapping",
104636
+ "studioEnvRotation",
104637
+ "studioShadowIntensity",
104638
+ "studioShadowSoftness",
104639
+ "studioAOIntensity",
104403
104640
  // Runtime
104404
- "activeTool", "animationMode", "animationSliderValue", "zscaleActive", "highlightedButton",
104641
+ "activeTool",
104642
+ "animationMode",
104643
+ "animationSliderValue",
104644
+ "zscaleActive",
104645
+ "highlightedButton",
104405
104646
  "activeTab",
104406
104647
  ]);
104407
104648
  /**
@@ -104608,7 +104849,8 @@ class ViewerState {
104608
104849
  const value = updates[key];
104609
104850
  // Skip undefined/null, except for keys where null is a valid value (reset to default)
104610
104851
  const KEYS_WITH_VALID_NULL = ["position", "quaternion", "target"];
104611
- if (value === undefined || (value === null && !KEYS_WITH_VALID_NULL.includes(key)))
104852
+ if (value === undefined ||
104853
+ (value === null && !KEYS_WITH_VALID_NULL.includes(key)))
104612
104854
  continue;
104613
104855
  const oldValue = this._state[key];
104614
104856
  if (!valuesEqual(oldValue, value)) {
@@ -104634,8 +104876,13 @@ class ViewerState {
104634
104876
  * Converts Vector3Tuple/QuaternionTuple to THREE objects.
104635
104877
  */
104636
104878
  updateViewerState(options, notify = true) {
104637
- // Extract properties that need conversion to THREE objects
104638
- const { clipNormal0, clipNormal1, clipNormal2, position, quaternion, target, ...rest } = options;
104879
+ // Extract properties that need conversion to THREE objects.
104880
+ // `tab` is also extracted: it's not a state key directly (state uses
104881
+ // `activeTab`), and setting activeTab here would trigger switchToTab
104882
+ // before the scene is built. Viewer.render() applies it after
104883
+ // scene-building completes (suppressing the CAD-mode paint when a
104884
+ // non-default tab is the target).
104885
+ const { tab: _tab, clipNormal0, clipNormal1, clipNormal2, position, quaternion, target, ...rest } = options;
104639
104886
  const converted = { ...rest };
104640
104887
  // Convert tuple values to THREE objects
104641
104888
  if (clipNormal0 !== undefined) {
@@ -104651,7 +104898,9 @@ class ViewerState {
104651
104898
  converted.position = position ? new Vector3(...position) : null;
104652
104899
  }
104653
104900
  if (quaternion !== undefined) {
104654
- converted.quaternion = quaternion ? new Quaternion(...quaternion) : null;
104901
+ converted.quaternion = quaternion
104902
+ ? new Quaternion(...quaternion)
104903
+ : null;
104655
104904
  }
104656
104905
  if (target !== undefined) {
104657
104906
  converted.target = target ? new Vector3(...target) : null;
@@ -104775,9 +105024,15 @@ class ViewerState {
104775
105024
  // Apply transform if defined (e.g., slider 0-1000 → relative 0-1)
104776
105025
  const transform = STATE_NOTIFICATION_TRANSFORM[key];
104777
105026
  const notifyChange = transform
104778
- ? { old: change.old != null ? transform(change.old) : null, new: transform(change.new) }
105027
+ ? {
105028
+ old: change.old != null ? transform(change.old) : null,
105029
+ new: transform(change.new),
105030
+ }
104779
105031
  : change;
104780
- this._externalNotifyCallback({ key: notificationKey, change: notifyChange });
105032
+ this._externalNotifyCallback({
105033
+ key: notificationKey,
105034
+ change: notifyChange,
105035
+ });
104781
105036
  }
104782
105037
  }
104783
105038
  }
@@ -104855,12 +105110,39 @@ ViewerState.DISPLAY_DEFAULTS = {
104855
105110
  glass: false,
104856
105111
  tools: true,
104857
105112
  keymap: {
104858
- shift: "shiftKey", ctrl: "ctrlKey", meta: "metaKey", alt: "altKey",
104859
- axes: "a", axes0: "A", grid: "g", gridxy: "G", perspective: "p", transparent: "t", blackedges: "b",
104860
- reset: "R", resize: "r",
104861
- iso: "5", front: "1", rear: "3", top: "8", bottom: "2", left: "4", right: "6",
104862
- explode: "x", zscale: "L", distance: "D", properties: "P", select: "S", help: "h", play: " ", stop: "Escape",
104863
- tree: "T", clip: "C", material: "M", zebra: "Z", studio: "s",
105113
+ shift: "shiftKey",
105114
+ ctrl: "ctrlKey",
105115
+ meta: "metaKey",
105116
+ alt: "altKey",
105117
+ axes: "a",
105118
+ axes0: "A",
105119
+ grid: "g",
105120
+ gridxy: "G",
105121
+ perspective: "p",
105122
+ transparent: "t",
105123
+ blackedges: "b",
105124
+ reset: "R",
105125
+ resize: "r",
105126
+ iso: "5",
105127
+ front: "1",
105128
+ rear: "3",
105129
+ top: "8",
105130
+ bottom: "2",
105131
+ left: "4",
105132
+ right: "6",
105133
+ explode: "x",
105134
+ zscale: "L",
105135
+ distance: "D",
105136
+ properties: "P",
105137
+ select: "S",
105138
+ help: "h",
105139
+ play: " ",
105140
+ stop: "Escape",
105141
+ tree: "T",
105142
+ clip: "C",
105143
+ material: "M",
105144
+ zebra: "Z",
105145
+ studio: "s",
104864
105146
  },
104865
105147
  newTreeBehavior: true,
104866
105148
  measureTools: true,
@@ -104938,7 +105220,12 @@ ViewerState.STUDIO_MODE_DEFAULTS = {
104938
105220
  studioToneMapping: "neutral",
104939
105221
  studioExposure: 1.0,
104940
105222
  studio4kEnvMaps: false,
104941
- studioTextureMapping: "triplanar",
105223
+ // "parametric" is the right default when the tessellator emits UVs:
105224
+ // each object's `nestedgroup.ts:applyTriplanarMapping` only kicks in
105225
+ // when the chosen mode is "triplanar" OR the geometry has no `uv`
105226
+ // attribute. So with the default "parametric", textured objects with
105227
+ // UVs use them; objects without UVs auto-fall back to triplanar.
105228
+ studioTextureMapping: "parametric",
104942
105229
  studioEnvRotation: 0,
104943
105230
  studioShadowIntensity: 0.5,
104944
105231
  studioShadowSoftness: 0.2,
@@ -104975,6 +105262,13 @@ class StudioManager {
104975
105262
  this._active = false;
104976
105263
  this._savedClippingState = null;
104977
105264
  this._shadowLights = [];
105265
+ /**
105266
+ * Renderer pixel ratio saved on Studio entry, restored on leave.
105267
+ * Studio mode bumps the pixel ratio to apply supersampling, which
105268
+ * compensates for low DPR (e.g., VSCode webviews report DPR=1 even
105269
+ * on Retina displays) and improves AA on shallow-angle edges.
105270
+ */
105271
+ this._savedPixelRatio = null;
104978
105272
  // -------------------------------------------------------------------------
104979
105273
  // Mode enter/leave
104980
105274
  // -------------------------------------------------------------------------
@@ -105026,9 +105320,28 @@ class StudioManager {
105026
105320
  this._ctx.getDirectLight().intensity = 0;
105027
105321
  // Floor
105028
105322
  this._configureFloor();
105323
+ // Studio-only supersampling. Bump pixel ratio so the renderer draws to
105324
+ // a higher-resolution buffer; the browser downsamples to the canvas
105325
+ // display size, giving smooth AA on shallow-angle silhouettes that
105326
+ // MSAA alone leaves stair-stepped. Especially important in webview
105327
+ // hosts (e.g., VSCode) where window.devicePixelRatio is reported as 1
105328
+ // even on Retina displays. Restored in leaveStudioMode.
105329
+ this._savedPixelRatio = renderer.getPixelRatio();
105330
+ const targetPixelRatio = Math.max(2, window.devicePixelRatio);
105331
+ if (targetPixelRatio !== this._savedPixelRatio) {
105332
+ renderer.setPixelRatio(targetPixelRatio);
105333
+ renderer.setSize(state.get("cadWidth"), state.get("height"));
105334
+ }
105029
105335
  // Create composer (must be before shadows)
105030
105336
  if (!this._composer) {
105031
- this._composer = new StudioComposer(renderer, scene, camera.getCamera(), state.get("cadWidth"), state.get("height"));
105337
+ this._composer = new StudioComposer(renderer, scene, camera.getCamera(), state.get("cadWidth"), state.get("height"), () => {
105338
+ // SMAA finished loading its async lookup textures. Re-render so
105339
+ // the first visible frame has anti-aliasing — without this the
105340
+ // user sees aliased edges until they interact with the scene.
105341
+ if (this._active && this._ctx.isRendered()) {
105342
+ this._ctx.update(true, false);
105343
+ }
105344
+ });
105032
105345
  }
105033
105346
  // Shadows (requires composer)
105034
105347
  if (state.get("studioShadowIntensity") > 0) {
@@ -105052,6 +105365,12 @@ class StudioManager {
105052
105365
  this._composer.dispose();
105053
105366
  this._composer = null;
105054
105367
  }
105368
+ // Restore pixel ratio if we bumped it before failure
105369
+ if (this._savedPixelRatio !== null) {
105370
+ renderer.setPixelRatio(this._savedPixelRatio);
105371
+ renderer.setSize(state.get("cadWidth"), state.get("height"));
105372
+ this._savedPixelRatio = null;
105373
+ }
105055
105374
  this._active = false;
105056
105375
  logger.error("Unexpected error entering studio mode", err);
105057
105376
  }
@@ -105068,6 +105387,12 @@ class StudioManager {
105068
105387
  this._composer.dispose();
105069
105388
  this._composer = null;
105070
105389
  }
105390
+ // Restore the renderer's pixel ratio that was bumped on Studio entry.
105391
+ if (this._savedPixelRatio !== null) {
105392
+ renderer.setPixelRatio(this._savedPixelRatio);
105393
+ renderer.setSize(state.get("cadWidth"), state.get("height"));
105394
+ this._savedPixelRatio = null;
105395
+ }
105071
105396
  // 3. Remove environment, disable shadows
105072
105397
  this.envManager.remove(this._ctx.getScene());
105073
105398
  this._setShadowsEnabled(false);
@@ -105200,7 +105525,9 @@ class StudioManager {
105200
105525
  state.subscribe("studioEnvironment", (change) => {
105201
105526
  if (!isActive())
105202
105527
  return;
105203
- this.envManager.loadEnvironment(change.new, this._ctx.renderer).then(() => {
105528
+ this.envManager
105529
+ .loadEnvironment(change.new, this._ctx.renderer)
105530
+ .then(() => {
105204
105531
  if (!isActive())
105205
105532
  return;
105206
105533
  reapplyEnv();
@@ -105209,7 +105536,8 @@ class StudioManager {
105209
105536
  }
105210
105537
  this._ctx.update(true, false);
105211
105538
  this._ctx.dispatchEvent(new Event("tcv-studio-ready"));
105212
- }).catch((err) => {
105539
+ })
105540
+ .catch((err) => {
105213
105541
  logger.error("Unexpected error loading studio environment", err);
105214
105542
  this._ctx.dispatchEvent(new Event("tcv-studio-ready"));
105215
105543
  });
@@ -105300,7 +105628,9 @@ class StudioManager {
105300
105628
  if (!isActive())
105301
105629
  return;
105302
105630
  const envName = state.get("studioEnvironment");
105303
- this.envManager.setUse4kEnvMaps(change.new, envName, this._ctx.renderer).then(() => {
105631
+ this.envManager
105632
+ .setUse4kEnvMaps(change.new, envName, this._ctx.renderer)
105633
+ .then(() => {
105304
105634
  if (!isActive())
105305
105635
  return;
105306
105636
  reapplyEnv();
@@ -105438,7 +105768,9 @@ class StudioManager {
105438
105768
  this.floor.setShadowsEnabled(true);
105439
105769
  this._ctx.getScene().traverse((obj) => {
105440
105770
  if (obj instanceof Mesh && obj.material) {
105441
- const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
105771
+ const mats = Array.isArray(obj.material)
105772
+ ? obj.material
105773
+ : [obj.material];
105442
105774
  for (const m of mats) {
105443
105775
  m.needsUpdate = true;
105444
105776
  }
@@ -105699,7 +106031,9 @@ class Viewer {
105699
106031
  return this._rendered;
105700
106032
  }
105701
106033
  /** Environment manager — proxied from StudioManager for display.ts access. */
105702
- get envManager() { return this._studioManager.envManager; }
106034
+ get envManager() {
106035
+ return this._studioManager.envManager;
106036
+ }
105703
106037
  // ---------------------------------------------------------------------------
105704
106038
  // Constructor & Initialization
105705
106039
  // ---------------------------------------------------------------------------
@@ -105712,6 +106046,10 @@ class Viewer {
105712
106046
  * @param updateMarker - enforce to redraw orientation marker after every ui activity
105713
106047
  */
105714
106048
  constructor(display, options, notifyCallback, pinAsPngCallback = null, updateMarker = true) {
106049
+ // Grid size from the previous render, used to decide whether the new
106050
+ // geometry is "the same model" for clip-slider preservation.
106051
+ // Survives clear() so reused viewers remember the previous geometry.
106052
+ this._previousGridSize = 0;
105715
106053
  // ---------------------------------------------------------------------------
105716
106054
  // Render Loop & Scene Updates
105717
106055
  // ---------------------------------------------------------------------------
@@ -105762,6 +106100,20 @@ class Viewer {
105762
106100
  this.update = (updateMarker, notify = true) => {
105763
106101
  if (!this.ready)
105764
106102
  return;
106103
+ // Skip painting while Studio mode is mid-async-load: composer hasn't
106104
+ // been created yet, so a fall-through to renderer.render() would paint
106105
+ // the scene with CAD materials (Studio's material swap is also async).
106106
+ // Without this guard, any setter that calls update() — setCameraZoom,
106107
+ // setView, setExplode, setTool, etc. — would paint a CAD-materials
106108
+ // frame before Studio's first proper paint, which is visible as a
106109
+ // 0.5–1 sec CAD render before Studio takes over. Studio's tab
106110
+ // handler does its own update() at completion, which is when the
106111
+ // first painted frame should appear. State changes still propagate
106112
+ // synchronously and are picked up by that eventual paint.
106113
+ if (this.state.get("activeTab") === "studio" &&
106114
+ !this._studioManager.hasComposer) {
106115
+ return;
106116
+ }
105765
106117
  if (this._externalGl) {
105766
106118
  this.renderer.resetState();
105767
106119
  }
@@ -105799,7 +106151,10 @@ class Viewer {
105799
106151
  }
105800
106152
  if (updateMarker) {
105801
106153
  this.renderer.clearDepth(); // ensure orientation Marker is at the top
105802
- this.rendered.orientationMarker.update(this.rendered.camera.getPosition().clone().sub(this.rendered.controls.getTarget()), this.rendered.camera.getQuaternion());
106154
+ this.rendered.orientationMarker.update(this.rendered.camera
106155
+ .getPosition()
106156
+ .clone()
106157
+ .sub(this.rendered.controls.getTarget()), this.rendered.camera.getQuaternion());
105803
106158
  this.rendered.orientationMarker.render(this.renderer);
105804
106159
  }
105805
106160
  if (this.animation) {
@@ -105866,6 +106221,11 @@ class Viewer {
105866
106221
  if (!isObjectGroup(objectGroup))
105867
106222
  continue;
105868
106223
  objectGroup.setShapeVisible(compactTree[0] === 1);
106224
+ // Re-apply clip-mode back visibility when re-showing — see
106225
+ // matching comment in setObject().
106226
+ if (compactTree[0] === 1 && this.expandedNestedGroup.backVisible) {
106227
+ objectGroup.setBackVisible(true);
106228
+ }
105869
106229
  objectGroup.setEdgesVisible(compactTree[1] === 1);
105870
106230
  // Sync state (unless disabled = 3)
105871
106231
  if (leafState[0] !== 3)
@@ -105897,6 +106257,11 @@ class Viewer {
105897
106257
  }
105898
106258
  }
105899
106259
  objectGroup.setShapeVisible(shapeVisible);
106260
+ // Re-apply clip-mode back visibility when re-showing — see
106261
+ // matching comment in setObject().
106262
+ if (shapeVisible && this.compactNestedGroup.backVisible) {
106263
+ objectGroup.setBackVisible(true);
106264
+ }
105900
106265
  objectGroup.setEdgesVisible(edgeVisible);
105901
106266
  // Sync compact state (unless disabled = 3)
105902
106267
  if (compactTree[0] !== 3)
@@ -106038,6 +106403,14 @@ class Viewer {
106038
106403
  if (objectGroup != null && objectGroup instanceof ObjectGroup) {
106039
106404
  if (iconNumber === 0) {
106040
106405
  objectGroup.setShapeVisible(state === 1);
106406
+ // When re-showing while clip-tab is active, re-apply the clip-mode
106407
+ // back-visibility for this object. setShapeVisible's show-path
106408
+ // doesn't touch back when !renderback (clip-tab owns it), so a
106409
+ // previously-hidden object would otherwise come back with front
106410
+ // visible but back still hidden — looking hollow under clipping.
106411
+ if (state === 1 && this.rendered.nestedGroup.backVisible) {
106412
+ objectGroup.setBackVisible(true);
106413
+ }
106041
106414
  }
106042
106415
  else {
106043
106416
  objectGroup.setEdgesVisible(state === 1);
@@ -107275,7 +107648,10 @@ class Viewer {
107275
107648
  this.hasAnimationLoop = false;
107276
107649
  this.display = display;
107277
107650
  if (options.keymap) {
107278
- this.setKeyMap({ ...ViewerState.DISPLAY_DEFAULTS.keymap, ...options.keymap });
107651
+ this.setKeyMap({
107652
+ ...ViewerState.DISPLAY_DEFAULTS.keymap,
107653
+ ...options.keymap,
107654
+ });
107279
107655
  }
107280
107656
  else {
107281
107657
  this.setKeyMap(ViewerState.DISPLAY_DEFAULTS.keymap);
@@ -107596,7 +107972,8 @@ class Viewer {
107596
107972
  this.renderer.renderLists.dispose();
107597
107973
  this.renderer.dispose();
107598
107974
  // Skip context loss for externally provided WebGL contexts
107599
- if (!this._externalGl && typeof this.renderer.forceContextLoss === "function") {
107975
+ if (!this._externalGl &&
107976
+ typeof this.renderer.forceContextLoss === "function") {
107600
107977
  this.renderer.forceContextLoss();
107601
107978
  }
107602
107979
  console.debug("three-cad-viewer: WebGL context disposed");
@@ -107699,6 +108076,19 @@ class Viewer {
107699
108076
  deepDispose(this.compactNestedGroup);
107700
108077
  this.compactNestedGroup = null;
107701
108078
  }
108079
+ // Reset scene-derived fields so the next render() recomputes them
108080
+ // from the new geometry. Without this, reuse (clear() + render())
108081
+ // re-uses stale values from the previous scene, producing wrong
108082
+ // camera framing and stale bookkeeping.
108083
+ this.bbox = null;
108084
+ this.bb_max = 0;
108085
+ this.bb_radius = 0;
108086
+ this.lastBbox = null;
108087
+ this.materialSettings = null;
108088
+ this.renderOptions = null;
108089
+ this.tree = null;
108090
+ this.compactTree = null;
108091
+ this.expandedTree = null;
107702
108092
  }
107703
108093
  /**
107704
108094
  * Build nestedGroup and treeview for initial render.
@@ -107997,16 +108387,28 @@ class Viewer {
107997
108387
  this.setDirectLight(this.state.get("directIntensity"));
107998
108388
  this.display.setSliderLimits(this.gridSize / 2);
107999
108389
  this.display.syncClipSlidersFromState();
108000
- // Compute clip slider values (used later after ready=true)
108001
- const clipSlider0 = viewerOptions.clipSlider0 != null
108002
- ? viewerOptions.clipSlider0
108003
- : this.gridSize / 2;
108004
- const clipSlider1 = viewerOptions.clipSlider1 != null
108005
- ? viewerOptions.clipSlider1
108006
- : this.gridSize / 2;
108007
- const clipSlider2 = viewerOptions.clipSlider2 != null
108008
- ? viewerOptions.clipSlider2
108009
- : this.gridSize / 2;
108390
+ // Compute clip slider values (used later after ready=true).
108391
+ //
108392
+ // Three-tier policy:
108393
+ // 1. Caller passed a value (viewerOptions.clipSliderN != null) → use
108394
+ // it. Caller intent always wins.
108395
+ // 2. Same geometry as last render (gridSize unchanged) AND state has
108396
+ // a real value (≠ -1, the default sentinel) → reuse state. This
108397
+ // preserves the user's slider drag when re-rendering the same
108398
+ // model.
108399
+ // 3. New geometry (or first render) → default to gridSize/2.
108400
+ const gridSizeChanged = this._previousGridSize !== this.gridSize;
108401
+ this._previousGridSize = this.gridSize;
108402
+ const resolveSlider = (passed, stateValue) => {
108403
+ if (passed != null)
108404
+ return passed;
108405
+ if (!gridSizeChanged && stateValue !== -1)
108406
+ return stateValue;
108407
+ return this.gridSize / 2;
108408
+ };
108409
+ const clipSlider0 = resolveSlider(viewerOptions.clipSlider0, this.state.get("clipSlider0"));
108410
+ const clipSlider1 = resolveSlider(viewerOptions.clipSlider1, this.state.get("clipSlider1"));
108411
+ const clipSlider2 = resolveSlider(viewerOptions.clipSlider2, this.state.get("clipSlider2"));
108010
108412
  nestedGroup.setClipPlanes(clipping.clipPlanes);
108011
108413
  this.setLocalClipping(false); // only allow clipping when Clipping tab is selected
108012
108414
  clipping.setVisible(false);
@@ -108027,15 +108429,29 @@ class Viewer {
108027
108429
  this.display.showToolsPanel(false);
108028
108430
  this.rendered.orientationMarker.setVisible(false);
108029
108431
  }
108030
- // Apply clip settings AFTER ready=true (clip setters check this.ready)
108031
- // Set normals first (if provided), passing slider values to avoid reset to gridSize/2
108032
- this.setClipNormal(0, viewerOptions.clipNormal0 ?? null, clipSlider0, true);
108033
- this.setClipNormal(1, viewerOptions.clipNormal1 ?? null, clipSlider1, true);
108034
- this.setClipNormal(2, viewerOptions.clipNormal2 ?? null, clipSlider2, true);
108035
- // Set sliders for any planes without custom normals (setClipNormal returns early if normal is null)
108036
- this.setClipSlider(0, clipSlider0, true);
108037
- this.setClipSlider(1, clipSlider1, true);
108038
- this.setClipSlider(2, clipSlider2, true);
108432
+ // Apply clip settings AFTER ready=true (clip setters check this.ready).
108433
+ //
108434
+ // Same three-tier policy as clipSlider above (caller wins reuse state
108435
+ // on same geometry → reset on new geometry). The default normals are
108436
+ // the axis-aligned planes that match the Clipping subsystem's own
108437
+ // DEFAULT_NORMALS.
108438
+ //
108439
+ // Always passing a non-null normal means setClipNormal also handles the
108440
+ // slider write (it calls setClipSlider internally), so no separate
108441
+ // setClipSlider follow-up is needed here.
108442
+ const resolveNormal = (passed, stateValue, defaultTuple) => {
108443
+ if (passed != null)
108444
+ return passed;
108445
+ if (!gridSizeChanged)
108446
+ return [stateValue.x, stateValue.y, stateValue.z];
108447
+ return defaultTuple;
108448
+ };
108449
+ const clipNormal0 = resolveNormal(viewerOptions.clipNormal0, this.state.get("clipNormal0"), [-1, 0, 0]);
108450
+ const clipNormal1 = resolveNormal(viewerOptions.clipNormal1, this.state.get("clipNormal1"), [0, -1, 0]);
108451
+ const clipNormal2 = resolveNormal(viewerOptions.clipNormal2, this.state.get("clipNormal2"), [0, 0, -1]);
108452
+ this.setClipNormal(0, clipNormal0, clipSlider0, true);
108453
+ this.setClipNormal(1, clipNormal1, clipSlider1, true);
108454
+ this.setClipNormal(2, clipNormal2, clipSlider2, true);
108039
108455
  this.setClipIntersection(viewerOptions.clipIntersection ?? false, true);
108040
108456
  this.setClipObjectColorCaps(viewerOptions.clipObjectColors ?? false, true);
108041
108457
  this.setClipPlaneHelpers(viewerOptions.clipPlaneHelpers ?? false, true);
@@ -108047,15 +108463,38 @@ class Viewer {
108047
108463
  // Computed values from controls/camera
108048
108464
  target: { old: null, new: toVector3Tuple(controls.target.toArray()) },
108049
108465
  target0: { old: null, new: toVector3Tuple(controls.target0.toArray()) },
108050
- position: { old: null, new: this.rendered.camera.getPosition().toArray() },
108051
- quaternion: { old: null, new: this.rendered.camera.getQuaternion().toArray() },
108466
+ position: {
108467
+ old: null,
108468
+ new: this.rendered.camera.getPosition().toArray(),
108469
+ },
108470
+ quaternion: {
108471
+ old: null,
108472
+ new: this.rendered.camera.getQuaternion().toArray(),
108473
+ },
108052
108474
  zoom: { old: null, new: this.rendered.camera.getZoom() },
108053
108475
  // All config values from state
108054
108476
  ...this.state.getAllNotifiable(),
108055
108477
  });
108056
108478
  }
108057
108479
  timer.split("notification done");
108058
- this.update(true, false);
108480
+ // Initial paint and tab-landing logic.
108481
+ //
108482
+ // viewerOptions.tab can request a non-tree tab as the landing
108483
+ // tab. To avoid a CAD-mode → target-tab flicker, we skip the default
108484
+ // CAD update() in that case and let the activeTab subscription's
108485
+ // switchToTab handler paint the right content (or, for studio, show
108486
+ // the spinner over a blank canvas while async setup runs).
108487
+ const targetTab = viewerOptions.tab ?? "tree";
108488
+ if (targetTab === "tree") {
108489
+ this.update(true, false);
108490
+ }
108491
+ else {
108492
+ // setActiveTab fires the subscription synchronously; switchToTab
108493
+ // either paints (clip / zebra / material) or initiates Studio's
108494
+ // async load (showing the spinner). The first painted frame the
108495
+ // user sees is the target tab, not CAD.
108496
+ this.setActiveTab(targetTab);
108497
+ }
108059
108498
  treeview.update();
108060
108499
  this.display.setTheme(this.state.get("theme"));
108061
108500
  this.setZebraCount(this.state.get("zebraCount"));
@@ -108456,7 +108895,9 @@ class Viewer {
108456
108895
  // Store current state
108457
108896
  const camera = this.rendered.camera.getCamera();
108458
108897
  const zoom = camera.zoom; // For orthographic cameras
108459
- const offset = camera.position.clone().sub(this.rendered.controls.getTarget());
108898
+ const offset = camera.position
108899
+ .clone()
108900
+ .sub(this.rendered.controls.getTarget());
108460
108901
  // Update position and target
108461
108902
  camera.position.copy(targetVec.clone().add(offset));
108462
108903
  camera.updateWorldMatrix(true, false);
@@ -108691,7 +109132,10 @@ class Viewer {
108691
109132
  version: partData.version,
108692
109133
  id: wrapperId,
108693
109134
  name: "__addPart_tmp__",
108694
- loc: [[0, 0, 0], [0, 0, 0, 1]],
109135
+ loc: [
109136
+ [0, 0, 0],
109137
+ [0, 0, 0, 1],
109138
+ ],
108695
109139
  parts: [partData],
108696
109140
  };
108697
109141
  const wrapperGroup = nestedGroup.renderLoop(wrapper);
@@ -108871,8 +109315,7 @@ class Viewer {
108871
109315
  else {
108872
109316
  const edgePosAttr = group.edges.geometry.getAttribute("position");
108873
109317
  sameEdges =
108874
- edgePosAttr != null &&
108875
- edgePosAttr.count === flatLen(shape.edges) / 3;
109318
+ edgePosAttr != null && edgePosAttr.count === flatLen(shape.edges) / 3;
108876
109319
  }
108877
109320
  }
108878
109321
  if (!sameVertices || !sameTriangles || !sameEdges) {
@@ -109003,7 +109446,8 @@ class Viewer {
109003
109446
  // Only rebuild stencils if geometry grew beyond the region that stencils
109004
109447
  // were last built for. Shrinking geometry still fits within existing
109005
109448
  // stencils, so skip the expensive rebuild in that case.
109006
- const newCSize = 1.1 * Math.max(Math.abs(this.bbox.min.length()), Math.abs(this.bbox.max.length()));
109449
+ const newCSize = 1.1 *
109450
+ Math.max(Math.abs(this.bbox.min.length()), Math.abs(this.bbox.max.length()));
109007
109451
  if (newCSize > this._stencilCSize + 1e-6) {
109008
109452
  this._stencilCSize = newCSize;
109009
109453
  const clipping = this.rendered.clipping;
@@ -109049,9 +109493,7 @@ class Viewer {
109049
109493
  }
109050
109494
  const min = new Vector3(bb.xmin, bb.ymin, bb.zmin);
109051
109495
  const max = new Vector3(bb.xmax, bb.ymax, bb.zmax);
109052
- const center = new Vector3()
109053
- .addVectors(min, max)
109054
- .multiplyScalar(0.5);
109496
+ const center = new Vector3().addVectors(min, max).multiplyScalar(0.5);
109055
109497
  const requiredCSize = 1.1 * Math.max(Math.abs(min.length()), Math.abs(max.length()));
109056
109498
  if (requiredCSize > this._stencilCSize + 1e-6) {
109057
109499
  this._stencilCSize = requiredCSize;
@@ -109278,7 +109720,8 @@ class Viewer {
109278
109720
  if (value === undefined)
109279
109721
  continue;
109280
109722
  if (modifierKeys.has(key)) {
109281
- modifiers[key] = value;
109723
+ modifiers[key] =
109724
+ value;
109282
109725
  }
109283
109726
  else {
109284
109727
  actions[key] = value;
@@ -109334,6 +109777,7 @@ class Viewer {
109334
109777
  */
109335
109778
  resizeCadView(cadWidth, treeWidth, height, glass = false) {
109336
109779
  this.state.set("cadWidth", cadWidth);
109780
+ this.state.set("treeWidth", treeWidth);
109337
109781
  this.state.set("height", height);
109338
109782
  // Adapt renderer dimensions
109339
109783
  this.renderer.setSize(cadWidth, height);
@@ -109391,7 +109835,9 @@ class Slider {
109391
109835
  * @param notify - Whether to trigger the notification
109392
109836
  */
109393
109837
  this._notify = (value, notify = true) => {
109394
- if (this.type == "plane" && this.notifyCallback && this.index !== undefined) {
109838
+ if (this.type == "plane" &&
109839
+ this.notifyCallback &&
109840
+ this.index !== undefined) {
109395
109841
  const change = {};
109396
109842
  change[`clip_slider_${this.index - 1}`] = parseFloat(String(value));
109397
109843
  this.notifyCallback(change, notify);
@@ -109435,7 +109881,8 @@ class Slider {
109435
109881
  this.onSetSlider = options.onSetSlider || null;
109436
109882
  const sliderEl = container.getElementsByClassName(`tcv_sld_value_${index}`)[0];
109437
109883
  const inputEl = container.getElementsByClassName(`tcv_inp_value_${index}`)[0];
109438
- if (!(sliderEl instanceof HTMLInputElement) || !(inputEl instanceof HTMLInputElement)) {
109884
+ if (!(sliderEl instanceof HTMLInputElement) ||
109885
+ !(inputEl instanceof HTMLInputElement)) {
109439
109886
  throw new Error(`Slider elements not found for index "${index}" in container`);
109440
109887
  }
109441
109888
  this.slider = sliderEl;
@@ -109943,20 +110390,112 @@ function px(val) {
109943
110390
  return `${val}px`;
109944
110391
  }
109945
110392
  const MAT_EDITOR_PARAMS = [
109946
- { key: "metalness", label: "Metallic", min: 0, max: 1, step: 0.01, group: "PBR Core" },
109947
- { key: "roughness", label: "Roughness", min: 0, max: 1, step: 0.01, group: "PBR Core" },
109948
- { key: "clearcoat", label: "Clearcoat", min: 0, max: 1, step: 0.01, group: "Clearcoat" },
109949
- { key: "clearcoatRoughness", label: "Clearcoat Rough.", min: 0, max: 1, step: 0.01, group: "Clearcoat" },
109950
- { key: "transmission", label: "Transmission", min: 0, max: 1, step: 0.01, group: "Transmission" },
109951
- { key: "ior", label: "IOR", min: 1.0, max: 2.5, step: 0.01, group: "Transmission" },
109952
- { key: "thickness", label: "Thickness", min: 0, max: 10, step: 0.1, group: "Transmission" },
109953
- { key: "attenuationDistance", label: "Atten. Distance", min: 0, max: 100, step: 0.5, group: "Transmission", infinity: true },
110393
+ {
110394
+ key: "metalness",
110395
+ label: "Metallic",
110396
+ min: 0,
110397
+ max: 1,
110398
+ step: 0.01,
110399
+ group: "PBR Core",
110400
+ },
110401
+ {
110402
+ key: "roughness",
110403
+ label: "Roughness",
110404
+ min: 0,
110405
+ max: 1,
110406
+ step: 0.01,
110407
+ group: "PBR Core",
110408
+ },
110409
+ {
110410
+ key: "clearcoat",
110411
+ label: "Clearcoat",
110412
+ min: 0,
110413
+ max: 1,
110414
+ step: 0.01,
110415
+ group: "Clearcoat",
110416
+ },
110417
+ {
110418
+ key: "clearcoatRoughness",
110419
+ label: "Clearcoat Rough.",
110420
+ min: 0,
110421
+ max: 1,
110422
+ step: 0.01,
110423
+ group: "Clearcoat",
110424
+ },
110425
+ {
110426
+ key: "transmission",
110427
+ label: "Transmission",
110428
+ min: 0,
110429
+ max: 1,
110430
+ step: 0.01,
110431
+ group: "Transmission",
110432
+ },
110433
+ {
110434
+ key: "ior",
110435
+ label: "IOR",
110436
+ min: 1.0,
110437
+ max: 2.5,
110438
+ step: 0.01,
110439
+ group: "Transmission",
110440
+ },
110441
+ {
110442
+ key: "thickness",
110443
+ label: "Thickness",
110444
+ min: 0,
110445
+ max: 10,
110446
+ step: 0.1,
110447
+ group: "Transmission",
110448
+ },
110449
+ {
110450
+ key: "attenuationDistance",
110451
+ label: "Atten. Distance",
110452
+ min: 0,
110453
+ max: 100,
110454
+ step: 0.5,
110455
+ group: "Transmission",
110456
+ infinity: true,
110457
+ },
109954
110458
  { key: "sheen", label: "Sheen", min: 0, max: 1, step: 0.01, group: "Sheen" },
109955
- { key: "sheenRoughness", label: "Sheen Roughness", min: 0, max: 1, step: 0.01, group: "Sheen" },
109956
- { key: "specularIntensity", label: "Specular Intensity", min: 0, max: 2, step: 0.01, group: "Specular" },
109957
- { key: "anisotropy", label: "Anisotropy", min: 0, max: 1, step: 0.01, group: "Anisotropy" },
109958
- { key: "anisotropyRotation", label: "Anisotropy Rotation", min: 0, max: 6.28, step: 0.01, group: "Anisotropy" },
109959
- { key: "emissiveIntensity", label: "Emissive Intensity", min: 0, max: 5, step: 0.1, group: "Emissive" },
110459
+ {
110460
+ key: "sheenRoughness",
110461
+ label: "Sheen Roughness",
110462
+ min: 0,
110463
+ max: 1,
110464
+ step: 0.01,
110465
+ group: "Sheen",
110466
+ },
110467
+ {
110468
+ key: "specularIntensity",
110469
+ label: "Specular Intensity",
110470
+ min: 0,
110471
+ max: 2,
110472
+ step: 0.01,
110473
+ group: "Specular",
110474
+ },
110475
+ {
110476
+ key: "anisotropy",
110477
+ label: "Anisotropy",
110478
+ min: 0,
110479
+ max: 1,
110480
+ step: 0.01,
110481
+ group: "Anisotropy",
110482
+ },
110483
+ {
110484
+ key: "anisotropyRotation",
110485
+ label: "Anisotropy Rotation",
110486
+ min: 0,
110487
+ max: 6.28,
110488
+ step: 0.01,
110489
+ group: "Anisotropy",
110490
+ },
110491
+ {
110492
+ key: "emissiveIntensity",
110493
+ label: "Emissive Intensity",
110494
+ min: 0,
110495
+ max: 5,
110496
+ step: 0.1,
110497
+ group: "Emissive",
110498
+ },
109960
110499
  ];
109961
110500
  function _formatMatValue(value, step) {
109962
110501
  const decimals = step < 0.1 ? 2 : 1;
@@ -110405,7 +110944,13 @@ class Display {
110405
110944
  if (!(e.target instanceof HTMLSelectElement))
110406
110945
  return;
110407
110946
  const value = e.target.value;
110408
- if (value === "grey" || value === "darkgrey" || value === "white" || value === "gradient" || value === "gradient-dark" || value === "environment" || value === "transparent") {
110947
+ if (value === "grey" ||
110948
+ value === "darkgrey" ||
110949
+ value === "white" ||
110950
+ value === "gradient" ||
110951
+ value === "gradient-dark" ||
110952
+ value === "environment" ||
110953
+ value === "transparent") {
110409
110954
  this.state.set("studioBackground", value);
110410
110955
  }
110411
110956
  };
@@ -110623,7 +111168,8 @@ class Display {
110623
111168
  // Skip if target is a text-entry input element (but allow buttons/checkboxes)
110624
111169
  const target = e.target;
110625
111170
  if ((target instanceof HTMLInputElement &&
110626
- target.type !== "button" && target.type !== "checkbox") ||
111171
+ target.type !== "button" &&
111172
+ target.type !== "checkbox") ||
110627
111173
  target instanceof HTMLTextAreaElement ||
110628
111174
  target instanceof HTMLSelectElement) {
110629
111175
  return;
@@ -110767,7 +111313,7 @@ class Display {
110767
111313
  this._spinnerEl = this.container.querySelector(".tcv_studio_spinner");
110768
111314
  this._warningBannerEl = this.container.querySelector(".tcv_warning_banner");
110769
111315
  this.container.addEventListener("tcv-material-warnings", ((e) => {
110770
- this._showWarningBanner(`Unresolved material tag(s): ${e.detail.map(t => `"${t}"`).join(", ")}`);
111316
+ this._showWarningBanner(`Unresolved material tag(s): ${e.detail.map((t) => `"${t}"`).join(", ")}`);
110771
111317
  }));
110772
111318
  this.tabTree = this.getElement("tcv_tab_tree");
110773
111319
  this.tabClip = this.getElement("tcv_tab_clip");
@@ -111585,7 +112131,8 @@ class Display {
111585
112131
  attachCanvas(canvasElement) {
111586
112132
  // If the canvas is already attached elsewhere
111587
112133
  // do not re-parent it into this display.
111588
- if (canvasElement.parentElement && canvasElement.parentElement !== this.cadView) {
112134
+ if (canvasElement.parentElement &&
112135
+ canvasElement.parentElement !== this.cadView) {
111589
112136
  listeners.add(canvasElement, "click", () => {
111590
112137
  if (this.help_shown) {
111591
112138
  this.showHelp(false);
@@ -111636,7 +112183,8 @@ class Display {
111636
112183
  _deactivateToolsForStudio() {
111637
112184
  // If a tool is currently active, deactivate it cleanly
111638
112185
  const activeTool = this.state.get("activeTool");
111639
- if (activeTool && ["distance", "properties", "angle", "select"].includes(activeTool)) {
112186
+ if (activeTool &&
112187
+ ["distance", "properties", "angle", "select"].includes(activeTool)) {
111640
112188
  this.clickButtons[activeTool]?.set(false);
111641
112189
  this.setTool(activeTool, false);
111642
112190
  // setTool→toggleTab(false) silently sets activeTab to "tree" (no notification).
@@ -111744,7 +112292,13 @@ class Display {
111744
112292
  });
111745
112293
  }
111746
112294
  // Update tab styling
111747
- [this.tabTree, this.tabClip, this.tabZebra, this.tabMaterial, this.tabStudio].forEach((tabEl) => {
112295
+ [
112296
+ this.tabTree,
112297
+ this.tabClip,
112298
+ this.tabZebra,
112299
+ this.tabMaterial,
112300
+ this.tabStudio,
112301
+ ].forEach((tabEl) => {
111748
112302
  tabEl.classList.add("tcv_tab-unselected");
111749
112303
  tabEl.classList.remove("tcv_tab-selected");
111750
112304
  });
@@ -111841,11 +112395,15 @@ class Display {
111841
112395
  originalMat = currentMat;
111842
112396
  mat = currentMat.clone();
111843
112397
  // Preserve triplanar mapping if the original material uses it
111844
- if (currentMat.customProgramCacheKey() === "triplanar" && object.shapeGeometry) {
112398
+ if (currentMat.customProgramCacheKey() === "triplanar" &&
112399
+ object.shapeGeometry) {
111845
112400
  applyTriplanarMapping(mat, object.shapeGeometry);
111846
112401
  }
111847
112402
  object.front.material = mat;
111848
- this._matEditorClones.set(objectPath, { original: originalMat, clone: mat });
112403
+ this._matEditorClones.set(objectPath, {
112404
+ original: originalMat,
112405
+ clone: mat,
112406
+ });
111849
112407
  }
111850
112408
  // Restore elements that _showMatEditorHint may have hidden
111851
112409
  const resetBtn = dialog.querySelector(".tcv_mat_editor_reset");
@@ -111896,7 +112454,9 @@ class Display {
111896
112454
  try {
111897
112455
  groups = this.viewer.rendered.nestedGroup.groups;
111898
112456
  }
111899
- catch { /* not rendered */ }
112457
+ catch {
112458
+ /* not rendered */
112459
+ }
111900
112460
  for (const [path, { original, clone }] of this._matEditorClones.entries()) {
111901
112461
  // Restore original material on mesh before disposing the clone
111902
112462
  if (groups) {
@@ -111958,7 +112518,8 @@ class Display {
111958
112518
  if (!(currentMat instanceof MeshPhysicalMaterial))
111959
112519
  continue;
111960
112520
  const clone = currentMat.clone();
111961
- if (currentMat.customProgramCacheKey() === "triplanar" && group.shapeGeometry) {
112521
+ if (currentMat.customProgramCacheKey() === "triplanar" &&
112522
+ group.shapeGeometry) {
111962
112523
  applyTriplanarMapping(clone, group.shapeGeometry);
111963
112524
  }
111964
112525
  for (const [key, value] of Object.entries(changes)) {
@@ -111966,7 +112527,10 @@ class Display {
111966
112527
  clone[key] = value;
111967
112528
  }
111968
112529
  group.front.material = clone;
111969
- this._matEditorClones.set(path, { original: currentMat, clone });
112530
+ this._matEditorClones.set(path, {
112531
+ original: currentMat,
112532
+ clone,
112533
+ });
111970
112534
  }
111971
112535
  this._savedMatEditorChanges.clear();
111972
112536
  }
@@ -112009,7 +112573,10 @@ class Display {
112009
112573
  startX = e.clientX;
112010
112574
  startY = e.clientY;
112011
112575
  const rect = dialog.getBoundingClientRect();
112012
- const parentRect = dialog.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 };
112576
+ const parentRect = dialog.offsetParent?.getBoundingClientRect() ?? {
112577
+ left: 0,
112578
+ top: 0,
112579
+ };
112013
112580
  origLeft = rect.left - parentRect.left;
112014
112581
  origTop = rect.top - parentRect.top;
112015
112582
  // Switch from right-positioning to left-positioning for drag
@@ -112023,8 +112590,8 @@ class Display {
112023
112590
  return;
112024
112591
  const dx = e.clientX - startX;
112025
112592
  const dy = e.clientY - startY;
112026
- dialog.style.left = (origLeft + dx) + "px";
112027
- dialog.style.top = (origTop + dy) + "px";
112593
+ dialog.style.left = origLeft + dx + "px";
112594
+ dialog.style.top = origTop + dy + "px";
112028
112595
  }, { signal });
112029
112596
  document.addEventListener("mouseup", () => {
112030
112597
  dragging = false;
@@ -112042,7 +112609,8 @@ class Display {
112042
112609
  }
112043
112610
  let currentValue = // eslint-disable-next-line @typescript-eslint/no-explicit-any
112044
112611
  material[param.key];
112045
- const isInfinity = param.infinity === true && (currentValue === Infinity || currentValue == null);
112612
+ const isInfinity = param.infinity === true &&
112613
+ (currentValue === Infinity || currentValue == null);
112046
112614
  if (isInfinity)
112047
112615
  currentValue = param.max;
112048
112616
  this._buildMatEditorRow(content, param, currentValue ?? 0, isInfinity, originalMat);
@@ -112054,8 +112622,8 @@ class Display {
112054
112622
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
112055
112623
  const origValue = originalMat[param.key];
112056
112624
  const isChanged = (v) => param.infinity
112057
- ? (v >= param.max) !== (origValue === Infinity || origValue == null)
112058
- || (v < param.max && Math.abs(v - origValue) > param.step * 0.5)
112625
+ ? v >= param.max !== (origValue === Infinity || origValue == null) ||
112626
+ (v < param.max && Math.abs(v - origValue) > param.step * 0.5)
112059
112627
  : Math.abs(v - origValue) > param.step * 0.5;
112060
112628
  const label = document.createElement("label");
112061
112629
  label.className = "tcv_mat_editor_label";
@@ -112075,7 +112643,9 @@ class Display {
112075
112643
  const valueDisplay = document.createElement("input");
112076
112644
  valueDisplay.className = "tcv_clip_input";
112077
112645
  valueDisplay.readOnly = true;
112078
- valueDisplay.value = isInfinity ? "\u221E" : _formatMatValue(value, param.step);
112646
+ valueDisplay.value = isInfinity
112647
+ ? "\u221E"
112648
+ : _formatMatValue(value, param.step);
112079
112649
  slider.addEventListener("input", () => {
112080
112650
  const newValue = parseFloat(slider.value);
112081
112651
  const result = this.viewer.getSelectedObjectGroup();
@@ -112164,7 +112734,8 @@ class Display {
112164
112734
  this.studioShadowIntensitySlider?.setValueFromState(state.get("studioShadowIntensity") * 100);
112165
112735
  this.studioShadowSoftnessSlider?.setValueFromState(state.get("studioShadowSoftness") * 100);
112166
112736
  this.studioAOIntensitySlider?.setValueFromState(state.get("studioAOIntensity") * 10);
112167
- this.getInputElement("tcv_studio_4k_env_maps").checked = state.get("studio4kEnvMaps");
112737
+ this.getInputElement("tcv_studio_4k_env_maps").checked =
112738
+ state.get("studio4kEnvMaps");
112168
112739
  this._syncEnvDropdown(state.get("studioEnvironment"));
112169
112740
  const bgEl = this.container.querySelector(".tcv_studio_background");
112170
112741
  if (bgEl instanceof HTMLSelectElement)
@@ -112193,7 +112764,10 @@ class Display {
112193
112764
  }
112194
112765
  else {
112195
112766
  // Add or update a "Custom" optgroup with the custom HDR entry
112196
- const label = envName.split("/").pop()?.replace(/\.hdr$/i, "") || "Custom HDR";
112767
+ const label = envName
112768
+ .split("/")
112769
+ .pop()
112770
+ ?.replace(/\.hdr$/i, "") || "Custom HDR";
112197
112771
  let customGroup = el.querySelector("optgroup[data-custom]");
112198
112772
  if (customGroup) {
112199
112773
  const opt = customGroup.querySelector("option");
@@ -112224,7 +112798,8 @@ class Display {
112224
112798
  const isPreset = this.viewer.envManager.isPreset(envName);
112225
112799
  cb.disabled = !isPreset;
112226
112800
  if (!isPreset) {
112227
- cb.title = "4K switching is only available for built-in Poly Haven presets";
112801
+ cb.title =
112802
+ "4K switching is only available for built-in Poly Haven presets";
112228
112803
  }
112229
112804
  else {
112230
112805
  cb.title = "";
@@ -112257,8 +112832,17 @@ class Display {
112257
112832
  _dispatchAction(action) {
112258
112833
  // Toggle buttons
112259
112834
  const toggleActions = [
112260
- "axes", "axes0", "grid", "perspective", "transparent", "blackedges",
112261
- "explode", "zscale", "distance", "properties", "select",
112835
+ "axes",
112836
+ "axes0",
112837
+ "grid",
112838
+ "perspective",
112839
+ "transparent",
112840
+ "blackedges",
112841
+ "explode",
112842
+ "zscale",
112843
+ "distance",
112844
+ "properties",
112845
+ "select",
112262
112846
  ];
112263
112847
  if (toggleActions.includes(action)) {
112264
112848
  this._toggleClickButton(action);
@@ -112468,6 +113052,7 @@ class Display {
112468
113052
  * @public
112469
113053
  */
112470
113054
  setTheme(theme) {
113055
+ let resolved;
112471
113056
  if (theme === "dark" ||
112472
113057
  (theme === "browser" &&
112473
113058
  window.matchMedia("(prefers-color-scheme: dark)").matches)) {
@@ -112479,7 +113064,7 @@ class Display {
112479
113064
  this.viewer.gridHelper.update(this.viewer.getCameraZoom(), true, "dark");
112480
113065
  }
112481
113066
  this.viewer.update(true);
112482
- return "dark";
113067
+ resolved = "dark";
112483
113068
  }
112484
113069
  else {
112485
113070
  this.container.setAttribute("data-theme", "light");
@@ -112490,8 +113075,14 @@ class Display {
112490
113075
  this.viewer.gridHelper.update(this.viewer.getCameraZoom(), true, "light");
112491
113076
  }
112492
113077
  this.viewer.update(true);
112493
- return "light";
112494
- }
113078
+ resolved = "light";
113079
+ }
113080
+ // Keep state.theme in sync with the DOM. Without this, paths that call
113081
+ // setTheme directly (matchMedia listener, viewer.setTheme, MutationObserver
113082
+ // bridges) would update the DOM while leaving state.theme stale, and the
113083
+ // next viewer.render() would re-apply the stale state value
113084
+ this.viewer.state.set("theme", resolved, false);
113085
+ return resolved;
112495
113086
  }
112496
113087
  }
112497
113088