partforge 0.38.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/README.md +3 -0
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +1 -1
- package/src/framework/app.css +6 -0
- package/src/framework/chrome.css +77 -12
- 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/mobile-tabs.js +138 -0
- package/src/framework/mount.js +21 -2
- package/src/framework/rail.js +24 -8
- package/src/framework/viewer.js +22 -5
package/README.md
CHANGED
|
@@ -109,6 +109,9 @@ const runtime = mount(part, {
|
|
|
109
109
|
onPick: ({ selection, label, prompt, token }) => {}, // programmatic click-to-select
|
|
110
110
|
});
|
|
111
111
|
await runtime.ready; // first successful build (rejects on a first-build error)
|
|
112
|
+
runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
113
|
+
// rail ('stage' | 'rail'), suppressing the
|
|
114
|
+
// built-in tab bar. null hands selection back.
|
|
112
115
|
runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
|
|
113
116
|
```
|
|
114
117
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -215,6 +215,12 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
|
|
|
215
215
|
- **Cause:** Only pages listed in `build.rollupOptions.input` are compiled by the production build; other root `*.html` pages are dev-only conveniences Vite serves without building.
|
|
216
216
|
- **Fix:** Add the page to `build.rollupOptions.input` in `vite.config.js` if it should ship. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Wiring a part into a runnable app".
|
|
217
217
|
|
|
218
|
+
## cutaway-capture-hatch-flood
|
|
219
|
+
|
|
220
|
+
- **Symptom:** With cutaway enabled, a `captureCurrent`/`captureViews` image comes back with section hatch flooding a whole quad and burying the part, while the live viewer looks correct; a consumer may instead report the capture being rejected as too large, because full-frame hatch is worst-case JPEG content.
|
|
221
|
+
- **Cause:** Cutaway masks its section caps with the stencil buffer, and a `THREE.WebGLRenderTarget` has none unless it asks for one — so the mask no-ops in offscreen renders even though the visible canvas (created with `stencil: true`) is fine.
|
|
222
|
+
- **Fix:** Allocate offscreen render targets with `stencilBuffer: true` (`renderOffscreen` in `src/framework/viewer.js`); `scripts/check-app.mjs` measures hatch coverage in a real-GL capture to keep it that way.
|
|
223
|
+
|
|
218
224
|
## options-unknown-key
|
|
219
225
|
|
|
220
226
|
- **Symptom:** `unknown option` — e.g. `cylinder: unknown option "radius" — did you mean r?`
|
package/package.json
CHANGED
package/src/framework/app.css
CHANGED
|
@@ -190,6 +190,12 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
190
190
|
font-size: 15px; line-height: 1;
|
|
191
191
|
display: flex; align-items: center; justify-content: center;
|
|
192
192
|
}
|
|
193
|
+
/* the display: flex above is author-origin and beats the UA's [hidden] rule,
|
|
194
|
+
so any viewbar button hidden via the `hidden` property (today: #rail-toggle
|
|
195
|
+
below the narrow breakpoint, where the pane tab bar takes over) needs this
|
|
196
|
+
to actually disappear — the same trap .pf-cutaway-actions[hidden] below
|
|
197
|
+
already guards against. */
|
|
198
|
+
#viewbar button[hidden] { display: none; }
|
|
193
199
|
#viewbar .pf-cutaway-actions { display: flex; gap: 4px; }
|
|
194
200
|
#viewbar .pf-cutaway-actions[hidden] { display: none; }
|
|
195
201
|
#viewbar .pf-cutaway-actions button { width: auto; min-width: 56px; padding: 0 8px; }
|
package/src/framework/chrome.css
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
Prerequisite: this sheet consumes --pf-* custom properties (--pf-rail-w,
|
|
16
16
|
--pf-rail-pad, --pf-border, --pf-surface, --pf-bg, --pf-text, --pf-muted,
|
|
17
|
-
--pf-shadow-rail) but does not import them — a standalone
|
|
18
|
-
also load "partforge/tokens.css" (kept separate so the two stay
|
|
17
|
+
--pf-accent, --pf-shadow-rail) but does not import them — a standalone
|
|
18
|
+
consumer must also load "partforge/tokens.css" (kept separate so the two stay
|
|
19
19
|
independently composable). The host is also responsible for giving
|
|
20
20
|
.pf-shell a height (e.g. `height: 100%` on an ancestor chain rooted at
|
|
21
21
|
`html, body`, or `position: absolute; inset: 0`); this sheet does not size
|
|
@@ -33,11 +33,21 @@
|
|
|
33
33
|
|
|
34
34
|
/* ---- stage: the viewer column, which owns its floating chrome ------------
|
|
35
35
|
min-width: 0 lets the column shrink past the canvas's intrinsic width, so
|
|
36
|
-
dragging the rail wider actually narrows the viewer instead of overflowing.
|
|
36
|
+
dragging the rail wider actually narrows the viewer instead of overflowing.
|
|
37
|
+
|
|
38
|
+
min-height: 0 is the same escape hatch for the OTHER axis, and it is what
|
|
39
|
+
the narrow layout needs: there the shell is a column, so the canvas's
|
|
40
|
+
height — a real pixel height the renderer wrote, not an intrinsic 150px —
|
|
41
|
+
becomes this flex item's automatic minimum size. Without it the stage
|
|
42
|
+
refuses to shrink, and the tab bar below it is pushed off the bottom of
|
|
43
|
+
the screen: still display:flex, still in the DOM, simply not on screen.
|
|
44
|
+
That failure survived a `display` assertion in the smoke check, which is
|
|
45
|
+
why that check now asserts the bar is inside the viewport instead. */
|
|
37
46
|
.pf-stage {
|
|
38
47
|
flex: 1;
|
|
39
48
|
position: relative;
|
|
40
49
|
min-width: 0;
|
|
50
|
+
min-height: 0;
|
|
41
51
|
background: var(--pf-bg);
|
|
42
52
|
}
|
|
43
53
|
|
|
@@ -151,23 +161,78 @@
|
|
|
151
161
|
.pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
|
|
152
162
|
.pf-float-viewbar { bottom: 12px; right: 12px; }
|
|
153
163
|
|
|
154
|
-
/* ----
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
/* ---- the narrow-layout tab bar ------------------------------------------
|
|
165
|
+
Created by mobile-tabs.js (no host markup declares it). Hidden by default:
|
|
166
|
+
below the breakpoint the rail sits beside the viewer and needs no tab, so
|
|
167
|
+
the media query below is what reveals it.
|
|
168
|
+
|
|
169
|
+
The [hidden] rule is not redundant. `hidden` is how mobile-tabs.js stands
|
|
170
|
+
the bar down when a HOST owns pane selection, and the UA's
|
|
171
|
+
[hidden] { display: none } is a UA-origin rule that the media query's
|
|
172
|
+
author-origin display:flex would otherwise beat — leaving a second,
|
|
173
|
+
competing tab bar on screen inside the host's own. */
|
|
174
|
+
.pf-tabbar {
|
|
175
|
+
display: none;
|
|
176
|
+
flex: none;
|
|
177
|
+
border-top: 1px solid var(--pf-border);
|
|
178
|
+
background: var(--pf-surface);
|
|
179
|
+
color: var(--pf-text);
|
|
180
|
+
/* The home indicator on a notched phone. Zero everywhere else. */
|
|
181
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
182
|
+
}
|
|
183
|
+
.pf-tabbar[hidden] { display: none; }
|
|
184
|
+
.pf-tabbar button {
|
|
185
|
+
flex: 1;
|
|
186
|
+
display: flex;
|
|
187
|
+
flex-direction: column;
|
|
188
|
+
align-items: center;
|
|
189
|
+
justify-content: center;
|
|
190
|
+
gap: 2px;
|
|
191
|
+
padding: 6px 0;
|
|
192
|
+
border: 0;
|
|
193
|
+
background: transparent;
|
|
194
|
+
color: var(--pf-muted);
|
|
195
|
+
font: inherit;
|
|
196
|
+
font-size: 10px;
|
|
197
|
+
letter-spacing: 0.02em;
|
|
198
|
+
cursor: pointer;
|
|
199
|
+
}
|
|
200
|
+
.pf-tabbar button[aria-pressed="true"] { color: var(--pf-accent); }
|
|
201
|
+
|
|
202
|
+
/* ---- narrow layout: ONE pane at a time, chosen by a tab bar ---------------
|
|
203
|
+
The old layout here stacked the rail as a 45vh strip under the viewer, which
|
|
204
|
+
left both panes too cramped to use on a phone. Instead the shell becomes a
|
|
205
|
+
column of [pane][tab bar] and shows exactly one pane, full height, keyed on
|
|
206
|
+
data-pf-pane (written by mobile-tabs.js, or by a host through its
|
|
207
|
+
setHostPane).
|
|
208
|
+
|
|
209
|
+
Written as :not([data-pf-pane="rail"]) so a MISSING attribute falls to the
|
|
210
|
+
stage rather than showing both panes at once — the layout is correct from
|
|
211
|
+
first paint, before any JS runs.
|
|
212
|
+
|
|
213
|
+
The seam stays hidden and resize is absent at this width (rail.js also
|
|
214
|
+
refuses to start a drag). Collapse has no meaning here either: rail.js
|
|
215
|
+
suppresses it entirely below this breakpoint, so there is deliberately no
|
|
216
|
+
.pf-rail[inert] rule left — nothing sets inert at this width. */
|
|
157
217
|
@media (max-width: 719px) {
|
|
158
218
|
.pf-shell { flex-direction: column; }
|
|
219
|
+
.pf-shell:not([data-pf-pane="rail"]) .pf-rail { display: none; }
|
|
220
|
+
.pf-shell[data-pf-pane="rail"] .pf-stage { display: none; }
|
|
221
|
+
/* Whichever pane shows fills the column. --pf-rail-w, the left border, and
|
|
222
|
+
the inset shadow all existed to make the rail read as a set-back right
|
|
223
|
+
EDGE; none of that means anything when the rail IS the whole surface. */
|
|
159
224
|
.pf-rail {
|
|
160
|
-
|
|
161
|
-
|
|
225
|
+
flex: 1;
|
|
226
|
+
width: auto;
|
|
227
|
+
border-left: 0;
|
|
162
228
|
box-shadow: none;
|
|
163
229
|
}
|
|
164
230
|
.pf-rail-seam { display: none; }
|
|
165
|
-
|
|
166
|
-
so hide the rail outright — otherwise the toggle's first tap leaves a
|
|
167
|
-
full-size rail on screen that is inert and unresponsive. */
|
|
168
|
-
.pf-rail[inert] { display: none; }
|
|
231
|
+
.pf-tabbar { display: flex; }
|
|
169
232
|
}
|
|
170
233
|
|
|
234
|
+
|
|
235
|
+
|
|
171
236
|
/* ---- reduced motion -----------------------------------------------------
|
|
172
237
|
Collapsing the rail slides 288px of layout across the screen — the first
|
|
173
238
|
layout-scale animation in the framework, and the kind of movement a
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The narrow-layout pane tab bar. Below RAIL_NARROW_BREAKPOINT there is no room
|
|
2
|
+
// for a rail beside the viewer, so the shell shows exactly ONE pane and this bar
|
|
3
|
+
// chooses which. chrome.css owns the layout, keyed on the data-pf-pane attribute
|
|
4
|
+
// this module writes onto .pf-shell; a missing attribute reads as "stage", so
|
|
5
|
+
// the layout is already correct before any of this runs.
|
|
6
|
+
//
|
|
7
|
+
// Two independent reasons the bar can be absent, and they are deliberately
|
|
8
|
+
// handled by different mechanisms:
|
|
9
|
+
// * WIDTH — above the breakpoint the rail sits beside the viewer and no tab is
|
|
10
|
+
// needed. That is pure CSS (.pf-tabbar { display: none }). There is no JS
|
|
11
|
+
// media-query bookkeeping in this file at all, and no resize listener.
|
|
12
|
+
// * A HOST owns pane selection — partforge-cloud draws its own bottom bar at
|
|
13
|
+
// the window level and drives this layout through setHostPane(). Then our
|
|
14
|
+
// bar is `hidden` and only the host's choice writes data-pf-pane.
|
|
15
|
+
//
|
|
16
|
+
// State is in-memory: a fresh load starts on the stage, and nothing persists.
|
|
17
|
+
// Which pane you are looking at right now is not a preference.
|
|
18
|
+
//
|
|
19
|
+
// The DOM is created here, so no host markup declares it — same contract as
|
|
20
|
+
// rail.js's seam, and the reason this composes with partforge-cloud's
|
|
21
|
+
// hand-built sandbox scaffold without that scaffold knowing anything about it.
|
|
22
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
23
|
+
|
|
24
|
+
// Hand-authored in lucide's 24x24 / 2px-stroke idiom so these sit beside
|
|
25
|
+
// rail.js's toggle icon without introducing a second icon vocabulary (and
|
|
26
|
+
// without a dependency for three glyphs). `stroke="currentColor"` is
|
|
27
|
+
// load-bearing: it is how each icon picks up its button's themed colour, and
|
|
28
|
+
// the accent colour of the pressed state, with no icon-specific CSS anywhere.
|
|
29
|
+
const ICONS = {
|
|
30
|
+
// A box: the part itself.
|
|
31
|
+
stage: [
|
|
32
|
+
"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z",
|
|
33
|
+
"m3.3 7 8.7 5 8.7-5",
|
|
34
|
+
"M12 22V12",
|
|
35
|
+
],
|
|
36
|
+
// Three horizontal sliders: the parameter controls.
|
|
37
|
+
rail: [
|
|
38
|
+
"M21 4h-7", "M10 4H3", "M21 12h-9", "M8 12H3", "M21 20h-5", "M12 20H3",
|
|
39
|
+
"M14 2v4", "M8 10v4", "M16 18v4",
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
const LABELS = { stage: "3D", rail: "Controls" };
|
|
43
|
+
const PANES = ["stage", "rail"];
|
|
44
|
+
|
|
45
|
+
function buildIcon(paths) {
|
|
46
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
47
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
48
|
+
svg.setAttribute("width", "20");
|
|
49
|
+
svg.setAttribute("height", "20");
|
|
50
|
+
svg.setAttribute("fill", "none");
|
|
51
|
+
svg.setAttribute("stroke", "currentColor");
|
|
52
|
+
svg.setAttribute("stroke-width", "2");
|
|
53
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
54
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
55
|
+
// Decorative: every button carries a visible text label already.
|
|
56
|
+
svg.setAttribute("aria-hidden", "true");
|
|
57
|
+
for (const d of paths) {
|
|
58
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
59
|
+
path.setAttribute("d", d);
|
|
60
|
+
svg.append(path);
|
|
61
|
+
}
|
|
62
|
+
return svg;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// role="group" + aria-pressed, NOT role="tablist": a real tablist owes its tabs
|
|
66
|
+
// aria-controls'd tabpanels, and .pf-stage / .pf-rail are host-built DOM this
|
|
67
|
+
// module does not own and must not relabel. Two pressed-state buttons describe
|
|
68
|
+
// what this actually is without promising semantics we cannot honour.
|
|
69
|
+
//
|
|
70
|
+
// Everything is optional, like attachRail: with no shell to manage this returns
|
|
71
|
+
// a no-op handle, so a legacy id-only page or a host that lays the framework out
|
|
72
|
+
// itself (embed-test.html) is unaffected — including its setHostPane, which
|
|
73
|
+
// stays a callable no-op so mount()'s handle shape never varies.
|
|
74
|
+
export function attachMobileTabs({ shell, stage, rail } = {}) {
|
|
75
|
+
if (!shell || !stage || !rail) {
|
|
76
|
+
return { setHostPane: () => {}, detach: () => {} };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let pane = "stage"; // the standalone user's choice
|
|
80
|
+
let hostPane = null; // a host's lease over that choice, while non-null
|
|
81
|
+
|
|
82
|
+
const bar = document.createElement("div");
|
|
83
|
+
bar.className = "pf-tabbar";
|
|
84
|
+
bar.setAttribute("role", "group");
|
|
85
|
+
bar.setAttribute("aria-label", "Viewer panes");
|
|
86
|
+
const buttons = new Map();
|
|
87
|
+
for (const p of PANES) {
|
|
88
|
+
const button = document.createElement("button");
|
|
89
|
+
button.type = "button";
|
|
90
|
+
button.dataset.pfPaneTab = p;
|
|
91
|
+
button.title = LABELS[p];
|
|
92
|
+
button.append(buildIcon(ICONS[p]));
|
|
93
|
+
const label = document.createElement("span");
|
|
94
|
+
label.textContent = LABELS[p];
|
|
95
|
+
button.append(label);
|
|
96
|
+
bar.append(button);
|
|
97
|
+
buttons.set(p, button);
|
|
98
|
+
}
|
|
99
|
+
// Last child of the shell: in the narrow layout .pf-shell is a column, so the
|
|
100
|
+
// bar lands under whichever pane is showing. Above the breakpoint it is
|
|
101
|
+
// display:none and its position in the flex row is moot.
|
|
102
|
+
shell.append(bar);
|
|
103
|
+
|
|
104
|
+
function apply() {
|
|
105
|
+
const active = hostPane ?? pane;
|
|
106
|
+
shell.dataset.pfPane = active;
|
|
107
|
+
for (const [p, button] of buttons) {
|
|
108
|
+
button.setAttribute("aria-pressed", String(p === active));
|
|
109
|
+
}
|
|
110
|
+
// A host that owns pane selection draws its own control; ours would be a
|
|
111
|
+
// second, competing one.
|
|
112
|
+
bar.hidden = hostPane !== null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Delegated, so the icon <svg>/<span> inside a button resolve to the button.
|
|
116
|
+
const onClick = (e) => {
|
|
117
|
+
const next = e.target?.closest?.("[data-pf-pane-tab]")?.dataset?.pfPaneTab;
|
|
118
|
+
if (!PANES.includes(next)) return;
|
|
119
|
+
pane = next;
|
|
120
|
+
apply();
|
|
121
|
+
};
|
|
122
|
+
bar.addEventListener("click", onClick);
|
|
123
|
+
apply();
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
// 'stage' | 'rail' takes the lease; anything else (including null) releases
|
|
127
|
+
// it and restores the standalone bar and the user's own last choice.
|
|
128
|
+
setHostPane: (next) => {
|
|
129
|
+
hostPane = PANES.includes(next) ? next : null;
|
|
130
|
+
apply();
|
|
131
|
+
},
|
|
132
|
+
detach: () => {
|
|
133
|
+
bar.removeEventListener("click", onClick);
|
|
134
|
+
bar.remove();
|
|
135
|
+
delete shell.dataset.pfPane;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createViewer } from "./viewer.js";
|
|
|
4
4
|
import { attachViewerControls } from "./viewer-controls.js";
|
|
5
5
|
import { attachCutawayControls } from "./cutaway-controls.js";
|
|
6
6
|
import { attachRail } from "./rail.js";
|
|
7
|
+
import { attachMobileTabs } from "./mobile-tabs.js";
|
|
7
8
|
import { createTooltipPresenter } from "./tooltip.js";
|
|
8
9
|
import { loadCamera } from "./view-state.js";
|
|
9
10
|
import { buildControls } from "./controls.js";
|
|
@@ -25,13 +26,17 @@ import { createExportController } from "./export-controller.js";
|
|
|
25
26
|
|
|
26
27
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
27
28
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
28
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts }) {
|
|
29
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane }) {
|
|
29
30
|
return {
|
|
30
31
|
ready, dispose, setParams,
|
|
31
32
|
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
32
33
|
captureCurrent: (opts) => viewer.captureCurrent(opts),
|
|
33
34
|
listExportableParts,
|
|
34
35
|
exportParts,
|
|
36
|
+
// Narrow-layout pane selection, for a host that draws its own tab bar
|
|
37
|
+
// (partforge-cloud does, at the window level). Defaulted to a no-op so the
|
|
38
|
+
// handle's shape never depends on whether this mount resolved a rail.
|
|
39
|
+
setHostPane: setHostPane ?? (() => {}),
|
|
35
40
|
};
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -64,7 +69,7 @@ function createCleanupStack() {
|
|
|
64
69
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
65
70
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
66
71
|
//
|
|
67
|
-
// Embedding contract (0.
|
|
72
|
+
// Embedding contract (0.39.0):
|
|
68
73
|
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
|
|
69
74
|
// await runtime.ready; // first successful build of the default view
|
|
70
75
|
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
@@ -78,6 +83,9 @@ function createCleanupStack() {
|
|
|
78
83
|
// // headless export of a chosen subset; resolves when the file is
|
|
79
84
|
// // written (handed to your onDownload sink, or downloaded directly
|
|
80
85
|
// // if you don't supply one), rejects on failure
|
|
86
|
+
// runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
87
|
+
// // rail ('stage' | 'rail'), suppressing the
|
|
88
|
+
// // built-in tab bar. null hands selection back.
|
|
81
89
|
// runtime.dispose(); // full teardown
|
|
82
90
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
83
91
|
// those are repaired in the viewer and produce no build at all.
|
|
@@ -133,6 +141,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
133
141
|
// framework itself (no #panel / no elements.rail).
|
|
134
142
|
const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell });
|
|
135
143
|
cleanup.defer(() => railChrome.detach());
|
|
144
|
+
// Narrow-layout pane tabs. Below RAIL_NARROW_BREAKPOINT the rail cannot sit
|
|
145
|
+
// beside the viewer, so exactly one pane shows and this bar picks it. Same
|
|
146
|
+
// resolution and same no-op-when-absent contract as the rail above — the
|
|
147
|
+
// shell default mirrors attachRail's (rail.parentElement).
|
|
148
|
+
const paneTabs = attachMobileTabs({
|
|
149
|
+
shell: els.shell ?? els.rail?.parentElement,
|
|
150
|
+
stage: els.viewer,
|
|
151
|
+
rail: els.rail,
|
|
152
|
+
});
|
|
153
|
+
cleanup.defer(() => paneTabs.detach());
|
|
136
154
|
const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
|
|
137
155
|
cleanup.defer(() => hover.detach());
|
|
138
156
|
const ui = createStatusUi({ ...els.status, exports: [els.exports.stl, els.exports.step, els.exports.threeMf] });
|
|
@@ -448,6 +466,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
448
466
|
|
|
449
467
|
return makeHandle({
|
|
450
468
|
ready, dispose, viewer, setParams,
|
|
469
|
+
setHostPane: paneTabs.setHostPane,
|
|
451
470
|
listExportableParts: () =>
|
|
452
471
|
exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
|
|
453
472
|
exportParts: (opts) => exportCtl.exportParts(opts),
|
package/src/framework/rail.js
CHANGED
|
@@ -96,7 +96,9 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
96
96
|
// hand back a plain, unwired button rather than a dead "Show controls" one.
|
|
97
97
|
// innerHTML (not textContent) so a host's original content - markup, not
|
|
98
98
|
// just text - genuinely round-trips; the icon apply() writes is markup too.
|
|
99
|
-
const toggleOriginal = toggle
|
|
99
|
+
const toggleOriginal = toggle
|
|
100
|
+
? { html: toggle.innerHTML, title: toggle.title, hidden: toggle.hidden }
|
|
101
|
+
: null;
|
|
100
102
|
// Built once here rather than inside apply() (which reruns on every resize/
|
|
101
103
|
// key/drag tick); apply() only ever swaps toggleChevron's `d`.
|
|
102
104
|
let toggleChevron = null;
|
|
@@ -123,7 +125,17 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
123
125
|
// flush, not a cached value.
|
|
124
126
|
function apply({ persist = false, shellW } = {}) {
|
|
125
127
|
const sw = shellW ?? shellWidth();
|
|
126
|
-
|
|
128
|
+
// Below the breakpoint the shell shows exactly ONE pane (chrome.css, keyed
|
|
129
|
+
// on data-pf-pane) and mobile-tabs.js picks it, so collapse has no meaning:
|
|
130
|
+
// a persisted `collapsed: true` must not make the Controls tab land on an
|
|
131
|
+
// inert, invisible rail. `state` is deliberately NOT rewritten — the stored
|
|
132
|
+
// preference applies again the moment the window widens.
|
|
133
|
+
const narrow = window.innerWidth < RAIL_NARROW_BREAKPOINT;
|
|
134
|
+
const collapsed = state.collapsed && !narrow;
|
|
135
|
+
// Narrow: the rail is either the whole surface or absent, so it reserves no
|
|
136
|
+
// width beside the viewer — and anything centring itself against the rail
|
|
137
|
+
// (app.css's #pf-pick-banner) must not be offset by a stale 288px.
|
|
138
|
+
const width = collapsed || narrow ? 0 : clampRailWidth(state.width, sw);
|
|
127
139
|
// Written on :root, not the rail/shell, so body-appended overlays (the
|
|
128
140
|
// pick banner, the ?debug overlay) inherit it — see spec §4.4. This
|
|
129
141
|
// assumes ONE rail per document: attachRail is written for a single
|
|
@@ -133,17 +145,20 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
133
145
|
// multiple mount() calls in general (e.g. cross-fade swaps), which is
|
|
134
146
|
// fine as long as at most one has a resolvable rail at a time.
|
|
135
147
|
root.style.setProperty("--pf-rail-w", `${width}px`);
|
|
136
|
-
rail.toggleAttribute("inert",
|
|
137
|
-
seam.toggleAttribute("data-collapsed",
|
|
148
|
+
rail.toggleAttribute("inert", collapsed);
|
|
149
|
+
seam.toggleAttribute("data-collapsed", collapsed);
|
|
138
150
|
seam.setAttribute("aria-valuenow", String(width));
|
|
139
151
|
seam.setAttribute("aria-valuemax", String(railMaxWidth(sw)));
|
|
140
152
|
if (toggle) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
toggle.
|
|
153
|
+
// The tab bar owns pane selection below the breakpoint, so a second
|
|
154
|
+
// collapse affordance in the viewbar would be a competing control.
|
|
155
|
+
toggle.hidden = narrow;
|
|
156
|
+
toggleChevron?.setAttribute("d", collapsed ? CHEVRON_RAIL_COLLAPSED : CHEVRON_RAIL_OPEN);
|
|
157
|
+
const label = collapsed ? "Show controls" : "Hide controls";
|
|
158
|
+
toggle.setAttribute("aria-expanded", String(!collapsed));
|
|
144
159
|
toggle.setAttribute("aria-label", label);
|
|
145
160
|
toggle.title = label;
|
|
146
|
-
toggle.classList.toggle("on",
|
|
161
|
+
toggle.classList.toggle("on", collapsed);
|
|
147
162
|
}
|
|
148
163
|
if (persist) writeRailPref(state, storage);
|
|
149
164
|
}
|
|
@@ -312,6 +327,7 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
312
327
|
if (toggle) {
|
|
313
328
|
toggle.innerHTML = toggleOriginal.html;
|
|
314
329
|
toggle.title = toggleOriginal.title;
|
|
330
|
+
toggle.hidden = toggleOriginal.hidden;
|
|
315
331
|
toggle.removeAttribute("aria-expanded");
|
|
316
332
|
toggle.removeAttribute("aria-label");
|
|
317
333
|
toggle.classList.remove("on");
|
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
-
|
|
194
|
+
_meshBounds.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld);
|
|
195
|
+
_worldBounds.union(_meshBounds);
|
|
186
196
|
}
|
|
187
197
|
return _worldBounds;
|
|
188
198
|
}
|
|
@@ -379,12 +389,19 @@ export function createViewer(container, part) {
|
|
|
379
389
|
// fov 45, quality 0.9). A custom size (captureCurrent) gets a fresh render
|
|
380
390
|
// target, disposed after the read — those captures are rare, so per-call
|
|
381
391
|
// allocation beats caching one target per size ever requested.
|
|
392
|
+
//
|
|
393
|
+
// stencilBuffer is NOT optional: cutaway masks its section caps with the
|
|
394
|
+
// stencil buffer, and a WebGLRenderTarget defaults to not having one (the
|
|
395
|
+
// visible canvas does, via the `stencil: true` renderer above). Without it
|
|
396
|
+
// the mask silently no-ops and every cap floods its whole plane with hatch —
|
|
397
|
+
// no error, live view unaffected, wrong only in the capture.
|
|
398
|
+
const RT_OPTIONS = { samples: 4, stencilBuffer: true };
|
|
382
399
|
function renderOffscreen({ position, up, target },
|
|
383
400
|
{ width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {}) {
|
|
384
401
|
const cachedSize = width === _rtSize && height === _rtSize;
|
|
385
402
|
const rt = cachedSize
|
|
386
|
-
? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize,
|
|
387
|
-
: new THREE.WebGLRenderTarget(width, height,
|
|
403
|
+
? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
|
|
404
|
+
: new THREE.WebGLRenderTarget(width, height, RT_OPTIONS);
|
|
388
405
|
_capLights = _capLights ?? createCaptureLights();
|
|
389
406
|
const cam = new THREE.PerspectiveCamera(fov, width / height, 0.1, 1000);
|
|
390
407
|
cam.position.set(position[0], position[1], position[2]);
|