partforge 0.39.0 → 0.40.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.39.0",
3
+ "version": "0.40.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
  }
@@ -175,14 +175,24 @@ export function createViewer(container, part) {
175
175
  }
176
176
 
177
177
  // The cutaway plane lives in world space, so its initial/reset bounds must
178
- // include the pivot rotation and the per-view recentering transform.
178
+ // include the pivot rotation and the per-view recentering transform
179
+ // mesh.matrixWorld carries both. Union each visible mesh's own
180
+ // geometry.boundingBox rather than `Box3.expandByObject`, which recurses into
181
+ // children: the two stencil-pass meshes share `mesh.geometry` so that
182
+ // recursion is harmless for them, but the cut-face outline child carries its
183
+ // own independent geometry that only re-slices while the cutaway is enabled
184
+ // and visible — while hidden it can keep segments from an older, larger part
185
+ // and inflate these bounds. A subpart's initial placeholder BufferGeometry
186
+ // has no boundingBox computed (only buildGeometry computes one), so skip it.
179
187
  const _worldBounds = new THREE.Box3();
188
+ const _meshBounds = new THREE.Box3();
180
189
  function getVisibleWorldBounds() {
181
190
  _worldBounds.makeEmpty();
182
191
  for (const mesh of Object.values(subMesh)) {
183
- if (!mesh.visible || !mesh.geometry) continue;
192
+ if (!mesh.visible || !mesh.geometry?.boundingBox) continue;
184
193
  mesh.updateWorldMatrix(true, false);
185
- _worldBounds.expandByObject(mesh);
194
+ _meshBounds.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld);
195
+ _worldBounds.union(_meshBounds);
186
196
  }
187
197
  return _worldBounds;
188
198
  }