three-cad-viewer 5.0.7 → 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
@@ -85096,7 +85135,7 @@ function focusSizeExpr(stateVar, hover, selected, none) {
85096
85135
  * renames an anchor must fail LOUDLY here, not silently drop the highlight (build
85097
85136
  * would otherwise stay green). One guard test asserts each anchor still exists.
85098
85137
  */
85099
- function replaceOrThrow(src, anchor, replacement, where) {
85138
+ function replaceOrThrow$1(src, anchor, replacement, where) {
85100
85139
  if (!src.includes(anchor)) {
85101
85140
  throw new Error(`HighlightController.${where}: shader anchor not found: ${JSON.stringify(anchor)}`);
85102
85141
  }
@@ -85214,6 +85253,23 @@ class HighlightController {
85214
85253
  }
85215
85254
  this._applyHover(`s${solidPath}`, ids);
85216
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
+ }
85217
85273
  /** Set or clear the SELECTED flag for a single component id. */
85218
85274
  setSelected(id, flag) {
85219
85275
  this._setBit(id, HighlightFlag.SELECTED, flag);
@@ -85316,7 +85372,7 @@ class HighlightController {
85316
85372
  shader.vertexShader =
85317
85373
  HL_VERTEX_HEADER +
85318
85374
  "\n" +
85319
- 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);
85320
85376
  shader.fragmentShader = HL_FRAGMENT_HEADER + "\n" + shader.fragmentShader;
85321
85377
  // Topo-specific color / size injection.
85322
85378
  customize(shader);
@@ -85331,7 +85387,7 @@ class HighlightController {
85331
85387
  */
85332
85388
  patchFaceMaterial(material) {
85333
85389
  this._install(material, "patchFaceMaterial", (shader) => {
85334
- 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");
85335
85391
  });
85336
85392
  }
85337
85393
  /**
@@ -85343,9 +85399,9 @@ class HighlightController {
85343
85399
  */
85344
85400
  patchEdgeMaterial(material) {
85345
85401
  this._install(material, "patchEdgeMaterial", (shader) => {
85346
- shader.vertexShader = replaceOrThrow(shader.vertexShader, "offset *= linewidth;", `uint hlEdgeState = highlightState();
85402
+ shader.vertexShader = replaceOrThrow$1(shader.vertexShader, "offset *= linewidth;", `uint hlEdgeState = highlightState();
85347
85403
  offset *= ${focusSizeExpr("hlEdgeState", EDGE_FOCUS_WIDTH, EDGE_FOCUS_WIDTH - 2, "linewidth")};`, "patchEdgeMaterial");
85348
- 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");
85349
85405
  });
85350
85406
  }
85351
85407
  /**
@@ -85360,12 +85416,12 @@ class HighlightController {
85360
85416
  const cull = options.cullUnhighlighted === true;
85361
85417
  const none = cull ? "0.0" : "size";
85362
85418
  this._install(material, "patchVertexMaterial", (shader) => {
85363
- shader.vertexShader = replaceOrThrow(shader.vertexShader, "gl_PointSize = size;", `uint hlPtState = highlightState();
85419
+ shader.vertexShader = replaceOrThrow$1(shader.vertexShader, "gl_PointSize = size;", `uint hlPtState = highlightState();
85364
85420
  gl_PointSize = ${focusSizeExpr("hlPtState", VERTEX_FOCUS_SIZE, VERTEX_FOCUS_SIZE - 2, none)};`, "patchVertexMaterial");
85365
85421
  const discard = cull
85366
85422
  ? "\n if (highlightState() == 0u) discard;"
85367
85423
  : "";
85368
- 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");
85369
85425
  },
85370
85426
  // The cull discard is the variant that must NOT share a program with the
85371
85427
  // standalone (always-visible) vertex cloud — the ghost-vertex bug.
@@ -90158,8 +90214,9 @@ class TreeView {
90158
90214
  * @param theme - The UI theme ('light' or 'dark').
90159
90215
  * @param linkIcons - Whether icon 0 and 1 are linked.
90160
90216
  * @param debug - Enable debug logging.
90217
+ * @param hoverHandler - Callback for label hover (node path, or null on leave).
90161
90218
  */
90162
- 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) {
90163
90220
  /************************************************************************************
90164
90221
  * Handlers
90165
90222
  ************************************************************************************/
@@ -90274,6 +90331,7 @@ class TreeView {
90274
90331
  this.theme = theme;
90275
90332
  this.linkIcons = linkIcons;
90276
90333
  this.debug = debug;
90334
+ this.hoverHandler = hoverHandler;
90277
90335
  this.model = null;
90278
90336
  this.container = null;
90279
90337
  this.lastLabel = null;
@@ -90471,6 +90529,12 @@ class TreeView {
90471
90529
  e.stopPropagation();
90472
90530
  this.handleLabelClick(node, e);
90473
90531
  };
90532
+ label.onmouseenter = () => {
90533
+ this.hoverHandler?.(this.getNodePath(node));
90534
+ };
90535
+ label.onmouseleave = () => {
90536
+ this.hoverHandler?.(null);
90537
+ };
90474
90538
  nodeContent.appendChild(label);
90475
90539
  let childrenContainer = null;
90476
90540
  if (node.children) {
@@ -90962,6 +91026,357 @@ let Timer$1 = class Timer {
90962
91026
  }
90963
91027
  };
90964
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
+
90965
91380
  // ============================================================================
90966
91381
  // Constants
90967
91382
  // ============================================================================
@@ -91214,6 +91629,9 @@ class Clipping extends Group {
91214
91629
  super();
91215
91630
  /** Per-solid stencil/cap units, the unit of screen-size culling. */
91216
91631
  this._capUnits = [];
91632
+ /** Shared uniforms of the section hatching on all cap quads. */
91633
+ this._hatchUniforms = createHatchUniforms(1);
91634
+ this._hatchCenter = new Vector3();
91217
91635
  /**
91218
91636
  * Whether {@link cull} last ran with clipping active. Lets the inactive path
91219
91637
  * gate stencils/caps off exactly once, then early-return on later still frames.
@@ -91277,6 +91695,10 @@ class Clipping extends Group {
91277
91695
  mesh.material.visible = flag;
91278
91696
  }
91279
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
+ };
91280
91702
  this.center = center;
91281
91703
  this.distance = size / 2;
91282
91704
  this.onNormalChange = options.onNormalChange || null;
@@ -91339,11 +91761,16 @@ class Clipping extends Group {
91339
91761
  _createStencils(center, size, theme) {
91340
91762
  this._planeMeshGroup = new PlaneMeshGroup();
91341
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]);
91342
91767
  // Group the per-(solid,plane) units by solid for screen-size culling. The
91343
91768
  // loop below is plane-major (and `objectColors`/`_planeMeshGroup` order must
91344
91769
  // stay plane-major for setObjectColorCaps), so accumulate into a Map keyed by
91345
91770
  // solid and flatten afterwards — without touching the plane-major structures.
91346
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;
91347
91774
  for (let i = 0; i < 3; i++) {
91348
91775
  const plane = this.clipPlanes[i];
91349
91776
  const otherPlanes = this.clipPlanes.filter((_, j) => j !== i);
@@ -91363,9 +91790,16 @@ class Clipping extends Group {
91363
91790
  clippingGroup.add(createStencil(`frontStencil-${i}-${j}`, ClippingMaterials.createFrontStencilMaterial(), group.shapeGeometry, plane));
91364
91791
  clippingGroup.add(createStencil(`backStencil-${i}-${j}`, ClippingMaterials.createBackStencilMaterial(), group.shapeGeometry, plane));
91365
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
+ }
91366
91797
  // Create stencil plane mesh
91367
91798
  const planeMaterial = ClippingMaterials.createStencilPlaneMaterial(PLANE_COLORS[theme][i], otherPlanes);
91799
+ capId++;
91800
+ patchHatchMaterial(planeMaterial, this._hatchUniforms, j, capIdColor(capId));
91368
91801
  const capMesh = new PlaneMesh(i, plane, center, size, planeMaterial, PLANE_COLORS[theme][i], `StencilPlane-${i}-${j}`);
91802
+ capMesh.layers.enable(CAP_LAYER);
91369
91803
  this._planeMeshGroup.add(capMesh);
91370
91804
  // Record the cull unit for this solid (one entry per solid, holding
91371
91805
  // its up-to-3 per-plane stencil groups + cap quads).
@@ -91430,6 +91864,10 @@ class Clipping extends Group {
91430
91864
  this.clipPlanes[index].setConstant(value);
91431
91865
  this.reverseClipPlanes[index].setConstant(-value);
91432
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
+ }
91433
91871
  /**
91434
91872
  * Bound the per-frame stencil/cap draw work to keep large assemblies from
91435
91873
  * overrunning the GPU watchdog on clip+rotate (see {@link CAP_CULL_MIN_PX}).
@@ -91464,6 +91902,9 @@ class Clipping extends Group {
91464
91902
  return;
91465
91903
  }
91466
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);
91467
91908
  // Camera right vector in world space = column 0 of the camera world matrix.
91468
91909
  camera.updateMatrixWorld();
91469
91910
  const e = camera.matrixWorld.elements;
@@ -97393,7 +97834,7 @@ class Tools {
97393
97834
  }
97394
97835
  }
97395
97836
 
97396
- const version = "5.0.7";
97837
+ const version = "5.1.0";
97397
97838
 
97398
97839
  /**
97399
97840
  * `PickedComponent` over a GPU id-pick result. Drives the shader
@@ -97619,6 +98060,58 @@ class PickingController {
97619
98060
  this.setSelectionInput(false);
97620
98061
  this.setPickHandler(false);
97621
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
+ }
97622
98115
  /**
97623
98116
  * Whether hover preselection (highlight + status line) is active. Disabled for GDS:
97624
98117
  * dense, stacked, instance-unrolled layout data where per-pixel hover flickers
@@ -97656,28 +98149,12 @@ class PickingController {
97656
98149
  this.lastObject = null;
97657
98150
  this.host.display.setStatusLine("");
97658
98151
  };
97659
- if (!this.idHoverInside) {
97660
- release();
97661
- return;
97662
- }
97663
- const rect = this.host.renderer.domElement.getBoundingClientRect();
97664
- const x = this.idHoverClientX - rect.left;
97665
- const y = this.idHoverClientY - rect.top;
97666
- if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
97667
- release();
97668
- return;
97669
- }
97670
- const filter = this.host.display.shapeFilterDropDownMenu.currentFilter;
97671
- const fromSolid = filter.includes(TopoFilter.solid);
97672
- const topoFilter = pickerTopoFilter(filter);
97673
- const hit = this.host.idPicker.pickAt(x, y, topoFilter === undefined ? {} : { topoFilter });
97674
- // The vertex/edge pick layers stay active even when the owning solid is hidden
97675
- // (visibility lives on the mesh material, not the pick layer), so gate the pick on
97676
- // visibility — otherwise a hidden component could be hovered AND selected.
97677
- if (hit === null || !this.pickVisible(hit.info)) {
98152
+ const hit = this.pickUnderCursor();
98153
+ if (hit === null) {
97678
98154
  release();
97679
98155
  return;
97680
98156
  }
98157
+ const fromSolid = this.host.display.shapeFilterDropDownMenu.currentFilter.includes(TopoFilter.solid);
97681
98158
  const picked = new IdPicked(hit.info, fromSolid, hit.point, highlight);
97682
98159
  if (!picked.equals(this.lastObject)) {
97683
98160
  this.releaseLastSelected();
@@ -108858,6 +109335,10 @@ function decodeInstancedFormat(data) {
108858
109335
  // =============================================================================
108859
109336
  // IMPORTS
108860
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;
108861
109342
  /**
108862
109343
  * Type guard to check if an Object3D is an IndexedMesh.
108863
109344
  */
@@ -108968,6 +109449,14 @@ class Viewer {
108968
109449
  // GPU id-based picker. Created/attached per render() once the scene + compact
108969
109450
  // registry exist; drives hover, selection, measure and double-click pick.
108970
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;
108971
109460
  // ---------------------------------------------------------------------------
108972
109461
  // Render Loop & Scene Updates
108973
109462
  // ---------------------------------------------------------------------------
@@ -109116,6 +109605,7 @@ class Viewer {
109116
109605
  }
109117
109606
  else {
109118
109607
  this.renderer.render(this.rendered.scene, this.rendered.camera.getCamera());
109608
+ this._renderCapOutline();
109119
109609
  }
109120
109610
  this.cadTools.update();
109121
109611
  this.rendered.directLight.position.copy(this.rendered.camera.getCamera().position);
@@ -109369,6 +109859,7 @@ class Viewer {
109369
109859
  return;
109370
109860
  switch (btn) {
109371
109861
  case "play":
109862
+ this._animationStarted = true;
109372
109863
  if (this.clipAction.paused) {
109373
109864
  this.clipAction.paused = false;
109374
109865
  }
@@ -109502,6 +109993,10 @@ class Viewer {
109502
109993
  return;
109503
109994
  }
109504
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
+ };
109505
110000
  /** Clear the current selection (and reset the select tool). */
109506
110001
  this.clearSelection = () => {
109507
110002
  this.pickingController.clearSelection();
@@ -110690,6 +111185,7 @@ class Viewer {
110690
111185
  this.toggleAnimationLoop(true);
110691
111186
  }
110692
111187
  this.state.set("animationMode", label === "E" ? "explode" : "animation");
111188
+ this._animationStarted = false;
110693
111189
  this.clipAction = this.animation.animate(this.rendered.nestedGroup.rootGroup, duration, speed, repeat);
110694
111190
  // Reset animation slider to start
110695
111191
  this.state.set("animationSliderValue", 0);
@@ -110708,6 +111204,7 @@ class Viewer {
110708
111204
  deepDispose(this.animation);
110709
111205
  }
110710
111206
  this.state.set("animationMode", "none");
111207
+ this._animationStarted = false;
110711
111208
  this.toggleAnimationLoop(false);
110712
111209
  }
110713
111210
  /**
@@ -110716,6 +111213,8 @@ class Viewer {
110716
111213
  * @param fraction - relative time between 0 and 1.
110717
111214
  */
110718
111215
  setRelativeTime(fraction) {
111216
+ if (fraction !== 0)
111217
+ this._animationStarted = true;
110719
111218
  this.animation.setRelativeTime(fraction);
110720
111219
  this.state.set("animationSliderValue", fraction * 1000);
110721
111220
  }
@@ -110747,6 +111246,60 @@ class Viewer {
110747
111246
  setTimeout(() => this.update(true, true), 50);
110748
111247
  }
110749
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
+ }
110750
111303
  // ---------------------------------------------------------------------------
110751
111304
  // Cleanup & Disposal
110752
111305
  // ---------------------------------------------------------------------------
@@ -110765,6 +111318,8 @@ class Viewer {
110765
111318
  */
110766
111319
  dispose() {
110767
111320
  this.clear();
111321
+ this._capOutline?.dispose();
111322
+ this._capOutline = null;
110768
111323
  // Remove all picking listeners (they hold a strong ref to this Viewer via the
110769
111324
  // controller's arrow-field handlers; in external-canvas mode the caller owns the
110770
111325
  // canvas, so not removing them would leak the disposed Viewer).
@@ -110810,6 +111365,7 @@ class Viewer {
110810
111365
  * @public
110811
111366
  */
110812
111367
  clear() {
111368
+ this._cancelCapOutline();
110813
111369
  if (this._rendered) {
110814
111370
  // Drop selection state + hover cache + status line — a PickedComponent may hold
110815
111371
  // a HighlightController / ObjectGroup that this clear() disposes; a stale
@@ -110931,7 +111487,7 @@ class Viewer {
110931
111487
  if (!this.tree) {
110932
111488
  throw new Error("Tree not initialized");
110933
111489
  }
110934
- 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);
110935
111491
  this.display.clearCadTree();
110936
111492
  const t = treeview.create();
110937
111493
  timer.split("created tree");
@@ -111397,6 +111953,40 @@ class Viewer {
111397
111953
  get lastSelection() {
111398
111954
  return this.pickingController.lastSelection;
111399
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
+ }
111400
111990
  /** Enable/disable the double-click pick handler (on when no tool is active). */
111401
111991
  setPickHandler(flag) {
111402
111992
  this.pickingController.setPickHandler(flag);
@@ -111811,7 +112401,7 @@ class Viewer {
111811
112401
  this.tree = this.compactTree;
111812
112402
  // Dispose old treeview and create new one
111813
112403
  deepDispose(this.rendered.treeview);
111814
- 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);
111815
112405
  this.rendered.treeview = treeview;
111816
112406
  this.display.clearCadTree();
111817
112407
  const t = treeview.create();
@@ -113370,6 +113960,33 @@ class Display {
113370
113960
  this._matEditorDragAbort = null;
113371
113961
  this._matEditorInputAbort = null;
113372
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
+ };
113373
113990
  // ---------------------------------------------------------------------------
113374
113991
  // Toolbar Button Handlers: View Settings
113375
113992
  // ---------------------------------------------------------------------------
@@ -114692,6 +115309,10 @@ class Display {
114692
115309
  // Focus handling for keyboard shortcuts
114693
115310
  listeners.add(this.container, "mousedown", () => this.container.focus());
114694
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);
114695
115316
  }
114696
115317
  /**
114697
115318
  * Subscribe to ViewerState changes to keep UI in sync.