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.
- package/dist/core/picking-controller.d.ts +17 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/core/viewer.d.ts +32 -0
- package/dist/rendering/cap-outline.d.ts +60 -0
- package/dist/rendering/clip-style.d.ts +49 -0
- package/dist/rendering/highlight.d.ts +5 -0
- package/dist/rendering/id-picking.d.ts +4 -1
- package/dist/scene/clipping.d.ts +7 -0
- package/dist/three-cad-viewer.esm.js +719 -50
- package/dist/three-cad-viewer.esm.js.map +1 -1
- package/dist/three-cad-viewer.esm.min.js +3 -3
- package/dist/three-cad-viewer.js +719 -50
- package/dist/three-cad-viewer.min.js +3 -3
- package/dist/ui/display.d.ts +5 -0
- package/dist/ui/treeview.d.ts +4 -1
- package/dist/utils/utils.d.ts +14 -1
- package/package.json +1 -1
package/dist/three-cad-viewer.js
CHANGED
|
@@ -81835,6 +81835,45 @@ void main() {
|
|
|
81835
81835
|
this.listeners = [];
|
|
81836
81836
|
}
|
|
81837
81837
|
}
|
|
81838
|
+
/**
|
|
81839
|
+
* Copy `text` to the clipboard from inside a user-initiated event handler.
|
|
81840
|
+
*
|
|
81841
|
+
* Tries the synchronous hidden-textarea `execCommand("copy")` first: it needs no
|
|
81842
|
+
* clipboard permission, so it also works in embedding iframes and WebKit views that
|
|
81843
|
+
* refuse `navigator.clipboard`. Falls back to the async Clipboard API.
|
|
81844
|
+
* Focus is restored to the previously focused element.
|
|
81845
|
+
*
|
|
81846
|
+
* @param text - The text to copy.
|
|
81847
|
+
* @param parent - Element the temporary textarea is attached to.
|
|
81848
|
+
* @returns true when the synchronous copy reported success.
|
|
81849
|
+
*/
|
|
81850
|
+
function copyText(text, parent) {
|
|
81851
|
+
const previous = document.activeElement;
|
|
81852
|
+
const area = document.createElement("textarea");
|
|
81853
|
+
area.value = text;
|
|
81854
|
+
area.setAttribute("readonly", "");
|
|
81855
|
+
area.style.position = "fixed";
|
|
81856
|
+
area.style.left = "-9999px";
|
|
81857
|
+
area.style.opacity = "0";
|
|
81858
|
+
parent.appendChild(area);
|
|
81859
|
+
area.select();
|
|
81860
|
+
let copied;
|
|
81861
|
+
try {
|
|
81862
|
+
copied = document.execCommand("copy");
|
|
81863
|
+
}
|
|
81864
|
+
catch {
|
|
81865
|
+
copied = false;
|
|
81866
|
+
}
|
|
81867
|
+
parent.removeChild(area);
|
|
81868
|
+
if (previous instanceof HTMLElement)
|
|
81869
|
+
previous.focus({ preventScroll: true });
|
|
81870
|
+
if (!copied && navigator.clipboard !== undefined) {
|
|
81871
|
+
navigator.clipboard.writeText(text).catch((err) => {
|
|
81872
|
+
logger.debug("Clipboard write refused:", err);
|
|
81873
|
+
});
|
|
81874
|
+
}
|
|
81875
|
+
return copied;
|
|
81876
|
+
}
|
|
81838
81877
|
|
|
81839
81878
|
/**
|
|
81840
81879
|
* Zebra Analysis Tool for Three.js CAD Viewer
|
|
@@ -84005,7 +84044,10 @@ void main() {
|
|
|
84005
84044
|
* `Uint32BufferAttribute` under this name; the pick pass reads it as an **integer**
|
|
84006
84045
|
* attribute (`gpuType = THREE.IntType`, GLSL3 `in uint`) so ids stay exact past
|
|
84007
84046
|
* 2^24. The geometry side must set `gpuType = THREE.IntType` on the attribute for
|
|
84008
|
-
* the pick shader to read it.
|
|
84047
|
+
* the pick shader to read it. Between the stages the id travels as two float
|
|
84048
|
+
* varyings (16-bit halves), deliberately NOT as a `flat uint` — see the note in
|
|
84049
|
+
* highlight.ts: a `flat` varying costs ~25x the scene's buffer bytes in graphics
|
|
84050
|
+
* memory under WebKit's ANGLE-on-Metal backend.
|
|
84009
84051
|
*/
|
|
84010
84052
|
const COMPONENT_ID_ATTRIBUTE = "componentId";
|
|
84011
84053
|
/**
|
|
@@ -84303,8 +84345,9 @@ void main() {
|
|
|
84303
84345
|
*/
|
|
84304
84346
|
const PICK_POINT_SIZE = 3;
|
|
84305
84347
|
/**
|
|
84306
|
-
* Shared GLSL3 MRT fragment shader for all pick materials (face/vertex/edge):
|
|
84307
|
-
* the
|
|
84348
|
+
* Shared GLSL3 MRT fragment shader for all pick materials (face/vertex/edge):
|
|
84349
|
+
* reassembles the id from its two interpolated halves and packs it into attachment 0
|
|
84350
|
+
* (byte order MUST match `packId()`: low byte in R)
|
|
84308
84351
|
* and writes the interpolated world position into attachment 1 (RGBA32F; `w=1` marks
|
|
84309
84352
|
* "has position"). The fragment clip chunk only `discard`s (non-ALPHA_TO_COVERAGE),
|
|
84310
84353
|
* so no `diffuseColor` symbol is required.
|
|
@@ -84312,13 +84355,15 @@ void main() {
|
|
|
84312
84355
|
const PICK_FRAGMENT_SHADER = /* glsl */ `
|
|
84313
84356
|
#include <clipping_planes_pars_fragment>
|
|
84314
84357
|
|
|
84315
|
-
|
|
84358
|
+
in float vIdLo;
|
|
84359
|
+
in float vIdHi;
|
|
84316
84360
|
in vec3 vWorldPos;
|
|
84317
84361
|
layout(location = 0) out vec4 fragId;
|
|
84318
84362
|
layout(location = 1) out vec4 fragPos;
|
|
84319
84363
|
|
|
84320
84364
|
void main() {
|
|
84321
84365
|
#include <clipping_planes_fragment>
|
|
84366
|
+
uint vId = ( uint( vIdHi + 0.5 ) << 16 ) | uint( vIdLo + 0.5 );
|
|
84322
84367
|
fragId = vec4(
|
|
84323
84368
|
float( vId & 0xFFu ) / 255.0,
|
|
84324
84369
|
float( ( vId >> 8 ) & 0xFFu ) / 255.0,
|
|
@@ -84336,19 +84381,24 @@ void main() {
|
|
|
84336
84381
|
function createFacePickMaterial(options = {}) {
|
|
84337
84382
|
// GLSL3 (WebGL2). For a (non-Raw) ShaderMaterial three.js auto-injects
|
|
84338
84383
|
// `in vec3 position;`, `uniform mat4 modelMatrix/modelViewMatrix/projectionMatrix`,
|
|
84339
|
-
// so we declare ONLY the custom integer attribute +
|
|
84340
|
-
//
|
|
84384
|
+
// so we declare ONLY the custom integer attribute + the two id-half varyings
|
|
84385
|
+
// (float, interpolated — an integer varying would have to be `flat`, which is
|
|
84386
|
+
// the ANGLE-on-Metal memory trap described in highlight.ts; all vertices of a
|
|
84387
|
+
// primitive carry the same id, so interpolation + rounding is exact). Clip
|
|
84388
|
+
// chunks use legacy `varying`/`attribute` keywords
|
|
84341
84389
|
// which three remaps via `#define` under GLSL3; the vertex chunk reads a local
|
|
84342
84390
|
// `vec4 mvPosition`, which we compute before the include.
|
|
84343
84391
|
const vertexShader = /* glsl */ `
|
|
84344
84392
|
#include <clipping_planes_pars_vertex>
|
|
84345
84393
|
|
|
84346
84394
|
in uint componentId;
|
|
84347
|
-
|
|
84395
|
+
out float vIdLo;
|
|
84396
|
+
out float vIdHi;
|
|
84348
84397
|
out vec3 vWorldPos;
|
|
84349
84398
|
|
|
84350
84399
|
void main() {
|
|
84351
|
-
|
|
84400
|
+
vIdLo = float( componentId & 0xFFFFu );
|
|
84401
|
+
vIdHi = float( componentId >> 16 );
|
|
84352
84402
|
vec4 worldPos = modelMatrix * vec4( position, 1.0 );
|
|
84353
84403
|
vWorldPos = worldPos.xyz;
|
|
84354
84404
|
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
|
|
@@ -84387,13 +84437,15 @@ void main() {
|
|
|
84387
84437
|
#include <clipping_planes_pars_vertex>
|
|
84388
84438
|
|
|
84389
84439
|
in uint componentId;
|
|
84390
|
-
|
|
84440
|
+
out float vIdLo;
|
|
84441
|
+
out float vIdHi;
|
|
84391
84442
|
out vec3 vWorldPos;
|
|
84392
84443
|
uniform float uPickSize;
|
|
84393
84444
|
uniform float uDepthBias;
|
|
84394
84445
|
|
|
84395
84446
|
void main() {
|
|
84396
|
-
|
|
84447
|
+
vIdLo = float( componentId & 0xFFFFu );
|
|
84448
|
+
vIdHi = float( componentId >> 16 );
|
|
84397
84449
|
vec4 worldPos = modelMatrix * vec4( position, 1.0 );
|
|
84398
84450
|
vWorldPos = worldPos.xyz;
|
|
84399
84451
|
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
|
|
@@ -84453,7 +84505,8 @@ void main() {
|
|
|
84453
84505
|
in vec3 instanceEnd;
|
|
84454
84506
|
in uint componentId;
|
|
84455
84507
|
|
|
84456
|
-
|
|
84508
|
+
out float vIdLo;
|
|
84509
|
+
out float vIdHi;
|
|
84457
84510
|
out vec3 vWorldPos;
|
|
84458
84511
|
|
|
84459
84512
|
void trimSegment( const in vec4 start, inout vec4 end ) {
|
|
@@ -84465,7 +84518,8 @@ void main() {
|
|
|
84465
84518
|
}
|
|
84466
84519
|
|
|
84467
84520
|
void main() {
|
|
84468
|
-
|
|
84521
|
+
vIdLo = float( componentId & 0xFFFFu );
|
|
84522
|
+
vIdHi = float( componentId >> 16 );
|
|
84469
84523
|
|
|
84470
84524
|
// World-space segment endpoints → approximate hit point along the edge.
|
|
84471
84525
|
vWorldPos = ( position.y < 0.5 )
|
|
@@ -85003,20 +85057,41 @@ void main() {
|
|
|
85003
85057
|
//
|
|
85004
85058
|
// three upgrades stock materials to GLSL ES 3.00 on WebGL2 via `#define attribute
|
|
85005
85059
|
// in` / `#define varying out|in` macros (WebGLProgram.js), so writing `attribute` /
|
|
85006
|
-
// `
|
|
85060
|
+
// `varying` here is converted automatically; `usampler2D` / `texelFetch` /
|
|
85007
85061
|
// integer attributes are then available.
|
|
85062
|
+
//
|
|
85063
|
+
// The component id crosses to the fragment stage as TWO ordinary (interpolated)
|
|
85064
|
+
// float varyings holding its 16-bit halves — NOT as a `flat uint`. A `flat`
|
|
85065
|
+
// varying is what an integer varying would require, and it is exactly what must
|
|
85066
|
+
// be avoided here: under WebKit's ANGLE-on-Metal backend, every draw whose
|
|
85067
|
+
// program has a `flat` varying makes ANGLE allocate a converted copy of the
|
|
85068
|
+
// draw's index data (>= 64 KB each, pooled, never returned to the GL on
|
|
85069
|
+
// deleteBuffer). With this varying on every visible material that was ~25x the
|
|
85070
|
+
// scene's vertex/index bytes in graphics memory (26 MB of buffers -> 812 MB;
|
|
85071
|
+
// 100 MB -> 2.3 GB) and it is charged to the page's WebContent process in the
|
|
85072
|
+
// system WebKit, which killed build123d Studio at 16 GB after a few large
|
|
85073
|
+
// shows. Without `flat` the same scene takes 63 MB / 160 MB. Measured 2026-09-17
|
|
85074
|
+
// (working-docs/leak-harness.html + probe2.html, Playwright WebKit and Safari).
|
|
85075
|
+
//
|
|
85076
|
+
// Interpolating floats is exact for the id's purpose: every vertex of a face,
|
|
85077
|
+
// every vertex of an instanced segment and a point carry the SAME id, so the
|
|
85078
|
+
// interpolated value equals it up to rounding, and `uint(x + 0.5)` recovers it.
|
|
85079
|
+
// Two 16-bit halves (both < 65536, exactly representable in float32) keep the
|
|
85080
|
+
// full 32-bit id range that `applyComponentIds` promises.
|
|
85008
85081
|
// ---------------------------------------------------------------------------
|
|
85009
85082
|
/**
|
|
85010
85083
|
* Shared state-fetch GLSL, injected into BOTH stages: the vertex stage needs it for
|
|
85011
85084
|
* widening / point size, the fragment for color. Declares the sampler + texWidth +
|
|
85012
|
-
* the
|
|
85013
|
-
* (0 for background / nothing).
|
|
85085
|
+
* the two id-half varyings and a helper returning the component's
|
|
85086
|
+
* {@link HighlightFlag} bits (0 for background / nothing).
|
|
85014
85087
|
*/
|
|
85015
85088
|
const HL_STATE_GLSL = `
|
|
85016
|
-
|
|
85089
|
+
varying float vHighlightIdLo;
|
|
85090
|
+
varying float vHighlightIdHi;
|
|
85017
85091
|
uniform highp usampler2D ${U_HIGHLIGHT_STATE};
|
|
85018
85092
|
uniform int ${U_HIGHLIGHT_TEX_WIDTH};
|
|
85019
85093
|
uint highlightState() {
|
|
85094
|
+
uint vHighlightId = (uint(vHighlightIdHi + 0.5) << 16) | uint(vHighlightIdLo + 0.5);
|
|
85020
85095
|
if (vHighlightId == 0u) return 0u;
|
|
85021
85096
|
ivec2 hlUv = ivec2(
|
|
85022
85097
|
int(vHighlightId) % ${U_HIGHLIGHT_TEX_WIDTH},
|
|
@@ -85031,8 +85106,8 @@ uint highlightState() {
|
|
|
85031
85106
|
const HL_VERTEX_HEADER = `
|
|
85032
85107
|
attribute uint ${COMPONENT_ID_ATTRIBUTE};
|
|
85033
85108
|
${HL_STATE_GLSL}`;
|
|
85034
|
-
/** Vertex main: forward the id. Injected right after `void main() {`. */
|
|
85035
|
-
const HL_VERTEX_ASSIGN = `
|
|
85109
|
+
/** Vertex main: forward the id as two 16-bit halves. Injected right after `void main() {`. */
|
|
85110
|
+
const HL_VERTEX_ASSIGN = `vHighlightIdLo = float(${COMPONENT_ID_ATTRIBUTE} & 0xFFFFu); vHighlightIdHi = float(${COMPONENT_ID_ATTRIBUTE} >> 16);`;
|
|
85036
85111
|
/** Fragment header: the shared state fetch + the two highlight colors. */
|
|
85037
85112
|
const HL_FRAGMENT_HEADER = `
|
|
85038
85113
|
${HL_STATE_GLSL}
|
|
@@ -85066,7 +85141,7 @@ uniform vec3 ${U_HIGHLIGHT_HOVER_COLOR};`;
|
|
|
85066
85141
|
* renames an anchor must fail LOUDLY here, not silently drop the highlight (build
|
|
85067
85142
|
* would otherwise stay green). One guard test asserts each anchor still exists.
|
|
85068
85143
|
*/
|
|
85069
|
-
function replaceOrThrow(src, anchor, replacement, where) {
|
|
85144
|
+
function replaceOrThrow$1(src, anchor, replacement, where) {
|
|
85070
85145
|
if (!src.includes(anchor)) {
|
|
85071
85146
|
throw new Error(`HighlightController.${where}: shader anchor not found: ${JSON.stringify(anchor)}`);
|
|
85072
85147
|
}
|
|
@@ -85184,6 +85259,23 @@ uniform vec3 ${U_HIGHLIGHT_HOVER_COLOR};`;
|
|
|
85184
85259
|
}
|
|
85185
85260
|
this._applyHover(`s${solidPath}`, ids);
|
|
85186
85261
|
}
|
|
85262
|
+
/**
|
|
85263
|
+
* HOVER every FACE at or below the tree path `prefix` (a leaf or a whole group),
|
|
85264
|
+
* or clear hover when `null`. Drives the tree-row hover highlight.
|
|
85265
|
+
*/
|
|
85266
|
+
setHoverPath(prefix) {
|
|
85267
|
+
if (prefix == null) {
|
|
85268
|
+
this._applyHover("", []);
|
|
85269
|
+
return;
|
|
85270
|
+
}
|
|
85271
|
+
const sub = prefix + "/";
|
|
85272
|
+
const ids = [];
|
|
85273
|
+
for (const info of this.registry.entries()) {
|
|
85274
|
+
if (info.topo === "face" && info.path.startsWith(sub))
|
|
85275
|
+
ids.push(info.id);
|
|
85276
|
+
}
|
|
85277
|
+
this._applyHover(`p${prefix}`, ids);
|
|
85278
|
+
}
|
|
85187
85279
|
/** Set or clear the SELECTED flag for a single component id. */
|
|
85188
85280
|
setSelected(id, flag) {
|
|
85189
85281
|
this._setBit(id, HighlightFlag.SELECTED, flag);
|
|
@@ -85282,11 +85374,11 @@ uniform vec3 ${U_HIGHLIGHT_HOVER_COLOR};`;
|
|
|
85282
85374
|
this.uniforms.uHighlightSelectedColor;
|
|
85283
85375
|
shader.uniforms[U_HIGHLIGHT_HOVER_COLOR] =
|
|
85284
85376
|
this.uniforms.uHighlightHoverColor;
|
|
85285
|
-
// Common: forward the component id as
|
|
85377
|
+
// Common: forward the component id (as two interpolated float halves).
|
|
85286
85378
|
shader.vertexShader =
|
|
85287
85379
|
HL_VERTEX_HEADER +
|
|
85288
85380
|
"\n" +
|
|
85289
|
-
replaceOrThrow(shader.vertexShader, "void main() {", `void main() {\n ${HL_VERTEX_ASSIGN}`, where);
|
|
85381
|
+
replaceOrThrow$1(shader.vertexShader, "void main() {", `void main() {\n ${HL_VERTEX_ASSIGN}`, where);
|
|
85290
85382
|
shader.fragmentShader = HL_FRAGMENT_HEADER + "\n" + shader.fragmentShader;
|
|
85291
85383
|
// Topo-specific color / size injection.
|
|
85292
85384
|
customize(shader);
|
|
@@ -85301,7 +85393,7 @@ uniform vec3 ${U_HIGHLIGHT_HOVER_COLOR};`;
|
|
|
85301
85393
|
*/
|
|
85302
85394
|
patchFaceMaterial(material) {
|
|
85303
85395
|
this._install(material, "patchFaceMaterial", (shader) => {
|
|
85304
|
-
shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${HL_COLOR_OVERRIDE}`, "patchFaceMaterial");
|
|
85396
|
+
shader.fragmentShader = replaceOrThrow$1(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${HL_COLOR_OVERRIDE}`, "patchFaceMaterial");
|
|
85305
85397
|
});
|
|
85306
85398
|
}
|
|
85307
85399
|
/**
|
|
@@ -85313,9 +85405,9 @@ uniform vec3 ${U_HIGHLIGHT_HOVER_COLOR};`;
|
|
|
85313
85405
|
*/
|
|
85314
85406
|
patchEdgeMaterial(material) {
|
|
85315
85407
|
this._install(material, "patchEdgeMaterial", (shader) => {
|
|
85316
|
-
shader.vertexShader = replaceOrThrow(shader.vertexShader, "offset *= linewidth;", `uint hlEdgeState = highlightState();
|
|
85408
|
+
shader.vertexShader = replaceOrThrow$1(shader.vertexShader, "offset *= linewidth;", `uint hlEdgeState = highlightState();
|
|
85317
85409
|
offset *= ${focusSizeExpr("hlEdgeState", EDGE_FOCUS_WIDTH, EDGE_FOCUS_WIDTH - 2, "linewidth")};`, "patchEdgeMaterial");
|
|
85318
|
-
shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${HL_COLOR_OVERRIDE}`, "patchEdgeMaterial");
|
|
85410
|
+
shader.fragmentShader = replaceOrThrow$1(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${HL_COLOR_OVERRIDE}`, "patchEdgeMaterial");
|
|
85319
85411
|
});
|
|
85320
85412
|
}
|
|
85321
85413
|
/**
|
|
@@ -85330,12 +85422,12 @@ uniform vec3 ${U_HIGHLIGHT_HOVER_COLOR};`;
|
|
|
85330
85422
|
const cull = options.cullUnhighlighted === true;
|
|
85331
85423
|
const none = cull ? "0.0" : "size";
|
|
85332
85424
|
this._install(material, "patchVertexMaterial", (shader) => {
|
|
85333
|
-
shader.vertexShader = replaceOrThrow(shader.vertexShader, "gl_PointSize = size;", `uint hlPtState = highlightState();
|
|
85425
|
+
shader.vertexShader = replaceOrThrow$1(shader.vertexShader, "gl_PointSize = size;", `uint hlPtState = highlightState();
|
|
85334
85426
|
gl_PointSize = ${focusSizeExpr("hlPtState", VERTEX_FOCUS_SIZE, VERTEX_FOCUS_SIZE - 2, none)};`, "patchVertexMaterial");
|
|
85335
85427
|
const discard = cull
|
|
85336
85428
|
? "\n if (highlightState() == 0u) discard;"
|
|
85337
85429
|
: "";
|
|
85338
|
-
shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${discard}${HL_COLOR_OVERRIDE}`, "patchVertexMaterial");
|
|
85430
|
+
shader.fragmentShader = replaceOrThrow$1(shader.fragmentShader, "#include <color_fragment>", `#include <color_fragment>${discard}${HL_COLOR_OVERRIDE}`, "patchVertexMaterial");
|
|
85339
85431
|
},
|
|
85340
85432
|
// The cull discard is the variant that must NOT share a program with the
|
|
85341
85433
|
// standalone (always-visible) vertex cloud — the ghost-vertex bug.
|
|
@@ -88814,7 +88906,14 @@ float metalnessFactor = metalness;
|
|
|
88814
88906
|
group.name = `GridHelper-${i}`;
|
|
88815
88907
|
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));
|
|
88816
88908
|
let label;
|
|
88817
|
-
|
|
88909
|
+
// A grid of no size has no labels, and asking for them hangs the tab:
|
|
88910
|
+
// `niceBounds` answers [0, 0, 0] for a bounding box whose largest extent
|
|
88911
|
+
// is zero - which is what an empty model gives it - so `delta` is zero
|
|
88912
|
+
// and the loop below steps by zero, never advancing and never throwing.
|
|
88913
|
+
// Guarded here rather than in `niceBounds`, whose answer is right: there
|
|
88914
|
+
// is nothing to place ticks on.
|
|
88915
|
+
const step = this.delta / 2;
|
|
88916
|
+
for (let x = -this.size / 2; step > 0 && x <= this.size / 2; x += step) {
|
|
88818
88917
|
if (Math.abs(x) < 1e-6) {
|
|
88819
88918
|
continue;
|
|
88820
88919
|
} // skip center label
|
|
@@ -90121,8 +90220,9 @@ float metalnessFactor = metalness;
|
|
|
90121
90220
|
* @param theme - The UI theme ('light' or 'dark').
|
|
90122
90221
|
* @param linkIcons - Whether icon 0 and 1 are linked.
|
|
90123
90222
|
* @param debug - Enable debug logging.
|
|
90223
|
+
* @param hoverHandler - Callback for label hover (node path, or null on leave).
|
|
90124
90224
|
*/
|
|
90125
|
-
constructor(tree, scrollContainer, objectHandler, pickHandler, updateHandler, notificationHandler, colorGetter, theme, linkIcons, debug = false) {
|
|
90225
|
+
constructor(tree, scrollContainer, objectHandler, pickHandler, updateHandler, notificationHandler, colorGetter, theme, linkIcons, debug = false, hoverHandler = null) {
|
|
90126
90226
|
/************************************************************************************
|
|
90127
90227
|
* Handlers
|
|
90128
90228
|
************************************************************************************/
|
|
@@ -90237,6 +90337,7 @@ float metalnessFactor = metalness;
|
|
|
90237
90337
|
this.theme = theme;
|
|
90238
90338
|
this.linkIcons = linkIcons;
|
|
90239
90339
|
this.debug = debug;
|
|
90340
|
+
this.hoverHandler = hoverHandler;
|
|
90240
90341
|
this.model = null;
|
|
90241
90342
|
this.container = null;
|
|
90242
90343
|
this.lastLabel = null;
|
|
@@ -90434,6 +90535,12 @@ float metalnessFactor = metalness;
|
|
|
90434
90535
|
e.stopPropagation();
|
|
90435
90536
|
this.handleLabelClick(node, e);
|
|
90436
90537
|
};
|
|
90538
|
+
label.onmouseenter = () => {
|
|
90539
|
+
this.hoverHandler?.(this.getNodePath(node));
|
|
90540
|
+
};
|
|
90541
|
+
label.onmouseleave = () => {
|
|
90542
|
+
this.hoverHandler?.(null);
|
|
90543
|
+
};
|
|
90437
90544
|
nodeContent.appendChild(label);
|
|
90438
90545
|
let childrenContainer = null;
|
|
90439
90546
|
if (node.children) {
|
|
@@ -90925,6 +91032,357 @@ float metalnessFactor = metalness;
|
|
|
90925
91032
|
}
|
|
90926
91033
|
};
|
|
90927
91034
|
|
|
91035
|
+
/**
|
|
91036
|
+
* Section hatching for clip caps (CAD mode, shader-only). The per-solid cap quads
|
|
91037
|
+
* draw hatch lines anchored to the plane (stable while rotating/panning) whose
|
|
91038
|
+
* spacing is kept at a constant pixel distance by a per-frame world-per-pixel
|
|
91039
|
+
* uniform. The outline of the caps is drawn separately (see cap-outline.ts); for its
|
|
91040
|
+
* id pass the same cap shader can output the cap's id instead of its color.
|
|
91041
|
+
*
|
|
91042
|
+
* All shared uniforms are plain `{ value }` objects held by reference, so a single
|
|
91043
|
+
* write (e.g. the per-frame hatch spacing) updates every patched material without a
|
|
91044
|
+
* recompile.
|
|
91045
|
+
*/
|
|
91046
|
+
/** Distance between hatch lines in pixels. */
|
|
91047
|
+
const HATCH_SPACING_PX = 8;
|
|
91048
|
+
/** Width of a hatch line in pixels. */
|
|
91049
|
+
const HATCH_LINE_WIDTH_PX = 1;
|
|
91050
|
+
/**
|
|
91051
|
+
* Hatch angle (radians) and spacing factor per solid, cycled by the solid's index
|
|
91052
|
+
* so that neighbouring solids are told apart (drawing convention: alternate the
|
|
91053
|
+
* hatch direction between adjacent parts).
|
|
91054
|
+
*/
|
|
91055
|
+
const HATCH_VARIANTS = [
|
|
91056
|
+
{ angle: Math.PI / 4, scale: 1.0 },
|
|
91057
|
+
{ angle: (3 * Math.PI) / 4, scale: 1.0 },
|
|
91058
|
+
{ angle: Math.PI / 4, scale: 1.6 },
|
|
91059
|
+
{ angle: (3 * Math.PI) / 4, scale: 1.6 },
|
|
91060
|
+
];
|
|
91061
|
+
function createHatchUniforms(capHalfSize) {
|
|
91062
|
+
return {
|
|
91063
|
+
uHatchSpacing: { value: 1 },
|
|
91064
|
+
uCapHalfSize: { value: capHalfSize },
|
|
91065
|
+
uHatchLineWidth: { value: HATCH_LINE_WIDTH_PX },
|
|
91066
|
+
uHatchOn: { value: 1 },
|
|
91067
|
+
uCapIdPass: { value: 0 },
|
|
91068
|
+
};
|
|
91069
|
+
}
|
|
91070
|
+
/**
|
|
91071
|
+
* World units covered by one screen pixel at the given point (exact for an
|
|
91072
|
+
* orthographic camera; at the point's distance for a perspective camera).
|
|
91073
|
+
*/
|
|
91074
|
+
function worldPerPixel(camera, point, height) {
|
|
91075
|
+
if (height <= 0)
|
|
91076
|
+
return 1;
|
|
91077
|
+
if (camera instanceof OrthographicCamera) {
|
|
91078
|
+
return (camera.top - camera.bottom) / camera.zoom / height;
|
|
91079
|
+
}
|
|
91080
|
+
if (camera instanceof PerspectiveCamera) {
|
|
91081
|
+
const dist = camera.position.distanceTo(point);
|
|
91082
|
+
const halfFov = MathUtils.degToRad(camera.fov) / 2;
|
|
91083
|
+
return (2 * dist * Math.tan(halfFov)) / camera.zoom / height;
|
|
91084
|
+
}
|
|
91085
|
+
return 1;
|
|
91086
|
+
}
|
|
91087
|
+
function replaceOrThrow(source, anchor, replacement, where) {
|
|
91088
|
+
if (!source.includes(anchor)) {
|
|
91089
|
+
throw new Error(`${where}: shader anchor "${anchor}" not found`);
|
|
91090
|
+
}
|
|
91091
|
+
return source.replace(anchor, replacement);
|
|
91092
|
+
}
|
|
91093
|
+
/**
|
|
91094
|
+
* Patch a cap quad material to draw section hatching. `index` is the solid's index
|
|
91095
|
+
* among the caps of its plane; it selects the hatch direction and spacing. `capId`
|
|
91096
|
+
* is the cap's unique id color for the outline id pass (see `capIdColor`).
|
|
91097
|
+
*/
|
|
91098
|
+
function patchHatchMaterial(material, uniforms, index, capId) {
|
|
91099
|
+
if (material.userData.hatchPatched === true)
|
|
91100
|
+
return;
|
|
91101
|
+
material.userData.hatchPatched = true;
|
|
91102
|
+
const variant = HATCH_VARIANTS[index % HATCH_VARIANTS.length];
|
|
91103
|
+
const uHatchAngle = { value: variant.angle };
|
|
91104
|
+
const uHatchScale = { value: variant.scale };
|
|
91105
|
+
const uCapId = { value: capId };
|
|
91106
|
+
const prev = material.onBeforeCompile;
|
|
91107
|
+
material.onBeforeCompile = (shader, renderer) => {
|
|
91108
|
+
prev.call(material, shader, renderer);
|
|
91109
|
+
shader.uniforms.uHatchSpacing = uniforms.uHatchSpacing;
|
|
91110
|
+
shader.uniforms.uCapHalfSize = uniforms.uCapHalfSize;
|
|
91111
|
+
shader.uniforms.uHatchLineWidth = uniforms.uHatchLineWidth;
|
|
91112
|
+
shader.uniforms.uHatchOn = uniforms.uHatchOn;
|
|
91113
|
+
shader.uniforms.uHatchAngle = uHatchAngle;
|
|
91114
|
+
shader.uniforms.uHatchScale = uHatchScale;
|
|
91115
|
+
shader.uniforms.uCapIdPass = uniforms.uCapIdPass;
|
|
91116
|
+
shader.uniforms.uCapId = uCapId;
|
|
91117
|
+
shader.vertexShader =
|
|
91118
|
+
"uniform float uCapHalfSize;\nvarying vec2 vTcvCapUV;\n" +
|
|
91119
|
+
replaceOrThrow(shader.vertexShader, "#include <begin_vertex>", "#include <begin_vertex>\n vTcvCapUV = position.xy * uCapHalfSize;", "patchHatchMaterial");
|
|
91120
|
+
shader.fragmentShader =
|
|
91121
|
+
"uniform float uHatchSpacing;\nuniform float uHatchLineWidth;\nuniform float uHatchOn;\n" +
|
|
91122
|
+
"uniform float uHatchAngle;\nuniform float uHatchScale;\nvarying vec2 vTcvCapUV;\n" +
|
|
91123
|
+
"uniform float uCapIdPass;\nuniform vec3 uCapId;\n" +
|
|
91124
|
+
shader.fragmentShader;
|
|
91125
|
+
// Hatch the unlit base color, before lighting: the ink (dark lines on light
|
|
91126
|
+
// parts, light lines on dark parts) is then fixed per part, and lines and cap
|
|
91127
|
+
// darken together when the cap turns away from the light.
|
|
91128
|
+
shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <color_fragment>",
|
|
91129
|
+
/* glsl */ `#include <color_fragment>
|
|
91130
|
+
if ( uHatchOn > 0.5 ) {
|
|
91131
|
+
vec2 tcvAcross = vec2( - sin( uHatchAngle ), cos( uHatchAngle ) );
|
|
91132
|
+
float t = dot( vTcvCapUV, tcvAcross ) / max( uHatchSpacing * uHatchScale, 1e-9 );
|
|
91133
|
+
float tw = max( fwidth( t ), 1e-6 );
|
|
91134
|
+
float dpx = ( 0.5 - abs( fract( t ) - 0.5 ) ) / tw;
|
|
91135
|
+
float line = 1.0 - smoothstep( 0.5 * uHatchLineWidth - 0.5, 0.5 * uHatchLineWidth + 0.5, dpx );
|
|
91136
|
+
// Seen edge-on the lines crowd below a few pixels apart and would merge into
|
|
91137
|
+
// solid ink: fade them out between 4 and 2 px spacing (1 / tw = px per line).
|
|
91138
|
+
line *= smoothstep( 2.0, 4.0, 1.0 / tw );
|
|
91139
|
+
float lum = dot( diffuseColor.rgb, vec3( 0.2126, 0.7152, 0.0722 ) );
|
|
91140
|
+
vec3 ink = lum > 0.2 ? diffuseColor.rgb * 0.3 : mix( diffuseColor.rgb, vec3( 1.0 ), 0.6 );
|
|
91141
|
+
diffuseColor.rgb = mix( diffuseColor.rgb, ink, line );
|
|
91142
|
+
}`, "patchHatchMaterial");
|
|
91143
|
+
shader.fragmentShader = replaceOrThrow(shader.fragmentShader, "#include <opaque_fragment>", `#include <opaque_fragment>
|
|
91144
|
+
if ( uCapIdPass > 0.5 ) gl_FragColor = vec4( uCapId, 1.0 );`, "patchHatchMaterial");
|
|
91145
|
+
};
|
|
91146
|
+
material.needsUpdate = true;
|
|
91147
|
+
}
|
|
91148
|
+
|
|
91149
|
+
/**
|
|
91150
|
+
* Screen-space outline of the clip caps (CAD mode).
|
|
91151
|
+
*
|
|
91152
|
+
* The caps carry no geometry of their own outline, and the faces next to a cut may
|
|
91153
|
+
* face away from the camera or belong to a touching solid, so the outline is found
|
|
91154
|
+
* in screen space instead:
|
|
91155
|
+
*
|
|
91156
|
+
* 1. Id pass into an offscreen target (color + depth + stencil):
|
|
91157
|
+
* - visible faces as black occluders (face pick layer + override material), so a
|
|
91158
|
+
* cap hidden behind another part gets no outline; face pixels within the line
|
|
91159
|
+
* width of a clip plane write {@link CUT_EDGE_ID} instead, which draws the cut
|
|
91160
|
+
* contour on the faces themselves (needed when the caps are behind the faces),
|
|
91161
|
+
* - the stencil meshes and caps (on {@link CAP_LAYER}) with each cap writing its
|
|
91162
|
+
* own id instead of its shaded color — the same stencil sequence as the main
|
|
91163
|
+
* render, since three orders them identically (material creation order).
|
|
91164
|
+
* 2. Composite over the canvas: a pixel is drawn in the edge color when its id is
|
|
91165
|
+
* larger than a neighbour's id, which yields a line on one side of every boundary
|
|
91166
|
+
* cap↔cap, cap↔surface and cap↔background. Cut-edge pixels are the largest id, so
|
|
91167
|
+
* where they meet a cap only they are drawn — one line, not two.
|
|
91168
|
+
*/
|
|
91169
|
+
/** Render layer of the clip stencil meshes and cap quads (pick layers use 1-3). */
|
|
91170
|
+
const CAP_LAYER = 4;
|
|
91171
|
+
/** Id of face pixels on the cut contour (larger than any cap id). */
|
|
91172
|
+
const CUT_EDGE_ID = 0xffffff;
|
|
91173
|
+
/** Encode a cap id (1..2^24-1) as an exact 8-bit RGB color (0 = no cap). */
|
|
91174
|
+
function capIdColor(id) {
|
|
91175
|
+
return new Vector3((id & 0xff) / 255, ((id >> 8) & 0xff) / 255, ((id >> 16) & 0xff) / 255);
|
|
91176
|
+
}
|
|
91177
|
+
// Occluder: a face fragment lies on the cut contour when it is within uCutWidth
|
|
91178
|
+
// pixels of a clip plane that bounds the visible part, mirroring three's
|
|
91179
|
+
// clipping_planes_fragment (kept fragments have d >= 0). Skipped where the face is
|
|
91180
|
+
// (nearly) parallel to the plane: there d barely changes across the pixel, and a
|
|
91181
|
+
// face lying in the plane would otherwise become one big edge.
|
|
91182
|
+
const CUT_EDGE_GLSL = /* glsl */ `
|
|
91183
|
+
bool tcvCut = false;
|
|
91184
|
+
#if NUM_CLIPPING_PLANES > 0
|
|
91185
|
+
{
|
|
91186
|
+
float tcvPosGrad = length( fwidth( vClipPosition ) );
|
|
91187
|
+
float tcvD[ NUM_CLIPPING_PLANES ];
|
|
91188
|
+
bool tcvNear[ NUM_CLIPPING_PLANES ];
|
|
91189
|
+
for ( int i = 0; i < NUM_CLIPPING_PLANES; i ++ ) {
|
|
91190
|
+
vec4 p = clippingPlanes[ i ];
|
|
91191
|
+
float d = p.w - dot( vClipPosition, p.xyz );
|
|
91192
|
+
// Step of d per pixel along the dominant screen axis — the same 1-pixel
|
|
91193
|
+
// convention as the composite's horizontal/vertical neighbour test.
|
|
91194
|
+
float g = max( abs( dFdx( d ) ), abs( dFdy( d ) ) );
|
|
91195
|
+
tcvD[ i ] = d;
|
|
91196
|
+
tcvNear[ i ] = g > 0.05 * tcvPosGrad && d >= 0.0 && d < uCutWidth * g;
|
|
91197
|
+
}
|
|
91198
|
+
for ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {
|
|
91199
|
+
tcvCut = tcvCut || tcvNear[ i ];
|
|
91200
|
+
}
|
|
91201
|
+
#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES
|
|
91202
|
+
// Intersection planes: plane i bounds the visible part only where every
|
|
91203
|
+
// other intersection plane clips.
|
|
91204
|
+
for ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {
|
|
91205
|
+
bool othersClip = true;
|
|
91206
|
+
for ( int j = UNION_CLIPPING_PLANES; j < NUM_CLIPPING_PLANES; j ++ ) {
|
|
91207
|
+
if ( j != i && tcvD[ j ] >= 0.0 ) othersClip = false;
|
|
91208
|
+
}
|
|
91209
|
+
if ( othersClip && tcvNear[ i ] ) tcvCut = true;
|
|
91210
|
+
}
|
|
91211
|
+
#endif
|
|
91212
|
+
}
|
|
91213
|
+
#endif
|
|
91214
|
+
`;
|
|
91215
|
+
const COMPOSITE_VERTEX = /* glsl */ `
|
|
91216
|
+
varying vec2 vUv;
|
|
91217
|
+
void main() {
|
|
91218
|
+
vUv = uv;
|
|
91219
|
+
gl_Position = vec4( position.xy, 0.0, 1.0 );
|
|
91220
|
+
}
|
|
91221
|
+
`;
|
|
91222
|
+
const COMPOSITE_FRAGMENT = /* glsl */ `
|
|
91223
|
+
uniform sampler2D tId;
|
|
91224
|
+
uniform vec2 uTexel;
|
|
91225
|
+
uniform float uWidth;
|
|
91226
|
+
uniform vec3 uColor;
|
|
91227
|
+
varying vec2 vUv;
|
|
91228
|
+
|
|
91229
|
+
float idAt( vec2 uv ) {
|
|
91230
|
+
vec3 c = floor( texture2D( tId, uv ).rgb * 255.0 + 0.5 );
|
|
91231
|
+
return c.r + 256.0 * c.g + 65536.0 * c.b;
|
|
91232
|
+
}
|
|
91233
|
+
|
|
91234
|
+
void main() {
|
|
91235
|
+
float c = idAt( vUv );
|
|
91236
|
+
bool edge = c == ${CUT_EDGE_ID.toFixed(1)};
|
|
91237
|
+
for ( int k = 1; k <= 4; k ++ ) {
|
|
91238
|
+
if ( float( k ) > uWidth ) break;
|
|
91239
|
+
vec2 o = uTexel * float( k );
|
|
91240
|
+
edge = edge
|
|
91241
|
+
|| c > idAt( vUv + vec2( o.x, 0.0 ) ) || c > idAt( vUv - vec2( o.x, 0.0 ) )
|
|
91242
|
+
|| c > idAt( vUv + vec2( 0.0, o.y ) ) || c > idAt( vUv - vec2( 0.0, o.y ) );
|
|
91243
|
+
}
|
|
91244
|
+
if ( ! edge ) discard;
|
|
91245
|
+
gl_FragColor = vec4( uColor, 1.0 );
|
|
91246
|
+
#include <colorspace_fragment>
|
|
91247
|
+
}
|
|
91248
|
+
`;
|
|
91249
|
+
class CapOutlinePass {
|
|
91250
|
+
constructor() {
|
|
91251
|
+
this.target = null;
|
|
91252
|
+
/** Width of the cut contour on the faces, in device pixels (= outline width). */
|
|
91253
|
+
this.cutWidth = { value: 1 };
|
|
91254
|
+
this.size = new Vector2();
|
|
91255
|
+
this.savedClearColor = new Color();
|
|
91256
|
+
this.savedViewport = new Vector4();
|
|
91257
|
+
// One depth unit behind the caps (polygonOffset 1/1), so a face coplanar with a
|
|
91258
|
+
// cap does not hide it; same slope factor, so steep faces in front still occlude.
|
|
91259
|
+
this.occluder = new MeshBasicMaterial({
|
|
91260
|
+
color: 0x000000,
|
|
91261
|
+
side: DoubleSide,
|
|
91262
|
+
polygonOffset: true,
|
|
91263
|
+
polygonOffsetFactor: 1,
|
|
91264
|
+
polygonOffsetUnits: 2,
|
|
91265
|
+
});
|
|
91266
|
+
this.occluder.onBeforeCompile = (shader) => {
|
|
91267
|
+
const clipAnchor = "#include <clipping_planes_fragment>";
|
|
91268
|
+
const opaqueAnchor = "#include <opaque_fragment>";
|
|
91269
|
+
for (const anchor of [clipAnchor, opaqueAnchor]) {
|
|
91270
|
+
if (!shader.fragmentShader.includes(anchor)) {
|
|
91271
|
+
throw new Error(`CapOutlinePass: shader anchor "${anchor}" not found`);
|
|
91272
|
+
}
|
|
91273
|
+
}
|
|
91274
|
+
shader.uniforms.uCutWidth = this.cutWidth;
|
|
91275
|
+
shader.fragmentShader =
|
|
91276
|
+
"uniform float uCutWidth;\n" +
|
|
91277
|
+
shader.fragmentShader
|
|
91278
|
+
.replace(clipAnchor, `${clipAnchor}\n${CUT_EDGE_GLSL}`)
|
|
91279
|
+
.replace(opaqueAnchor, `${opaqueAnchor}\n if ( tcvCut ) gl_FragColor = vec4( 1.0 );`);
|
|
91280
|
+
};
|
|
91281
|
+
this.composite = new ShaderMaterial({
|
|
91282
|
+
uniforms: {
|
|
91283
|
+
tId: { value: null },
|
|
91284
|
+
uTexel: { value: new Vector2(1, 1) },
|
|
91285
|
+
uWidth: { value: 1 },
|
|
91286
|
+
uColor: { value: new Color() },
|
|
91287
|
+
},
|
|
91288
|
+
vertexShader: COMPOSITE_VERTEX,
|
|
91289
|
+
fragmentShader: COMPOSITE_FRAGMENT,
|
|
91290
|
+
depthTest: false,
|
|
91291
|
+
depthWrite: false,
|
|
91292
|
+
});
|
|
91293
|
+
this.quad = new Mesh(new PlaneGeometry(2, 2), this.composite);
|
|
91294
|
+
this.quad.frustumCulled = false;
|
|
91295
|
+
this.quadScene = new Scene();
|
|
91296
|
+
this.quadScene.add(this.quad);
|
|
91297
|
+
this.quadCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
91298
|
+
}
|
|
91299
|
+
ensureTarget(width, height) {
|
|
91300
|
+
if (this.target === null) {
|
|
91301
|
+
this.target = new WebGLRenderTarget(width, height, {
|
|
91302
|
+
minFilter: NearestFilter,
|
|
91303
|
+
magFilter: NearestFilter,
|
|
91304
|
+
generateMipmaps: false,
|
|
91305
|
+
depthBuffer: true,
|
|
91306
|
+
stencilBuffer: true,
|
|
91307
|
+
});
|
|
91308
|
+
}
|
|
91309
|
+
else if (this.target.width !== width || this.target.height !== height) {
|
|
91310
|
+
this.target.setSize(width, height);
|
|
91311
|
+
}
|
|
91312
|
+
return this.target;
|
|
91313
|
+
}
|
|
91314
|
+
/**
|
|
91315
|
+
* Render the id pass and draw the outline over the current (canvas) framebuffer.
|
|
91316
|
+
* Call right after the main scene render.
|
|
91317
|
+
*/
|
|
91318
|
+
render(renderer, scene, camera, options) {
|
|
91319
|
+
renderer.getDrawingBufferSize(this.size);
|
|
91320
|
+
const width = Math.floor(this.size.x);
|
|
91321
|
+
const height = Math.floor(this.size.y);
|
|
91322
|
+
if (width < 1 || height < 1)
|
|
91323
|
+
return;
|
|
91324
|
+
const target = this.ensureTarget(width, height);
|
|
91325
|
+
// Line width in device pixels, shared by the cut contour and the cap outline.
|
|
91326
|
+
const lineWidth = Math.max(1, Math.round(renderer.getPixelRatio()));
|
|
91327
|
+
this.cutWidth.value = lineWidth;
|
|
91328
|
+
const savedMask = camera.layers.mask;
|
|
91329
|
+
const savedOverride = scene.overrideMaterial;
|
|
91330
|
+
const savedBackground = scene.background;
|
|
91331
|
+
const savedTarget = renderer.getRenderTarget();
|
|
91332
|
+
const savedAutoClear = renderer.autoClear;
|
|
91333
|
+
const savedAlpha = renderer.getClearAlpha();
|
|
91334
|
+
renderer.getClearColor(this.savedClearColor);
|
|
91335
|
+
renderer.getViewport(this.savedViewport);
|
|
91336
|
+
try {
|
|
91337
|
+
renderer.autoClear = false;
|
|
91338
|
+
scene.background = null;
|
|
91339
|
+
renderer.setRenderTarget(target);
|
|
91340
|
+
renderer.setClearColor(0x000000, 0); // id 0 = no cap
|
|
91341
|
+
renderer.clear(true, true, true);
|
|
91342
|
+
if (options.occlude) {
|
|
91343
|
+
this.occluder.clippingPlanes = options.planes;
|
|
91344
|
+
this.occluder.clipIntersection = options.intersection;
|
|
91345
|
+
camera.layers.set(PICK_LAYER.FACE);
|
|
91346
|
+
scene.overrideMaterial = this.occluder;
|
|
91347
|
+
renderer.render(scene, camera);
|
|
91348
|
+
scene.overrideMaterial = savedOverride;
|
|
91349
|
+
}
|
|
91350
|
+
options.setCapIdPass(true);
|
|
91351
|
+
try {
|
|
91352
|
+
camera.layers.set(CAP_LAYER);
|
|
91353
|
+
renderer.render(scene, camera);
|
|
91354
|
+
}
|
|
91355
|
+
finally {
|
|
91356
|
+
options.setCapIdPass(false);
|
|
91357
|
+
}
|
|
91358
|
+
renderer.setRenderTarget(savedTarget);
|
|
91359
|
+
renderer.setViewport(this.savedViewport);
|
|
91360
|
+
const u = this.composite.uniforms;
|
|
91361
|
+
u.tId.value = target.texture;
|
|
91362
|
+
u.uTexel.value.set(1 / width, 1 / height);
|
|
91363
|
+
u.uWidth.value = lineWidth;
|
|
91364
|
+
u.uColor.value.set(options.color);
|
|
91365
|
+
renderer.render(this.quadScene, this.quadCamera);
|
|
91366
|
+
}
|
|
91367
|
+
finally {
|
|
91368
|
+
camera.layers.mask = savedMask;
|
|
91369
|
+
scene.overrideMaterial = savedOverride;
|
|
91370
|
+
scene.background = savedBackground;
|
|
91371
|
+
renderer.setRenderTarget(savedTarget);
|
|
91372
|
+
renderer.setClearColor(this.savedClearColor, savedAlpha);
|
|
91373
|
+
renderer.setViewport(this.savedViewport);
|
|
91374
|
+
renderer.autoClear = savedAutoClear;
|
|
91375
|
+
}
|
|
91376
|
+
}
|
|
91377
|
+
dispose() {
|
|
91378
|
+
this.target?.dispose();
|
|
91379
|
+
this.target = null;
|
|
91380
|
+
this.occluder.dispose();
|
|
91381
|
+
this.composite.dispose();
|
|
91382
|
+
this.quad.geometry.dispose();
|
|
91383
|
+
}
|
|
91384
|
+
}
|
|
91385
|
+
|
|
90928
91386
|
// ============================================================================
|
|
90929
91387
|
// Constants
|
|
90930
91388
|
// ============================================================================
|
|
@@ -91177,6 +91635,9 @@ float metalnessFactor = metalness;
|
|
|
91177
91635
|
super();
|
|
91178
91636
|
/** Per-solid stencil/cap units, the unit of screen-size culling. */
|
|
91179
91637
|
this._capUnits = [];
|
|
91638
|
+
/** Shared uniforms of the section hatching on all cap quads. */
|
|
91639
|
+
this._hatchUniforms = createHatchUniforms(1);
|
|
91640
|
+
this._hatchCenter = new Vector3();
|
|
91180
91641
|
/**
|
|
91181
91642
|
* Whether {@link cull} last ran with clipping active. Lets the inactive path
|
|
91182
91643
|
* gate stencils/caps off exactly once, then early-return on later still frames.
|
|
@@ -91240,6 +91701,10 @@ float metalnessFactor = metalness;
|
|
|
91240
91701
|
mesh.material.visible = flag;
|
|
91241
91702
|
}
|
|
91242
91703
|
};
|
|
91704
|
+
/** Switch all cap materials between shaded output and outline id output. */
|
|
91705
|
+
this.setCapIdPass = (flag) => {
|
|
91706
|
+
this._hatchUniforms.uCapIdPass.value = flag ? 1 : 0;
|
|
91707
|
+
};
|
|
91243
91708
|
this.center = center;
|
|
91244
91709
|
this.distance = size / 2;
|
|
91245
91710
|
this.onNormalChange = options.onNormalChange || null;
|
|
@@ -91302,11 +91767,16 @@ float metalnessFactor = metalness;
|
|
|
91302
91767
|
_createStencils(center, size, theme) {
|
|
91303
91768
|
this._planeMeshGroup = new PlaneMeshGroup();
|
|
91304
91769
|
this._planeMeshGroup.name = "PlaneMeshes";
|
|
91770
|
+
// Cap quads are PlaneGeometry(2, 2) scaled by size/2 (PlaneMesh.updateMatrixWorld).
|
|
91771
|
+
this._hatchUniforms.uCapHalfSize.value = 0.5 * size;
|
|
91772
|
+
this._hatchCenter.set(center[0], center[1], center[2]);
|
|
91305
91773
|
// Group the per-(solid,plane) units by solid for screen-size culling. The
|
|
91306
91774
|
// loop below is plane-major (and `objectColors`/`_planeMeshGroup` order must
|
|
91307
91775
|
// stay plane-major for setObjectColorCaps), so accumulate into a Map keyed by
|
|
91308
91776
|
// solid and flatten afterwards — without touching the plane-major structures.
|
|
91309
91777
|
const unitsBySolid = new Map();
|
|
91778
|
+
// Unique id per cap quad (plane × solid) for the cap outline id pass; 0 = none.
|
|
91779
|
+
let capId = 0;
|
|
91310
91780
|
for (let i = 0; i < 3; i++) {
|
|
91311
91781
|
const plane = this.clipPlanes[i];
|
|
91312
91782
|
const otherPlanes = this.clipPlanes.filter((_, j) => j !== i);
|
|
@@ -91326,9 +91796,16 @@ float metalnessFactor = metalness;
|
|
|
91326
91796
|
clippingGroup.add(createStencil(`frontStencil-${i}-${j}`, ClippingMaterials.createFrontStencilMaterial(), group.shapeGeometry, plane));
|
|
91327
91797
|
clippingGroup.add(createStencil(`backStencil-${i}-${j}`, ClippingMaterials.createBackStencilMaterial(), group.shapeGeometry, plane));
|
|
91328
91798
|
group.addClipping(clippingGroup, i);
|
|
91799
|
+
// The outline id pass renders the stencil sequence + caps on CAP_LAYER.
|
|
91800
|
+
for (const stencil of clippingGroup.children) {
|
|
91801
|
+
stencil.layers.enable(CAP_LAYER);
|
|
91802
|
+
}
|
|
91329
91803
|
// Create stencil plane mesh
|
|
91330
91804
|
const planeMaterial = ClippingMaterials.createStencilPlaneMaterial(PLANE_COLORS[theme][i], otherPlanes);
|
|
91805
|
+
capId++;
|
|
91806
|
+
patchHatchMaterial(planeMaterial, this._hatchUniforms, j, capIdColor(capId));
|
|
91331
91807
|
const capMesh = new PlaneMesh(i, plane, center, size, planeMaterial, PLANE_COLORS[theme][i], `StencilPlane-${i}-${j}`);
|
|
91808
|
+
capMesh.layers.enable(CAP_LAYER);
|
|
91332
91809
|
this._planeMeshGroup.add(capMesh);
|
|
91333
91810
|
// Record the cull unit for this solid (one entry per solid, holding
|
|
91334
91811
|
// its up-to-3 per-plane stencil groups + cap quads).
|
|
@@ -91393,6 +91870,10 @@ float metalnessFactor = metalness;
|
|
|
91393
91870
|
this.clipPlanes[index].setConstant(value);
|
|
91394
91871
|
this.reverseClipPlanes[index].setConstant(-value);
|
|
91395
91872
|
}
|
|
91873
|
+
/** Whether any solid has clip caps (the cap outline pass has work to do). */
|
|
91874
|
+
get hasCaps() {
|
|
91875
|
+
return this._capUnits.length > 0;
|
|
91876
|
+
}
|
|
91396
91877
|
/**
|
|
91397
91878
|
* Bound the per-frame stencil/cap draw work to keep large assemblies from
|
|
91398
91879
|
* overrunning the GPU watchdog on clip+rotate (see {@link CAP_CULL_MIN_PX}).
|
|
@@ -91427,6 +91908,9 @@ float metalnessFactor = metalness;
|
|
|
91427
91908
|
return;
|
|
91428
91909
|
}
|
|
91429
91910
|
this._cullActive = true;
|
|
91911
|
+
// Keep the hatch spacing constant on screen (planes pass near the model center).
|
|
91912
|
+
this._hatchUniforms.uHatchSpacing.value =
|
|
91913
|
+
HATCH_SPACING_PX * worldPerPixel(camera, this._hatchCenter, height);
|
|
91430
91914
|
// Camera right vector in world space = column 0 of the camera world matrix.
|
|
91431
91915
|
camera.updateMatrixWorld();
|
|
91432
91916
|
const e = camera.matrixWorld.elements;
|
|
@@ -97356,7 +97840,7 @@ float metalnessFactor = metalness;
|
|
|
97356
97840
|
}
|
|
97357
97841
|
}
|
|
97358
97842
|
|
|
97359
|
-
const version = "5.0
|
|
97843
|
+
const version = "5.1.0";
|
|
97360
97844
|
|
|
97361
97845
|
/**
|
|
97362
97846
|
* `PickedComponent` over a GPU id-pick result. Drives the shader
|
|
@@ -97582,6 +98066,58 @@ float metalnessFactor = metalness;
|
|
|
97582
98066
|
this.setSelectionInput(false);
|
|
97583
98067
|
this.setPickHandler(false);
|
|
97584
98068
|
}
|
|
98069
|
+
/**
|
|
98070
|
+
* Pick the visible component under the cursor with the current topo filter, or
|
|
98071
|
+
* `null` when the cursor is off the canvas or over background.
|
|
98072
|
+
*/
|
|
98073
|
+
pickUnderCursor() {
|
|
98074
|
+
if (this.host.idPicker === null || !this.idHoverInside)
|
|
98075
|
+
return null;
|
|
98076
|
+
const rect = this.host.renderer.domElement.getBoundingClientRect();
|
|
98077
|
+
const x = this.idHoverClientX - rect.left;
|
|
98078
|
+
const y = this.idHoverClientY - rect.top;
|
|
98079
|
+
if (x < 0 || y < 0 || x > rect.width || y > rect.height)
|
|
98080
|
+
return null;
|
|
98081
|
+
const topoFilter = pickerTopoFilter(this.host.display.shapeFilterDropDownMenu.currentFilter);
|
|
98082
|
+
const hit = this.host.idPicker.pickAt(x, y, topoFilter === undefined ? {} : { topoFilter });
|
|
98083
|
+
// The vertex/edge pick layers stay active even when the owning solid is hidden
|
|
98084
|
+
// (visibility lives on the mesh material, not the pick layer), so gate the pick on
|
|
98085
|
+
// visibility — otherwise a hidden component could be hovered AND selected.
|
|
98086
|
+
if (hit === null || !this.pickVisible(hit.info))
|
|
98087
|
+
return null;
|
|
98088
|
+
return hit;
|
|
98089
|
+
}
|
|
98090
|
+
/**
|
|
98091
|
+
* World-space point on the visible component under the cursor (same pick as the
|
|
98092
|
+
* hover), or `null` when there is none or the GPU lacks the position attachment.
|
|
98093
|
+
*/
|
|
98094
|
+
pointUnderCursor() {
|
|
98095
|
+
if (!this.host.ready)
|
|
98096
|
+
return null;
|
|
98097
|
+
const hit = this.pickUnderCursor();
|
|
98098
|
+
if (hit === null || hit.point === null)
|
|
98099
|
+
return null;
|
|
98100
|
+
return hit.point.clone();
|
|
98101
|
+
}
|
|
98102
|
+
/**
|
|
98103
|
+
* Tree-row hover: HOVER the faces of the tree node at `path` (leaf or group), or
|
|
98104
|
+
* clear when `null`. Drops the canvas hover target first — otherwise the next
|
|
98105
|
+
* render's {@link handleIdHover} (cursor outside the canvas) would release that
|
|
98106
|
+
* stale target and wipe the tree hover with it.
|
|
98107
|
+
*/
|
|
98108
|
+
setTreeHover(path) {
|
|
98109
|
+
if (!this.host.ready || this.host.studioActive)
|
|
98110
|
+
return;
|
|
98111
|
+
const highlight = this.host.rendered.nestedGroup?.highlight ?? null;
|
|
98112
|
+
if (highlight === null)
|
|
98113
|
+
return;
|
|
98114
|
+
this.releaseLastSelected();
|
|
98115
|
+
this.lastObject = null;
|
|
98116
|
+
this.host.display.setStatusLine("");
|
|
98117
|
+
highlight.setHoverPath(path);
|
|
98118
|
+
if (!this.host.hasAnimationLoop)
|
|
98119
|
+
this.host.update(true, false);
|
|
98120
|
+
}
|
|
97585
98121
|
/**
|
|
97586
98122
|
* Whether hover preselection (highlight + status line) is active. Disabled for GDS:
|
|
97587
98123
|
* dense, stacked, instance-unrolled layout data where per-pixel hover flickers
|
|
@@ -97619,28 +98155,12 @@ float metalnessFactor = metalness;
|
|
|
97619
98155
|
this.lastObject = null;
|
|
97620
98156
|
this.host.display.setStatusLine("");
|
|
97621
98157
|
};
|
|
97622
|
-
|
|
97623
|
-
|
|
97624
|
-
return;
|
|
97625
|
-
}
|
|
97626
|
-
const rect = this.host.renderer.domElement.getBoundingClientRect();
|
|
97627
|
-
const x = this.idHoverClientX - rect.left;
|
|
97628
|
-
const y = this.idHoverClientY - rect.top;
|
|
97629
|
-
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
|
|
97630
|
-
release();
|
|
97631
|
-
return;
|
|
97632
|
-
}
|
|
97633
|
-
const filter = this.host.display.shapeFilterDropDownMenu.currentFilter;
|
|
97634
|
-
const fromSolid = filter.includes(TopoFilter.solid);
|
|
97635
|
-
const topoFilter = pickerTopoFilter(filter);
|
|
97636
|
-
const hit = this.host.idPicker.pickAt(x, y, topoFilter === undefined ? {} : { topoFilter });
|
|
97637
|
-
// The vertex/edge pick layers stay active even when the owning solid is hidden
|
|
97638
|
-
// (visibility lives on the mesh material, not the pick layer), so gate the pick on
|
|
97639
|
-
// visibility — otherwise a hidden component could be hovered AND selected.
|
|
97640
|
-
if (hit === null || !this.pickVisible(hit.info)) {
|
|
98158
|
+
const hit = this.pickUnderCursor();
|
|
98159
|
+
if (hit === null) {
|
|
97641
98160
|
release();
|
|
97642
98161
|
return;
|
|
97643
98162
|
}
|
|
98163
|
+
const fromSolid = this.host.display.shapeFilterDropDownMenu.currentFilter.includes(TopoFilter.solid);
|
|
97644
98164
|
const picked = new IdPicked(hit.info, fromSolid, hit.point, highlight);
|
|
97645
98165
|
if (!picked.equals(this.lastObject)) {
|
|
97646
98166
|
this.releaseLastSelected();
|
|
@@ -108821,6 +109341,10 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
108821
109341
|
// =============================================================================
|
|
108822
109342
|
// IMPORTS
|
|
108823
109343
|
// =============================================================================
|
|
109344
|
+
/** Decimals of a point copied with Ctrl/Cmd-C ({@link Viewer.copyPointUnderCursor}). */
|
|
109345
|
+
const COPY_POINT_DECIMALS = 4;
|
|
109346
|
+
/** Delay after the last camera change before the clip cap outline is drawn again. */
|
|
109347
|
+
const CAP_OUTLINE_SETTLE_MS = 150;
|
|
108824
109348
|
/**
|
|
108825
109349
|
* Type guard to check if an Object3D is an IndexedMesh.
|
|
108826
109350
|
*/
|
|
@@ -108931,6 +109455,14 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
108931
109455
|
// GPU id-based picker. Created/attached per render() once the scene + compact
|
|
108932
109456
|
// registry exist; drives hover, selection, measure and double-click pick.
|
|
108933
109457
|
this.idPicker = null;
|
|
109458
|
+
/** Outline of the clip caps; created on first use, kept across renders. */
|
|
109459
|
+
this._capOutline = null;
|
|
109460
|
+
/** Camera world + projection matrix of the previous outline frame (motion test). */
|
|
109461
|
+
this._capOutlineCamera = [];
|
|
109462
|
+
/** Pending redraw that adds the outline once the camera has settled. */
|
|
109463
|
+
this._capOutlineSettle = null;
|
|
109464
|
+
/** Whether the attached animation has moved parts (Play pressed or time slider moved). */
|
|
109465
|
+
this._animationStarted = false;
|
|
108934
109466
|
// ---------------------------------------------------------------------------
|
|
108935
109467
|
// Render Loop & Scene Updates
|
|
108936
109468
|
// ---------------------------------------------------------------------------
|
|
@@ -109079,6 +109611,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
109079
109611
|
}
|
|
109080
109612
|
else {
|
|
109081
109613
|
this.renderer.render(this.rendered.scene, this.rendered.camera.getCamera());
|
|
109614
|
+
this._renderCapOutline();
|
|
109082
109615
|
}
|
|
109083
109616
|
this.cadTools.update();
|
|
109084
109617
|
this.rendered.directLight.position.copy(this.rendered.camera.getCamera().position);
|
|
@@ -109332,6 +109865,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
109332
109865
|
return;
|
|
109333
109866
|
switch (btn) {
|
|
109334
109867
|
case "play":
|
|
109868
|
+
this._animationStarted = true;
|
|
109335
109869
|
if (this.clipAction.paused) {
|
|
109336
109870
|
this.clipAction.paused = false;
|
|
109337
109871
|
}
|
|
@@ -109465,6 +109999,10 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
109465
109999
|
return;
|
|
109466
110000
|
}
|
|
109467
110001
|
};
|
|
110002
|
+
/** Tree-row hover: highlight the faces of the node at `path`, or clear on `null`. */
|
|
110003
|
+
this.handleTreeHover = (path) => {
|
|
110004
|
+
this.pickingController.setTreeHover(path);
|
|
110005
|
+
};
|
|
109468
110006
|
/** Clear the current selection (and reset the select tool). */
|
|
109469
110007
|
this.clearSelection = () => {
|
|
109470
110008
|
this.pickingController.clearSelection();
|
|
@@ -110653,6 +111191,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
110653
111191
|
this.toggleAnimationLoop(true);
|
|
110654
111192
|
}
|
|
110655
111193
|
this.state.set("animationMode", label === "E" ? "explode" : "animation");
|
|
111194
|
+
this._animationStarted = false;
|
|
110656
111195
|
this.clipAction = this.animation.animate(this.rendered.nestedGroup.rootGroup, duration, speed, repeat);
|
|
110657
111196
|
// Reset animation slider to start
|
|
110658
111197
|
this.state.set("animationSliderValue", 0);
|
|
@@ -110671,6 +111210,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
110671
111210
|
deepDispose(this.animation);
|
|
110672
111211
|
}
|
|
110673
111212
|
this.state.set("animationMode", "none");
|
|
111213
|
+
this._animationStarted = false;
|
|
110674
111214
|
this.toggleAnimationLoop(false);
|
|
110675
111215
|
}
|
|
110676
111216
|
/**
|
|
@@ -110679,6 +111219,8 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
110679
111219
|
* @param fraction - relative time between 0 and 1.
|
|
110680
111220
|
*/
|
|
110681
111221
|
setRelativeTime(fraction) {
|
|
111222
|
+
if (fraction !== 0)
|
|
111223
|
+
this._animationStarted = true;
|
|
110682
111224
|
this.animation.setRelativeTime(fraction);
|
|
110683
111225
|
this.state.set("animationSliderValue", fraction * 1000);
|
|
110684
111226
|
}
|
|
@@ -110710,6 +111252,60 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
110710
111252
|
setTimeout(() => this.update(true, true), 50);
|
|
110711
111253
|
}
|
|
110712
111254
|
}
|
|
111255
|
+
/**
|
|
111256
|
+
* Draw the outline of the clip caps over the frame (Clip tab active only). Skipped
|
|
111257
|
+
* while the camera moves (drag, wheel, damping, preset views): the outline pass
|
|
111258
|
+
* renders faces and caps a second time, so it is drawn once the camera has been
|
|
111259
|
+
* still for {@link CAP_OUTLINE_SETTLE_MS}.
|
|
111260
|
+
*/
|
|
111261
|
+
_renderCapOutline() {
|
|
111262
|
+
const clipping = this.rendered.clipping;
|
|
111263
|
+
if (!this.renderer.localClippingEnabled || !clipping.hasCaps)
|
|
111264
|
+
return;
|
|
111265
|
+
const camera = this.rendered.camera.getCamera();
|
|
111266
|
+
if (this._cameraMovedSinceLastOutline(camera)) {
|
|
111267
|
+
this._scheduleCapOutline();
|
|
111268
|
+
return;
|
|
111269
|
+
}
|
|
111270
|
+
if (this._capOutline === null)
|
|
111271
|
+
this._capOutline = new CapOutlinePass();
|
|
111272
|
+
const blackEdges = this.state.get("blackEdges") === true;
|
|
111273
|
+
this._capOutline.render(this.renderer, this.rendered.scene, camera, {
|
|
111274
|
+
planes: clipping.clipPlanes,
|
|
111275
|
+
intersection: this.state.get("clipIntersection") === true,
|
|
111276
|
+
occlude: this.state.get("transparent") !== true,
|
|
111277
|
+
color: blackEdges ? 0x000000 : this.state.get("edgeColor"),
|
|
111278
|
+
setCapIdPass: clipping.setCapIdPass,
|
|
111279
|
+
});
|
|
111280
|
+
}
|
|
111281
|
+
/** Whether the camera's world or projection matrix changed since the last call. */
|
|
111282
|
+
_cameraMovedSinceLastOutline(camera) {
|
|
111283
|
+
const now = [
|
|
111284
|
+
...camera.matrixWorld.elements,
|
|
111285
|
+
...camera.projectionMatrix.elements,
|
|
111286
|
+
];
|
|
111287
|
+
const last = this._capOutlineCamera;
|
|
111288
|
+
const moved = last.length !== now.length || now.some((value, i) => value !== last[i]);
|
|
111289
|
+
this._capOutlineCamera = now;
|
|
111290
|
+
return moved;
|
|
111291
|
+
}
|
|
111292
|
+
/** Redraw once the camera has settled, so the skipped outline appears. */
|
|
111293
|
+
_scheduleCapOutline() {
|
|
111294
|
+
if (this._capOutlineSettle !== null)
|
|
111295
|
+
clearTimeout(this._capOutlineSettle);
|
|
111296
|
+
this._capOutlineSettle = setTimeout(() => {
|
|
111297
|
+
this._capOutlineSettle = null;
|
|
111298
|
+
if (this.ready)
|
|
111299
|
+
this.update(true, false);
|
|
111300
|
+
}, CAP_OUTLINE_SETTLE_MS);
|
|
111301
|
+
}
|
|
111302
|
+
/** Cancel a pending outline redraw. */
|
|
111303
|
+
_cancelCapOutline() {
|
|
111304
|
+
if (this._capOutlineSettle !== null)
|
|
111305
|
+
clearTimeout(this._capOutlineSettle);
|
|
111306
|
+
this._capOutlineSettle = null;
|
|
111307
|
+
this._capOutlineCamera = [];
|
|
111308
|
+
}
|
|
110713
111309
|
// ---------------------------------------------------------------------------
|
|
110714
111310
|
// Cleanup & Disposal
|
|
110715
111311
|
// ---------------------------------------------------------------------------
|
|
@@ -110728,6 +111324,8 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
110728
111324
|
*/
|
|
110729
111325
|
dispose() {
|
|
110730
111326
|
this.clear();
|
|
111327
|
+
this._capOutline?.dispose();
|
|
111328
|
+
this._capOutline = null;
|
|
110731
111329
|
// Remove all picking listeners (they hold a strong ref to this Viewer via the
|
|
110732
111330
|
// controller's arrow-field handlers; in external-canvas mode the caller owns the
|
|
110733
111331
|
// canvas, so not removing them would leak the disposed Viewer).
|
|
@@ -110773,6 +111371,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
110773
111371
|
* @public
|
|
110774
111372
|
*/
|
|
110775
111373
|
clear() {
|
|
111374
|
+
this._cancelCapOutline();
|
|
110776
111375
|
if (this._rendered) {
|
|
110777
111376
|
// Drop selection state + hover cache + status line — a PickedComponent may hold
|
|
110778
111377
|
// a HighlightController / ObjectGroup that this clear() disposes; a stale
|
|
@@ -110826,6 +111425,11 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
110826
111425
|
deepDispose(this._rendered.camera);
|
|
110827
111426
|
deepDispose(this._rendered.controls);
|
|
110828
111427
|
deepDispose(this._rendered.treeview);
|
|
111428
|
+
// The orientation marker owns its own THREE.Scene (cones, labels, sphere,
|
|
111429
|
+
// axes), not part of the main scene above, so it needs its own dispose —
|
|
111430
|
+
// without it every clear()/render() cycle left 8 geometries and 3 programs
|
|
111431
|
+
// behind on the GL (measured 2026-09-17, working-docs/leak-harness.html).
|
|
111432
|
+
this._rendered.orientationMarker.dispose();
|
|
110829
111433
|
// clear tree view
|
|
110830
111434
|
this.display.clearCadTree();
|
|
110831
111435
|
// clear info
|
|
@@ -110889,7 +111493,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
110889
111493
|
if (!this.tree) {
|
|
110890
111494
|
throw new Error("Tree not initialized");
|
|
110891
111495
|
}
|
|
110892
|
-
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);
|
|
111496
|
+
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);
|
|
110893
111497
|
this.display.clearCadTree();
|
|
110894
111498
|
const t = treeview.create();
|
|
110895
111499
|
timer.split("created tree");
|
|
@@ -111355,6 +111959,40 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
111355
111959
|
get lastSelection() {
|
|
111356
111960
|
return this.pickingController.lastSelection;
|
|
111357
111961
|
}
|
|
111962
|
+
/**
|
|
111963
|
+
* Copy the world-space point under the cursor as `"x, y, z"` to the clipboard and
|
|
111964
|
+
* send it to the host as a `copiedPoint` notification (for hosts that block the
|
|
111965
|
+
* browser clipboard). Does nothing while parts may be displaced from their model
|
|
111966
|
+
* positions — explode mode, an animation once started (Play or time slider), a
|
|
111967
|
+
* z-scale other than 1 — or when no visible component is under the cursor.
|
|
111968
|
+
* @returns true when a point was copied (the caller then consumes the key event).
|
|
111969
|
+
*/
|
|
111970
|
+
copyPointUnderCursor() {
|
|
111971
|
+
if (!this.ready)
|
|
111972
|
+
return false;
|
|
111973
|
+
const mode = this.state.get("animationMode");
|
|
111974
|
+
if (mode === "explode")
|
|
111975
|
+
return false;
|
|
111976
|
+
if (mode === "animation" && this._animationStarted)
|
|
111977
|
+
return false;
|
|
111978
|
+
if (this.zScale !== undefined && this.zScale !== 1)
|
|
111979
|
+
return false;
|
|
111980
|
+
const point = this.pickingController.pointUnderCursor();
|
|
111981
|
+
if (point === null)
|
|
111982
|
+
return false;
|
|
111983
|
+
const coords = toVector3Tuple(point.toArray());
|
|
111984
|
+
const fmt = (v) => {
|
|
111985
|
+
const s = v.toFixed(COPY_POINT_DECIMALS);
|
|
111986
|
+
return Number(s) === 0 ? (0).toFixed(COPY_POINT_DECIMALS) : s;
|
|
111987
|
+
};
|
|
111988
|
+
const text = coords.map(fmt).join(", ");
|
|
111989
|
+
copyText(text, this.display.container);
|
|
111990
|
+
if (this.notifyCallback !== null) {
|
|
111991
|
+
this.notifyCallback({ copiedPoint: { old: null, new: coords } });
|
|
111992
|
+
}
|
|
111993
|
+
this.display.setStatusLine(`copied: ${text}`);
|
|
111994
|
+
return true;
|
|
111995
|
+
}
|
|
111358
111996
|
/** Enable/disable the double-click pick handler (on when no tool is active). */
|
|
111359
111997
|
setPickHandler(flag) {
|
|
111360
111998
|
this.pickingController.setPickHandler(flag);
|
|
@@ -111769,7 +112407,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
111769
112407
|
this.tree = this.compactTree;
|
|
111770
112408
|
// Dispose old treeview and create new one
|
|
111771
112409
|
deepDispose(this.rendered.treeview);
|
|
111772
|
-
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);
|
|
112410
|
+
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);
|
|
111773
112411
|
this.rendered.treeview = treeview;
|
|
111774
112412
|
this.display.clearCadTree();
|
|
111775
112413
|
const t = treeview.create();
|
|
@@ -113328,6 +113966,33 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
113328
113966
|
this._matEditorDragAbort = null;
|
|
113329
113967
|
this._matEditorInputAbort = null;
|
|
113330
113968
|
this._animWasVisible = false;
|
|
113969
|
+
/**
|
|
113970
|
+
* Ctrl/Cmd-C while the cursor is over the canvas copies the point under it.
|
|
113971
|
+
* Text-entry fields and selected text keep the normal copy.
|
|
113972
|
+
*/
|
|
113973
|
+
this._handleCopyPoint = (e) => {
|
|
113974
|
+
if (!(e instanceof KeyboardEvent))
|
|
113975
|
+
return;
|
|
113976
|
+
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey)
|
|
113977
|
+
return;
|
|
113978
|
+
if (e.key.toLowerCase() !== "c")
|
|
113979
|
+
return;
|
|
113980
|
+
const target = e.target;
|
|
113981
|
+
if ((target instanceof HTMLInputElement &&
|
|
113982
|
+
target.type !== "button" &&
|
|
113983
|
+
target.type !== "checkbox") ||
|
|
113984
|
+
target instanceof HTMLTextAreaElement ||
|
|
113985
|
+
(target instanceof HTMLElement && target.isContentEditable)) {
|
|
113986
|
+
return;
|
|
113987
|
+
}
|
|
113988
|
+
const selection = window.getSelection();
|
|
113989
|
+
if (selection !== null && selection.toString() !== "")
|
|
113990
|
+
return;
|
|
113991
|
+
if (this.viewer.copyPointUnderCursor()) {
|
|
113992
|
+
e.preventDefault();
|
|
113993
|
+
e.stopPropagation();
|
|
113994
|
+
}
|
|
113995
|
+
};
|
|
113331
113996
|
// ---------------------------------------------------------------------------
|
|
113332
113997
|
// Toolbar Button Handlers: View Settings
|
|
113333
113998
|
// ---------------------------------------------------------------------------
|
|
@@ -114650,6 +115315,10 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth,
|
|
|
114650
115315
|
// Focus handling for keyboard shortcuts
|
|
114651
115316
|
listeners.add(this.container, "mousedown", () => this.container.focus());
|
|
114652
115317
|
listeners.add(this.container, "keydown", this._handleKeyboardShortcut);
|
|
115318
|
+
// Ctrl/Cmd-C acts on the point under the cursor, so it must not depend on the
|
|
115319
|
+
// viewer having focus (e.g. after using a host dropdown). Capture phase: runs
|
|
115320
|
+
// before host shortcuts; the key is consumed only when a point was copied.
|
|
115321
|
+
listeners.add(document, "keydown", this._handleCopyPoint, true);
|
|
114653
115322
|
}
|
|
114654
115323
|
/**
|
|
114655
115324
|
* Subscribe to ViewerState changes to keep UI in sync.
|