partforge 0.52.0 → 0.54.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/docs/AUTHORING-PARTS.md +20 -5
- package/package.json +1 -1
- package/src/framework/app.css +27 -2
- package/src/framework/cutaway-controls.js +4 -27
- package/src/framework/cutaway-gizmo-scene.js +3 -1
- package/src/framework/measure/dim3-place.js +455 -0
- package/src/framework/measure/dim3-scene.js +438 -0
- package/src/framework/measure/feature-dims.js +258 -0
- package/src/framework/measure/measure-controls.js +110 -0
- package/src/framework/measure/measure-mode.js +546 -0
- package/src/framework/measure/param-link.js +32 -0
- package/src/framework/measure/pins.js +36 -0
- package/src/framework/mount.js +61 -6
- package/src/framework/panel/render.js +41 -0
- package/src/framework/selection/drag-tracker.js +39 -0
- package/src/framework/selection/feature-highlight.js +99 -0
- package/src/framework/selection/hover.js +14 -90
- package/src/framework/selection/index.js +1 -0
- package/src/framework/selection/pick.js +11 -37
- package/src/framework/teardown.js +29 -0
- package/src/framework/viewer.js +65 -5
- package/types/index.d.ts +16 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -487,6 +487,12 @@ if (p.drain > 0) s = s.cut(k.cylinder({ r: d.drainR, h: p.floor + 4 }).at([0, 0,
|
|
|
487
487
|
- Names should describe intent ("Drainage hole", not "cylinder2"); make them
|
|
488
488
|
unique per sub-part unless you specifically want the merge behavior.
|
|
489
489
|
|
|
490
|
+
Labels do double duty in the viewer: the hover tooltip names the feature, and
|
|
491
|
+
**measurement mode** (the ruler button in the viewbar) measures it — a labeled
|
|
492
|
+
hole reads ⌀ + depth, a labeled face reads its extents, and a click pins that
|
|
493
|
+
dimension so it tracks parameter changes live. Label the features a user would
|
|
494
|
+
want to measure; unlabeled geometry still measures as its bounding box.
|
|
495
|
+
|
|
490
496
|
### Caching & determinism
|
|
491
497
|
|
|
492
498
|
The preview kernel memoizes geometry by content hash, so editing a parameter only
|
|
@@ -1230,7 +1236,7 @@ stylesheet). `mount` looks up these element IDs:
|
|
|
1230
1236
|
| `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` and opens the resolved default (see the "Which view the viewer opens on" rule above) |
|
|
1231
1237
|
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
1232
1238
|
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
1233
|
-
| `#viewbar` with `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
1239
|
+
| `#viewbar` with `#reframe` / `#cutaway` / `#measure` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
1234
1240
|
| `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
|
|
1235
1241
|
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme` |
|
|
1236
1242
|
|
|
@@ -1250,9 +1256,14 @@ instead of (or in addition to) the built-in `#part` bar:
|
|
|
1250
1256
|
`viewName` rendered offscreen (falling back to the resolved default view — see
|
|
1251
1257
|
`resolveDefaultView` / `default-view.js` — when `viewName` is omitted or names a view the
|
|
1252
1258
|
part doesn't declare). Never disturbs the active tab, the live camera, or the on-screen
|
|
1253
|
-
scene; `opts` forwards to the underlying render (size, quality, angle).
|
|
1254
|
-
failure rather than throwing (a build error, a part with no sub-parts
|
|
1255
|
-
disposed runtime).
|
|
1259
|
+
scene; `opts` forwards to the underlying render (size, quality, angle, background).
|
|
1260
|
+
Resolves `null` on failure rather than throwing (a build error, a part with no sub-parts
|
|
1261
|
+
in that view, a disposed runtime). The render happens in a throwaway scene, so it takes
|
|
1262
|
+
no colour from the viewer's light/dark theme: it gets a fixed neutral grey, on the
|
|
1263
|
+
reasoning that a thumbnail is captured once and then displayed under host chrome
|
|
1264
|
+
partforge cannot see. Pass `background` (any `THREE.Color`-compatible value) to choose
|
|
1265
|
+
your own, or `background: null` for no background at all — which clears to opaque black
|
|
1266
|
+
unless the embedder has set a clear colour.
|
|
1256
1267
|
|
|
1257
1268
|
Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
|
|
1258
1269
|
synchronously during mount with the initial resolved view (before `runtime.ready` settles),
|
|
@@ -1291,7 +1302,10 @@ pane's pixel size:
|
|
|
1291
1302
|
`null` when the runtime is disposed or nothing is built/visible yet — it never throws.
|
|
1292
1303
|
`hideGrid: false` keeps the floor grid so the capture matches the on-screen look
|
|
1293
1304
|
exactly. The live view is untouched: the camera never moves, and lights/grid/render
|
|
1294
|
-
target are restored after the render.
|
|
1305
|
+
target are restored after the render. Measurement-mode dimensions render directly
|
|
1306
|
+
in the scene, so a dimensioned capture needs no special handling — enable measure
|
|
1307
|
+
mode (`runtime.measure.setEnabled(true)`) and call `captureCurrent()`; the dims are
|
|
1308
|
+
just part of the rendered frame.
|
|
1295
1309
|
- `runtime.captureViews(viewNames) → [{ view, dataUrl }]` — the canonical-angle
|
|
1296
1310
|
counterpart (fixed poses, framed to the visible assembly, 1024², grid hidden). Sized
|
|
1297
1311
|
for feeding a vision model, not for display; use `captureCurrent` for showcase images.
|
|
@@ -1365,6 +1379,7 @@ mount(part, {
|
|
|
1365
1379
|
chrome: {
|
|
1366
1380
|
reframe,
|
|
1367
1381
|
cutaway,
|
|
1382
|
+
measure,
|
|
1368
1383
|
theme,
|
|
1369
1384
|
railToggle,
|
|
1370
1385
|
},
|
package/package.json
CHANGED
package/src/framework/app.css
CHANGED
|
@@ -313,6 +313,9 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
313
313
|
#viewbar .pf-cutaway-actions { display: flex; gap: 4px; }
|
|
314
314
|
#viewbar .pf-cutaway-actions[hidden] { display: none; }
|
|
315
315
|
#viewbar .pf-cutaway-actions button { width: auto; min-width: 56px; padding: 0 8px; }
|
|
316
|
+
#viewbar .pf-measure-actions { display: flex; gap: 4px; }
|
|
317
|
+
#viewbar .pf-measure-actions[hidden] { display: none; }
|
|
318
|
+
#viewbar .pf-measure-actions button { width: auto; min-width: 56px; padding: 0 8px; }
|
|
316
319
|
#viewbar button:disabled { opacity: .38; cursor: not-allowed; }
|
|
317
320
|
#viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
|
|
318
321
|
#viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
@@ -327,10 +330,21 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
327
330
|
@media (max-width: 360px) {
|
|
328
331
|
#viewbar { gap: 3px; }
|
|
329
332
|
#viewbar button { width: 30px; height: 30px; font-size: 13px; }
|
|
330
|
-
#viewbar .pf-cutaway-actions { gap: 3px; }
|
|
331
|
-
#viewbar .pf-cutaway-actions button { min-width: 44px; padding: 0 6px; }
|
|
333
|
+
#viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions { gap: 3px; }
|
|
334
|
+
#viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button { min-width: 44px; padding: 0 6px; }
|
|
332
335
|
}
|
|
333
336
|
|
|
337
|
+
/* ---- measurement mode -----------------------------------------------------
|
|
338
|
+
Dimensions render in-scene — three.js objects placed alongside the part in
|
|
339
|
+
the viewer, not a DOM/SVG overlay. This CSS block only styles the viewbar
|
|
340
|
+
chrome: the unit tag shown next to the measure controls. */
|
|
341
|
+
.pf-measure-unit {
|
|
342
|
+
font-family: var(--pf-mono); font-size: 10px; color: var(--pf-muted);
|
|
343
|
+
align-self: center; user-select: none; cursor: pointer;
|
|
344
|
+
}
|
|
345
|
+
#viewbar .pf-measure-actions button.pf-measure-unit { min-width: auto; padding: 0 6px; }
|
|
346
|
+
.pf-measure-unit:hover { color: var(--pf-text-2); }
|
|
347
|
+
|
|
334
348
|
/* animation transport bar (placement: chrome.css's .pf-anim-bar). APPEARANCE
|
|
335
349
|
is ungated for the same reason as #viewbar above. */
|
|
336
350
|
.pf-anim-bar {
|
|
@@ -582,3 +596,14 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
582
596
|
white-space: pre-wrap; word-break: break-word; display: none;
|
|
583
597
|
}
|
|
584
598
|
#pf-pick-toast.show { display: block; }
|
|
599
|
+
|
|
600
|
+
/* dimension->control reveal: a decaying accent pulse that hands off to the
|
|
601
|
+
focused control's own focus ring */
|
|
602
|
+
/* outline (not box-shadow) so outline-offset can space the ring away from
|
|
603
|
+
the control without touching layout */
|
|
604
|
+
.pf-param-flash { animation: pf-param-flash 900ms ease-out; outline-offset: 5px; border-radius: 6px; }
|
|
605
|
+
@keyframes pf-param-flash {
|
|
606
|
+
0% { outline: 3px solid color-mix(in oklab, var(--pf-accent) 70%, transparent); }
|
|
607
|
+
100% { outline: 3px solid transparent; }
|
|
608
|
+
}
|
|
609
|
+
@media (prefers-reduced-motion: reduce) { .pf-param-flash { animation: none; } }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { attachButtonTooltips } from "./tooltip.js";
|
|
2
|
+
import { runCleanupSteps, captureAttributes, restoreAttributes } from "./teardown.js";
|
|
2
3
|
|
|
3
4
|
const UNSUPPORTED_TITLE = "Cutaway requires a stencil-capable WebGL context";
|
|
4
5
|
const BUTTON_ATTRIBUTES = [
|
|
@@ -12,17 +13,6 @@ const BUTTON_ATTRIBUTES = [
|
|
|
12
13
|
|
|
13
14
|
const noop = () => {};
|
|
14
15
|
|
|
15
|
-
function runCleanupSteps(steps) {
|
|
16
|
-
const errors = [];
|
|
17
|
-
for (const step of steps) {
|
|
18
|
-
try { step(); } catch (error) { errors.push(error); }
|
|
19
|
-
}
|
|
20
|
-
if (errors.length === 1) throw errors[0];
|
|
21
|
-
if (errors.length > 1) {
|
|
22
|
-
throw new AggregateError(errors, "cutaway control cleanup failed");
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
function actionButton(label, title) {
|
|
27
17
|
const button = document.createElement("button");
|
|
28
18
|
button.type = "button";
|
|
@@ -32,23 +22,9 @@ function actionButton(label, title) {
|
|
|
32
22
|
return button;
|
|
33
23
|
}
|
|
34
24
|
|
|
35
|
-
function captureAttributes(element, names) {
|
|
36
|
-
return new Map(names.map((name) => [name, {
|
|
37
|
-
present: element.hasAttribute(name),
|
|
38
|
-
value: element.getAttribute(name),
|
|
39
|
-
}]));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function restoreAttributes(element, attributes) {
|
|
43
|
-
for (const [name, { present, value }] of attributes) {
|
|
44
|
-
if (present) element.setAttribute(name, value);
|
|
45
|
-
else element.removeAttribute(name);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
25
|
// Wire the optional cutaway button to the viewer and create its contextual
|
|
50
26
|
// actions. Hosts that omit the primary button opt out of all DOM behavior.
|
|
51
|
-
export function attachCutawayControls(viewer, { cutaway: button } = {}, { tooltip } = {}) {
|
|
27
|
+
export function attachCutawayControls(viewer, { cutaway: button } = {}, { tooltip, escapeGuard } = {}) {
|
|
52
28
|
if (!button) return { reset: noop, detach: noop };
|
|
53
29
|
|
|
54
30
|
const canvas = viewer.domElement;
|
|
@@ -116,6 +92,7 @@ export function attachCutawayControls(viewer, { cutaway: button } = {}, { toolti
|
|
|
116
92
|
};
|
|
117
93
|
const onEscape = (event) => {
|
|
118
94
|
if (event.key !== "Escape" || !viewer.cutawayEnabled()) return;
|
|
95
|
+
if (escapeGuard?.()) return; // an inner lens (measure mode) claims this Escape
|
|
119
96
|
event.preventDefault();
|
|
120
97
|
disable();
|
|
121
98
|
};
|
|
@@ -149,7 +126,7 @@ export function attachCutawayControls(viewer, { cutaway: button } = {}, { toolti
|
|
|
149
126
|
() => restoreAttributes(button, hostButtonAttributes),
|
|
150
127
|
() => { button.disabled = hostButtonDisabled; },
|
|
151
128
|
() => button.classList.toggle("on", hostButtonOn),
|
|
152
|
-
]);
|
|
129
|
+
], "cutaway control cleanup failed");
|
|
153
130
|
},
|
|
154
131
|
};
|
|
155
132
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// +10: above the caps/highlight tier AND above measure mode's dims
|
|
5
|
+
// (dim3-scene renders at +2/+3) — an actively-dragged control outranks ink.
|
|
6
|
+
const GIZMO_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 10;
|
|
5
7
|
const HANDLE_HOVER_THICKNESS = 1.6;
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// Pure in-scene dimension placement (spec v2 §Placement + amendments).
|
|
2
|
+
// Everything works in the PARTS frame — the meshes' shared parent group
|
|
3
|
+
// (delivered geometry composed with pose matrices) — so the resulting drawing
|
|
4
|
+
// rides the pivot rotation and per-view recentring untouched. No DOM, no GL,
|
|
5
|
+
// no rendering objects: three's math classes only, so this runs under plain
|
|
6
|
+
// vitest.
|
|
7
|
+
//
|
|
8
|
+
// Placement does the expensive DISCOVERY only: anchor points (extreme-vertex
|
|
9
|
+
// scans, surface raycasts, plane snapping), side selection, dedupe and stagger
|
|
10
|
+
// lanes. Every display distance — standoff, surface gap, arrowheads, the
|
|
11
|
+
// overshoot past the dim line, leader length, text size — is screen-constant,
|
|
12
|
+
// so the final geometry depends on zoom and is assembled per frame by
|
|
13
|
+
// dim3-scene from the parametric records emitted here. That keeps zoom fully
|
|
14
|
+
// rebuild-free.
|
|
15
|
+
//
|
|
16
|
+
// Split in two so the orchestrator can score cheaply every frame and rebuild
|
|
17
|
+
// rarely: evaluateChoices() is dot-products + hysteresis over the previous
|
|
18
|
+
// choices; placeDims() does the discovery only when a choice flipped or the
|
|
19
|
+
// scene changed.
|
|
20
|
+
//
|
|
21
|
+
// Drawing contract (consumed by dim3-scene):
|
|
22
|
+
// { itemId, tier, pinned,
|
|
23
|
+
// dims: [{ pA, pB, baseA, baseB, ext, dir, lane, standoffScale,
|
|
24
|
+
// label: { text, value, x, y } }],
|
|
25
|
+
// diams: [{ rimA, rimB, du, dv, label }],
|
|
26
|
+
// leaders: [{ rim, dir, perp, label }] }
|
|
27
|
+
// All vectors are number[3] in the parts frame. A linear dim's line endpoints
|
|
28
|
+
// at display time are base± + ext·offset (offset chosen on-screen); pA/pB are
|
|
29
|
+
// the discovered surface-contact anchors its extension lines run from. A diam
|
|
30
|
+
// is the fixed line across a circle (rim to rim); a leader points at `rim`
|
|
31
|
+
// along `dir`.
|
|
32
|
+
import * as THREE from "three";
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
// --- discovery constants ------------------------------------------------------
|
|
36
|
+
// standoffNominal: the mm offset ASSUMED while discovering surface contacts
|
|
37
|
+
// (raycast origins) and extreme-vertex tie-break targets. Display standoff is
|
|
38
|
+
// screen-constant and lives in dim3-scene; discovery only needs a plausible
|
|
39
|
+
// out-of-the-part reference, and the contact points it finds barely depend on
|
|
40
|
+
// it.
|
|
41
|
+
export const standoffNominal = (modelSize) => Math.max(6, modelSize * 0.10);
|
|
42
|
+
|
|
43
|
+
// Display units. Values are ALWAYS mm internally (kernel units; label.value
|
|
44
|
+
// stays mm so control matching keeps working) — only the rendered text
|
|
45
|
+
// converts. Inches show 3 decimals: 0.001 in ≈ 0.0254 mm, in the same
|
|
46
|
+
// precision neighbourhood as the 0.01 mm display quantum.
|
|
47
|
+
export const UNITS = {
|
|
48
|
+
mm: { format: (v) => v.toFixed(2), suffix: " mm" },
|
|
49
|
+
in: { format: (v) => (v / 25.4).toFixed(3), suffix: " in" },
|
|
50
|
+
};
|
|
51
|
+
export const HYSTERESIS = 1.15; // challenger must beat the holder by 15%
|
|
52
|
+
export const FLIP_DEADBAND_DEG = 25; // cylinder ⌀ direction re-aim threshold
|
|
53
|
+
|
|
54
|
+
const AXES = [
|
|
55
|
+
new THREE.Vector3(1, 0, 0),
|
|
56
|
+
new THREE.Vector3(0, 1, 0),
|
|
57
|
+
new THREE.Vector3(0, 0, 1),
|
|
58
|
+
];
|
|
59
|
+
const v3 = (a) => new THREE.Vector3(a[0], a[1], a[2]);
|
|
60
|
+
|
|
61
|
+
// --- duplicate-dimension suppression ------------------------------------------
|
|
62
|
+
// Two items measuring the same thing draw identical dims on top of each other:
|
|
63
|
+
// a hovered sub-part over the overall bounds (single-part apps), a hover over
|
|
64
|
+
// its own pin. The signature identifies "the same measurement" independent of
|
|
65
|
+
// item id/tier. Within one placeDims call the LATER item wins (pins carry the
|
|
66
|
+
// param pill the overall lacks); across the orchestrator's base/hover split,
|
|
67
|
+
// the hover pass hands the base items' sigs in as `suppress`.
|
|
68
|
+
export function specSig(spec) {
|
|
69
|
+
return JSON.stringify({ kind: spec.kind, values: spec.values, anchors: spec.anchors });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- stagger lanes ------------------------------------------------------------
|
|
73
|
+
// Dims extending the same outward direction stack at increasing standoff
|
|
74
|
+
// (drafting-style stacked dimension lines), so co-located labels stagger
|
|
75
|
+
// instead of overlapping. Lanes are per-placeDims-call and deterministic in
|
|
76
|
+
// item order (overall, pins, hover). The lane's on-screen spacing lives in
|
|
77
|
+
// dim3-scene.
|
|
78
|
+
function laneFor(lanes, ext) {
|
|
79
|
+
const key = `${ext.x.toFixed(2)},${ext.y.toFixed(2)},${ext.z.toFixed(2)}`;
|
|
80
|
+
const lane = lanes.get(key) ?? 0;
|
|
81
|
+
lanes.set(key, lane + 1);
|
|
82
|
+
return lane;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Lane occupancy of already-placed drawings, in laneFor's key space — the
|
|
86
|
+
// orchestrator seeds the hover pass with the cached base pass's counts so a
|
|
87
|
+
// hovered dim takes the SAME lane it will occupy once pinned (pins append
|
|
88
|
+
// after the base items in the same order), instead of starting at lane 0 and
|
|
89
|
+
// jumping on click.
|
|
90
|
+
export function laneCounts(drawings) {
|
|
91
|
+
const lanes = new Map();
|
|
92
|
+
for (const d of drawings) {
|
|
93
|
+
for (const dim of d.dims ?? []) {
|
|
94
|
+
const key = `${dim.ext[0].toFixed(2)},${dim.ext[1].toFixed(2)},${dim.ext[2].toFixed(2)}`;
|
|
95
|
+
lanes.set(key, Math.max(lanes.get(key) ?? 0, (dim.lane ?? 0) + 1));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return lanes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- candidate sides for a box-extent dim ------------------------------------
|
|
102
|
+
// Measuring along `axis`, the dim can extend outward along ± each of the other
|
|
103
|
+
// two axes; the plane normal is the remaining axis. Keys are stable across
|
|
104
|
+
// frames so hysteresis can hold a choice.
|
|
105
|
+
function boxCandidates(axis) {
|
|
106
|
+
const others = [0, 1, 2].filter((i) => i !== axis);
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const extAxis of others) {
|
|
109
|
+
const nAxis = others.find((i) => i !== extAxis);
|
|
110
|
+
for (const sign of [1, -1]) out.push({ key: `e${extAxis}s${sign}`, extAxis, sign, nAxis });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function scoreCandidate(ext, n, toCam) {
|
|
116
|
+
// Readability first: the dim's plane should FACE the camera — a face-on
|
|
117
|
+
// drawing is what a viewer can read. "Extend toward the viewer" only breaks
|
|
118
|
+
// ties between equally-oblique planes (and biases the near side there): the
|
|
119
|
+
// two terms are antagonistic — an ext pointing AT the camera means the
|
|
120
|
+
// plane containing it is near edge-on — so an ext-dominant weighting (the
|
|
121
|
+
// original 0.6/0.4) actively picked tilted, foreshortened planes that
|
|
122
|
+
// fought the viewer during orbit.
|
|
123
|
+
return Math.abs(n.dot(toCam)) + 0.15 * Math.max(0, ext.dot(toCam));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Hold the previous choice unless a challenger beats it by HYSTERESIS — and
|
|
127
|
+
// by an absolute margin too: near-degenerate views score every candidate close
|
|
128
|
+
// to zero, where a multiplicative margin is meaningless and tiny camera moves
|
|
129
|
+
// would flip-flop the choice (and force rebuilds) for no visible benefit.
|
|
130
|
+
function chooseWithHysteresis(scored, prevKey) {
|
|
131
|
+
scored.sort((a, b) => b.score - a.score);
|
|
132
|
+
const best = scored[0];
|
|
133
|
+
const prev = prevKey != null ? scored.find((s) => s.key === prevKey) : null;
|
|
134
|
+
if (prev && best.key !== prev.key
|
|
135
|
+
&& (best.score < prev.score * HYSTERESIS || best.score - prev.score < 0.02)) return prev;
|
|
136
|
+
return best;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- per-frame-cheap choice scoring ------------------------------------------
|
|
140
|
+
export function evaluateChoices(items, { camPos, center, prev = {} }) {
|
|
141
|
+
const cam = v3(camPos);
|
|
142
|
+
const toCam = cam.clone().sub(v3(center)).normalize();
|
|
143
|
+
const choices = {};
|
|
144
|
+
for (const item of items) {
|
|
145
|
+
const spec = item.spec;
|
|
146
|
+
if (spec.kind === "bbox") {
|
|
147
|
+
for (const axis of [0, 1, 2]) {
|
|
148
|
+
// zero-span axes place no dim (placeBox skips them) — emitting a
|
|
149
|
+
// choice anyway would let its degenerate near-zero scores flip on
|
|
150
|
+
// tiny camera moves and force pointless rebuilds
|
|
151
|
+
if (spec.anchors.max[axis] - spec.anchors.min[axis] < 1e-6) continue;
|
|
152
|
+
const ck = `${item.id}|ax${axis}`;
|
|
153
|
+
const scored = boxCandidates(axis).map((c) => ({
|
|
154
|
+
...c,
|
|
155
|
+
score: scoreCandidate(AXES[c.extAxis].clone().multiplyScalar(c.sign), AXES[c.nAxis], toCam),
|
|
156
|
+
}));
|
|
157
|
+
choices[ck] = { key: chooseWithHysteresis(scored, prev[ck]?.key).key };
|
|
158
|
+
}
|
|
159
|
+
} else if (spec.kind === "plane") {
|
|
160
|
+
const n = v3(spec.anchors.normal).normalize();
|
|
161
|
+
for (const dimKey of ["width", "height"]) {
|
|
162
|
+
const { a, b } = spec.anchors[dimKey];
|
|
163
|
+
const dir = v3(b).sub(v3(a)).normalize();
|
|
164
|
+
const perp = new THREE.Vector3().crossVectors(n, dir).normalize();
|
|
165
|
+
const ck = `${item.id}|${dimKey}`;
|
|
166
|
+
const scored = [
|
|
167
|
+
{ key: "p+", sign: 1, score: scoreCandidate(perp, n, toCam) },
|
|
168
|
+
{ key: "p-", sign: -1, score: scoreCandidate(perp.clone().negate(), n, toCam) },
|
|
169
|
+
];
|
|
170
|
+
choices[ck] = { key: chooseWithHysteresis(scored, prev[ck]?.key).key };
|
|
171
|
+
}
|
|
172
|
+
} else if (spec.kind === "cylinder") {
|
|
173
|
+
// ⌀/R direction: radial component of the view direction, re-aimed only
|
|
174
|
+
// past the deadband so the drawing doesn't chase every orbit degree.
|
|
175
|
+
const axis = v3(spec.anchors.axis).normalize();
|
|
176
|
+
const toCamHere = cam.clone().sub(v3(spec.anchors.center)).normalize();
|
|
177
|
+
let du = toCamHere.clone().addScaledVector(axis, -toCamHere.dot(axis));
|
|
178
|
+
if (du.lengthSq() < 1e-6) du = v3(spec.anchors.rimDir ?? [1, 0, 0]);
|
|
179
|
+
du.normalize();
|
|
180
|
+
const ck = `${item.id}|du`;
|
|
181
|
+
const prevDu = prev[ck]?.du ? v3(prev[ck].du) : null;
|
|
182
|
+
const hold = prevDu && du.angleTo(prevDu) < (FLIP_DEADBAND_DEG * Math.PI) / 180;
|
|
183
|
+
choices[ck] = { du: (hold ? prevDu : du).toArray() };
|
|
184
|
+
// depth dim side: candidates around the axis — the tangential pair
|
|
185
|
+
// (±dv) puts the dim's plane FACE-ON to the camera, hanging off the
|
|
186
|
+
// cylinder's visible silhouette; the radial pair (±du) is near edge-on
|
|
187
|
+
// and only wins in degenerate down-the-axis views. Same readability
|
|
188
|
+
// rule as the box candidates.
|
|
189
|
+
const dck = `${item.id}|depth`;
|
|
190
|
+
const duHeld = hold ? prevDu : du;
|
|
191
|
+
const dv = new THREE.Vector3().crossVectors(axis, duHeld).normalize();
|
|
192
|
+
const scored = [
|
|
193
|
+
{ key: "t+", ext: dv },
|
|
194
|
+
{ key: "t-", ext: dv.clone().negate() },
|
|
195
|
+
{ key: "d+", ext: duHeld },
|
|
196
|
+
{ key: "d-", ext: duHeld.clone().negate() },
|
|
197
|
+
].map((c) => ({
|
|
198
|
+
key: c.key,
|
|
199
|
+
score: scoreCandidate(c.ext, new THREE.Vector3().crossVectors(axis, c.ext).normalize(), toCamHere),
|
|
200
|
+
}));
|
|
201
|
+
choices[dck] = { key: chooseWithHysteresis(scored, prev[dck]?.key).key, du: duHeld.toArray() };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return choices;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function choicesEqual(a, b) {
|
|
208
|
+
const ka = Object.keys(a), kb = Object.keys(b);
|
|
209
|
+
if (ka.length !== kb.length) return false;
|
|
210
|
+
for (const k of ka) {
|
|
211
|
+
const x = a[k], y = b[k];
|
|
212
|
+
if (!y) return false;
|
|
213
|
+
if (x.key !== y.key) return false;
|
|
214
|
+
if (!!x.du !== !!y.du) return false;
|
|
215
|
+
if (x.du && (x.du[0] !== y.du[0] || x.du[1] !== y.du[1] || x.du[2] !== y.du[2])) return false;
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- extreme vertex scan ------------------------------------------------------
|
|
221
|
+
// The vertex realizing the extreme along `axis` over the posed meshes; ties
|
|
222
|
+
// within tolerance (a flat base is all "the minimum") break toward `near`, so
|
|
223
|
+
// the anchor lands on the side of the part the dimension is drawn on.
|
|
224
|
+
const _sv = new THREE.Vector3();
|
|
225
|
+
export function extremeVertex(meshData, axis, sign, near) {
|
|
226
|
+
let bestVal = sign > 0 ? -Infinity : Infinity;
|
|
227
|
+
for (const { positions, matrix } of meshData) {
|
|
228
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
229
|
+
_sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
|
|
230
|
+
const val = _sv.getComponent(axis);
|
|
231
|
+
if (sign > 0 ? val > bestVal : val < bestVal) bestVal = val;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!Number.isFinite(bestVal)) return null;
|
|
235
|
+
let best = null, bestD = Infinity;
|
|
236
|
+
for (const { positions, matrix } of meshData) {
|
|
237
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
238
|
+
_sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
|
|
239
|
+
if (Math.abs(_sv.getComponent(axis) - bestVal) > 1e-3) continue;
|
|
240
|
+
const d = _sv.distanceToSquared(near);
|
|
241
|
+
if (d < bestD) { bestD = d; best = _sv.clone(); }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return best;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- one parametric linear dimension -----------------------------------------
|
|
248
|
+
// pA/pB: surface anchor points. nomA/nomB: NOMINAL dim-line endpoints used for
|
|
249
|
+
// discovery only. baseA/baseB: dim-line endpoints at ZERO standoff — the scene
|
|
250
|
+
// slides them out along `ext` by the screen-derived offset. When `surfaceHit`
|
|
251
|
+
// is given, each extension line starts at the first in-plane surface hit
|
|
252
|
+
// walking from the nominal endpoint back toward the part (ray nudged 0.05 mm
|
|
253
|
+
// inside the extreme plane so a grazing ray on the extreme face registers);
|
|
254
|
+
// otherwise (feature dims — anchors already ON the surface) it starts at the
|
|
255
|
+
// anchor.
|
|
256
|
+
function linearDim(out, {
|
|
257
|
+
pA, pB, baseA, baseB, nomA, nomB, ext, lane, standoffScale = 1,
|
|
258
|
+
text, value, surfaceHit, planeAxis, planeC,
|
|
259
|
+
}) {
|
|
260
|
+
const dir = baseB.clone().sub(baseA).normalize();
|
|
261
|
+
const anchors = [pA, pB];
|
|
262
|
+
[[pA, nomA, 1], [pB, nomB, -1]].forEach(([p, nom, inwardSign], i) => {
|
|
263
|
+
if (!surfaceHit) return;
|
|
264
|
+
const nudged = nom.clone().addScaledVector(dir, 0.05 * inwardSign);
|
|
265
|
+
const toward = p.clone().sub(nom).normalize();
|
|
266
|
+
const hit = surfaceHit(nudged, toward);
|
|
267
|
+
if (hit) {
|
|
268
|
+
const start = hit.clone();
|
|
269
|
+
if (planeAxis != null) start.setComponent(planeAxis, planeC); // stay exactly coplanar
|
|
270
|
+
anchors[i] = start;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
out.dims.push({
|
|
274
|
+
pA: anchors[0].toArray(), pB: anchors[1].toArray(),
|
|
275
|
+
baseA: baseA.toArray(), baseB: baseB.toArray(),
|
|
276
|
+
ext: ext.toArray(), dir: dir.toArray(),
|
|
277
|
+
lane, standoffScale,
|
|
278
|
+
label: { text, value: value ?? null, x: dir.toArray(), y: ext.clone().negate().toArray() },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- per-kind placement -------------------------------------------------------
|
|
283
|
+
function placeBox(out, item, spec, choices, { meshData, surfaceHit, modelSize, lanes, unit }, refSide) {
|
|
284
|
+
const min = spec.anchors.min, max = spec.anchors.max;
|
|
285
|
+
const nomOff = standoffNominal(modelSize);
|
|
286
|
+
const scan = meshData; // caller pre-filtered by item.meshes
|
|
287
|
+
const valueByAxis = [spec.values.w, spec.values.d, spec.values.h];
|
|
288
|
+
const seenValues = new Set();
|
|
289
|
+
for (const axis of [0, 1, 2]) {
|
|
290
|
+
const span = max[axis] - min[axis];
|
|
291
|
+
if (span < 1e-6) continue;
|
|
292
|
+
// duplicate-value suppression within the item: a round or square part has
|
|
293
|
+
// equal extents — one dim carries the shared value
|
|
294
|
+
const text = `${unit.format(valueByAxis[axis])}${unit.suffix}`;
|
|
295
|
+
if (seenValues.has(text)) continue;
|
|
296
|
+
seenValues.add(text);
|
|
297
|
+
const cand = boxCandidates(axis).find((c) => c.key === choices[`${item.id}|ax${axis}`]?.key)
|
|
298
|
+
?? boxCandidates(axis)[0];
|
|
299
|
+
const { extAxis, sign, nAxis } = cand;
|
|
300
|
+
const ext = AXES[extAxis].clone().multiplyScalar(sign);
|
|
301
|
+
// dim-line base points: measured coordinate at min/max, ext coordinate on
|
|
302
|
+
// the near face (zero standoff); the plane coordinate (nAxis) is snapped
|
|
303
|
+
// below. Nominal points add the discovery standoff for raycast origins and
|
|
304
|
+
// tie-break targets.
|
|
305
|
+
const extBase = sign > 0 ? max[extAxis] : min[extAxis];
|
|
306
|
+
const mk = (m, off) => {
|
|
307
|
+
const p = new THREE.Vector3();
|
|
308
|
+
p.setComponent(axis, m);
|
|
309
|
+
p.setComponent(extAxis, extBase + sign * off);
|
|
310
|
+
p.setComponent(nAxis, refSide(nAxis, min, max));
|
|
311
|
+
return p;
|
|
312
|
+
};
|
|
313
|
+
const nomA = mk(min[axis], nomOff), nomB = mk(max[axis], nomOff);
|
|
314
|
+
// true extreme anchors (tie-break toward the nominal dim line), then plane
|
|
315
|
+
// snap: slide the plane along nAxis to whichever anchor sits nearer the
|
|
316
|
+
// mid-plane reference; the other anchor projects into the plane.
|
|
317
|
+
const ref = refSide(nAxis, min, max);
|
|
318
|
+
let pA = extremeVertex(scan, axis, -1, nomA) ?? new THREE.Vector3().setComponent(axis, min[axis]);
|
|
319
|
+
let pB = extremeVertex(scan, axis, +1, nomB) ?? new THREE.Vector3().setComponent(axis, max[axis]);
|
|
320
|
+
const cA = pA.getComponent(nAxis), cB = pB.getComponent(nAxis);
|
|
321
|
+
const c = Math.abs(cA - ref) <= Math.abs(cB - ref) ? cA : cB;
|
|
322
|
+
const baseA = mk(min[axis], 0), baseB = mk(max[axis], 0);
|
|
323
|
+
for (const p of [pA, pB, nomA, nomB, baseA, baseB]) p.setComponent(nAxis, c);
|
|
324
|
+
linearDim(out, {
|
|
325
|
+
pA, pB, baseA, baseB, nomA, nomB, ext,
|
|
326
|
+
lane: laneFor(lanes, ext),
|
|
327
|
+
text, value: valueByAxis[axis],
|
|
328
|
+
surfaceHit, planeAxis: nAxis, planeC: c,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function placePlane(out, item, spec, choices, { lanes, unit }) {
|
|
334
|
+
const n = v3(spec.anchors.normal).normalize();
|
|
335
|
+
const dims = [
|
|
336
|
+
["width", spec.values.width],
|
|
337
|
+
["height", spec.values.height],
|
|
338
|
+
];
|
|
339
|
+
for (const [dimKey, value] of dims) {
|
|
340
|
+
if (value < 1e-6) continue;
|
|
341
|
+
const a = v3(spec.anchors[dimKey].a), b = v3(spec.anchors[dimKey].b);
|
|
342
|
+
const dir = b.clone().sub(a).normalize();
|
|
343
|
+
const perp = new THREE.Vector3().crossVectors(n, dir).normalize();
|
|
344
|
+
const sign = choices[`${item.id}|${dimKey}`]?.key === "p-" ? -1 : 1;
|
|
345
|
+
const ext = perp.multiplyScalar(sign);
|
|
346
|
+
linearDim(out, {
|
|
347
|
+
pA: a, pB: b, baseA: a.clone(), baseB: b.clone(), nomA: a, nomB: b, ext,
|
|
348
|
+
lane: laneFor(lanes, ext), standoffScale: 0.55, // feature dims hug their feature
|
|
349
|
+
text: `${unit.format(value)}${unit.suffix}`, value, surfaceHit: null,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function placeCylinder(out, item, spec, choices, { lanes, unit }) {
|
|
355
|
+
const axis = v3(spec.anchors.axis).normalize();
|
|
356
|
+
const top = v3(spec.anchors.top);
|
|
357
|
+
const bottom = v3(spec.anchors.bottom);
|
|
358
|
+
const r = spec.values.diameter / 2;
|
|
359
|
+
const du = v3(choices[`${item.id}|du`]?.du ?? spec.anchors.rimDir ?? [1, 0, 0]).normalize();
|
|
360
|
+
const dv = new THREE.Vector3().crossVectors(axis, du).normalize();
|
|
361
|
+
|
|
362
|
+
if (spec.values.partial) {
|
|
363
|
+
// R leader from the covered-arc midpoint, radial, in the top plane
|
|
364
|
+
const rd = v3(spec.anchors.rimDir ?? du.toArray()).normalize();
|
|
365
|
+
const rim = top.clone().addScaledVector(rd, r);
|
|
366
|
+
out.leaders.push({
|
|
367
|
+
rim: rim.toArray(), dir: rd.toArray(),
|
|
368
|
+
perp: new THREE.Vector3().crossVectors(axis, rd).normalize().toArray(),
|
|
369
|
+
label: {
|
|
370
|
+
text: `R${unit.format(r)}`, value: r,
|
|
371
|
+
x: new THREE.Vector3().crossVectors(axis, rd).normalize().toArray(),
|
|
372
|
+
y: rd.clone().negate().toArray(),
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
} else {
|
|
376
|
+
// full circle: diameter line across the top circle along dv — the
|
|
377
|
+
// projected ellipse's WIDE axis (du, the camera radial, is its
|
|
378
|
+
// foreshortened one) — arrows outward at both rim points, ⌀ text
|
|
379
|
+
// continuing off the end of the line
|
|
380
|
+
const rimA = top.clone().addScaledVector(dv, r);
|
|
381
|
+
const rimB = top.clone().addScaledVector(dv, -r);
|
|
382
|
+
out.diams.push({
|
|
383
|
+
// scene contract: `du` is the label-offset direction off rimA (here the
|
|
384
|
+
// line's own direction, so the text runs off the end of the diameter);
|
|
385
|
+
// `dv` is the arrows' in-plane spread direction
|
|
386
|
+
rimA: rimA.toArray(), rimB: rimB.toArray(), du: dv.toArray(), dv: du.toArray(),
|
|
387
|
+
label: {
|
|
388
|
+
text: `⌀${unit.format(spec.values.diameter)}`, value: spec.values.diameter,
|
|
389
|
+
x: dv.toArray(), y: du.clone().negate().toArray(),
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// depth: linear dim along the axis, hung off the silhouette at the chosen side
|
|
395
|
+
if (spec.values.depth > 1e-6) {
|
|
396
|
+
const extKey = choices[`${item.id}|depth`]?.key ?? "t+";
|
|
397
|
+
const ext = ({
|
|
398
|
+
"t+": dv.clone(), "t-": dv.clone().negate(),
|
|
399
|
+
"d+": du.clone(), "d-": du.clone().negate(),
|
|
400
|
+
})[extKey] ?? dv.clone();
|
|
401
|
+
const pA = bottom.clone().addScaledVector(ext, r);
|
|
402
|
+
const pB = top.clone().addScaledVector(ext, r);
|
|
403
|
+
linearDim(out, {
|
|
404
|
+
pA, pB, baseA: pA.clone(), baseB: pB.clone(), nomA: pA, nomB: pB, ext,
|
|
405
|
+
lane: laneFor(lanes, ext), standoffScale: 0.55,
|
|
406
|
+
text: `${unit.format(spec.values.depth)}${unit.suffix}`, value: spec.values.depth, surfaceHit: null,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --- entry point --------------------------------------------------------------
|
|
412
|
+
export function placeDims(items, { meshData = [], surfaceHit = null, bounds, suppress = null, lanes: laneSeed = null, units = "mm" }, choices) {
|
|
413
|
+
const size = bounds
|
|
414
|
+
? Math.max(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2])
|
|
415
|
+
: 10;
|
|
416
|
+
// duplicate-measurement suppression: see specSig. Later item wins in-call;
|
|
417
|
+
// `suppress` carries sigs already drawn by another call (the base pass).
|
|
418
|
+
const sigs = items.map((i) => (i.spec ? specSig(i.spec) : null));
|
|
419
|
+
const skip = new Set();
|
|
420
|
+
for (let i = 0; i < items.length; i++) {
|
|
421
|
+
if (!sigs[i]) { skip.add(i); continue; }
|
|
422
|
+
if (suppress?.has(sigs[i])) { skip.add(i); continue; }
|
|
423
|
+
for (let j = i + 1; j < items.length; j++) {
|
|
424
|
+
if (sigs[i] === sigs[j]) { skip.add(i); break; }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// plane-snap reference: for bbox dims the plane snaps to whichever true
|
|
428
|
+
// extreme anchor sits nearer the model's mid-plane along nAxis (see refSide
|
|
429
|
+
// below) rather than to a camera side — deterministic and adequate: the
|
|
430
|
+
// spec only requires "the side of the model the dim is drawn toward".
|
|
431
|
+
const lanes = new Map(laneSeed ?? undefined);
|
|
432
|
+
const unit = UNITS[units] ?? UNITS.mm;
|
|
433
|
+
const drawings = [];
|
|
434
|
+
items.forEach((item, idx) => {
|
|
435
|
+
const spec = item.spec;
|
|
436
|
+
if (!spec || skip.has(idx)) return;
|
|
437
|
+
const out = { itemId: item.id, tier: item.tier, pinned: !!item.pinned, dims: [], diams: [], leaders: [] };
|
|
438
|
+
const scan = item.meshes ? item.meshes.map((i) => meshData[i]).filter(Boolean) : meshData;
|
|
439
|
+
if (spec.kind === "bbox") {
|
|
440
|
+
const refSide = (nAxis, min, max) => {
|
|
441
|
+
// draw-side reference along the plane normal: mid-plane — the snap then
|
|
442
|
+
// picks whichever anchor is nearer the model's middle along n, keeping
|
|
443
|
+
// the drawing close to where the extent actually occurs.
|
|
444
|
+
return (min[nAxis] + max[nAxis]) / 2;
|
|
445
|
+
};
|
|
446
|
+
placeBox(out, item, spec, choices, { meshData: scan, surfaceHit, modelSize: size, lanes, unit }, refSide);
|
|
447
|
+
} else if (spec.kind === "plane") {
|
|
448
|
+
placePlane(out, item, spec, choices, { lanes, unit });
|
|
449
|
+
} else if (spec.kind === "cylinder") {
|
|
450
|
+
placeCylinder(out, item, spec, choices, { lanes, unit });
|
|
451
|
+
}
|
|
452
|
+
if (out.dims.length || out.diams.length || out.leaders.length) drawings.push(out);
|
|
453
|
+
});
|
|
454
|
+
return drawings;
|
|
455
|
+
}
|