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.
@@ -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 { expandCases } from "./cases.js";
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, message: "unavailable" });
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)) checks.push(check("subpart", s.name, metric, expr, SUBPART_METRICS, s));
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
- export function verify(kernel, part, { process, view, measureFn = defaultMeasure, seed } = {}) {
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
- const resolveExpect = (params) => {
185
- if (typeof expectSpec !== "function") return expectSpec;
186
- const { p, d } = resolveParams(part, params);
187
- return expectSpec(p, d) ?? {};
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
- if (seed?.result && (seed.result.measuredMinWall || !needMinWall) && seed.result.view === view) {
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 (!memo.has(key)) memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
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
- const caseResults = expanded.map(({ name, params, expect }) => ({ name, params, checks: evaluateCase(measureCase(params), { profile, expect, subPartNames }) }));
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
- ok: !all.some((c) => c.status === "fail"),
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: all.filter((c) => c.status === "fail"),
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
- const a = DIRS[view];
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).