three-cad-viewer 5.0.6 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -81829,6 +81829,45 @@ class EventListenerManager {
81829
81829
  this.listeners = [];
81830
81830
  }
81831
81831
  }
81832
+ /**
81833
+ * Copy `text` to the clipboard from inside a user-initiated event handler.
81834
+ *
81835
+ * Tries the synchronous hidden-textarea `execCommand("copy")` first: it needs no
81836
+ * clipboard permission, so it also works in embedding iframes and WebKit views that
81837
+ * refuse `navigator.clipboard`. Falls back to the async Clipboard API.
81838
+ * Focus is restored to the previously focused element.
81839
+ *
81840
+ * @param text - The text to copy.
81841
+ * @param parent - Element the temporary textarea is attached to.
81842
+ * @returns true when the synchronous copy reported success.
81843
+ */
81844
+ function copyText(text, parent) {
81845
+ const previous = document.activeElement;
81846
+ const area = document.createElement("textarea");
81847
+ area.value = text;
81848
+ area.setAttribute("readonly", "");
81849
+ area.style.position = "fixed";
81850
+ area.style.left = "-9999px";
81851
+ area.style.opacity = "0";
81852
+ parent.appendChild(area);
81853
+ area.select();
81854
+ let copied;
81855
+ try {
81856
+ copied = document.execCommand("copy");
81857
+ }
81858
+ catch {
81859
+ copied = false;
81860
+ }
81861
+ parent.removeChild(area);
81862
+ if (previous instanceof HTMLElement)
81863
+ previous.focus({ preventScroll: true });
81864
+ if (!copied && navigator.clipboard !== undefined) {
81865
+ navigator.clipboard.writeText(text).catch((err) => {
81866
+ logger.debug("Clipboard write refused:", err);
81867
+ });
81868
+ }
81869
+ return copied;
81870
+ }
81832
81871
 
81833
81872
  /**
81834
81873
  * Zebra Analysis Tool for Three.js CAD Viewer
@@ -83999,7 +84038,10 @@ const BACKGROUND_ID = 0;
83999
84038
  * `Uint32BufferAttribute` under this name; the pick pass reads it as an **integer**
84000
84039
  * attribute (`gpuType = THREE.IntType`, GLSL3 `in uint`) so ids stay exact past
84001
84040
  * 2^24. The geometry side must set `gpuType = THREE.IntType` on the attribute for
84002
- * the pick shader to read it.
84041
+ * the pick shader to read it. Between the stages the id travels as two float
84042
+ * varyings (16-bit halves), deliberately NOT as a `flat uint` — see the note in
84043
+ * highlight.ts: a `flat` varying costs ~25x the scene's buffer bytes in graphics
84044
+ * memory under WebKit's ANGLE-on-Metal backend.
84003
84045
  */
84004
84046
  const COMPONENT_ID_ATTRIBUTE = "componentId";
84005
84047
  /**
@@ -84297,8 +84339,9 @@ const VERTEX_DEPTH_BIAS_FACTOR = 2e-4;
84297
84339
  */
84298
84340
  const PICK_POINT_SIZE = 3;
