partforge 0.71.0 → 0.73.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 +71 -3
- package/package.json +1 -1
- package/src/framework/animation-controls.js +171 -16
- package/src/framework/annotate/annotate-mode.js +31 -3
- package/src/framework/app.css +122 -4
- package/src/framework/camera-orbit.js +84 -0
- package/src/framework/camera-tween.js +22 -10
- package/src/framework/chrome.css +113 -3
- package/src/framework/cutaway-gizmo.js +6 -1
- package/src/framework/cutaway.js +9 -1
- package/src/framework/jobs.js +22 -5
- package/src/framework/measure/dim3-scene.js +15 -2
- package/src/framework/mount.js +79 -2
- package/src/framework/oracle/gates.js +50 -0
- package/src/framework/oracle/measure.js +21 -4
- package/src/framework/oracle/min-wall.js +17 -0
- package/src/framework/oracle/verify.js +63 -22
- package/src/framework/projection.js +19 -0
- package/src/framework/view-angles.js +69 -1
- package/src/framework/view-state.js +9 -0
- package/src/framework/viewcube/cube-canvas.js +410 -0
- package/src/framework/viewcube/cube-geom.js +367 -0
- package/src/framework/viewcube/viewcube-controls.js +157 -0
- package/src/framework/viewcube/viewcube-mode.js +201 -0
- package/src/framework/viewer.js +289 -23
|
@@ -2,9 +2,8 @@ import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
|
|
|
2
2
|
import { measure as defaultMeasure } from "./measure.js";
|
|
3
3
|
import { pairKey, CONTACT_EPS } from "./gaps.js";
|
|
4
4
|
import { resolveProfile } from "./dfm-profiles.js";
|
|
5
|
-
import {
|
|
5
|
+
import { expandExpectations, partGatesMinWall } from "./gates.js";
|
|
6
6
|
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
|
|
7
|
-
import { resolveParams } from "../part-model.js";
|
|
8
7
|
import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
|
|
9
8
|
|
|
10
9
|
// Re-exported for backwards compatibility: the registries moved to framework/ so
|
|
@@ -52,11 +51,19 @@ function pairGapChecks(facts, { contacts, clearance }, subPartNames) {
|
|
|
52
51
|
// No gap table at all = legacy facts → skip. A table that MERELY LACKS the pair
|
|
53
52
|
// = the sub-part built empty (meshGaps skips empty meshes) → a declared gate
|
|
54
53
|
// must fail loudly, not skip, or verify.ok would vouch for an unverified pair.
|
|
54
|
+
// `measuredGaps === false` says the pass was deliberately skipped (a quick lap);
|
|
55
|
+
// an absent stamp is a legacy result, whose skip stays exactly as untagged as it
|
|
56
|
+
// has always been rather than retroactively withholding verdicts.
|
|
57
|
+
const gapsSkipped = facts.measuredGaps === false;
|
|
55
58
|
const noReading = (base) => (facts.gaps
|
|
56
59
|
? { ...base, actual: null, status: "fail", pass: false,
|
|
57
60
|
message: "no measured distance for the pair",
|
|
58
61
|
hint: "one sub-part produced no mesh (an empty solid?) — fix the build before trusting this gate" }
|
|
59
|
-
: { ...base, actual: null, status: "skip", pass: null,
|
|
62
|
+
: { ...base, actual: null, status: "skip", pass: null,
|
|
63
|
+
...(gapsSkipped
|
|
64
|
+
? { unevaluated: true, message: "not measured (quick check)",
|
|
65
|
+
hint: "re-run this check without `quick` to measure pair distances" }
|
|
66
|
+
: { message: "unavailable" }) });
|
|
60
67
|
|
|
61
68
|
if (contacts != null && !Array.isArray(contacts)) {
|
|
62
69
|
throw new Error(`contacts: must be an array of ["a", "b"] pairs, got ${JSON.stringify(contacts)}`);
|
|
@@ -157,12 +164,25 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
|
157
164
|
for (const [metric, expr] of Object.entries(viewExp)) checks.push(check("view", null, metric, expr, VIEW_METRICS, facts));
|
|
158
165
|
checks.push(...pairGapChecks(facts, { contacts, clearance }, subPartNames));
|
|
159
166
|
|
|
167
|
+
// Same rule as gapsSkipped above, one metric over: a min-wall gate with no reading
|
|
168
|
+
// is a warn ("min wall unavailable"), which does not move `ok` — right when the
|
|
169
|
+
// rays were cast and all missed, wrong when they were never cast. The stamp is
|
|
170
|
+
// what tells those apart, and it is measure()'s own, never a caller's claim.
|
|
171
|
+
const minWallSkipped = facts.measuredMinWall === false;
|
|
160
172
|
for (const s of facts.subparts) {
|
|
161
173
|
const merged = {
|
|
162
174
|
...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
|
|
163
175
|
...(expect?.[s.name] ?? {}),
|
|
164
176
|
};
|
|
165
|
-
for (const [metric, expr] of Object.entries(merged))
|
|
177
|
+
for (const [metric, expr] of Object.entries(merged)) {
|
|
178
|
+
const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
|
|
179
|
+
if (minWallSkipped && metric === "minWall" && c.actual == null) {
|
|
180
|
+
checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
|
|
181
|
+
hint: "re-run this check without `quick` to measure min wall" });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
checks.push(c);
|
|
185
|
+
}
|
|
166
186
|
}
|
|
167
187
|
return checks;
|
|
168
188
|
}
|
|
@@ -170,26 +190,23 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
|
170
190
|
// `seed` lets a caller that has ALREADY measured this part hand the result in so
|
|
171
191
|
// verify does not recompute it — see the seeding block below for the shape and
|
|
172
192
|
// the one correctness rule that governs it.
|
|
173
|
-
|
|
193
|
+
// `quick` is the fast lap (see jobs.js): evaluate everything the seed already
|
|
194
|
+
// supports and MEASURE NOTHING. What that leaves unchecked is reported as
|
|
195
|
+
// `unevaluated` rather than quietly downgraded, and one unevaluated gate withholds
|
|
196
|
+
// `ok` — a fast lap yields facts, never a verdict.
|
|
197
|
+
export function verify(kernel, part, { process, view, measureFn = defaultMeasure, seed, quick = false } = {}) {
|
|
174
198
|
view = view ?? Object.keys(part.views)[0];
|
|
175
199
|
const profileSpec = process ?? part.verify?.process;
|
|
176
200
|
const profile = profileSpec ? resolveProfile(profileSpec) : null;
|
|
177
|
-
const expectSpec = part.verify?.expect ?? {};
|
|
178
201
|
|
|
179
|
-
const cases = expandCases(part);
|
|
180
202
|
// `expect` can be a pure function of the case's resolved params — (p, d) →
|
|
181
203
|
// expect object — so topology that legitimately changes with a preset (an
|
|
182
204
|
// optional drain or bore flipping the genus) can be pinned per case instead
|
|
183
|
-
// of one static number that some presets must violate.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
};
|
|
189
|
-
const expanded = cases.map((c) => ({ ...c, expect: resolveExpect(c.params) }));
|
|
190
|
-
const expectMentionsMinWall = expanded.some(({ expect }) =>
|
|
191
|
-
Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o));
|
|
192
|
-
const needMinWall = profile?.minWall != null || expectMentionsMinWall;
|
|
205
|
+
// of one static number that some presets must violate. The expansion lives in
|
|
206
|
+
// gates.js because measure() needs the same answer (it sizes the min-wall sample
|
|
207
|
+
// budget by it) and must not derive it separately — see there.
|
|
208
|
+
const expanded = expandExpectations(part);
|
|
209
|
+
const needMinWall = partGatesMinWall(part, { process, expanded });
|
|
193
210
|
const readKeys = subPartReadKeys(part, view, part.defaults);
|
|
194
211
|
const signature = (params) =>
|
|
195
212
|
readKeys === RELEVANT_ALL
|
|
@@ -237,24 +254,48 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
237
254
|
//
|
|
238
255
|
// Non-default measure options (a custom `gapThreshold`) are the caller's
|
|
239
256
|
// responsibility: seed only a measurement taken the way verify would take it.
|
|
240
|
-
|
|
257
|
+
// Under `quick` the superset rule is not waived so much as satisfied differently:
|
|
258
|
+
// a min-wall-less seed is reused, and the min-wall gate it cannot answer becomes
|
|
259
|
+
// `unevaluated` instead of being re-measured. The rule exists to stop a coarse
|
|
260
|
+
// reading standing in for a gate's verdict, and a withheld verdict does that too.
|
|
261
|
+
if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && seed.result.view === view) {
|
|
241
262
|
memo.set(signature({ ...part.defaults, ...(seed.params ?? {}) }), seed.result);
|
|
242
263
|
}
|
|
243
264
|
|
|
244
265
|
const measureCase = (params) => {
|
|
245
266
|
const key = signature(params);
|
|
246
|
-
if (
|
|
267
|
+
if (memo.has(key)) return memo.get(key);
|
|
268
|
+
if (quick) return null; // a case the seed does not cover — reported, never built
|
|
269
|
+
memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
|
|
247
270
|
return memo.get(key);
|
|
248
271
|
};
|
|
249
272
|
|
|
250
273
|
const subPartNames = Object.keys(part.parts);
|
|
251
|
-
|
|
274
|
+
// An unmeasured case gets ONE check standing for the whole case rather than a
|
|
275
|
+
// silent absence: "this preset was not checked" has to be visible in the same
|
|
276
|
+
// list every other verdict lives in, or a reader counting passes sees a shorter
|
|
277
|
+
// list and no reason.
|
|
278
|
+
const notMeasured = (name) => [{
|
|
279
|
+
scope: "case", subpart: null, metric: "measured", kind: "gate", expr: "measured",
|
|
280
|
+
actual: null, status: "skip", pass: null, unevaluated: true,
|
|
281
|
+
message: "not measured (quick check)",
|
|
282
|
+
hint: "re-run this check without `quick` to evaluate this case",
|
|
283
|
+
}];
|
|
284
|
+
const caseResults = expanded.map(({ name, params, expect }) => {
|
|
285
|
+
const facts = measureCase(params);
|
|
286
|
+
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames }) : notMeasured(name) };
|
|
287
|
+
});
|
|
252
288
|
const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
|
|
289
|
+
const failures = all.filter((c) => c.status === "fail");
|
|
290
|
+
const unevaluated = all.filter((c) => c.unevaluated);
|
|
253
291
|
return {
|
|
254
|
-
|
|
292
|
+
// Tri-state, and the order matters: a real failure is still a failure even on a
|
|
293
|
+
// lap that skipped other gates, so `false` outranks the withheld `null`.
|
|
294
|
+
ok: failures.length ? false : unevaluated.length ? null : true,
|
|
255
295
|
view,
|
|
256
296
|
cases: caseResults,
|
|
257
|
-
failures
|
|
297
|
+
failures,
|
|
258
298
|
warnings: all.filter((c) => c.status === "warn"),
|
|
299
|
+
unevaluated,
|
|
259
300
|
};
|
|
260
301
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The perspective <-> orthographic framing pair. Pure, so the swap's only
|
|
2
|
+
// interesting property — that the part does not change size the instant the
|
|
3
|
+
// user hits the toggle — is unit-testable without a renderer.
|
|
4
|
+
//
|
|
5
|
+
// Perspective frames by DISTANCE; orthographic frames by a frustum height plus
|
|
6
|
+
// a zoom (OrbitControls dollies an ortho camera by changing camera.zoom, not by
|
|
7
|
+
// moving it). These two functions convert between the two descriptions.
|
|
8
|
+
|
|
9
|
+
const halfHeightAt = (fovDeg, distance) => distance * Math.tan((fovDeg * Math.PI) / 360);
|
|
10
|
+
|
|
11
|
+
export function orthoFrustum({ fovDeg, distance, aspect = 1 }) {
|
|
12
|
+
const halfH = halfHeightAt(fovDeg, distance);
|
|
13
|
+
const halfW = halfH * aspect;
|
|
14
|
+
return { halfW, halfH, left: -halfW, right: halfW, top: halfH, bottom: -halfH };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function perspectiveDistance({ halfH, zoom = 1, fovDeg }) {
|
|
18
|
+
return halfH / (zoom * Math.tan((fovDeg * Math.PI) / 360));
|
|
19
|
+
}
|
|
@@ -15,13 +15,81 @@ const DIRS = {
|
|
|
15
15
|
right: { dir: [1, 0, 0], up: [0, 1, 0] },
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// The view cube's 26 orientations: 6 faces, 12 edges, 8 corners. Deliberately
|
|
19
|
+
// SEPARATE from CANONICAL_VIEWS, which stays at 7 — captureViewsFromScene
|
|
20
|
+
// slices against its length and the CLI names it, so growing that list would
|
|
21
|
+
// change contracts the cube has no business touching. The seven canonical
|
|
22
|
+
// names resolve to identical poses (iso === top-front-right).
|
|
23
|
+
//
|
|
24
|
+
// Face names are MODEL-frame (parts are authored Z-up); the world directions
|
|
25
|
+
// below already carry the pivot's rotation.x = -PI/2, which maps model
|
|
26
|
+
// (x, y, z) -> world (x, z, -y).
|
|
27
|
+
const FACE_DIRS = {
|
|
28
|
+
right: [1, 0, 0], // model +X
|
|
29
|
+
left: [-1, 0, 0], // model -X
|
|
30
|
+
top: [0, 1, 0], // model +Z
|
|
31
|
+
bottom: [0, -1, 0], // model -Z
|
|
32
|
+
front: [0, 0, 1], // model -Y
|
|
33
|
+
back: [0, 0, -1], // model +Y
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Canonical id ordering. A compound id always reads vertical, then depth, then
|
|
37
|
+
// side — "top-front-right", never "right-front-top" — so cube-geom.js can
|
|
38
|
+
// assemble an id from three independent axis choices and land on the same
|
|
39
|
+
// string every time.
|
|
40
|
+
const VERTICAL = ["top", "bottom"];
|
|
41
|
+
const DEPTH = ["front", "back"];
|
|
42
|
+
const SIDE = ["left", "right"];
|
|
43
|
+
|
|
44
|
+
// A pure top or bottom view is degenerate against a +Y up vector, so those two
|
|
45
|
+
// keep the special-cased ups DIRS already used. Every compound orientation has
|
|
46
|
+
// a well-defined +Y up.
|
|
47
|
+
//
|
|
48
|
+
// These ups matter to the OFFSCREEN capture path, which builds a temp camera and
|
|
49
|
+
// calls lookAt itself with no orbit frame to fall back on. The LIVE camera never
|
|
50
|
+
// needs them: it is driven through OrbitControls, whose polar frame derives the
|
|
51
|
+
// same roll on its own at azimuth 0 (a top cue lands with screen-up on world -Z,
|
|
52
|
+
// which is exactly [0, 0, -1]). Handing the live camera a non-+Y `up` would also
|
|
53
|
+
// re-base every subsequent orbit drag and would not survive getCameraState, so it
|
|
54
|
+
// deliberately keeps the default.
|
|
55
|
+
function upFor(parts) {
|
|
56
|
+
if (parts.length === 1 && parts[0] === "top") return [0, 0, -1];
|
|
57
|
+
if (parts.length === 1 && parts[0] === "bottom") return [0, 0, 1];
|
|
58
|
+
return [0, 1, 0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildOrientations() {
|
|
62
|
+
const out = {};
|
|
63
|
+
const add = (parts) => {
|
|
64
|
+
const dir = [0, 0, 0];
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
const d = FACE_DIRS[part];
|
|
67
|
+
dir[0] += d[0];
|
|
68
|
+
dir[1] += d[1];
|
|
69
|
+
dir[2] += d[2];
|
|
70
|
+
}
|
|
71
|
+
const id = parts.join("-");
|
|
72
|
+
out[id] = { id, parts: [...parts], dir, up: upFor(parts) };
|
|
73
|
+
};
|
|
74
|
+
for (const face of Object.keys(FACE_DIRS)) add([face]);
|
|
75
|
+
for (const v of VERTICAL) for (const other of [...DEPTH, ...SIDE]) add([v, other]);
|
|
76
|
+
for (const d of DEPTH) for (const s of SIDE) add([d, s]);
|
|
77
|
+
for (const v of VERTICAL) for (const d of DEPTH) for (const s of SIDE) add([v, d, s]);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const ORIENTATIONS = buildOrientations();
|
|
82
|
+
export const ORIENTATION_IDS = Object.keys(ORIENTATIONS);
|
|
83
|
+
|
|
18
84
|
const norm = (v) => {
|
|
19
85
|
const l = Math.hypot(v[0], v[1], v[2]) || 1;
|
|
20
86
|
return [v[0] / l, v[1] / l, v[2] / l];
|
|
21
87
|
};
|
|
22
88
|
|
|
23
89
|
export function cameraPoseForView(view, { center, radius }) {
|
|
24
|
-
|
|
90
|
+
// DIRS first so the seven canonical names keep their exact existing poses;
|
|
91
|
+
// ORIENTATIONS covers the other nineteen the cube can reach.
|
|
92
|
+
const a = DIRS[view] ?? ORIENTATIONS[view];
|
|
25
93
|
if (!a) throw new Error(`unknown canonical view "${view}"`);
|
|
26
94
|
const d = norm(a.dir);
|
|
27
95
|
const dist = radius * 2.6 + 6; // matches viewer.frameTo's framing distance
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const KEY = {
|
|
12
12
|
camera: "partforge:camera",
|
|
13
13
|
theme: "partforge:theme",
|
|
14
|
+
projection: "partforge:projection",
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
const viewKey = (partKey) => `partforge:view:${partKey}`;
|
|
@@ -55,6 +56,14 @@ export function saveTheme(mode) {
|
|
|
55
56
|
if (mode === "light" || mode === "dark") write(KEY.theme, mode);
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
export function loadProjection() {
|
|
60
|
+
return read(KEY.projection) === "orthographic" ? "orthographic" : "perspective";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function saveProjection(mode) {
|
|
64
|
+
if (mode === "perspective" || mode === "orthographic") write(KEY.projection, mode);
|
|
65
|
+
}
|
|
66
|
+
|
|
58
67
|
// `partKey` identifies the part — createViewTabs passes `meta.title`. Without one
|
|
59
68
|
// there is nothing safe to key on, so both calls no-op rather than falling back to a
|
|
60
69
|
// shared key (the cross-part bleed this scoping exists to remove).
|