partforge 0.53.0 → 0.55.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 +12 -2
- 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/jobs.js +75 -2
- 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 +58 -5
- package/src/framework/oracle/match.js +263 -0
- package/src/framework/oracle/measure.js +5 -1
- package/src/framework/oracle/silhouette.js +146 -0
- 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 +25 -1
- package/src/testing.js +5 -0
- package/types/index.d.ts +16 -0
- package/types/testing.d.ts +116 -1
|
@@ -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
|
+
}
|