partforge 0.89.0 → 0.91.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/package.json +1 -1
- package/src/framework/cutaway.js +9 -0
- package/src/framework/mount.js +37 -8
- package/src/framework/pick-flash.js +44 -0
- package/src/framework/selection/format.js +7 -2
- package/src/framework/selection/pick.js +3 -1
- package/src/framework/selection/raycast.js +97 -1
- package/src/framework/selection/resolve.js +4 -0
- package/src/framework/viewer.js +42 -4
- package/types/index.d.ts +6 -0
package/package.json
CHANGED
package/src/framework/cutaway.js
CHANGED
|
@@ -387,6 +387,14 @@ export function createCutaway({
|
|
|
387
387
|
return !enabled || pointSurvivesPlane(plane, point);
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
// The live clip plane, for a caller that has to reason about the CUT itself
|
|
391
|
+
// rather than test a point against it — clicking the cut face is the only one
|
|
392
|
+
// today. Null while disabled, and a copy either way, so no caller can hold a
|
|
393
|
+
// stale plane or move ours.
|
|
394
|
+
function getPlane(target = new THREE.Plane()) {
|
|
395
|
+
return enabled ? target.copy(plane) : null;
|
|
396
|
+
}
|
|
397
|
+
|
|
390
398
|
function registerClippableMaterial(material) {
|
|
391
399
|
if (disposed || !material) return () => {};
|
|
392
400
|
let entry = auxiliaryMaterials.get(material);
|
|
@@ -521,6 +529,7 @@ export function createCutaway({
|
|
|
521
529
|
setTheme,
|
|
522
530
|
setViewportSize,
|
|
523
531
|
isPointVisible,
|
|
532
|
+
getPlane,
|
|
524
533
|
registerClippableMaterial,
|
|
525
534
|
updateForCamera,
|
|
526
535
|
renderOverlay,
|
package/src/framework/mount.js
CHANGED
|
@@ -444,15 +444,44 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
444
444
|
stage: els.viewer, tooltip, send: annotateSend,
|
|
445
445
|
});
|
|
446
446
|
cleanup.defer(() => sketchToolbar.detach());
|
|
447
|
-
|
|
448
|
-
|
|
447
|
+
// Chrome the sketch toolbar stands in for while the mode is on. #viewbar
|
|
448
|
+
// because the toolbar carries its own tools; the view tabs because the
|
|
449
|
+
// toolbar floats in THEIR slot — both are the stage's top centre — and the
|
|
450
|
+
// two are not the same width, so a part with several views left its tabs
|
|
451
|
+
// peeking out either side (78px each side for a five-view part on a
|
|
452
|
+
// 1400px stage). The toolbar paints over them at z 20, so this was only
|
|
453
|
+
// ever about what shows.
|
|
454
|
+
//
|
|
455
|
+
// Each element remembers whatever hidden state the host had set and gets
|
|
456
|
+
// it back on exit, rather than being forced visible: a host with its own
|
|
457
|
+
// reason to hide either one must see that reason survive a round-trip.
|
|
458
|
+
//
|
|
459
|
+
// `hidden` is set for the semantics AND `display` inline for the effect.
|
|
460
|
+
// The property alone is not enough: app.css gives both the viewbar and the
|
|
461
|
+
// tab pill an author-origin `display`, which beats the UA's [hidden] rule
|
|
462
|
+
// — #viewbar[hidden] already carries an explicit rule for exactly that.
|
|
463
|
+
// A rule cannot fix the tabs the same way, because a host that restyles
|
|
464
|
+
// the pill under its own id (partforge-cloud's `#viewer #part`) outranks
|
|
465
|
+
// anything this framework could ship, so the hide rides on the element
|
|
466
|
+
// itself for the same reason view-tabs.js's nowrap does.
|
|
467
|
+
const sketchHides = [els.viewer.querySelector("#viewbar"), els.tabs]
|
|
468
|
+
.filter(Boolean)
|
|
469
|
+
.map((el) => ({ el, wasHidden: false, wasDisplay: "" }));
|
|
449
470
|
cleanup.defer(annotateMode.onModeChange(() => {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
471
|
+
// setEnabled early-returns when the mode is unchanged, so this only ever
|
|
472
|
+
// runs on a real transition and the saved state cannot be overwritten
|
|
473
|
+
// with the state we just imposed.
|
|
474
|
+
const on = annotateMode.isEnabled();
|
|
475
|
+
for (const entry of sketchHides) {
|
|
476
|
+
if (on) {
|
|
477
|
+
entry.wasHidden = entry.el.hidden;
|
|
478
|
+
entry.wasDisplay = entry.el.style.display;
|
|
479
|
+
entry.el.hidden = true;
|
|
480
|
+
entry.el.style.display = "none";
|
|
481
|
+
} else {
|
|
482
|
+
entry.el.hidden = entry.wasHidden;
|
|
483
|
+
entry.el.style.display = entry.wasDisplay;
|
|
484
|
+
}
|
|
456
485
|
}
|
|
457
486
|
}));
|
|
458
487
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Sizing for the transient pick marker (viewer.flashPoint).
|
|
2
|
+
//
|
|
3
|
+
// The marker used to be a fixed 1.2 mm sphere — a constant WORLD size, so it
|
|
4
|
+
// swelled to cover the very feature it was pointing at as soon as you zoomed
|
|
5
|
+
// in, and shrank to nothing on a large part. Sizing it from the camera each
|
|
6
|
+
// frame keeps it a constant handful of CSS pixels under both projections.
|
|
7
|
+
import * as THREE from "three";
|
|
8
|
+
|
|
9
|
+
export const FLASH_PIXEL_RADIUS = 5; // CSS px — a ~10px dot, the size it read as at default framing
|
|
10
|
+
|
|
11
|
+
const MIN_RADIUS = 1e-6; // never a zero-scale matrix
|
|
12
|
+
|
|
13
|
+
const _offset = new THREE.Vector3();
|
|
14
|
+
const _forward = new THREE.Vector3();
|
|
15
|
+
|
|
16
|
+
// World units spanned by one CSS pixel at `worldPoint`, for `camera`.
|
|
17
|
+
//
|
|
18
|
+
// Perspective: the frustum widens with view depth, so the answer is measured
|
|
19
|
+
// along the view direction rather than as a straight distance to the camera —
|
|
20
|
+
// an off-axis point is farther from the eye than it is deep, and would size a
|
|
21
|
+
// fraction too large. Orthographic: depth does not enter into it at all; the
|
|
22
|
+
// frustum height (as zoom leaves it) is the whole story.
|
|
23
|
+
export function worldPerPixel(camera, worldPoint, viewportHeightPx) {
|
|
24
|
+
const height = viewportHeightPx > 0 ? viewportHeightPx : 1;
|
|
25
|
+
if (camera.isOrthographicCamera) {
|
|
26
|
+
const extent = Math.abs(camera.top - camera.bottom) / (camera.zoom || 1);
|
|
27
|
+
return extent / height;
|
|
28
|
+
}
|
|
29
|
+
camera.getWorldDirection(_forward);
|
|
30
|
+
const depth = Math.abs(_offset.copy(worldPoint).sub(camera.position).dot(_forward));
|
|
31
|
+
const fov = ((camera.fov ?? 50) * Math.PI) / 180;
|
|
32
|
+
return (2 * Math.tan(fov / 2) * depth) / height;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Radius, in world units, for a marker that should read as `pixelRadius` CSS px.
|
|
36
|
+
export function flashWorldRadius(
|
|
37
|
+
camera,
|
|
38
|
+
worldPoint,
|
|
39
|
+
viewportHeightPx,
|
|
40
|
+
pixelRadius = FLASH_PIXEL_RADIUS,
|
|
41
|
+
) {
|
|
42
|
+
const radius = worldPerPixel(camera, worldPoint, viewportHeightPx) * pixelRadius;
|
|
43
|
+
return Number.isFinite(radius) && radius > MIN_RADIUS ? radius : MIN_RADIUS;
|
|
44
|
+
}
|
|
@@ -10,13 +10,18 @@ const fmtParams = (p) => Object.entries(p).map(([k, v]) => `${k}:${v}`).join(","
|
|
|
10
10
|
function tokenStyle(s) {
|
|
11
11
|
const head = `@${s.subPart}`;
|
|
12
12
|
const feat = s.feature ? ` · ${s.feature.label}` : "";
|
|
13
|
-
|
|
13
|
+
const cut = s.onCutPlane ? " · section" : "";
|
|
14
|
+
return `${head}${feat}${cut} · pt(${s.point.join(",")}) n(${fmtNormal(s.normal)}) · {${fmtParams(s.params)}}`;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
function promptStyle(s) {
|
|
17
18
|
const params = Object.entries(s.params).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
18
19
|
const feat = s.feature ? ` **${s.feature.label}**,` : "";
|
|
19
|
-
|
|
20
|
+
// The section wording is load-bearing: without it the point reads as a spot
|
|
21
|
+
// on the part's surface, when it is a spot inside the material that the
|
|
22
|
+
// cutaway happens to have opened up.
|
|
23
|
+
const cut = s.onCutPlane ? " the cutaway section face at" : "";
|
|
24
|
+
return `On sub-part **${s.subPart}**, the user pointed at${feat}${cut} local point (${s.point.join(", ")}), `
|
|
20
25
|
+ `normal ${fmtNormal(s.normal)}, with params {${params}}.`;
|
|
21
26
|
}
|
|
22
27
|
|
|
@@ -19,7 +19,9 @@ export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
|
|
|
19
19
|
// a suppressed click must still clear its just-dragged flag.
|
|
20
20
|
const wasDragged = drag.consumeClick();
|
|
21
21
|
if (!active || wasDragged || suppressed?.()) return;
|
|
22
|
-
|
|
22
|
+
// includeSection: in a cutaway the flat cut face is the biggest thing on
|
|
23
|
+
// screen, and it is the one surface with no geometry behind it to hit.
|
|
24
|
+
const hit = raycastViewer(viewer, ev.clientX, ev.clientY, { includeSection: true });
|
|
23
25
|
if (!hit) return;
|
|
24
26
|
const selection = resolveSelection(part, getContext(), hit);
|
|
25
27
|
viewer.flashPoint([hit.pointWorld.x, hit.pointWorld.y, hit.pointWorld.z]);
|
|
@@ -26,6 +26,18 @@ function normalInSubPartFrame(mesh, normal) {
|
|
|
26
26
|
return [_normal.x, _normal.y, _normal.z];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const _inverseWorld = new THREE.Matrix4();
|
|
30
|
+
|
|
31
|
+
// The same trip for a WORLD direction: back through the mesh's world transform
|
|
32
|
+
// into the geometry frame, then forward by its local matrix like any other
|
|
33
|
+
// geometry-frame direction. The cut plane's normal is the only one that arrives
|
|
34
|
+
// this way — it belongs to the scene, not to a triangle.
|
|
35
|
+
function worldNormalInSubPartFrame(mesh, worldNormal) {
|
|
36
|
+
_inverseWorld.copy(mesh.matrixWorld).invert();
|
|
37
|
+
_normal.copy(worldNormal).transformDirection(_inverseWorld).transformDirection(mesh.matrix);
|
|
38
|
+
return [_normal.x, _normal.y, _normal.z];
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
// The feature carried by a mesh triangle, or null (unlabeled / no attribution data).
|
|
30
42
|
export function featureAt(mesh, triIndex) {
|
|
31
43
|
const { featureIds, features } = mesh.geometry.userData;
|
|
@@ -33,7 +45,84 @@ export function featureAt(mesh, triIndex) {
|
|
|
33
45
|
return id > 0 ? { id, label: features[id - 1] } : null;
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
const SECTION_EPSILON = 1e-6;
|
|
49
|
+
const _sectionPoint = new THREE.Vector3();
|
|
50
|
+
const _sectionNormal = new THREE.Vector3();
|
|
51
|
+
const _reverseDirection = new THREE.Vector3();
|
|
52
|
+
const _cutPlane = new THREE.Plane();
|
|
53
|
+
|
|
54
|
+
// The cut FACE under the pointer, when a cutaway is showing one.
|
|
55
|
+
//
|
|
56
|
+
// Nothing real is there to hit: a section view removes the near half by
|
|
57
|
+
// clipping, which the raycaster knows nothing about, so every triangle along
|
|
58
|
+
// that ray is either on the discarded side (rejected by isWorldPointVisible) or
|
|
59
|
+
// a back face three does not report at all. Clicking the largest surface in the
|
|
60
|
+
// view therefore selected nothing.
|
|
61
|
+
//
|
|
62
|
+
// What IS there is solid material, sliced open at the plane. So: find where the
|
|
63
|
+
// ray crosses into the retained half, and ask whether the sub-part is solid at
|
|
64
|
+
// that crossing. It is solid when the ray has entered it more often than it has
|
|
65
|
+
// left it — and the exits of a ray are the entries of the same ray reversed,
|
|
66
|
+
// which is how this stays inside three's front-face raycasting rather than
|
|
67
|
+
// flipping every material to DoubleSide behind the renderer's back.
|
|
68
|
+
function sectionHit(viewer, meshes, forwardHits, surfaceHit) {
|
|
69
|
+
const plane = viewer.getCutawayPlane?.(_cutPlane);
|
|
70
|
+
if (!plane) return null;
|
|
71
|
+
const ray = raycaster.ray;
|
|
72
|
+
// Only a ray crossing INTO the retained half can reveal a cut face; if the
|
|
73
|
+
// camera is already on that side, nothing in front of it was removed.
|
|
74
|
+
if (plane.normal.dot(ray.direction) <= SECTION_EPSILON) return null;
|
|
75
|
+
const distance = ray.distanceToPlane(plane);
|
|
76
|
+
if (distance == null || distance <= 0) return null;
|
|
77
|
+
// A retained surface nearer than the crossing is simply in front of the cut.
|
|
78
|
+
if (surfaceHit && surfaceHit.distance <= distance) return null;
|
|
79
|
+
// Nothing entered before the plane can be solid at it, so the reverse
|
|
80
|
+
// raycast below is pure cost on the common miss — a click on the backdrop.
|
|
81
|
+
if (!forwardHits.some((hit) => hit.distance < distance)) return null;
|
|
82
|
+
ray.at(distance, _sectionPoint);
|
|
83
|
+
|
|
84
|
+
raycaster.set(_sectionPoint, _reverseDirection.copy(ray.direction).negate());
|
|
85
|
+
const exitHits = raycaster.intersectObjects(meshes, false);
|
|
86
|
+
|
|
87
|
+
let best = null;
|
|
88
|
+
for (const mesh of meshes) {
|
|
89
|
+
let net = 0;
|
|
90
|
+
let entry = null;
|
|
91
|
+
for (const hit of forwardHits) {
|
|
92
|
+
if (hit.object !== mesh || hit.distance >= distance) continue;
|
|
93
|
+
net += 1;
|
|
94
|
+
entry ??= hit; // hits arrive sorted, so this is the nearest entry
|
|
95
|
+
}
|
|
96
|
+
for (const hit of exitHits) {
|
|
97
|
+
if (hit.object === mesh && hit.distance < distance) net -= 1;
|
|
98
|
+
}
|
|
99
|
+
if (net > 0 && (best == null || entry.distance < best.entry.distance)) best = { mesh, entry };
|
|
100
|
+
}
|
|
101
|
+
if (!best) return null;
|
|
102
|
+
|
|
103
|
+
const { mesh, entry } = best;
|
|
104
|
+
return {
|
|
105
|
+
mesh,
|
|
106
|
+
subPart: mesh.name,
|
|
107
|
+
// The triangle the ray entered through. It is not the one under the
|
|
108
|
+
// pointer — a cut face has no triangles — but it is the material the cut
|
|
109
|
+
// opened, and callers that index by triangle need something real.
|
|
110
|
+
triIndex: entry.faceIndex,
|
|
111
|
+
pointWorld: _sectionPoint.clone(),
|
|
112
|
+
pointLocal: worldToSubPartLocal(mesh, _sectionPoint),
|
|
113
|
+
// The cut face looks back at the discarded half, opposite the plane normal.
|
|
114
|
+
normalLocal: worldNormalInSubPartFrame(mesh, _sectionNormal.copy(plane.normal).negate()),
|
|
115
|
+
// Deliberately unattributed: a cut face is an artefact of the section, not
|
|
116
|
+
// a surface the part's build ever labelled.
|
|
117
|
+
feature: null,
|
|
118
|
+
onCutPlane: true,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// `includeSection` opts into the cut-face pick above. It is off by default so
|
|
123
|
+
// hover labels and measurement mode keep hitting real geometry only — a
|
|
124
|
+
// dimension pinned to a plane the user can move is not a dimension.
|
|
125
|
+
export function raycastViewer(viewer, clientX, clientY, { includeSection = false } = {}) {
|
|
37
126
|
const rect = viewer.domElement.getBoundingClientRect();
|
|
38
127
|
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
39
128
|
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
@@ -43,6 +132,13 @@ export function raycastViewer(viewer, clientX, clientY) {
|
|
|
43
132
|
const hit = hits.find((candidate) =>
|
|
44
133
|
viewer.isWorldPointVisible?.(candidate.point) ?? true
|
|
45
134
|
);
|
|
135
|
+
// Ordered before the surface answer, not after: the crossing into the
|
|
136
|
+
// retained half is nearer than anything retained, so when a cut face is
|
|
137
|
+
// there it is what the pointer is over.
|
|
138
|
+
if (includeSection) {
|
|
139
|
+
const section = sectionHit(viewer, meshes, hits, hit);
|
|
140
|
+
if (section) return section;
|
|
141
|
+
}
|
|
46
142
|
if (!hit) return null;
|
|
47
143
|
return {
|
|
48
144
|
mesh: hit.object,
|
|
@@ -45,5 +45,9 @@ export function resolveSelection(part, ctx, hit) {
|
|
|
45
45
|
// Feature attribution from the mesh payload (Solid.label() in the part's build) —
|
|
46
46
|
// the same name the hover tooltip shows, so user, agent, and viewer share vocabulary.
|
|
47
47
|
if (hit.feature) selection.feature = { label: hit.feature.label };
|
|
48
|
+
// A cutaway section pick: the point is inside the material, on the cut face,
|
|
49
|
+
// not on a surface the part was built with. Callers that describe the click
|
|
50
|
+
// in words need to say so — the coordinates alone read as a normal surface.
|
|
51
|
+
if (hit.onCutPlane) selection.onCutPlane = true;
|
|
48
52
|
return selection;
|
|
49
53
|
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -5,6 +5,8 @@ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
|
|
|
5
5
|
import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
7
|
import { createCutaway } from "./cutaway.js";
|
|
8
|
+
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
|
|
9
|
+
import { flashWorldRadius } from "./pick-flash.js";
|
|
8
10
|
import { createCameraTween } from "./camera-tween.js";
|
|
9
11
|
import { orbitPose } from "./camera-orbit.js";
|
|
10
12
|
import { orthoFrustum, perspectiveDistance } from "./projection.js";
|
|
@@ -1049,6 +1051,10 @@ export function createViewer(container, part) {
|
|
|
1049
1051
|
try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
|
|
1050
1052
|
}
|
|
1051
1053
|
if (cutaway.isEnabled) cutaway.updateForCamera();
|
|
1054
|
+
// Re-size the pick markers against the pose this frame will actually draw:
|
|
1055
|
+
// a dot is only alive for about a second, but orbiting or zooming inside
|
|
1056
|
+
// that second must not resize it.
|
|
1057
|
+
for (const dot of flashDots) scaleFlashDot(dot);
|
|
1052
1058
|
renderer.render(scene, activeCamera);
|
|
1053
1059
|
cutaway.renderOverlay(renderer, activeCamera);
|
|
1054
1060
|
}
|
|
@@ -1139,18 +1145,44 @@ export function createViewer(container, part) {
|
|
|
1139
1145
|
function onCameraEnd(cb) { controls.addEventListener("end", cb); }
|
|
1140
1146
|
|
|
1141
1147
|
// Transient marker at a world-space point — visual confirmation of a pick.
|
|
1148
|
+
//
|
|
1149
|
+
// Two things keep it visible where it used to disappear. It is ordered above
|
|
1150
|
+
// every band the cutaway raises its surfaces, edges and outlines into (a
|
|
1151
|
+
// section view moves them past 1,000,000, so the old fixed 999 was painted
|
|
1152
|
+
// over by the very geometry the marker sat on — and `depthTest: false` means
|
|
1153
|
+
// no depth is written either, so nothing behind it survives the overdraw).
|
|
1154
|
+
// And it is `transparent`, which puts it in the pass three draws LAST: a
|
|
1155
|
+
// translucent part puts the cutaway's caps in the transparent list, where an
|
|
1156
|
+
// opaque marker is already behind whatever its render order.
|
|
1157
|
+
//
|
|
1158
|
+
// The sphere is a UNIT sphere scaled per frame (see below) so it reads as a
|
|
1159
|
+
// constant handful of CSS pixels instead of a fixed millimetre size.
|
|
1160
|
+
const FLASH_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
|
|
1142
1161
|
const flashTimers = new Set();
|
|
1162
|
+
const flashDots = new Set();
|
|
1163
|
+
const flashGeometry = new THREE.SphereGeometry(1, 16, 12);
|
|
1164
|
+
const flashViewport = new THREE.Vector2();
|
|
1165
|
+
function scaleFlashDot(dot) {
|
|
1166
|
+
renderer.getSize(flashViewport); // CSS px, which is what a pixel radius means
|
|
1167
|
+
dot.scale.setScalar(flashWorldRadius(activeCamera, dot.position, flashViewport.y));
|
|
1168
|
+
}
|
|
1143
1169
|
function flashPoint(world) {
|
|
1144
1170
|
const dot = new THREE.Mesh(
|
|
1145
|
-
|
|
1146
|
-
new THREE.MeshBasicMaterial({
|
|
1171
|
+
flashGeometry,
|
|
1172
|
+
new THREE.MeshBasicMaterial({
|
|
1173
|
+
color: 0xffcc33, depthTest: false, depthWrite: false, transparent: true,
|
|
1174
|
+
})
|
|
1147
1175
|
);
|
|
1148
|
-
dot.renderOrder =
|
|
1176
|
+
dot.renderOrder = FLASH_RENDER_ORDER;
|
|
1149
1177
|
dot.position.set(world[0], world[1], world[2]);
|
|
1178
|
+
scaleFlashDot(dot); // sized before its first frame, not one frame late
|
|
1150
1179
|
scene.add(dot);
|
|
1180
|
+
flashDots.add(dot);
|
|
1151
1181
|
const t = setTimeout(() => {
|
|
1152
1182
|
flashTimers.delete(t);
|
|
1153
|
-
|
|
1183
|
+
flashDots.delete(dot);
|
|
1184
|
+
// The geometry is shared by every dot and freed in dispose(), not here.
|
|
1185
|
+
scene.remove(dot); dot.material.dispose();
|
|
1154
1186
|
}, 1200);
|
|
1155
1187
|
flashTimers.add(t);
|
|
1156
1188
|
}
|
|
@@ -1179,6 +1211,11 @@ export function createViewer(container, part) {
|
|
|
1179
1211
|
controls.dispose();
|
|
1180
1212
|
for (const t of flashTimers) clearTimeout(t);
|
|
1181
1213
|
flashTimers.clear();
|
|
1214
|
+
// A dot whose timer was just cancelled still holds its own material; the
|
|
1215
|
+
// sphere geometry is shared across all of them and freed once.
|
|
1216
|
+
for (const dot of flashDots) { scene.remove(dot); dot.material.dispose(); }
|
|
1217
|
+
flashDots.clear();
|
|
1218
|
+
flashGeometry.dispose();
|
|
1182
1219
|
cutaway.dispose();
|
|
1183
1220
|
for (const n of names) {
|
|
1184
1221
|
const g = subCache[n];
|
|
@@ -1253,6 +1290,7 @@ export function createViewer(container, part) {
|
|
|
1253
1290
|
flipCutaway: cutaway.flip,
|
|
1254
1291
|
resetCutaway: cutaway.reset,
|
|
1255
1292
|
isWorldPointVisible: cutaway.isPointVisible,
|
|
1293
|
+
getCutawayPlane: cutaway.getPlane,
|
|
1256
1294
|
registerCutawayMaterial: cutaway.registerClippableMaterial,
|
|
1257
1295
|
registerCanonicalCaptureHidden,
|
|
1258
1296
|
onCutawayHandleHover: cutaway.onHandleHoverChange,
|
package/types/index.d.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface Selection {
|
|
|
28
28
|
params: Record<string, ParamValue>;
|
|
29
29
|
/** Present when the hit surface carries a `Solid.label()` name. */
|
|
30
30
|
feature?: { label: string };
|
|
31
|
+
/**
|
|
32
|
+
* Present when the click landed on a cutaway's cut face. The point is inside
|
|
33
|
+
* the material rather than on a surface the part was built with, and the
|
|
34
|
+
* normal faces the half the section removed.
|
|
35
|
+
*/
|
|
36
|
+
onCutPlane?: true;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export interface PickEvent {
|