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 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})` : ""}`);
@@ -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`); overlap entries are
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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)).normalize();
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)).normalize();
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
- const normal = camera.getWorldDirection(new THREE.Vector3()).normalize();
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
  }
@@ -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 && !disposed) gizmo.updateForCamera();
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
  }
@@ -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: measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true }),
154
- verify: verify(kernel, part, { view: msg.view }),
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
  }