84299
84341
  /**
84300
- * Shared GLSL3 MRT fragment shader for all pick materials (face/vertex/edge): packs
84301
- * the flat `vId` into attachment 0 (byte order MUST match `packId()`: low byte in R)
84342
+ * Shared GLSL3 MRT fragment shader for all pick materials (face/vertex/edge):
84343
+ * reassembles the id from its two interpolated halves and packs it into attachment 0
84344
+ * (byte order MUST match `packId()`: low byte in R)
84302
84345
  * and writes the interpolated world position into attachment 1 (RGBA32F; `w=1` marks
84303
84346
  * "has position"). The fragment clip chunk only `discard`s (non-ALPHA_TO_COVERAGE),
84304
84347
  * so no `diffuseColor` symbol is required.
@@ -84306,13 +84349,15 @@ const PICK_POINT_SIZE = 3;
84306
84349
  const PICK_FRAGMENT_SHADER = /* glsl */ `
84307
84350
  #include <clipping_planes_pars_fragment>
84308
84351
 
84309
- flat in uint vId;
84352
+ in float vIdLo;
84353
+ in float vIdHi;
84310
84354
  in vec3 vWorldPos;
84311
84355
  layout(location = 0) out vec4 fragId;
84312
84356
  layout(location = 1) out vec4 fragPos;
84313
84357
 
84314
84358
  void main() {
84315
84359
  #include <clipping_planes_fragment>
84360
+ uint vId = ( uint( vIdHi + 0.5 ) << 16 ) | uint( vIdLo + 0.5 );
84316
84361
  fragId = vec4(
84317
84362
  float( vId & 0xFFu ) / 255.0,
84318
84363
  float( ( vId >> 8 ) & 0xFFu ) / 255.0,
@@ -84330,19 +84375,24 @@ const PICK_FRAGMENT_SHADER = /* glsl */ `
84330
84375
  function createFacePickMaterial(options = {}) {
84331
84376
  // GLSL3 (WebGL2). For a (non-Raw) ShaderMaterial three.js auto-injects
84332
84377
  // `in vec3 position;`, `uniform mat4 modelMatrix/modelViewMatrix/projectionMatrix`,
84333
- // so we declare ONLY the custom integer attribute + flat varying. Integer
84334
- // varyings MUST be `flat`. Clip chunks use legacy `varying`/`attribute` keywords
84378
+ // so we declare ONLY the custom integer attribute + the two id-half varyings
84379
+ // (float, interpolated — an integer varying would have to be `flat`, which is
84380
+ // the ANGLE-on-Metal memory trap described in highlight.ts; all vertices of a
84381
+ // primitive carry the same id, so interpolation + rounding is exact). Clip
84382
+ // chunks use legacy `varying`/`attribute` keywords
84335
84383
  // which three remaps via `#define` under GLSL3; the vertex chunk reads a local
84336
84384
  // `vec4 mvPosition`, which we compute before the include.
84337
84385
  const vertexShader = /* glsl */ `
84338
84386
  #include <clipping_planes_pars_vertex>
84339
84387
 
84340
84388
  in uint componentId;
84341
- flat out uint vId;
84389
+ out float vIdLo;
84390
+ out float vIdHi;
84342
84391
  out vec3 vWorldPos;
84343
84392
 
84344
84393
  void main() {
84345
- vId = componentId;
84394
+ vIdLo = float( componentId & 0xFFFFu );
84395
+ vIdHi = float( componentId >> 16 );
84346
84396
  vec4 worldPos = modelMatrix * vec4( position, 1.0 );
84347
84397
  vWorldPos = worldPos.xyz;
84348
84398
  vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
@@ -84381,13 +84431,15 @@ function createVertexPickMaterial(options = {}) {
84381
84431
  #include <clipping_planes_pars_vertex>
84382
84432
 
84383
84433
  in uint componentId;
84384
- flat out uint vId;
84434
+ out float vIdLo;
84435
+ out float vIdHi;
84385
84436
  out vec3 vWorldPos;
84386
84437
  uniform float uPickSize;
84387
84438
  uniform float uDepthBias;
84388
84439
 
84389
84440
  void main() {
84390
- vId = componentId;
84441
+ vIdLo = float( componentId & 0xFFFFu );
84442
+ vIdHi = float( componentId >> 16 );
84391
84443
  vec4 worldPos = modelMatrix * vec4( position, 1.0 );
84392
84444
  vWorldPos = worldPos.xyz;
84393
84445
  vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
@@ -84447,7 +84499,8 @@ function createEdgePickMaterial(options = {}) {
84447
84499
  in vec3 instanceEnd;
84448
84500
  in uint componentId;
84449
84501
 
84450
- flat out uint vId;
84502
+ out float vIdLo;
84503
+ out float vIdHi;
84451
84504
  out vec3 vWorldPos;
84452
84505
 
84453
84506
  void trimSegment( const in vec4 start, inout vec4 end ) {
@@ -84459,7 +84512,8 @@ function createEdgePickMaterial(options = {}) {
84459
84512
  }
84460
84513
 
84461
84514
  void main() {
84462
- vId = componentId;
84515
+ vIdLo = float( componentId & 0xFFFFu );
84516
+ vIdHi = float( componentId >> 16 );
84463
84517
 
84464
84518
  // World-space segment endpoints → approximate hit point along the edge.
84465
84519
  vWorldPos = ( position.y < 0.5 )
@@ -84997,20 +85051,41 @@ const U_HIGHLIGHT_HOVER_COLOR = "uHighlightHoverColor";
84997
85051
  //
84998
85052
  // three upgrades stock materials to GLSL ES 3.00 on WebGL2 via `#define attribute
84999
85053
  // in` / `#define varying out|in` macros (WebGLProgram.js), so writing `attribute` /
85000
- // `flat varying` here is converted automatically; `usampler2D` / `texelFetch` /
85054
+ // `varying` here is converted automatically; `usampler2D` / `texelFetch` /
85001
85055
  // integer attributes are then available.
85056
+ //
85057
+ // The component id crosses to the fragment stage as TWO ordinary (interpolated)
85058
+ // float varyings holding its 16-bit halves — NOT as a `flat uint`. A `flat`
85059
+ // varying is what an integer varying would require, and it is exactly what must
85060
+ // be avoided here: under WebKit's ANGLE-on-Metal backend, every draw whose
85061
+ // program has a `flat` varying makes ANGLE allocate a converted copy of the
85062
+ // draw's index data (>= 64 KB each, pooled, never returned to the GL on
85063
+ // deleteBuffer). With this varying on every visible material that was ~25x the
85064
+ // scene's vertex/index bytes in graphics memory (26 MB of buffers -> 812 MB;
85065
+ // 100 MB -> 2.3 GB) and it is charged to the page's WebContent process in the
85066
+ // system WebKit, which killed build123d Studio at 16 GB after a few large
85067
+ // shows. Without `flat` the same scene takes 63 MB / 160 MB. Measured 2026-09-17
85068
+ // (working-docs/leak-harness.html + probe2.html, Playwright WebKit and Safari).
85069
+ //
85070
+ // Interpolating floats is exact for the id's purpose: every vertex of a face,
85071
+ // every vertex of an instanced segment and a point carry the SAME id, so the
85072
+ // interpolated value equals it up to rounding, and `uint(x + 0.5)` recovers it.
85073
+ // Two 16-bit halves (both < 65536, exactly representable in float32) keep the
85074
+ // full 32-bit id range that `applyComponentIds` promises.
85002
85075
  // ---------------------------------------------------------------------------
85003
85076
  /**
85004
85077
  * Shared state-fetch GLSL, injected into BOTH stages: the vertex stage needs it for
85005
85078
  * widening / point size, the fragment for color. Declares the sampler + texWidth +
85006
- * the flat varying and a helper returning the component's {@link HighlightFlag} bits
85007
- * (0 for background / nothing).
85079
+ * the two id-half varyings and a helper returning the component's
85080
+ * {@link HighlightFlag} bits (0 for background / nothing).
85008
85081
  */
85009
85082
  const HL_STATE_GLSL = `
85010
- flat varying uint vHighlightId;
85083
+ varying float vHighlightIdLo;
85084
+ varying float vHighlightIdHi;
85011
85085
  uniform highp usampler2D ${U_HIGHLIGHT_STATE};
85012
85086
  uniform int ${U_HIGHLIGHT_TEX_WIDTH};
85013
85087
  uint highlightState() {
85088
+ uint vHighlightId = (uint(vHighlightIdHi + 0.5) << 16) | uint(vHighlightIdLo + 0.5);
85014
85089
  if (vHighlightId == 0u) return 0u;
85015
85090
  ivec2 hlUv = ivec2(
85016
85091
  int(vHighlightId) % ${U_HIGHLIGHT_TEX_WIDTH},
@@ -85025,8 +85100,8 @@ uint highlightState() {
85025
85100
  const HL_VERTEX_HEADER = `
85026
85101
  attribute uint ${COMPONENT_ID_ATTRIBUTE};
85027
85102
  ${HL_STATE_GLSL}`;
85028
- /** Vertex main: forward the id. Injected right after `void main() {`. */
85029
- const HL_VERTEX_ASSIGN = `vHighlightId = ${COMPONENT_ID_ATTRIBUTE};`;
85103
+ /** Vertex main: forward the id as two 16-bit halves. Injected right after `void main() {`. */
85104
+ const HL_VERTEX_ASSIGN = `vHighlightIdLo = float(${COMPONENT_ID_ATTRIBUTE} & 0xFFFFu); vHighlightIdHi = float(${COMPONENT_ID_ATTRIBUTE} >> 16);`;
85030
85105
  /** Fragment header: the shared state fetch + the two highlight colors. */
85031
85106
  const HL_FRAGMENT_HEADER = `
85032
85107
  ${HL_STATE_GLSL}
@@ -85060,7 +85135,7 @@ function focusSizeExpr(stateVar, hover, selected, none) {
85060
85135
  * renames an anchor must fail LOUDLY here, not silently drop the highlight (build
85061
85136
  * would otherwise stay green). One guard test asserts each anchor still exists.
85062
85137
  */
85063
- function replaceOrThrow(src, anchor, replacement, where) {
85138
+ function replaceOrThrow$1(src, anchor, replacement, where) {
85064
85139
  if (!src.includes(anchor)) {
85065
85140
  throw new Error(`HighlightController.${where}: shader anchor not found: ${JSON.stringify(anchor)}`);
85066
85141
  }
@@ -85178,6 +85253,23 @@ class HighlightController {
85178
85253
  }
85179
85254
  this._applyHover(`s${solidPath}`, ids);
85180
85255
  }
85256
+ /**
85257
+ * HOVER every FACE at or below the tree path `prefix` (a leaf or a whole group),
85258
+ * or clear hover when `null`. Drives the tree-row hover highlight.
85259
+ */
85260
+ setHoverPath(prefix) {
85261
+ if (prefix == null) {
85262
+ this._applyHover("", []);
85263
+ return;
85264
+ }
85265
+ const sub = prefix + "/";
85266
+ const ids = [];
85267
+ for (const info of this.registry.entries()) {
85268
+ if (info.topo === "face" && info.path.startsWith(sub))
85269
+ ids.push(info.id);
85270
+ }
85271
+ this._applyHover(`p${prefix}`, ids);
85272
+ }
85181
85273
  /** Set or clear the SELECTED flag for a single component id. */
85182
85274
  setSelected(id, flag) {
85183
85275
  this._setBit(id, HighlightFlag.SELECTED, flag);
@@ -85276,11 +85368,11 @@ class HighlightController {
85276
85368
  this.uniforms.uHighlightSelectedColor;
85277
85369
  shader.uniforms[U_HIGHLIGHT_HOVER_COLOR] =
85278
85370
  this.uniforms.uHighlightHoverColor;
85279
- // Common: forward the component id as a flat varying.
85371
+ // Common: forward the component id (as two interpolated float halves).
85280
85372
  shader.vertexShader =
85281
85373
  HL_VERTEX_HEADER +
85282
85374
  "\n" +
85283
- replaceOrThrow(shader.vertexShader, "void main() {", `void main() {\n ${HL_VERTEX_ASSIGN}`, where);
85375
+ replaceOrThrow$1(shader.vertexShader, "void main() {", `void main() {\n ${HL_VERTEX_ASSIGN}`, where);
85284
85376
  shader.fragmentShader = HL_FRAGMENT_HEADER + "\n" + shader.fragmentShader;
85285
85377
  // Topo-specific color / size injection.
85286
85378
  customize(shader);
@@ -85295,7 +85387,7 @@ class HighlightController {
85295
85387
  */
85296
85388
  patchFaceMaterial(material) {
85297
85389
  this._install(material, "patchFaceMaterial", (shader) => {
85298
- shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${HL_COLOR_OVERRIDE}`, "patchFaceMaterial");
85390
+ shader.fragmentShader = replaceOrThrow$1(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${HL_COLOR_OVERRIDE}`, "patchFaceMaterial");
85299
85391
  });
85300
85392
  }
85301
85393
  /**
@@ -85307,9 +85399,9 @@ class HighlightController {
85307
85399
  */
85308
85400
  patchEdgeMaterial(material) {
85309
85401
  this._install(material, "patchEdgeMaterial", (shader) => {
85310
- shader.vertexShader = replaceOrThrow(shader.vertexShader, "offset *= linewidth;", `uint hlEdgeState = highlightState();
85402
+ shader.vertexShader = replaceOrThrow$1(shader.vertexShader, "offset *= linewidth;", `uint hlEdgeState = highlightState();
85311
85403
  offset *= ${focusSizeExpr("hlEdgeState", EDGE_FOCUS_WIDTH, EDGE_FOCUS_WIDTH - 2, "linewidth")};`, "patchEdgeMaterial");
85312
- shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${HL_COLOR_OVERRIDE}`, "patchEdgeMaterial");
85404
+ shader.fragmentShader = replaceOrThrow$1(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${HL_COLOR_OVERRIDE}`, "patchEdgeMaterial");
85313
85405
  });
85314
85406
  }
85315
85407
  /**
@@ -85324,12 +85416,12 @@ class HighlightController {
85324
85416
  const cull = options.cullUnhighlighted === true;
85325
85417
  const none = cull ? "0.0" : "size";
85326
85418
  this._install(material, "patchVertexMaterial", (shader) => {
85327
- shader.vertexShader = replaceOrThrow(shader.vertexShader, "gl_PointSize = size;", `uint hlPtState = highlightState();
85419
+ shader.vertexShader = replaceOrThrow$1(shader.vertexShader, "gl_PointSize = size;", `uint hlPtState = highlightState();
85328
85420
  gl_PointSize = ${focusSizeExpr("hlPtState", VERTEX_FOCUS_SIZE, VERTEX_FOCUS_SIZE - 2, none)};`, "patchVertexMaterial");
85329
85421
  const discard = cull
85330
85422
  ? "\n if (highlightState() == 0u) discard;"
85331
85423
  : "";
85332
- shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${discard}${HL_COLOR_OVERRIDE}`, "patchVertexMaterial");
85424
+ shader.fragmentShader = replaceOrThrow$1(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${discard}${HL_COLOR_OVERRIDE}`, "patchVertexMaterial");
85333
85425
  },
85334
85426
  // The cull discard is the variant that must NOT share a program with the
85335
85427
  // standalone (always-visible) vertex cloud — the ghost-vertex bug.
@@ -88808,7 +88900,14 @@ class Grid extends Group {
88808
88900
  group.name = `GridHelper-${i}`;
88809
88901
  group.add(new GridHelper(this.size, 2 * this.ticks, this.colors[this.theme][i === 0 ? 1 : i === 1 ? 0 : 2], this.colors[this.theme][i === 0 ? 0 : i === 1 ? 2 : 1], this.theme == "dark" ? 0x7777777 : 0xbbbbbb));
88810
88902
  let label;
88811
- for (let x = -this.size / 2; x <= this.size / 2; x += this.delta / 2) {
88903
+ // A grid of no size has no labels, and asking for them hangs the tab:
88904
+ // `niceBounds` answers [0, 0, 0] for a bounding box whose largest extent
88905
+ // is zero - which is what an empty model gives it - so `delta` is zero
88906
+ // and the loop below steps by zero, never advancing and never throwing.
88907
+ // Guarded here rather than in `niceBounds`, whose answer is right: there
88908
+ // is nothing to place ticks on.
88909
+ const step = this.delta / 2;
88910
+ for (let x = -this.size / 2; step > 0 && x <= this.size / 2; x += step) {
88812
88911
  if (Math.abs(x) < 1e-6) {
88813
88912
  continue;
88814
88913
  } // skip center label
@@ -90115,8 +90214,9 @@ class TreeView {
90115
90214
  * @param theme - The UI theme ('light' or 'dark').
90116
90215
  * @param linkIcons - Whether icon 0 and 1 are linked.
90117
90216
  * @param debug - Enable debug logging.
90217
+ * @param hoverHandler - Callback for label hover (node path, or null on leave).
90118
90218
  */
90119
- constructor(tree, scrollContainer, objectHandler, pickHandler, updateHandler, notificationHandler, colorGetter, theme, linkIcons, debug = false) {
90219
+ constructor(tree, scrollContainer, objectHandler, pickHandler, updateHandler, notificationHandler, colorGetter, theme, linkIcons, debug = false, hoverHandler = null) {
90120
90220
  /************************************************************************************
90121
90221
  * Handlers
90122
90222
  ************************************************************************************/
@@ -90231,6 +90331,7 @@ class TreeView {
90231
90331
  this.theme = theme;
90232
90332
  this.linkIcons = linkIcons;
90233
90333
  this.debug = debug;
90334
+ this.hoverHandler = hoverHandler;
90234
90335
  this.model = null;
90235
90336
  this.container = null;
90236
90337
  this.lastLabel = null;
@@ -90428,6 +90529,12 @@ class TreeView {
90428
90529
  e.stopPropagation();
90429
90530
  this.handleLabelClick(node, e);
90430
90531
  };
90532
+ label.onmouseenter = () => {
90533
+ this.hoverHandler?.(this.getNodePath(node));
90534
+ };
90535
+ label.onmouseleave = () => {
90536
+ this.hoverHandler?.(null);
90537
+ };
90431
90538
  nodeContent.appendChild(label);
90432
90539
  let childrenContainer = null;
90433
90540
  if (node.children) {
@@ -90919,6 +91026,357 @@ let Timer$1 = class Timer {
90919
91026
  }
90920
91027
  };
90921
91028
 
91029
+ /**
91030
+ * Section hatching for clip caps (CAD mode, shader-only). The per-solid cap quads
91031
+ * draw hatch lines anchored to the plane (stable while rotating/panning) whose
91032
+ * spacing is kept at a constant pixel distance by a per-frame world-per-pixel
91033
+ * uniform. The outline of the caps is drawn separately (see cap-outline.ts); for its
91034
+ * id pass the same cap shader can output the cap's id instead of its color.
91035
+ *
91036
+ * All shared uniforms are plain `{ value }` objects held by reference, so a single
91037
+ * write (e.g. the per-frame hatch spacing) updates every patched material without a
91038
+ * recompile.
91039
+ */
91040
+ /** Distance between hatch lines in pixels. */
91041
+ const HATCH_SPACING_PX = 8;
91042
+ /** Width of a hatch line in pixels. */
91043
+ const HATCH_LINE_WIDTH_PX = 1;
91044
+ /**
91045
+ * Hatch angle (radians) and spacing factor per solid, cycled by the solid's index
91046
+ * so that neighbouring solids are told apart (drawing convention: alternate the
91047
+ * hatch direction between adjacent parts).
91048
+ */
91049
+ const HATCH_VARIANTS = [
91050
+ { angle: Math.PI / 4, scale: 1.0 },
91051
+ { angle: (3 * Math.PI) / 4, scale: 1.0 },
91052
+ { angle: Math.PI / 4, scale: 1.6 },
91053
+ { angle: (3 * Math.PI) / 4, scale: 1.6 },
91054
+ ];
91055
+ function createHatchUniforms(capHalfSize) {
91056
+ return {
91057
+ uHatchSpacing: { value: 1 },
91058
+ uCapHalfSize: { value: capHalfSize },
91059
+ uHatchLineWidth: { value: HATCH_LINE_WIDTH_PX },
91060
+ uHatchOn: { value: 1 },
91061
+ uCapIdPass: { value: 0 },
91062
+ };
91063
+ }
91064
+ /**
91065
+ * World units covered by one screen pixel at the given point (exact for an
91066
+ * orthographic camera; at the point's distance for a perspective camera).
91067
+ */
91068
+ function worldPerPixel(camera, point, height) {
91069
+ if (height <= 0)
91070
+ return 1;
91071
+ if (camera instanceof OrthographicCamera) {
91072
+ return (camera.top - camera.bottom) / camera.zoom / height;
91073
+ }
91074
+ if (camera instanceof PerspectiveCamera) {
91075
+ const dist = camera.position.distanceTo(point);
91076
+ const halfFov = MathUtils.degToRad(camera.fov) / 2;
91077
+ return (2 * dist * Math.tan(halfFov)) / camera.zoom / height;
91078
+ }
91079
+ return 1;
91080
+ }
91081
+ function replaceOrThrow(source, anchor, replacement, where) {
91082
+ if (!source.includes(anchor)) {
91083
+ throw new Error(`${where}: shader anchor "${anchor}" not found`);
91084
+ }
91085
+ return source.replace(anchor, replacement);
91086
+ }
91087
+ /**
91088
+ * Patch a cap quad material to draw section hatching. `index` is the solid's index
91089
+ * among the caps of its plane; it selects the hatch direction and spacing. `capId`
91090
+ * is the cap's unique id color for the outline id pass (see `capIdColor`).
91091
+ */
91092
+ function patchHatchMaterial(material, uniforms, index, capId) {
91093
+ if (material.userData.hatchPatched === true)
91094
+ return;
91095
+ material.userData.hatchPatched = true;
91096
+ const variant = HATCH_VARIANTS[index % HATCH_VARIANTS.length];
91097
+ const uHatchAngle = { value: variant.angle };
91098
+ const uHatchScale = { value: variant.scale };
91099
+ const uCapId = { value: capId };
91100
+ const prev = material.onBeforeCompile;
91101
+ material.onBeforeCompile = (shader, renderer) => {
91102
+ prev.call(material, shader, renderer);
91103
+ shader.uniforms.uHatchSpacing = uniforms.uHatchSpacing;
91104
+ shader.uniforms.uCapHalfSize = uniforms.uCapHalfSize;
91105
+ shader.uniforms.uHatchLineWidth = uniforms.uHatchLineWidth;
91106
+ shader.uniforms.uHatchOn = uniforms.uHatchOn;
91107
+ shader.uniforms.uHatchAngle = uHatchAngle;
91108
+ shader.uniforms.uHatchScale = uHatchScale;
91109
+ shader.uniforms.uCapIdPass = uniforms.uCapIdPass;
91110
+ shader.uniforms.uCapId = uCapId;
91111
+ shader.vertexShader =
91112
+ "uniform float uCapHalfSize;\nvarying vec2 vTcvCapUV;\n" +
91113
+ replaceOrThrow(shader.vertexShader, "#include <begin_vertex>", "#include <begin_vertex>\n vTcvCapUV = position.xy * uCapHalfSize;", "patchHatchMaterial");
91114
+ shader.fragmentShader =
91115
+ "uniform float uHatchSpacing;\nuniform float uHatchLineWidth;\nuniform float uHatchOn;\n" +
91116
+ "uniform float uHatchAngle;\nuniform float uHatchScale;\nvarying vec2 vTcvCapUV;\n" +
91117
+ "uniform float uCapIdPass;\nuniform vec3 uCapId;\n" +
91118
+ shader.fragmentShader;
91119
+ // Hatch the unlit base color, before lighting: the ink (dark lines on light
91120
+ // parts, light lines on dark parts) is then fixed per part, and lines and cap
91121
+ // darken together when the cap turns away from the light.
91122
+ shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <color_fragment>",
91123
+ /* glsl */ `#include <color_fragment>
91124
+ if ( uHatchOn > 0.5 ) {
91125
+ vec2 tcvAcross = vec2( - sin( uHatchAngle ), cos( uHatchAngle ) );
91126
+ float t = dot( vTcvCapUV, tcvAcross ) / max( uHatchSpacing * uHatchScale, 1e-9 );
91127
+ float tw = max( fwidth( t ), 1e-6 );
91128
+ float dpx = ( 0.5 - abs( fract( t ) - 0.5 ) ) / tw;
91129
+ float line = 1.0 - smoothstep( 0.5 * uHatchLineWidth - 0.5, 0.5 * uHatchLineWidth + 0.5, dpx );
91130
+ // Seen edge-on the lines crowd below a few pixels apart and would merge into
91131
+ // solid ink: fade them out between 4 and 2 px spacing (1 / tw = px per line).
91132
+ line *= smoothstep( 2.0, 4.0, 1.0 / tw );
91133
+ float lum = dot( diffuseColor.rgb, vec3( 0.2126, 0.7152, 0.0722 ) );
91134
+ vec3 ink = lum > 0.2 ? diffuseColor.rgb * 0.3 : mix( diffuseColor.rgb, vec3( 1.0 ), 0.6 );
91135
+ diffuseColor.rgb = mix( diffuseColor.rgb, ink, line );
91136
+ }`, "patchHatchMaterial");
91137
+ shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <opaque_fragment>", `#include <opaque_fragment>
91138
+ if ( uCapIdPass > 0.5 ) gl_FragColor = vec4( uCapId, 1.0 );`, "patchHatchMaterial");
91139
+ };
91140
+ material.needsUpdate = true;
91141
+ }
91142
+
91143
+ /**
91144
+ * Screen-space outline of the clip caps (CAD mode).
91145
+ *
91146
+ * The caps carry no geometry of their own outline, and the faces next to a cut may
91147
+ * face away from the camera or belong to a touching solid, so the outline is found
91148
+ * in screen space instead:
91149
+ *
91150
+ * 1. Id pass into an offscreen target (color + depth + stencil):
91151
+ * - visible faces as black occluders (face pick layer + override material), so a
91152
+ * cap hidden behind another part gets no outline; face pixels within the line
91153
+ * width of a clip plane write {@link CUT_EDGE_ID} instead, which draws the cut
91154
+ * contour on the faces themselves (needed when the caps are behind the faces),
91155
+ * - the stencil meshes and caps (on {@link CAP_LAYER}) with each cap writing its
91156
+ * own id instead of its shaded color — the same stencil sequence as the main
91157
+ * render, since three orders them identically (material creation order).
91158
+ * 2. Composite over the canvas: a pixel is drawn in the edge color when its id is
91159
+ * larger than a neighbour's id, which yields a line on one side of every boundary
91160
+ * cap↔cap, cap↔surface and cap↔background. Cut-edge pixels are the largest id, so
91161
+ * where they meet a cap only they are drawn — one line, not two.
91162
+ */
91163
+ /** Render layer of the clip stencil meshes and cap quads (pick layers use 1-3). */
91164
+ const CAP_LAYER = 4;
91165
+ /** Id of face pixels on the cut contour (larger than any cap id). */
91166
+ const CUT_EDGE_ID = 0xffffff;
91167
+ /** Encode a cap id (1..2^24-1) as an exact 8-bit RGB color (0 = no cap). */
91168
+ function capIdColor(id) {
91169
+ return new Vector3((id & 0xff) / 255, ((id >> 8) & 0xff) / 255, ((id >> 16) & 0xff) / 255);
91170
+ }
91171
+ // Occluder: a face fragment lies on the cut contour when it is within uCutWidth
91172
+ // pixels of a clip plane that bounds the visible part, mirroring three's
91173
+ // clipping_planes_fragment (kept fragments have d >= 0). Skipped where the face is
91174
+ // (nearly) parallel to the plane: there d barely changes across the pixel, and a
91175
+ // face lying in the plane would otherwise become one big edge.
91176
+ const CUT_EDGE_GLSL = /* glsl */ `
91177
+ bool tcvCut = false;
91178
+ #if NUM_CLIPPING_PLANES > 0
91179
+ {
91180
+ float tcvPosGrad = length( fwidth( vClipPosition ) );
91181
+ float tcvD[ NUM_CLIPPING_PLANES ];
91182
+ bool tcvNear[ NUM_CLIPPING_PLANES ];
91183
+ for ( int i = 0; i < NUM_CLIPPING_PLANES; i ++ ) {
91184
+ vec4 p = clippingPlanes[ i ];
91185
+ float d = p.w - dot( vClipPosition, p.xyz );
91186
+ // Step of d per pixel along the dominant screen axis — the same 1-pixel
91187
+ // convention as the composite's horizontal/vertical neighbour test.
91188
+ float g = max( abs( dFdx( d ) ), abs( dFdy( d ) ) );
91189
+ tcvD[ i ] = d;
91190
+ tcvNear[ i ] = g > 0.05 * tcvPosGrad && d >= 0.0 && d < uCutWidth * g;
91191
+ }
91192
+ for ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {
91193
+ tcvCut = tcvCut || tcvNear[ i ];
91194
+ }
91195
+ #if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES
91196
+ // Intersection planes: plane i bounds the visible part only where every
91197
+ // other intersection plane clips.
91198
+ for ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {
91199
+ bool othersClip = true;
91200
+ for ( int j = UNION_CLIPPING_PLANES; j < NUM_CLIPPING_PLANES; j ++ ) {
91201
+ if ( j != i && tcvD[ j ] >= 0.0 ) othersClip = false;
91202
+ }
91203
+ if ( othersClip && tcvNear[ i ] ) tcvCut = true;
91204
+ }
91205
+ #endif
91206
+ }
91207
+ #endif
91208
+ `;
91209
+ const COMPOSITE_VERTEX = /* glsl */ `
91210
+ varying vec2 vUv;
91211
+ void main() {
91212
+ vUv = uv;
91213
+ gl_Position = vec4( position.xy, 0.0, 1.0 );
91214
+ }
91215
+ `;
91216
+ const COMPOSITE_FRAGMENT = /* glsl */ `
91217
+ uniform sampler2D tId;
91218
+ uniform vec2 uTexel;
91219
+ uniform float uWidth;
91220
+ uniform vec3 uColor;
91221
+ varying vec2 vUv;
91222
+
91223
+ float idAt( vec2 uv ) {
91224
+ vec3 c = floor( texture2D( tId, uv ).rgb * 255.0 + 0.5 );
91225
+ return c.r + 256.0 * c.g + 65536.0 * c.b;
91226
+ }
91227
+
91228
+ void main() {
91229
+ float c = idAt( vUv );
91230
+ bool edge = c == ${CUT_EDGE_ID.toFixed(1)};
91231
+ for ( int k = 1; k <= 4; k ++ ) {
91232
+ if ( float( k ) > uWidth ) break;
91233
+ vec2 o = uTexel * float( k );
91234
+ edge = edge
91235
+ || c > idAt( vUv + vec2( o.x, 0.0 ) ) || c > idAt( vUv - vec2( o.x, 0.0 ) )
91236
+ || c > idAt( vUv + vec2( 0.0, o.y ) ) || c > idAt( vUv - vec2( 0.0, o.y ) );
91237
+ }
91238
+ if ( ! edge ) discard;
91239
+ gl_FragColor = vec4( uColor, 1.0 );
91240
+ #include <colorspace_fragment>
91241
+ }
91242
+ `;
91243
+ class CapOutlinePass {
91244
+ constructor() {
91245
+ this.target = null;
91246
+ /** Width of the cut contour on the faces, in device pixels (= outline width). */
91247
+ this.cutWidth = { value: 1 };
91248
+ this.size = new Vector2();
91249
+ this.savedClearColor = new Color();
91250
+ this.savedViewport = new Vector4();
91251
+ // One depth unit behind the caps (polygonOffset 1/1), so a face coplanar with a
91252
+ // cap does not hide it; same slope factor, so steep faces in front still occlude.
91253
+ this.occluder = new MeshBasicMaterial({
91254
+ color: 0x000000,
91255
+ side: DoubleSide,
91256
+ polygonOffset: true,
91257
+ polygonOffsetFactor: 1,
91258
+ polygonOffsetUnits: 2,
91259
+ });
91260
+ this.occluder.onBeforeCompile = (shader) => {
91261
+ const clipAnchor = "#include <clipping_planes_fragment>";
91262
+ const opaqueAnchor = "#include <opaque_fragment>";
91263
+ for (const anchor of [clipAnchor, opaqueAnchor]) {
91264
+ if (!shader.fragmentShader.includes(anchor)) {
91265
+ throw new Error(`CapOutlinePass: shader anchor "${anchor}" not found`);
91266
+ }
91267
+ }
91268
+ shader.uniforms.uCutWidth = this.cutWidth;
91269
+ shader.fragmentShader =
91270
+ "uniform float uCutWidth;\n" +
91271
+ shader.fragmentShader
91272
+ .replace(clipAnchor, `${clipAnchor}\n${CUT_EDGE_GLSL}`)
91273
+ .replace(opaqueAnchor, `${opaqueAnchor}\n if ( tcvCut ) gl_FragColor = vec4( 1.0 );`);
91274
+ };
91275
+ this.composite = new ShaderMaterial({
91276
+ uniforms: {
91277
+ tId: { value: null },
91278
+ uTexel: { value: new Vector2(1, 1) },
91279
+ uWidth: { value: 1 },
91280
+ uColor: { value: new Color() },
91281
+ },
91282
+ vertexShader: COMPOSITE_VERTEX,
91283
+ fragmentShader: COMPOSITE_FRAGMENT,
91284
+ depthTest: false,
91285
+ depthWrite: false,
91286
+ });
91287
+ this.quad = new Mesh(new PlaneGeometry(2, 2), this.composite);
91288
+ this.quad.frustumCulled = false;
91289
+ this.quadScene = new Scene();
91290
+ this.quadScene.add(this.quad);
91291
+ this.quadCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
91292
+ }
91293
+ ensureTarget(width, height) {
91294
+ if (this.target === null) {
91295
+ this.target = new WebGLRenderTarget(width, height, {
91296
+ minFilter: NearestFilter,
91297
+ magFilter: NearestFilter,
91298
+ generateMipmaps: false,
91299
+ depthBuffer: true,
91300
+ stencilBuffer: true,
91301
+ });
91302
+ }
91303
+ else if (this.target.width !== width || this.target.height !== height) {
91304
+ this.target.setSize(width, height);
91305
+ }
91306
+ return this.target;
91307
+ }
91308
+ /**
91309
+ * Render the id pass and draw the outline over the current (canvas) framebuffer.
91310
+ * Call right after the main scene render.
91311
+ */
91312
+ render(renderer, scene, camera, options) {
91313
+ renderer.getDrawingBufferSize(this.size);
91314
+ const width = Math.floor(this.size.x);
91315
+ const height = Math.floor(this.size.y);
91316
+ if (width < 1 || height < 1)
91317
+ return;
91318
+ const target = this.ensureTarget(width, height);
91319
+ // Line width in device pixels, shared by the cut contour and the cap outline.
91320
+ const lineWidth = Math.max(1, Math.round(renderer.getPixelRatio()));
91321
+ this.cutWidth.value = lineWidth;
91322
+ const savedMask = camera.layers.mask;
91323
+ const savedOverride = scene.overrideMaterial;
91324
+ const savedBackground = scene.background;
91325
+ const savedTarget = renderer.getRenderTarget();
91326
+ const savedAutoClear = renderer.autoClear;
91327
+ const savedAlpha = renderer.getClearAlpha();
91328
+ renderer.getClearColor(this.savedClearColor);
91329
+ renderer.getViewport(this.savedViewport);
91330
+ try {
91331
+ renderer.autoClear = false;
91332
+ scene.background = null;
91333
+ renderer.setRenderTarget(target);
91334
+ renderer.setClearColor(0x000000, 0); // id 0 = no cap
91335
+ renderer.clear(true, true, true);
91336
+ if (options.occlude) {
91337
+ this.occluder.clippingPlanes = options.planes;
91338
+ this.occluder.clipIntersection = options.intersection;
91339
+ camera.layers.set(PICK_LAYER.FACE);
91340
+ scene.overrideMaterial = this.occluder;
91341
+ renderer.render(scene, camera);
91342
+ scene.overrideMaterial = savedOverride;
91343
+ }
91344
+ options.setCapIdPass(true);
91345
+ try {
91346
+ camera.layers.set(CAP_LAYER);
91347
+ renderer.render(scene, camera);
91348
+ }
91349
+ finally {
91350
+ options.setCapIdPass(false);
91351
+ }
91352
+ renderer.setRenderTarget(savedTarget);
91353
+ renderer.setViewport(this.savedViewport);
91354
+ const u = this.composite.uniforms;
91355
+ u.tId.value = target.texture;
91356
+ u.uTexel.value.set(1 / width, 1 / height);
91357
+ u.uWidth.value = lineWidth;
91358
+ u.uColor.value.set(options.color);
91359
+ renderer.render(this.quadScene, this.quadCamera);
91360
+ }
91361
+ finally {
91362
+ camera.layers.mask = savedMask;
91363
+ scene.overrideMaterial = savedOverride;
91364
+ scene.background = savedBackground;
91365
+ renderer.setRenderTarget(savedTarget);
91366
+ renderer.setClearColor(this.savedClearColor, savedAlpha);
91367
+ renderer.setViewport(this.savedViewport);
91368
+ renderer.autoClear = savedAutoClear;
91369
+ }
91370
+ }
91371
+ dispose() {
91372
+ this.target?.dispose();
91373
+ this.target = null;
91374
+ this.occluder.dispose();
91375
+ this.composite.dispose();
91376
+ this.quad.geometry.dispose();
91377
+ }
91378
+ }
91379
+
90922
91380
  // ============================================================================
90923
91381
  // Constants
90924
91382
  // ============================================================================
@@ -91171,6 +91629,9 @@ class Clipping extends Group {
91171
91629
  super();
91172
91630
  /** Per-solid stencil/cap units, the unit of screen-size culling. */
91173
91631
  this._capUnits = [];
91632
+ /** Shared uniforms of the section hatching on all cap quads. */
91633
+ this._hatchUniforms = createHatchUniforms(1);
91634
+ this._hatchCenter = new Vector3();
91174
91635
  /**
91175
91636
  * Whether {@link cull} last ran with clipping active. Lets the inactive path
91176
91637
  * gate stencils/caps off exactly once, then early-return on later still frames.
@@ -91234,6 +91695,10 @@ class Clipping extends Group {
91234
91695
  mesh.material.visible = flag;
91235
91696
  }
91236
91697
  };
91698
+ /** Switch all cap materials between shaded output and outline id output. */
91699
+ this.setCapIdPass = (flag) => {
91700
+ this._hatchUniforms.uCapIdPass.value = flag ? 1 : 0;
91701
+ };
91237
91702
  this.center = center;
91238
91703
  this.distance = size / 2;
91239
91704
  this.onNormalChange = options.onNormalChange || null;
@@ -91296,11 +91761,16 @@ class Clipping extends Group {
91296
91761
  _createStencils(center, size, theme) {
91297
91762
  this._planeMeshGroup = new PlaneMeshGroup();
91298
91763
  this._planeMeshGroup.name = "PlaneMeshes";
91764
+ // Cap quads are PlaneGeometry(2, 2) scaled by size/2 (PlaneMesh.updateMatrixWorld).
91765
+ this._hatchUniforms.uCapHalfSize.value = 0.5 * size;
91766
+ this._hatchCenter.set(center[0], center[1], center[2]);
91299
91767
  // Group the per-(solid,plane) units by solid for screen-size culling. The
91300
91768
  // loop below is plane-major (and `objectColors`/`_planeMeshGroup` order must
91301
91769
  // stay plane-major for setObjectColorCaps), so accumulate into a Map keyed by
91302
91770
  // solid and flatten afterwards — without touching the plane-major structures.
91303
91771
  const unitsBySolid = new Map();
91772
+ // Unique id per cap quad (plane × solid) for the cap outline id pass; 0 = none.
91773
+ let capId = 0;
91304
91774
  for (let i = 0; i < 3; i++) {
91305
91775
  const plane = this.clipPlanes[i];
91306
91776
  const otherPlanes = this.clipPlanes.filter((_, j) => j !== i);
@@ -91320,9 +91790,16 @@ class Clipping extends Group {
91320
91790
  clippingGroup.add(createStencil(`frontStencil-${i}-${j}`, ClippingMaterials.createFrontStencilMaterial(), group.shapeGeometry, plane));
91321
91791
  clippingGroup.add(createStencil(`backStencil-${i}-${j}`, ClippingMaterials.createBackStencilMaterial(), group.shapeGeometry, plane));
91322
91792
  group.addClipping(clippingGroup, i);
91793
+ // The outline id pass renders the stencil sequence + caps on CAP_LAYER.
91794
+ for (const stencil of clippingGroup.children) {
91795
+ stencil.layers.enable(CAP_LAYER);
91796
+ }
91323
91797
  // Create stencil plane mesh
91324
91798
  const planeMaterial = ClippingMaterials.createStencilPlaneMaterial(PLANE_COLORS[theme][i], otherPlanes);
91799
+ capId++;
91800
+ patchHatchMaterial(planeMaterial, this._hatchUniforms, j, capIdColor(capId));
91325
91801
  const capMesh = new PlaneMesh(i, plane, center, size, planeMaterial, PLANE_COLORS[theme][i], `StencilPlane-${i}-${j}`);
91802
+ capMesh.layers.enable(CAP_LAYER);
91326
91803
  this._planeMeshGroup.add(capMesh);
91327
91804
  // Record the cull unit for this solid (one entry per solid, holding
91328
91805
  // its up-to-3 per-plane stencil groups + cap quads).
@@ -91387,6 +91864,10 @@ class Clipping extends Group {
91387
91864
  this.clipPlanes[index].setConstant(value);
91388
91865
  this.reverseClipPlanes[index].setConstant(-value);
91389
91866
  }
91867
+ /** Whether any solid has clip caps (the cap outline pass has work to do). */
91868
+ get hasCaps() {
91869
+ return this._capUnits.length > 0;
91870
+ }
91390
91871
  /**
91391
91872
  * Bound the per-frame stencil/cap draw work to keep large assemblies from
91392
91873
  * overrunning the GPU watchdog on clip+rotate (see {@link CAP_CULL_MIN_PX}).
@@ -91421,6 +91902,9 @@ class Clipping extends Group {
91421
91902
  return;
91422
91903
  }
91423
91904
  this._cullActive = true;
91905
+ // Keep the hatch spacing constant on screen (planes pass near the model center).
91906
+ this._hatchUniforms.uHatchSpacing.value =
91907
+ HATCH_SPACING_PX * worldPerPixel(camera, this._hatchCenter, height);
91424
91908
  // Camera right vector in world space = column 0 of the camera world matrix.
91425
91909
  camera.updateMatrixWorld();
91426
91910
  const e = camera.matrixWorld.elements;
@@ -97350,7 +97834,7 @@ class Tools {
97350
97834
  }
97351
97835
  }
97352
97836
 
97353
- const version = "5.0.6";
97837
+ const version = "5.1.0";
97354
97838
 
97355
97839
  /**
97356
97840
  * `PickedComponent` over a GPU id-pick result. Drives the shader
@@ -97576,6 +98060,58 @@ class PickingController {
97576
98060
  this.setSelectionInput(false);
97577
98061
  this.setPickHandler(false);
97578
98062
  }
98063
+ /**
98064
+ * Pick the visible component under the cursor with the current topo filter, or
98065
+ * `null` when the cursor is off the canvas or over background.
98066
+ */
98067
+ pickUnderCursor() {
98068
+ if (this.host.idPicker === null || !this.idHoverInside)
98069
+ return null;
98070
+ const rect = this.host.renderer.domElement.getBoundingClientRect();
98071
+ const x = this.idHoverClientX - rect.left;
98072
+ const y = this.idHoverClientY - rect.top;
98073
+ if (x < 0 || y < 0 || x > rect.width || y > rect.height)
98074
+ return null;
98075
+ const topoFilter = pickerTopoFilter(this.host.display.shapeFilterDropDownMenu.currentFilter);
98076
+ const hit = this.host.idPicker.pickAt(x, y, topoFilter === undefined ? {} : { topoFilter });
98077
+ // The vertex/edge pick layers stay active even when the owning solid is hidden
98078
+ // (visibility lives on the mesh material, not the pick layer), so gate the pick on
98079
+ // visibility — otherwise a hidden component could be hovered AND selected.
98080
+ if (hit === null || !this.pickVisible(hit.info))
98081
+ return null;
98082
+ return hit;
98083
+ }
98084
+ /**
98085
+ * World-space point on the visible component under the cursor (same pick as the
98086
+ * hover), or `null` when there is none or the GPU lacks the position attachment.
98087
+ */
98088
+ pointUnderCursor() {
98089
+ if (!this.host.ready)
98090
+ return null;
98091
+ const hit = this.pickUnderCursor();
98092
+ if (hit === null || hit.point === null)
98093
+ return null;
98094
+ return hit.point.clone();
98095
+ }
98096
+ /**
98097
+ * Tree-row hover: HOVER the faces of the tree node at `path` (leaf or group), or
98098
+ * clear when `null`. Drops the canvas hover target first — otherwise the next
98099
+ * render's {@link handleIdHover} (cursor outside the canvas) would release that
98100
+ * stale target and wipe the tree hover with it.
98101
+ */
98102
+ setTreeHover(path) {
98103
+ if (!this.host.ready || this.host.studioActive)
98104
+ return;
98105
+ const highlight = this.host.rendered.nestedGroup?.highlight ?? null;
98106
+ if (highlight === null)
98107
+ return;
98108
+ this.releaseLastSelected();
98109
+ this.lastObject = null;
98110
+ this.host.display.setStatusLine("");
98111
+ highlight.setHoverPath(path);
98112
+ if (!this.host.hasAnimationLoop)
98113
+ this.host.update(true, false);
98114
+ }
97579
98115
  /**
97580
98116
  * Whether hover preselection (highlight + status line) is active. Disabled for GDS:
97581
98117
  * dense, stacked, instance-unrolled layout data where per-pixel hover flickers
@@ -97613,28 +98149,12 @@ class PickingController {
97613
98149
  this.lastObject = null;
97614
98150
  this.host.display.setStatusLine("");
97615
98151
  };
97616
- if (!this.idHoverInside) {
97617
- release();
97618
- return;
97619
- }
97620
- const rect = this.host.renderer.domElement.getBoundingClientRect();
97621
- const x = this.idHoverClientX - rect.left;
97622
- const y = this.idHoverClientY - rect.top;
97623
- if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
97624
- release();
97625
- return;
97626
- }
97627
- const filter = this.host.display.shapeFilterDropDownMenu.currentFilter;
97628
- const fromSolid = filter.includes(TopoFilter.solid);
97629
- const topoFilter = pickerTopoFilter(filter);
97630
- const hit = this.host.idPicker.pickAt(x, y, topoFilter === undefined ? {} : { topoFilter });
97631
- // The vertex/edge pick layers stay active even when the owning solid is hidden
97632
- // (visibility lives on the mesh material, not the pick layer), so gate the pick on
97633
- // visibility — otherwise a hidden component could be hovered AND selected.
97634
- if (hit === null || !this.pickVisible(hit.info)) {
98152
+ const hit = this.pickUnderCursor();
98153
+ if (hit === null) {
97635
98154
  release();
97636
98155
  return;
97637
98156
  }
98157
+ const fromSolid = this.host.display.shapeFilterDropDownMenu.currentFilter.includes(TopoFilter.solid);
97638
98158
  const picked = new IdPicked(hit.info, fromSolid, hit.point, highlight);
97639
98159
  if (!picked.equals(this.lastObject)) {
97640
98160
  this.releaseLastSelected();
@@ -108815,6 +109335,10 @@ function decodeInstancedFormat(data) {
108815
109335
  // =============================================================================
108816
109336
  // IMPORTS
108817
109337
  // =============================================================================
109338
+ /** Decimals of a point copied with Ctrl/Cmd-C ({@link Viewer.copyPointUnderCursor}). */
109339
+ const COPY_POINT_DECIMALS = 4;
109340
+ /** Delay after the last camera change before the clip cap outline is drawn again. */
109341
+ const CAP_OUTLINE_SETTLE_MS = 150;
108818
109342
  /**
108819
109343
  * Type guard to check if an Object3D is an IndexedMesh.
108820
109344
  */
@@ -108925,6 +109449,14 @@ class Viewer {
108925
109449
  // GPU id-based picker. Created/attached per render() once the scene + compact
108926
109450
  // registry exist; drives hover, selection, measure and double-click pick.
108927
109451
  this.idPicker = null;
109452
+ /** Outline of the clip caps; created on first use, kept across renders. */
109453
+ this._capOutline = null;
109454
+ /** Camera world + projection matrix of the previous outline frame (motion test). */
109455
+ this._capOutlineCamera = [];
109456
+ /** Pending redraw that adds the outline once the camera has settled. */
109457
+ this._capOutlineSettle = null;
109458
+ /** Whether the attached animation has moved parts (Play pressed or time slider moved). */
109459
+ this._animationStarted = false;
108928
109460
  // ---------------------------------------------------------------------------
108929
109461
  // Render Loop & Scene Updates
108930
109462
  // ---------------------------------------------------------------------------
@@ -109073,6 +109605,7 @@ class Viewer {
109073
109605
  }
109074
109606
  else {
109075
109607
  this.renderer.render(this.rendered.scene, this.rendered.camera.getCamera());
109608
+ this._renderCapOutline();
109076
109609
  }
109077
109610
  this.cadTools.update();
109078
109611
  this.rendered.directLight.position.copy(this.rendered.camera.getCamera().position);
@@ -109326,6 +109859,7 @@ class Viewer {
109326
109859
  return;
109327
109860
  switch (btn) {
109328
109861
  case "play":
109862
+ this._animationStarted = true;
109329
109863
  if (this.clipAction.paused) {
109330
109864
  this.clipAction.paused = false;
109331
109865
  }
@@ -109459,6 +109993,10 @@ class Viewer {
109459
109993
  return;
109460
109994
  }
109461
109995
  };
109996
+ /** Tree-row hover: highlight the faces of the node at `path`, or clear on `null`. */
109997
+ this.handleTreeHover = (path) => {
109998
+ this.pickingController.setTreeHover(path);
109999
+ };
109462
110000
  /** Clear the current selection (and reset the select tool). */
109463
110001
  this.clearSelection = () => {
109464
110002
  this.pickingController.clearSelection();
@@ -110647,6 +111185,7 @@ class Viewer {
110647
111185
  this.toggleAnimationLoop(true);
110648
111186
  }
110649
111187
  this.state.set("animationMode", label === "E" ? "explode" : "animation");
111188
+ this._animationStarted = false;
110650
111189
  this.clipAction = this.animation.animate(this.rendered.nestedGroup.rootGroup, duration, speed, repeat);
110651
111190
  // Reset animation slider to start
110652
111191
  this.state.set("animationSliderValue", 0);
@@ -110665,6 +111204,7 @@ class Viewer {
110665
111204
  deepDispose(this.animation);
110666
111205
  }
110667
111206
  this.state.set("animationMode", "none");
111207
+ this._animationStarted = false;
110668
111208
  this.toggleAnimationLoop(false);
110669
111209
  }
110670
111210
  /**
@@ -110673,6 +111213,8 @@ class Viewer {
110673
111213
  * @param fraction - relative time between 0 and 1.
110674
111214
  */
110675
111215
  setRelativeTime(fraction) {
111216
+ if (fraction !== 0)
111217
+ this._animationStarted = true;
110676
111218
  this.animation.setRelativeTime(fraction);
110677
111219
  this.state.set("animationSliderValue", fraction * 1000);
110678
111220
  }
@@ -110704,6 +111246,60 @@ class Viewer {
110704
111246
  setTimeout(() => this.update(true, true), 50);
110705
111247
  }
110706
111248
  }
111249
+ /**
111250
+ * Draw the outline of the clip caps over the frame (Clip tab active only). Skipped
111251
+ * while the camera moves (drag, wheel, damping, preset views): the outline pass
111252
+ * renders faces and caps a second time, so it is drawn once the camera has been
111253
+ * still for {@link CAP_OUTLINE_SETTLE_MS}.
111254
+ */
111255
+ _renderCapOutline() {
111256
+ const clipping = this.rendered.clipping;
111257
+ if (!this.renderer.localClippingEnabled || !clipping.hasCaps)
111258
+ return;
111259
+ const camera = this.rendered.camera.getCamera();
111260
+ if (this._cameraMovedSinceLastOutline(camera)) {
111261
+ this._scheduleCapOutline();
111262
+ return;
111263
+ }
111264
+ if (this._capOutline === null)
111265
+ this._capOutline = new CapOutlinePass();
111266
+ const blackEdges = this.state.get("blackEdges") === true;
111267
+ this._capOutline.render(this.renderer, this.rendered.scene, camera, {
111268
+ planes: clipping.clipPlanes,
111269
+ intersection: this.state.get("clipIntersection") === true,
111270
+ occlude: this.state.get("transparent") !== true,
111271
+ color: blackEdges ? 0x000000 : this.state.get("edgeColor"),
111272
+ setCapIdPass: clipping.setCapIdPass,
111273
+ });
111274
+ }
111275
+ /** Whether the camera's world or projection matrix changed since the last call. */
111276
+ _cameraMovedSinceLastOutline(camera) {
111277
+ const now = [
111278
+ ...camera.matrixWorld.elements,
111279
+ ...camera.projectionMatrix.elements,
111280
+ ];
111281
+ const last = this._capOutlineCamera;
111282
+ const moved = last.length !== now.length || now.some((value, i) => value !== last[i]);
111283
+ this._capOutlineCamera = now;
111284
+ return moved;
111285
+ }
111286
+ /** Redraw once the camera has settled, so the skipped outline appears. */
111287
+ _scheduleCapOutline() {
111288
+ if (this._capOutlineSettle !== null)
111289
+ clearTimeout(this._capOutlineSettle);
111290
+ this._capOutlineSettle = setTimeout(() => {
111291
+ this._capOutlineSettle = null;
111292
+ if (this.ready)
111293
+ this.update(true, false);
111294
+ }, CAP_OUTLINE_SETTLE_MS);
111295
+ }
111296
+ /** Cancel a pending outline redraw. */
111297
+ _cancelCapOutline() {
111298
+ if (this._capOutlineSettle !== null)
111299
+ clearTimeout(this._capOutlineSettle);
111300
+ this._capOutlineSettle = null;
111301
+ this._capOutlineCamera = [];
111302
+ }
110707
111303
  // ---------------------------------------------------------------------------
110708
111304
  // Cleanup & Disposal
110709
111305
  // ---------------------------------------------------------------------------
@@ -110722,6 +111318,8 @@ class Viewer {
110722
111318
  */
110723
111319
  dispose() {
110724
111320
  this.clear();
111321
+ this._capOutline?.dispose();
111322
+ this._capOutline = null;
110725
111323
  // Remove all picking listeners (they hold a strong ref to this Viewer via the
110726
111324
  // controller's arrow-field handlers; in external-canvas mode the caller owns the
110727
111325
  // canvas, so not removing them would leak the disposed Viewer).
@@ -110767,6 +111365,7 @@ class Viewer {
110767
111365
  * @public
110768
111366
  */
110769
111367
  clear() {
111368
+ this._cancelCapOutline();
110770
111369
  if (this._rendered) {
110771
111370
  // Drop selection state + hover cache + status line — a PickedComponent may hold
110772
111371
  // a HighlightController / ObjectGroup that this clear() disposes; a stale
@@ -110820,6 +111419,11 @@ class Viewer {
110820
111419
  deepDispose(this._rendered.camera);
110821
111420
  deepDispose(this._rendered.controls);
110822
111421
  deepDispose(this._rendered.treeview);
111422
+ // The orientation marker owns its own THREE.Scene (cones, labels, sphere,
111423
+ // axes), not part of the main scene above, so it needs its own dispose —
111424
+ // without it every clear()/render() cycle left 8 geometries and 3 programs
111425
+ // behind on the GL (measured 2026-09-17, working-docs/leak-harness.html).
111426
+ this._rendered.orientationMarker.dispose();
110823
111427
  // clear tree view
110824
111428
  this.display.clearCadTree();
110825
111429
  // clear info
@@ -110883,7 +111487,7 @@ class Viewer {
110883
111487
  if (!this.tree) {
110884
111488
  throw new Error("Tree not initialized");
110885
111489
  }
110886
- const treeview = new TreeView(this.tree, this.display.cadTreeScrollContainer, this.setObject, this.handlePick, this.update, this.notifyStates, this.getNodeColor, this.state.get("theme"), this.state.get("newTreeBehavior"), false);
111490
+ const treeview = new TreeView(this.tree, this.display.cadTreeScrollContainer, this.setObject, this.handlePick, this.update, this.notifyStates, this.getNodeColor, this.state.get("theme"), this.state.get("newTreeBehavior"), false, this.handleTreeHover);
110887
111491
  this.display.clearCadTree();
110888
111492
  const t = treeview.create();
110889
111493
  timer.split("created tree");
@@ -111349,6 +111953,40 @@ class Viewer {
111349
111953
  get lastSelection() {
111350
111954
  return this.pickingController.lastSelection;
111351
111955
  }
111956
+ /**
111957
+ * Copy the world-space point under the cursor as `"x, y, z"` to the clipboard and
111958
+ * send it to the host as a `copiedPoint` notification (for hosts that block the
111959
+ * browser clipboard). Does nothing while parts may be displaced from their model
111960
+ * positions — explode mode, an animation once started (Play or time slider), a
111961
+ * z-scale other than 1 — or when no visible component is under the cursor.
111962
+ * @returns true when a point was copied (the caller then consumes the key event).
111963
+ */
111964
+ copyPointUnderCursor() {
111965
+ if (!this.ready)
111966
+ return false;
111967
+ const mode = this.state.get("animationMode");
111968
+ if (mode === "explode")
111969
+ return false;
111970
+ if (mode === "animation" && this._animationStarted)
111971
+ return false;
111972
+ if (this.zScale !== undefined && this.zScale !== 1)
111973
+ return false;
111974
+ const point = this.pickingController.pointUnderCursor();
111975
+ if (point === null)
111976
+ return false;
111977
+ const coords = toVector3Tuple(point.toArray());
111978
+ const fmt = (v) => {
111979
+ const s = v.toFixed(COPY_POINT_DECIMALS);
111980
+ return Number(s) === 0 ? (0).toFixed(COPY_POINT_DECIMALS) : s;
111981
+ };
111982
+ const text = coords.map(fmt).join(", ");
111983
+ copyText(text, this.display.container);
111984
+ if (this.notifyCallback !== null) {
111985
+ this.notifyCallback({ copiedPoint: { old: null, new: coords } });
111986
+ }
111987
+ this.display.setStatusLine(`copied: ${text}`);
111988
+ return true;
111989
+ }
111352
111990
  /** Enable/disable the double-click pick handler (on when no tool is active). */
111353
111991
  setPickHandler(flag) {
111354
111992
  this.pickingController.setPickHandler(flag);
@@ -111763,7 +112401,7 @@ class Viewer {
111763
112401
  this.tree = this.compactTree;
111764
112402
  // Dispose old treeview and create new one
111765
112403
  deepDispose(this.rendered.treeview);
111766
- const treeview = new TreeView(this.tree, this.display.cadTreeScrollContainer, this.setObject, this.handlePick, this.update, this.notifyStates, this.getNodeColor, this.state.get("theme"), this.state.get("newTreeBehavior"), false);
112404
+ const treeview = new TreeView(this.tree, this.display.cadTreeScrollContainer, this.setObject, this.handlePick, this.update, this.notifyStates, this.getNodeColor, this.state.get("theme"), this.state.get("newTreeBehavior"), false, this.handleTreeHover);
111767
112405
  this.rendered.treeview = treeview;
111768
112406
  this.display.clearCadTree();
111769
112407
  const t = treeview.create();
@@ -113322,6 +113960,33 @@ class Display {
113322
113960
  this._matEditorDragAbort = null;
113323
113961
  this._matEditorInputAbort = null;
113324
113962
  this._animWasVisible = false;
113963
+ /**
113964
+ * Ctrl/Cmd-C while the cursor is over the canvas copies the point under it.
113965
+ * Text-entry fields and selected text keep the normal copy.
113966
+ */
113967
+ this._handleCopyPoint = (e) => {
113968
+ if (!(e instanceof KeyboardEvent))
113969
+ return;
113970
+ if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey)
113971
+ return;
113972
+ if (e.key.toLowerCase() !== "c")
113973
+ return;
113974
+ const target = e.target;
113975
+ if ((target instanceof HTMLInputElement &&
113976
+ target.type !== "button" &&
113977
+ target.type !== "checkbox") ||
113978
+ target instanceof HTMLTextAreaElement ||
113979
+ (target instanceof HTMLElement && target.isContentEditable)) {
113980
+ return;
113981
+ }
113982
+ const selection = window.getSelection();
113983
+ if (selection !== null && selection.toString() !== "")
113984
+ return;
113985
+ if (this.viewer.copyPointUnderCursor()) {
113986
+ e.preventDefault();
113987
+ e.stopPropagation();
113988
+ }
113989
+ };
113325
113990
  // ---------------------------------------------------------------------------
113326
113991
  // Toolbar Button Handlers: View Settings
113327
113992
  // ---------------------------------------------------------------------------
@@ -114644,6 +115309,10 @@ class Display {
114644
115309
  // Focus handling for keyboard shortcuts
114645
115310
  listeners.add(this.container, "mousedown", () => this.container.focus());
114646
115311
  listeners.add(this.container, "keydown", this._handleKeyboardShortcut);
115312
+ // Ctrl/Cmd-C acts on the point under the cursor, so it must not depend on the
115313
+ // viewer having focus (e.g. after using a host dropdown). Capture phase: runs
115314
+ // before host shortcuts; the key is consumed only when a point was copied.
115315
+ listeners.add(document, "keydown", this._handleCopyPoint, true);
114647
115316
  }
114648
115317
  /**
114649
115318
  * Subscribe to ViewerState changes to keep UI in sync.