partforge 0.39.0 → 0.41.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/README.md +19 -0
- package/bin/cli.js +3 -0
- package/docs/AUTHORING-PARTS.md +17 -1
- package/package.json +1 -1
- package/src/framework/cutaway-gizmo.js +16 -2
- package/src/framework/cutaway-math.js +50 -1
- package/src/framework/cutaway-outline.js +233 -0
- package/src/framework/cutaway-render.js +20 -0
- package/src/framework/cutaway.js +47 -1
- package/src/framework/jobs.js +14 -2
- package/src/framework/mount.js +21 -1
- package/src/framework/verify-metrics.js +15 -2
- package/src/framework/viewer.js +84 -5
- package/src/testing/bvh.js +296 -106
- package/src/testing/gaps.js +6 -3
- package/src/testing/measure.js +33 -3
- package/src/testing/min-wall.js +80 -20
- package/src/testing/verify.js +58 -2
package/README.md
CHANGED
|
@@ -112,9 +112,28 @@ await runtime.ready; // first successful build (rejects on a first-build error
|
|
|
112
112
|
runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
113
113
|
// rail ('stage' | 'rail'), suppressing the
|
|
114
114
|
// built-in tab bar. null hands selection back.
|
|
115
|
+
runtime.setActive(false); // park the viewer: stop the render loop, release the
|
|
116
|
+
// drawing buffer. setActive(true) restores both.
|
|
117
|
+
const off = runtime.onContextLost(() => {}); // WebGL context loss; returns an unsubscribe
|
|
115
118
|
runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
|
|
116
119
|
```
|
|
117
120
|
|
|
121
|
+
**Park the viewer when you hide it.** A host that hides the canvas with
|
|
122
|
+
`display: none` needs nothing — the container collapses and the ResizeObserver
|
|
123
|
+
shrinks the drawing buffer for free. A host that hides it any other way
|
|
124
|
+
(`visibility: hidden`, an inactive tab, an off-screen pane) gets no such signal:
|
|
125
|
+
the full-resolution MSAA buffer stays resident and the render loop keeps drawing
|
|
126
|
+
an auto-rotating scene at 60fps that nobody can see. On a phone that is tens of
|
|
127
|
+
megabytes plus continuous GPU work, and it has been enough on its own to get a
|
|
128
|
+
tab killed. Call `runtime.setActive(false)` when the viewer goes off-screen and
|
|
129
|
+
`setActive(true)` when it comes back.
|
|
130
|
+
|
|
131
|
+
Parking releases both large GPU allocations — the drawing buffer and the cached
|
|
132
|
+
1024² capture target — and stops the render loop. Offscreen captures
|
|
133
|
+
(`captureCurrent`, `captureViews`) keep working while parked and framing is
|
|
134
|
+
unchanged, so a host can still take a build screenshot of a hidden viewer; the
|
|
135
|
+
first capture after parking just re-allocates its target.
|
|
136
|
+
|
|
118
137
|
Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
|
|
119
138
|
`#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
|
|
120
139
|
`#download`/`#download-step`/`#download-3mf`,
|
package/bin/cli.js
CHANGED
|
@@ -204,6 +204,9 @@ function printVerify(v) {
|
|
|
204
204
|
for (const ch of c.checks) {
|
|
205
205
|
const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠" : "·";
|
|
206
206
|
console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})`);
|
|
207
|
+
// A measurement caveat prints whatever the verdict — "passed, but sampled"
|
|
208
|
+
// is precisely the line a reader must not miss.
|
|
209
|
+
if (ch.note) console.log(` note: ${ch.note}`);
|
|
207
210
|
if (ch.status === "fail" || ch.status === "warn") {
|
|
208
211
|
if (ch.location) console.log(` at [${ch.location.map((n) => n.toFixed(1)).join(", ")}]`);
|
|
209
212
|
if (ch.hint) console.log(` hint: ${ch.hint}${ch.pattern ? ` (ERROR-PATTERNS.md#${ch.pattern})` : ""}`);
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1032,6 +1032,9 @@ carries:
|
|
|
1032
1032
|
- `hint` — one self-contained corrective sentence (always present),
|
|
1033
1033
|
- `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
|
|
1034
1034
|
applies (follow it with `ERROR-PATTERNS.md#<id>`),
|
|
1035
|
+
- `note` — an optional caveat about *how* the value was measured, attached
|
|
1036
|
+
whatever the verdict. Today only `minWall` sets one, when the reading came
|
|
1037
|
+
from a sample rather than every triangle (see below),
|
|
1035
1038
|
- `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
|
|
1036
1039
|
sample point) and `overlaps` (the center of the first offending intersection's
|
|
1037
1040
|
*bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
|
|
@@ -1042,7 +1045,20 @@ carries:
|
|
|
1042
1045
|
|
|
1043
1046
|
Subpart facts include `minWall` (number or `null` — null exactly when no reading
|
|
1044
1047
|
exists, e.g. the OCCT backend or min-wall measurement turned off, matching
|
|
1045
|
-
`minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`)
|
|
1048
|
+
`minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`). Min wall casts one ray
|
|
1049
|
+
per triangle, which is unbounded work on a dense mesh, so past 50,000 triangles
|
|
1050
|
+
it casts from a spread, deterministic subset instead — `minWallSampled` (boolean)
|
|
1051
|
+
and `minWallSamples` (`{ sampled, total }` or `null`) say whether that happened.
|
|
1052
|
+
`sampled` is how many triangles the walk *selected*, not how many rays were
|
|
1053
|
+
cast: a degenerate (zero-area) triangle has no normal to cast along and is
|
|
1054
|
+
skipped. A sampled reading is an **upper bound**: it can miss a thin spot, never
|
|
1055
|
+
invent one — and a sampled run that found no wall at all still reports its
|
|
1056
|
+
`minWallSamples`, so a null `minWall` there is "we looked and found nothing",
|
|
1057
|
+
not "nobody looked". Everything in `src/parts/` is far below the budget and
|
|
1058
|
+
reads exactly. The report's top-level `measuredMinWall` says whether this run
|
|
1059
|
+
cast min-wall rays at all — the difference between a null `minWall` that means
|
|
1060
|
+
"no wall found" and one that means "not measured".
|
|
1061
|
+
Overlap entries are
|
|
1046
1062
|
`{ a, b, volume, location }`. Pair-distance facts are `gaps` (every sub-part
|
|
1047
1063
|
pair: `{ a, b, distance, at }`, distance 0 = touching or overlapping) and
|
|
1048
1064
|
`nearMisses` (the pairs with an unintended-looking gap under 0.5 mm).
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
|
|
2
2
|
import {
|
|
3
3
|
axisParameterFromRay,
|
|
4
4
|
signedAngleAroundAxis,
|
|
5
|
+
snapQuaternionToAxis,
|
|
5
6
|
} from "./cutaway-math.js";
|
|
6
7
|
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
|
|
7
8
|
|
|
@@ -46,6 +47,7 @@ export function createCutawayGizmo({
|
|
|
46
47
|
onPoseChange = () => {},
|
|
47
48
|
onActivity = () => {},
|
|
48
49
|
onHandleHoverChange = () => {},
|
|
50
|
+
onDragChange = () => {},
|
|
49
51
|
pickHandle,
|
|
50
52
|
}) {
|
|
51
53
|
const group = new THREE.Group();
|
|
@@ -261,6 +263,7 @@ export function createCutawayGizmo({
|
|
|
261
263
|
let activeAppearance = true;
|
|
262
264
|
let themeMode = "dark";
|
|
263
265
|
const raycaster = new THREE.Raycaster();
|
|
266
|
+
const _snapped = new THREE.Quaternion();
|
|
264
267
|
const hitProxies = Object.values(handles);
|
|
265
268
|
|
|
266
269
|
function rayFromEvent(event) {
|
|
@@ -326,6 +329,7 @@ export function createCutawayGizmo({
|
|
|
326
329
|
drag = null;
|
|
327
330
|
if (orbitControls) orbitControls.enabled = ending.orbitEnabled;
|
|
328
331
|
safeRelease(ending.pointerId);
|
|
332
|
+
onDragChange(false);
|
|
329
333
|
}
|
|
330
334
|
|
|
331
335
|
function updateAppearance() {
|
|
@@ -361,6 +365,15 @@ export function createCutawayGizmo({
|
|
|
361
365
|
onHandleHoverChange(normalized);
|
|
362
366
|
}
|
|
363
367
|
|
|
368
|
+
// Rotation lands on a canonical axis when it gets close to one. Shift is read
|
|
369
|
+
// per move rather than latched at pointer-down, so it can be pressed and
|
|
370
|
+
// released mid-drag; it is unbound during a gizmo drag because orbit controls
|
|
371
|
+
// are already disabled.
|
|
372
|
+
function snapRotation(candidate, event) {
|
|
373
|
+
candidate.normalize();
|
|
374
|
+
return event.shiftKey ? candidate : snapQuaternionToAxis(candidate, undefined, _snapped);
|
|
375
|
+
}
|
|
376
|
+
|
|
364
377
|
function notifyPose() {
|
|
365
378
|
onPoseChange({
|
|
366
379
|
position: group.position.clone(),
|
|
@@ -468,6 +481,7 @@ export function createCutawayGizmo({
|
|
|
468
481
|
setHoveredHandle(handle);
|
|
469
482
|
onActivity();
|
|
470
483
|
drag = nextDrag;
|
|
484
|
+
onDragChange(true);
|
|
471
485
|
if (orbitControls) orbitControls.enabled = false;
|
|
472
486
|
safeCapture(event.pointerId);
|
|
473
487
|
event.preventDefault();
|
|
@@ -512,7 +526,7 @@ export function createCutawayGizmo({
|
|
|
512
526
|
* SCREEN_ROTATION_RADIANS_PER_PIXEL;
|
|
513
527
|
if (!Number.isFinite(angle)) return;
|
|
514
528
|
const delta = new THREE.Quaternion().setFromAxisAngle(drag.axis, angle);
|
|
515
|
-
group.quaternion.copy(delta.multiply(drag.startQuaternion))
|
|
529
|
+
group.quaternion.copy(snapRotation(delta.multiply(drag.startQuaternion), event));
|
|
516
530
|
group.position.copy(drag.startPosition);
|
|
517
531
|
syncHandleTransform();
|
|
518
532
|
notifyPose();
|
|
@@ -527,7 +541,7 @@ export function createCutawayGizmo({
|
|
|
527
541
|
const angle = signedAngleAroundAxis(drag.startRadial, radial, drag.axis);
|
|
528
542
|
if (!Number.isFinite(angle)) return;
|
|
529
543
|
const delta = new THREE.Quaternion().setFromAxisAngle(drag.axis, angle);
|
|
530
|
-
group.quaternion.copy(delta.multiply(drag.startQuaternion))
|
|
544
|
+
group.quaternion.copy(snapRotation(delta.multiply(drag.startQuaternion), event));
|
|
531
545
|
group.position.copy(drag.startPosition);
|
|
532
546
|
syncHandleTransform();
|
|
533
547
|
notifyPose();
|
|
@@ -4,10 +4,38 @@ const PLANE_LOCAL_NORMAL = new THREE.Vector3(0, 0, 1);
|
|
|
4
4
|
const POINT_EPSILON = 1e-6;
|
|
5
5
|
const PARALLEL_EPSILON = 1e-6;
|
|
6
6
|
|
|
7
|
+
// Nearest signed canonical axis (+/-X, +/-Y, +/-Z) to `direction`. Axes are
|
|
8
|
+
// scanned X, Y, Z and replaced only on a strictly larger |component|, so a tie
|
|
9
|
+
// resolves to the earlier axis — the default isometric framing is an exact tie
|
|
10
|
+
// between -X and -Z and lands on -X. Degenerate input falls back to +Z.
|
|
11
|
+
export function nearestCanonicalAxis(direction, target = new THREE.Vector3()) {
|
|
12
|
+
const components = [direction.x, direction.y, direction.z];
|
|
13
|
+
if (!components.every(Number.isFinite)) return target.set(0, 0, 1);
|
|
14
|
+
|
|
15
|
+
let bestIndex = -1;
|
|
16
|
+
let bestScore = 0;
|
|
17
|
+
for (let i = 0; i < 3; i++) {
|
|
18
|
+
const score = Math.abs(components[i]);
|
|
19
|
+
if (score > bestScore) {
|
|
20
|
+
bestScore = score;
|
|
21
|
+
bestIndex = i;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (bestIndex < 0) return target.set(0, 0, 1);
|
|
25
|
+
|
|
26
|
+
// Built by setComponent rather than negating a unit axis: multiplying a zero
|
|
27
|
+
// component by -1 yields -0, and toEqual([0, -1, 0]) does not accept -0.
|
|
28
|
+
return target.set(0, 0, 0).setComponent(bestIndex, components[bestIndex] < 0 ? -1 : 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
export function initialCutawayPose(box, camera) {
|
|
8
32
|
const position = box.getCenter(new THREE.Vector3());
|
|
9
33
|
const diagonal = Math.max(box.getSize(new THREE.Vector3()).length(), 1);
|
|
10
|
-
|
|
34
|
+
// Square the cut plane up with the part rather than the camera: the axis
|
|
35
|
+
// nearest the view direction, so the near half is still what gets cut away.
|
|
36
|
+
const normal = nearestCanonicalAxis(
|
|
37
|
+
camera.getWorldDirection(new THREE.Vector3()).normalize(),
|
|
38
|
+
);
|
|
11
39
|
const quaternion = new THREE.Quaternion().setFromUnitVectors(
|
|
12
40
|
PLANE_LOCAL_NORMAL,
|
|
13
41
|
normal,
|
|
@@ -20,6 +48,27 @@ export function initialCutawayPose(box, camera) {
|
|
|
20
48
|
};
|
|
21
49
|
}
|
|
22
50
|
|
|
51
|
+
export const AXIS_SNAP_RADIANS = (7 * Math.PI) / 180;
|
|
52
|
+
|
|
53
|
+
// Pull a plane pose onto the nearest canonical axis once its normal is within
|
|
54
|
+
// `maxAngle` of one. The correction is the minimal rotation carrying the normal
|
|
55
|
+
// onto the axis, not a rebuilt quaternion, so the plane's in-plane roll survives
|
|
56
|
+
// and the gizmo rings do not visibly spin at the moment of snapping. Roll does
|
|
57
|
+
// not affect the clip either way.
|
|
58
|
+
export function snapQuaternionToAxis(
|
|
59
|
+
quaternion,
|
|
60
|
+
maxAngle = AXIS_SNAP_RADIANS,
|
|
61
|
+
target = new THREE.Quaternion(),
|
|
62
|
+
) {
|
|
63
|
+
const normal = PLANE_LOCAL_NORMAL.clone().applyQuaternion(quaternion).normalize();
|
|
64
|
+
const axis = nearestCanonicalAxis(normal);
|
|
65
|
+
if (normal.angleTo(axis) > maxAngle) return target.copy(quaternion);
|
|
66
|
+
return target
|
|
67
|
+
.setFromUnitVectors(normal, axis)
|
|
68
|
+
.multiply(quaternion)
|
|
69
|
+
.normalize();
|
|
70
|
+
}
|
|
71
|
+
|
|
23
72
|
export function planeFromPose(plane, normalTarget, position, quaternion, flipped) {
|
|
24
73
|
normalTarget.copy(PLANE_LOCAL_NORMAL).applyQuaternion(quaternion).normalize();
|
|
25
74
|
if (flipped) normalTarget.negate();
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
3
|
+
import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
|
|
4
|
+
import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
|
|
5
|
+
|
|
6
|
+
// Matches POINT_EPSILON in cutaway-math.js: a vertex this close to the plane
|
|
7
|
+
// counts as lying on it, so a grazing plane produces neither duplicate nor
|
|
8
|
+
// zero-length segments.
|
|
9
|
+
const ON_PLANE_EPSILON = 1e-6;
|
|
10
|
+
|
|
11
|
+
const _vertices = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
|
|
12
|
+
const _distances = [0, 0, 0];
|
|
13
|
+
const _signs = [0, 0, 0];
|
|
14
|
+
const _crossing = new THREE.Vector3();
|
|
15
|
+
|
|
16
|
+
// Plane/triangle intersection over a BufferGeometry, in the geometry's own
|
|
17
|
+
// frame. Emits one segment per crossing triangle. Handles indexed (OCCT) and
|
|
18
|
+
// non-indexed (Manifold) geometry alike.
|
|
19
|
+
//
|
|
20
|
+
// Degenerate contact is resolved so each boundary edge is emitted exactly once:
|
|
21
|
+
// - a triangle lying in the plane emits nothing; its neighbours bound it;
|
|
22
|
+
// - a triangle touching the plane at one vertex only emits nothing;
|
|
23
|
+
// - a triangle with an edge in the plane emits that edge only when its third
|
|
24
|
+
// vertex is on the clipped side, so the two triangles sharing that edge do
|
|
25
|
+
// not both emit it.
|
|
26
|
+
//
|
|
27
|
+
// Known limitation: that last rule reads only its own triangle, so it cannot
|
|
28
|
+
// tell "the neighbour is on the other side" (a real crossing, one emission)
|
|
29
|
+
// from "the neighbour is also clipped" (a ridge merely tangent to the plane,
|
|
30
|
+
// two emissions of the same edge). Telling them apart needs per-slice edge
|
|
31
|
+
// bookkeeping on a path that runs every frame of a gizmo drag, and the payoff
|
|
32
|
+
// is small: the duplicates are coincident, so they are invisible on opaque
|
|
33
|
+
// parts and only slightly darken a translucent one, in the measure-zero case
|
|
34
|
+
// where a plane lands exactly on a crease.
|
|
35
|
+
export function sectionSegments(geometry, plane) {
|
|
36
|
+
const position = geometry?.getAttribute?.("position");
|
|
37
|
+
if (!position) return new Float32Array(0);
|
|
38
|
+
|
|
39
|
+
const index = geometry.getIndex?.() ?? null;
|
|
40
|
+
const count = index ? index.count : position.count;
|
|
41
|
+
const out = [];
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i + 2 < count; i += 3) {
|
|
44
|
+
for (let k = 0; k < 3; k++) {
|
|
45
|
+
_vertices[k].fromBufferAttribute(position, index ? index.getX(i + k) : i + k);
|
|
46
|
+
const distance = plane.distanceToPoint(_vertices[k]);
|
|
47
|
+
_distances[k] = distance;
|
|
48
|
+
_signs[k] = distance > ON_PLANE_EPSILON
|
|
49
|
+
? 1
|
|
50
|
+
: distance < -ON_PLANE_EPSILON ? -1 : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const onPlane = (_signs[0] === 0 ? 1 : 0)
|
|
54
|
+
+ (_signs[1] === 0 ? 1 : 0)
|
|
55
|
+
+ (_signs[2] === 0 ? 1 : 0);
|
|
56
|
+
|
|
57
|
+
if (onPlane === 3) continue;
|
|
58
|
+
|
|
59
|
+
if (onPlane === 2) {
|
|
60
|
+
const solo = _signs[0] !== 0 ? 0 : _signs[1] !== 0 ? 1 : 2;
|
|
61
|
+
if (_signs[solo] !== -1) continue;
|
|
62
|
+
pushPoint(out, _vertices[(solo + 1) % 3]);
|
|
63
|
+
pushPoint(out, _vertices[(solo + 2) % 3]);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (onPlane === 1) {
|
|
68
|
+
const zero = _signs[0] === 0 ? 0 : _signs[1] === 0 ? 1 : 2;
|
|
69
|
+
const a = (zero + 1) % 3;
|
|
70
|
+
const b = (zero + 2) % 3;
|
|
71
|
+
if (_signs[a] === _signs[b]) continue;
|
|
72
|
+
pushPoint(out, _vertices[zero]);
|
|
73
|
+
pushPoint(out, crossingPoint(a, b));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (_signs[0] === _signs[1] && _signs[1] === _signs[2]) continue;
|
|
78
|
+
for (let k = 0; k < 3; k++) {
|
|
79
|
+
const a = k;
|
|
80
|
+
const b = (k + 1) % 3;
|
|
81
|
+
if (_signs[a] === _signs[b]) continue;
|
|
82
|
+
pushPoint(out, crossingPoint(a, b));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return new Float32Array(out);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function crossingPoint(a, b) {
|
|
90
|
+
const t = _distances[a] / (_distances[a] - _distances[b]);
|
|
91
|
+
return _crossing.copy(_vertices[a]).lerp(_vertices[b], t);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pushPoint(out, point) {
|
|
95
|
+
out.push(point.x, point.y, point.z);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const defaultNow = () => (typeof performance !== "undefined" ? performance.now() : 0);
|
|
99
|
+
|
|
100
|
+
// One cut-face outline for one subpart. The object is parented to the mesh, the
|
|
101
|
+
// same trick the stencil helpers use, so it inherits every present and future
|
|
102
|
+
// transform including the pose fast path — and it always slices whatever
|
|
103
|
+
// `mesh.geometry` currently draws, so the outline cannot disagree with the
|
|
104
|
+
// surface it bounds.
|
|
105
|
+
//
|
|
106
|
+
// The section moves for four unrelated reasons (plane pose, geometry swap,
|
|
107
|
+
// frameTo recentring the assembly under a world-fixed plane, and setSubPose),
|
|
108
|
+
// and only the first two notify the cutaway. Rather than thread invalidation
|
|
109
|
+
// through all four, refresh() compares a signature and re-slices when it
|
|
110
|
+
// differs — roughly 21 float compares per frame.
|
|
111
|
+
export function createSectionOutline({ mesh, plane, inkColor, now = defaultNow }) {
|
|
112
|
+
const material = new LineMaterial({
|
|
113
|
+
color: inkColor,
|
|
114
|
+
linewidth: 1,
|
|
115
|
+
// The outline lies exactly in the cap plane; pull it toward the viewer so
|
|
116
|
+
// the coincident-depth line wins against the cap it sits in.
|
|
117
|
+
polygonOffset: true,
|
|
118
|
+
polygonOffsetFactor: -1,
|
|
119
|
+
polygonOffsetUnits: -1,
|
|
120
|
+
});
|
|
121
|
+
material.resolution.set(1, 1);
|
|
122
|
+
// Deliberately no clippingPlanes: the outline sits at distance ~0 from its
|
|
123
|
+
// own plane, and clipping it would speckle.
|
|
124
|
+
|
|
125
|
+
const object = new LineSegments2(new LineSegmentsGeometry(), material);
|
|
126
|
+
object.frustumCulled = false;
|
|
127
|
+
object.visible = false;
|
|
128
|
+
mesh.add(object);
|
|
129
|
+
|
|
130
|
+
const localPlane = new THREE.Plane();
|
|
131
|
+
const inverse = new THREE.Matrix4();
|
|
132
|
+
const lastNormal = new THREE.Vector3(NaN, NaN, NaN);
|
|
133
|
+
const lastMatrix = new THREE.Matrix4();
|
|
134
|
+
let lastConstant = NaN;
|
|
135
|
+
let lastGeometry = null;
|
|
136
|
+
let lastCost = 0;
|
|
137
|
+
let hasSegments = false;
|
|
138
|
+
let visible = false;
|
|
139
|
+
let suppressed = false;
|
|
140
|
+
let disposed = false;
|
|
141
|
+
// A show transition (setVisible(true) / setSuppressed(false)) must not put
|
|
142
|
+
// the last slice on screen if the plane or mesh moved while hidden - that
|
|
143
|
+
// slice belongs to a pose nobody asked to see. Cleared once slice() has
|
|
144
|
+
// caught up; markShown() below leaves it alone when nothing moved, so an
|
|
145
|
+
// ordinary show doesn't wait on a needless re-slice.
|
|
146
|
+
let needsSlice = true;
|
|
147
|
+
|
|
148
|
+
function applyVisibility() {
|
|
149
|
+
object.visible = visible && hasSegments && !suppressed && !disposed && !needsSlice;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Same signature the plane/mesh state is judged by in refresh(), reused so
|
|
153
|
+
// a show transition can tell "still matches the last slice" from "moved
|
|
154
|
+
// while hidden" without duplicating that comparison.
|
|
155
|
+
function signatureMatches(geometry, matrixWorld) {
|
|
156
|
+
return geometry === lastGeometry
|
|
157
|
+
&& plane.constant === lastConstant
|
|
158
|
+
&& plane.normal.equals(lastNormal)
|
|
159
|
+
&& matrixWorld.equals(lastMatrix);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function markShown() {
|
|
163
|
+
mesh.updateWorldMatrix(true, false);
|
|
164
|
+
if (!signatureMatches(mesh.geometry, mesh.matrixWorld)) needsSlice = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function slice(geometry, matrixWorld) {
|
|
168
|
+
const start = now();
|
|
169
|
+
localPlane.copy(plane).applyMatrix4(inverse.copy(matrixWorld).invert());
|
|
170
|
+
const segments = geometry
|
|
171
|
+
? sectionSegments(geometry, localPlane)
|
|
172
|
+
: new Float32Array(0);
|
|
173
|
+
hasSegments = segments.length > 0;
|
|
174
|
+
const previous = object.geometry;
|
|
175
|
+
const next = new LineSegmentsGeometry();
|
|
176
|
+
if (hasSegments) next.setPositions(segments);
|
|
177
|
+
object.geometry = next;
|
|
178
|
+
previous?.dispose();
|
|
179
|
+
lastCost = now() - start;
|
|
180
|
+
needsSlice = false;
|
|
181
|
+
applyVisibility();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function refresh() {
|
|
185
|
+
if (disposed || !visible || suppressed) return false;
|
|
186
|
+
mesh.updateWorldMatrix(true, false);
|
|
187
|
+
const geometry = mesh.geometry;
|
|
188
|
+
if (signatureMatches(geometry, mesh.matrixWorld)) return false;
|
|
189
|
+
lastGeometry = geometry;
|
|
190
|
+
lastConstant = plane.constant;
|
|
191
|
+
lastNormal.copy(plane.normal);
|
|
192
|
+
lastMatrix.copy(mesh.matrixWorld);
|
|
193
|
+
slice(geometry, mesh.matrixWorld);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
object,
|
|
199
|
+
refresh,
|
|
200
|
+
sliceCost: () => lastCost,
|
|
201
|
+
setVisible(on) {
|
|
202
|
+
if (disposed) return;
|
|
203
|
+
visible = Boolean(on);
|
|
204
|
+
if (visible) markShown();
|
|
205
|
+
applyVisibility();
|
|
206
|
+
},
|
|
207
|
+
setSuppressed(on) {
|
|
208
|
+
if (disposed) return;
|
|
209
|
+
suppressed = Boolean(on);
|
|
210
|
+
if (!suppressed) markShown();
|
|
211
|
+
applyVisibility();
|
|
212
|
+
},
|
|
213
|
+
setInk(color) {
|
|
214
|
+
if (!disposed) material.color.set(color);
|
|
215
|
+
},
|
|
216
|
+
setTransparent(on) {
|
|
217
|
+
if (disposed || material.transparent === Boolean(on)) return;
|
|
218
|
+
material.transparent = Boolean(on);
|
|
219
|
+
material.needsUpdate = true;
|
|
220
|
+
},
|
|
221
|
+
setViewportSize(width, height) {
|
|
222
|
+
if (!disposed) material.resolution.set(width, height);
|
|
223
|
+
},
|
|
224
|
+
dispose() {
|
|
225
|
+
if (disposed) return;
|
|
226
|
+
disposed = true;
|
|
227
|
+
object.visible = false;
|
|
228
|
+
mesh.remove(object);
|
|
229
|
+
object.geometry?.dispose();
|
|
230
|
+
material.dispose();
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
|
|
3
|
+
import { createSectionOutline } from "./cutaway-outline.js";
|
|
4
|
+
|
|
3
5
|
export const HATCH_PERIOD_CSS_PX = 5;
|
|
4
6
|
export const HATCH_LINE_CSS_PX = 1;
|
|
5
7
|
|
|
@@ -9,6 +11,7 @@ export const HATCH_LINE_CSS_PX = 1;
|
|
|
9
11
|
const SURFACE_ORDER_BASE = 1_000_000;
|
|
10
12
|
const EDGE_ORDER_BASE = 2_000_000;
|
|
11
13
|
const SECTION_ORDER_STRIDE = 2;
|
|
14
|
+
export const OUTLINE_ORDER_BASE = 2_500_000;
|
|
12
15
|
export const CUTAWAY_OVERLAY_RENDER_ORDER = 3_000_000;
|
|
13
16
|
|
|
14
17
|
export function createHatchMaterial({ color, opacity, inkColor }) {
|
|
@@ -122,6 +125,7 @@ export function createSectionRenderSet({
|
|
|
122
125
|
capGeometry,
|
|
123
126
|
order,
|
|
124
127
|
inkColor,
|
|
128
|
+
now,
|
|
125
129
|
}) {
|
|
126
130
|
let originalMeshMaterial = mesh.material;
|
|
127
131
|
let originalEdgeMaterial = edgeLines?.material;
|
|
@@ -187,6 +191,13 @@ export function createSectionRenderSet({
|
|
|
187
191
|
front.renderOrder = stencilOrder;
|
|
188
192
|
cap.renderOrder = stencilOrder + 1;
|
|
189
193
|
|
|
194
|
+
// Cut-face outline: real 3D segments sliced from the mesh, drawn with the
|
|
195
|
+
// same fat-line renderer as the viewer's feature edges. Ordered above the
|
|
196
|
+
// clipped edges so it wins the coincident depth against its own cap.
|
|
197
|
+
const outline = createSectionOutline({ mesh, plane, inkColor, now });
|
|
198
|
+
outline.object.renderOrder = OUTLINE_ORDER_BASE + order;
|
|
199
|
+
outline.setTransparent(capMaterial.transparent);
|
|
200
|
+
|
|
190
201
|
let enabled = false;
|
|
191
202
|
let visible = mesh.visible;
|
|
192
203
|
let disposed = false;
|
|
@@ -196,6 +207,7 @@ export function createSectionRenderSet({
|
|
|
196
207
|
back.visible = on;
|
|
197
208
|
front.visible = on;
|
|
198
209
|
cap.visible = on;
|
|
210
|
+
outline.setVisible(on);
|
|
199
211
|
}
|
|
200
212
|
|
|
201
213
|
back.visible = false;
|
|
@@ -245,12 +257,14 @@ export function createSectionRenderSet({
|
|
|
245
257
|
function setHatchInk(color) {
|
|
246
258
|
if (disposed) return;
|
|
247
259
|
capMaterial.userData.setInkColor(color);
|
|
260
|
+
outline.setInk(color);
|
|
248
261
|
}
|
|
249
262
|
|
|
250
263
|
function setViewportSize(width, height, pixelRatio = 1) {
|
|
251
264
|
if (disposed) return;
|
|
252
265
|
viewportSize = { width, height, pixelRatio };
|
|
253
266
|
setLineResolution(clippedEdgeMaterial, width, height);
|
|
267
|
+
outline.setViewportSize(width, height);
|
|
254
268
|
capMaterial.userData.setScreenScale(pixelRatio);
|
|
255
269
|
}
|
|
256
270
|
|
|
@@ -302,6 +316,7 @@ export function createSectionRenderSet({
|
|
|
302
316
|
backMaterial.transparent = capMaterial.transparent;
|
|
303
317
|
frontMaterial.transparent = capMaterial.transparent;
|
|
304
318
|
if (clippedEdgeMaterial && capMaterial.transparent) makeTransparent(clippedEdgeMaterial);
|
|
319
|
+
outline.setTransparent(capMaterial.transparent);
|
|
305
320
|
|
|
306
321
|
if (enabled) {
|
|
307
322
|
mesh.material = clippedMeshMaterial;
|
|
@@ -319,6 +334,7 @@ export function createSectionRenderSet({
|
|
|
319
334
|
disposed = true;
|
|
320
335
|
mesh.remove(back, front);
|
|
321
336
|
scene.remove(cap);
|
|
337
|
+
outline.dispose();
|
|
322
338
|
for (const material of ownedMaterials) material.dispose();
|
|
323
339
|
}
|
|
324
340
|
|
|
@@ -326,6 +342,7 @@ export function createSectionRenderSet({
|
|
|
326
342
|
back,
|
|
327
343
|
front,
|
|
328
344
|
cap,
|
|
345
|
+
outline,
|
|
329
346
|
setEnabled,
|
|
330
347
|
setVisible,
|
|
331
348
|
setGeometry,
|
|
@@ -333,6 +350,9 @@ export function createSectionRenderSet({
|
|
|
333
350
|
setHatchInk,
|
|
334
351
|
setViewportSize,
|
|
335
352
|
refreshSourceMaterial,
|
|
353
|
+
refreshOutline: outline.refresh,
|
|
354
|
+
outlineSliceCost: outline.sliceCost,
|
|
355
|
+
setOutlineSuppressed: outline.setSuppressed,
|
|
336
356
|
dispose,
|
|
337
357
|
};
|
|
338
358
|
}
|
package/src/framework/cutaway.js
CHANGED
|
@@ -10,6 +10,11 @@ import { createSectionRenderSet } from "./cutaway-render.js";
|
|
|
10
10
|
|
|
11
11
|
const IDLE_DELAY_MS = 800;
|
|
12
12
|
|
|
13
|
+
// Slicing rides on top of everything else in the frame, so the whole visible
|
|
14
|
+
// assembly gets about an eighth of a 60 fps frame before outlines step aside
|
|
15
|
+
// for the duration of a drag.
|
|
16
|
+
export const OUTLINE_SLICE_BUDGET_MS = 2;
|
|
17
|
+
|
|
13
18
|
function defaultSchedule(callback, delay) {
|
|
14
19
|
const timer = setTimeout(callback, delay);
|
|
15
20
|
return () => clearTimeout(timer);
|
|
@@ -33,6 +38,7 @@ export function createCutaway({
|
|
|
33
38
|
getBounds,
|
|
34
39
|
edgeColor,
|
|
35
40
|
schedule = defaultSchedule,
|
|
41
|
+
now,
|
|
36
42
|
}) {
|
|
37
43
|
let supported = false;
|
|
38
44
|
try {
|
|
@@ -61,6 +67,7 @@ export function createCutaway({
|
|
|
61
67
|
let previousLocalClippingEnabled;
|
|
62
68
|
let disposed = false;
|
|
63
69
|
let disabling = false;
|
|
70
|
+
let outlinesSuppressed = false;
|
|
64
71
|
let hoveredHandle = null;
|
|
65
72
|
const handleHoverSubscribers = new Set();
|
|
66
73
|
const pendingHandlePublications = [];
|
|
@@ -218,6 +225,7 @@ export function createCutaway({
|
|
|
218
225
|
onPoseChange,
|
|
219
226
|
onActivity: showActive,
|
|
220
227
|
onHandleHoverChange: publishHandleHover,
|
|
228
|
+
onDragChange: setDragging,
|
|
221
229
|
});
|
|
222
230
|
gizmo.setVisible(false);
|
|
223
231
|
gizmo.setTheme(theme);
|
|
@@ -236,6 +244,7 @@ export function createCutaway({
|
|
|
236
244
|
capGeometry,
|
|
237
245
|
order,
|
|
238
246
|
inkColor: hatchInk,
|
|
247
|
+
now,
|
|
239
248
|
});
|
|
240
249
|
renderSets.set(name, { renderSet, mesh, edgeLines, order });
|
|
241
250
|
if (viewportSize) {
|
|
@@ -248,6 +257,7 @@ export function createCutaway({
|
|
|
248
257
|
applyCapPose(renderSet);
|
|
249
258
|
renderSet.setVisible(enabled && selected(name));
|
|
250
259
|
renderSet.setEnabled(enabled);
|
|
260
|
+
renderSet.setOutlineSuppressed(outlinesSuppressed);
|
|
251
261
|
return true;
|
|
252
262
|
}
|
|
253
263
|
|
|
@@ -402,8 +412,42 @@ export function createCutaway({
|
|
|
402
412
|
};
|
|
403
413
|
}
|
|
404
414
|
|
|
415
|
+
// Per-frame maintenance while the cutaway is on: the gizmo rescales for the
|
|
416
|
+
// camera, and every visible section re-slices its outline if anything it
|
|
417
|
+
// depends on moved. Both are cheap no-ops when nothing changed.
|
|
418
|
+
function refreshSections() {
|
|
419
|
+
for (const { renderSet } of renderSets.values()) renderSet.refreshOutline();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Outlines re-slice on every frame of a gizmo drag. On heavy assemblies that
|
|
423
|
+
// is the one place the cost could show, so decide once at drag start — from
|
|
424
|
+
// costs already measured, so the drag never pays a spike to discover it is
|
|
425
|
+
// too expensive — and hide all of them or none. Half-outlined assemblies read
|
|
426
|
+
// as broken. Only sections that are actually showing count toward the budget:
|
|
427
|
+
// a hidden section's refresh() early-returns without slicing, so its cost
|
|
428
|
+
// entry is whatever was last measured while it *was* visible — stale, and
|
|
429
|
+
// irrelevant to what the drag will actually spend time on.
|
|
430
|
+
function setDragging(active) {
|
|
431
|
+
if (disposed) return;
|
|
432
|
+
if (active) {
|
|
433
|
+
let total = 0;
|
|
434
|
+
for (const [name, { renderSet }] of renderSets) {
|
|
435
|
+
if (enabled && selected(name)) total += renderSet.outlineSliceCost();
|
|
436
|
+
}
|
|
437
|
+
outlinesSuppressed = total > OUTLINE_SLICE_BUDGET_MS;
|
|
438
|
+
} else {
|
|
439
|
+
outlinesSuppressed = false;
|
|
440
|
+
}
|
|
441
|
+
for (const { renderSet } of renderSets.values()) {
|
|
442
|
+
renderSet.setOutlineSuppressed(outlinesSuppressed);
|
|
443
|
+
}
|
|
444
|
+
if (!active) refreshSections();
|
|
445
|
+
}
|
|
446
|
+
|
|
405
447
|
function updateForCamera() {
|
|
406
|
-
if (enabled
|
|
448
|
+
if (!enabled || disposed) return;
|
|
449
|
+
gizmo.updateForCamera();
|
|
450
|
+
refreshSections();
|
|
407
451
|
}
|
|
408
452
|
|
|
409
453
|
function renderOverlay(targetRenderer, targetCamera) {
|
|
@@ -465,5 +509,7 @@ export function createCutaway({
|
|
|
465
509
|
renderOverlay,
|
|
466
510
|
onHandleHoverChange,
|
|
467
511
|
dispose,
|
|
512
|
+
_renderSetFor: (name) => renderSets.get(name)?.renderSet ?? null,
|
|
513
|
+
_setDragging: setDragging,
|
|
468
514
|
};
|
|
469
515
|
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -149,9 +149,21 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
149
149
|
// — the main thread only has mesh arrays, so this can only happen here.
|
|
150
150
|
// measure/verify build their own solids via buildView and are cleaned up by
|
|
151
151
|
// the `finally` below.
|
|
152
|
+
// The two halves overlap: verify always expands a "defaults" case, and for
|
|
153
|
+
// an unparameterized inspect that case IS this measurement. Seeding it in
|
|
154
|
+
// (see verify.js's seeding block for the min-wall superset rule that makes
|
|
155
|
+
// the reuse sound) stops the oracle from rebuilding the same geometry and
|
|
156
|
+
// re-casting the same min-wall rays a second time. Measuring `{ minWall:
|
|
157
|
+
// true }` here is what makes the seed usable by any verify run, min-wall
|
|
158
|
+
// gated or not — the result says so itself (`measuredMinWall`), so this
|
|
159
|
+
// call and the seed cannot drift apart.
|
|
160
|
+
const measured = measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true });
|
|
152
161
|
const report = {
|
|
153
|
-
measure:
|
|
154
|
-
verify: verify(kernel, part, {
|
|
162
|
+
measure: measured,
|
|
163
|
+
verify: verify(kernel, part, {
|
|
164
|
+
view: msg.view,
|
|
165
|
+
seed: { params: msg.params ?? {}, result: measured },
|
|
166
|
+
}),
|
|
155
167
|
};
|
|
156
168
|
post({ type: "report", ...report });
|
|
157
169
|
}
|