partforge 0.158.1 → 0.160.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.
@@ -3982,7 +3982,7 @@ runtime a throwing probe only degrades quietly to `{ error }`.
3982
3982
  `verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
3983
3983
  `verify-unknown-orientation`,
3984
3984
  `verify-expect-throws` (all errors). Note `_view` also accepts the pair-wise
3985
- `contacts` / `clearance` keys, which are not scalar view metrics; they are
3985
+ `contacts` / `clearance` / `held` keys, which are not scalar view metrics; they are
3986
3986
  validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
3987
3987
 
3988
3988
  **Animations block** — static validation of each view's `animations` block,
@@ -4150,7 +4150,8 @@ carries:
4150
4150
  *bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
4151
4151
  more than one place the bbox center can fall in the empty space between regions)
4152
4152
  and the pair checks `contact` / `clearance` / `nearMiss` (the midpoint between
4153
- the pair's closest surface points). Whole-solid metrics (bbox, volume, …) have
4153
+ the pair's closest surface points) and `held` (the first floating sub-part's
4154
+ centre of mass). Whole-solid metrics (bbox, volume, …) have
4154
4155
  none.
4155
4156
 
4156
4157
  Subpart facts include `minWall` (number or `null` — null exactly when no reading
@@ -4268,7 +4269,7 @@ A part can declare how it should be checked, co-located with its schema, so
4268
4269
  ```js
4269
4270
  verify: {
4270
4271
  process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an
4271
- // inline { bed:[x,y,z], minWall, clearance, overhang } object
4272
+ // inline { base, bed:[x,y,z], minWall, overhang, fits, holeUndersize, bridge } object
4272
4273
  orientation: "print", // optional; ONLY with this is overhang checked — it says the
4273
4274
  // part is laid out for its bed (Z up, bed at the lowest Z)
4274
4275
  cases: ["defaults", "M3"], // optional; default = defaults + every preset
@@ -4276,7 +4277,8 @@ verify: {
4276
4277
  spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
4277
4278
  _view: { overlaps: 0,
4278
4279
  contacts: [["drum", "flange"]], // these pairs must touch
4279
- clearance: { "lid×body": ">=0.3" } }, // intended free fits
4280
+ clearance: { "lid×body": ">=0.3" }, // intended free fits
4281
+ held: true }, // every printed piece reaches the base
4280
4282
  },
4281
4283
  }
4282
4284
  ```
@@ -4303,7 +4305,30 @@ is why this is a warning and never a gate. Two more limits, stated: it is judged
4303
4305
  the DISPLAY pose, so a sub-part whose `assembly` pose stands it differently from how it prints (a lid that prints
4304
4306
  flat beside its base) is measured as displayed, and `exportable: false` sub-parts
4305
4307
  are skipped. Switch it off under an FDM profile with an inline
4306
- `{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
4308
+ `{ base: "fdm-pla", overhang: null }`.
4309
+
4310
+ **What the profile tells you.** Three more fields carry the numbers a mating
4311
+ feature needs. Nothing checks them; they are the one place the value is spelled,
4312
+ so `derive()` copies it with a comment naming the fit (`// slip fit: fdm-pla 0.25`).
4313
+ `fits` are diametral offsets in mm, negative is interference; `holeUndersize` is
4314
+ added to a horizontal bore's diameter because a printed arch sags inward; `bridge`
4315
+ is the longest flat span the process prints unsupported (none on resin, which
4316
+ prints on supports). They are guide defaults, not measurements of your printer:
4317
+ an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
4318
+
4319
+ | field | fdm-pla | fdm-petg | resin |
4320
+ | --- | --- | --- | --- |
4321
+ | `fits.press` — a pin that never comes out | -0.10 | -0.10 | -0.05 |
4322
+ | `fits.push` — pressed on by hand | 0.05 | 0.05 | 0.05 |
4323
+ | `fits.slip` — a lid that lifts off | 0.25 | 0.30 | 0.10 |
4324
+ | `fits.moving` — a hinge pin or axle | 0.35 | 0.40 | 0.15 |
4325
+ | `fits.printInPlace` — printed already assembled | 0.45 | 0.50 | 0.20 |
4326
+ | `holeUndersize` | 0.25 | 0.30 | 0.05 |
4327
+ | `bridge` (mm) | 10 | 10 | — |
4328
+
4329
+ `fits` are diametral; the `clearance` expectation below reads a pair's minimum surface distance, so a pin in a bore is declared at half the fit (`fits.slip` 0.25 → `clearance: { "pin×body": "0.08..0.18" }`).
4330
+
4331
+ **What `expect` gives you:** per-sub-part
4307
4332
  assertions on the facts `measure` already reports — `holes` (through-bores / genus),
4308
4333
  `volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
4309
4334
  `overhangArea`, `wall` (a range — see below), `bounds` (the axis-aligned corner
@@ -4311,7 +4336,7 @@ positions, written in the same `{ min, max }` shape `measure` reports them — w
4311
4336
  the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
4312
4337
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
4313
4338
  `volume`, `overlaps`, `centerOfMass`, `bounds`, plus the pair-wise
4314
- `contacts` / `clearance` below.
4339
+ `contacts` / `clearance` and the graph check `held` below.
4315
4340
 
4316
4341
  Passing these checks does **not** prove structural strength, fatigue life, stability,
4317
4342
  manufacturing tolerance stack-up, regulatory compliance, or safe real-world use.
@@ -4444,6 +4469,22 @@ no overlap detection there (`Solid.intersect` is Manifold-only), a sub-part
4444
4469
  *fully contained* inside another reads as its surface-to-surface distance, so it
4445
4470
  can surface as a near miss — check containment cases on Manifold.
4446
4471
 
4472
+ **Held (the contact graph).** `contacts` asserts a pair you named; it cannot say a
4473
+ sub-part touches nothing at all — a lid placed a hair above its box, a bracket
4474
+ beside the wall it should bolt to. `held: true` builds a graph over every
4475
+ exportable sub-part in the view, joining two whenever they overlap or their gap
4476
+ is within the same ~1 µm `contacts` tolerates, and fails naming each sub-part not
4477
+ connected to the base — the lowest sub-part by its bounds, or the one you name
4478
+ with `held: "body"`. The location is the first floating sub-part's centre of
4479
+ mass. A reference ghost (`exportable: false`) is left out entirely: it neither
4480
+ counts nor holds, since it is never printed (lint refuses one named as the base).
4481
+ A named base that is `enabled()`-off
4482
+ in a case skips that case; a name that exists nowhere throws, like a `contacts`
4483
+ pair. It is an expectation, never a default, for the overhang reason: a part
4484
+ still being shaped should not be nagged. Topological only — a part balanced on a
4485
+ point passes — and the same OCCT caveat applies: a sub-part fully inside another
4486
+ reads as not touching there. A view with fewer than two printed sub-parts passes with nothing to hold. A pin or axle running in a clearance bore, or a print-in-place piece, touches nothing in the model and reads as floating: `held` is for assemblies whose pieces seat on each other, so leave it off when a piece is captured with running clearance (`fits.moving`, `fits.printInPlace`) and declare that pair's `clearance` instead. A quick lap answers it `unevaluated`.
4487
+
4447
4488
  **Running it:**
4448
4489
 
4449
4490
  ```bash
@@ -198,6 +198,12 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
198
198
  - **Cause:** Two sub-parts that should meet don't quite — a boss shorter than the gap it must bridge, a mis-placed mating datum in `derive()`, or a union that silently missed. Renders and volume/bbox checks cannot see sub-mm joint gaps; this check exists precisely for them.
199
199
  - **Fix:** If the pair should touch, grow the joining feature or fix the datum math so the faces meet, then declare the pair in `verify.expect._view.contacts`; if a free fit is intended, declare it under `clearance`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
200
200
 
201
+ ## floating-subpart
202
+
203
+ - **Symptom:** A `✗ … held` failure from `verify` reporting `<name> not connected to <base>`: a sub-part that looks assembled in the preview touches nothing that reaches the base.
204
+ - **Cause:** The piece is placed by eye rather than against a mating datum — a lid a hair above its box, a bracket beside the wall it should bolt to, a pin whose bore it was never moved into — or it is held only by a reference ghost (`exportable: false`), which is never printed. On OCCT a sub-part fully inside another reads as not touching, since overlap detection is Manifold-only.
205
+ - **Fix:** Seat the part on a mating face by deriving its position from the face it sits on in `derive()`, or add the fastener, boss or tab that holds it, then keep `verify.expect._view.held` so the next edit re-checks it. If the piece is meant to run free in a clearance bore (a hinge pin, a print-in-place link), the clearance is correct and `held` is the wrong check for that assembly — remove `held` and declare the pair under `clearance`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
206
+
201
207
  ## expect-static-across-presets
202
208
 
203
209
  - **Symptom:** A `verify` exact gate (`holes`, `volume`, …) fails on SOME presets only — e.g. `✗ planter holes 1 (0 != 1)` on two cases while defaults pass — and the preview looks right for every preset.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.158.1",
3
+ "version": "0.160.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,12 +28,13 @@ export function resolveExpect(verify, p, d) {
28
28
  const isExpectation = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && "expr" in v;
29
29
  const exprOf = (v) => (isExpectation(v) ? v.expr : v);
30
30
 
31
- // `contacts` (must-touch pairs) and `clearance` (free-fit gaps) live under
32
- // `verify.expect._view` but are pair-wise checks, not scalar VIEW_METRICS — mirror
33
- // verify.js's own peel (verify.js:143) so they never hit the scalar-metric /
34
- // assertion-expression checks below. Validated for real by `verify-bad-pair-check`.
31
+ // `contacts` (must-touch pairs), `clearance` (free-fit gaps) and `held` (the
32
+ // contact graph) live under `verify.expect._view` but are pair-derived checks,
33
+ // not scalar VIEW_METRICS — mirror verify.js's own peel (evaluateCase) so they
34
+ // never hit the scalar-metric / assertion-expression checks below. Validated for
35
+ // real by `verify-bad-pair-check`.
35
36
  const peelPairKeys = (metrics) => {
36
- const { contacts, clearance, ...rest } = metrics;
37
+ const { contacts, clearance, held, ...rest } = metrics;
37
38
  return rest;
38
39
  };
39
40
 
@@ -124,6 +125,33 @@ function checkClearance(clearance, names, path) {
124
125
  return out;
125
126
  }
126
127
 
128
+ // Static analogue of held.js: `true`, or the name of the sub-part the graph is
129
+ // rooted at. Anything else throws at runtime, so it is an error here. A ghost
130
+ // (`exportable: false`) is never printed, so it cannot be the base either — the
131
+ // runtime would accept it and report a misleading skip.
132
+ function checkHeld(held, parts, path) {
133
+ if (held === undefined || held === null || held === true) return [];
134
+ const names = Object.keys(parts);
135
+ if (typeof held === "string") {
136
+ if (names.includes(held)) {
137
+ if (parts[held]?.exportable !== false) return [];
138
+ return [err("verify-bad-pair-check",
139
+ `\`${path}\` names "${held}", which is exportable: false and never printed, so it cannot be the base`,
140
+ "Root the contact graph at a printed sub-part, or `held: true` for the lowest one.",
141
+ path)];
142
+ }
143
+ const hint = safeSuggest(held, names);
144
+ return [err("verify-unknown-subpart",
145
+ `\`${path}\` references "${held}", which is not a sub-part`,
146
+ `Use \`true\` (the lowest sub-part is the base) or one of the sub-part names (${names.join(", ")})${hint ? ` — did you mean "${hint}"?` : "."}`,
147
+ path)];
148
+ }
149
+ return [err("verify-bad-pair-check",
150
+ `\`${path}\` must be true or a sub-part name, got ${describe(held)}`,
151
+ "Set `held: true` to root the contact graph at the lowest sub-part, or `held: \"<name>\"` to root it at that sub-part.",
152
+ path)];
153
+ }
154
+
127
155
  // `clearance`'s values genuinely are assertion expressions run through the DSL
128
156
  // (verify.js:96), unlike `contacts` (pair arrays with nothing to parse). Mirror
129
157
  // verify.js's own normalization — a bare value or an `{ expr, hint }` wrapper,
@@ -337,6 +365,7 @@ export const VERIFY_RULES = [
337
365
  return [
338
366
  ...checkContacts(view.contacts, names, "verify.expect._view.contacts"),
339
367
  ...checkClearance(view.clearance, names, "verify.expect._view.clearance"),
368
+ ...checkHeld(view.held, part?.parts ?? {}, "verify.expect._view.held"),
340
369
  ];
341
370
  },
342
371
  },
@@ -23,6 +23,13 @@
23
23
  // it, so a parked preview is a paused one. `handle.animation` is null once
24
24
  // disposed.
25
25
  //
26
+ // A playing animation also gets a play/pause/replay button in #viewbar,
27
+ // directly before the view style button (preview-play-button.js): Pause while
28
+ // it plays (or tweens to its intro view), Play while paused, Replay once a
29
+ // one-time animation has finished. No button under reduced motion, for a still
30
+ // file, with `animate: false`, or for a host with no #viewbar; the host can
31
+ // still drive `handle.animation` (state / pause / resume / replay) itself.
32
+ //
26
33
  // The decoder loads on demand (it carries the
27
34
  // GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
28
35
  // Hence `ready`. Every handle method is safe before it resolves.
@@ -36,6 +43,7 @@ import { createTooltipPresenter } from "./tooltip.js";
36
43
  import { isFaceAlignedState } from "./projection.js";
37
44
  import { ENVIRONMENTS } from "./materials/environments.js";
38
45
  import { createPreviewPlayer } from "./preview-player.js";
46
+ import { attachPreviewPlayButton } from "./preview-play-button.js";
39
47
 
40
48
  function previewPart({ meshes, view, meta }) {
41
49
  return {
@@ -112,6 +120,10 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
112
120
  const own = player;
113
121
  cleanups.push(() => own.dispose());
114
122
  player.start();
123
+ if (!reducedMotion && toolbar) {
124
+ const playButton = attachPreviewPlayButton(own, { toolbar, before: viewStyle.element }, { tooltip });
125
+ cleanups.push(() => playButton.detach());
126
+ }
115
127
  }
116
128
  });
117
129
  ready.catch(() => {}); // the host awaits it; never an unhandled rejection
@@ -151,7 +163,7 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
151
163
  getView: () => live?.view ?? null,
152
164
  // null before ready, for a still file, with animate: false, and once disposed.
153
165
  get animation() {
154
- return player ? { state: player.state, pause: player.pause, resume: player.resume } : null;
166
+ return player ? { state: player.state, pause: player.pause, resume: player.resume, replay: player.replay } : null;
155
167
  },
156
168
  getViewerState,
157
169
  setViewerState(next) {
@@ -5,10 +5,34 @@
5
5
  // (a warn, armed only by the part — see overhangAngleFor; resin prints on
6
6
  // supports, so it carries none); `clearance` mm is carried for a future gap check
7
7
  // (not enforced yet).
8
+ //
9
+ // `fits`, `holeUndersize` and `bridge` are the numbers an author (or the cloud
10
+ // agent) copies into derive() for a mating feature; nothing in this repo reads
11
+ // them yet. They are guide defaults from the FDM design guides, not measurements:
12
+ // a calibrated printer overrides them through an inline `{ base, fits: {...} }`
13
+ // profile, which REPLACES the fits table whole. `fits` are diametral offsets in
14
+ // mm (negative = interference): press (a pin that never comes out), push (a cap
15
+ // pressed on by hand), slip (a lid that lifts off), moving (a hinge pin or axle),
16
+ // printInPlace (parts printed already assembled). `holeUndersize` is added to a
17
+ // horizontal bore's diameter, since the top of a printed arch sags inward.
18
+ // `bridge` is the longest flat span the process prints unsupported; resin prints
19
+ // on supports, so it carries none.
8
20
  export const PROFILES = {
9
- "fdm-pla": { bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45 },
10
- "fdm-petg": { bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45 },
11
- "resin": { bed: [120, 68, 160], minWall: 0.6, clearance: 0.1 },
21
+ "fdm-pla": {
22
+ bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45,
23
+ fits: { press: -0.1, push: 0.05, slip: 0.25, moving: 0.35, printInPlace: 0.45 },
24
+ holeUndersize: 0.25, bridge: 10,
25
+ },
26
+ "fdm-petg": {
27
+ bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45,
28
+ fits: { press: -0.1, push: 0.05, slip: 0.3, moving: 0.4, printInPlace: 0.5 },
29
+ holeUndersize: 0.3, bridge: 10,
30
+ },
31
+ "resin": {
32
+ bed: [120, 68, 160], minWall: 0.6, clearance: 0.1,
33
+ fits: { press: -0.05, push: 0.05, slip: 0.1, moving: 0.15, printInPlace: 0.2 },
34
+ holeUndersize: 0.05,
35
+ },
12
36
  };
13
37
 
14
38
  export function resolveProfile(spec) {
@@ -0,0 +1,79 @@
1
+ // `_view.held`: every exportable sub-part in the view must be connected, through
2
+ // contact or overlap, to a base. A pair-derived check like `contacts` — it reads
3
+ // the gap table and overlap list measure already computed and never touches a
4
+ // mesh — so it lives beside pairGapChecks, outside the scalar VIEW_METRICS
5
+ // registry, and emits the same structured check object.
6
+ //
7
+ // Topological only: a part balanced on a point passes. Load path and strength
8
+ // are not this check's business (AUTHORING-PARTS.md, "Self-verification").
9
+ import { CONTACT_EPS } from "./gaps.js";
10
+
11
+ export const HELD_HINT = "sub-part touches nothing — seat it on a mating face or add the fastener that holds it";
12
+
13
+ // `held` is `true` (base = the lowest sub-part by bounds.min z) or a sub-part
14
+ // name. `subPartNames` is the part's whole vocabulary (so a base that is
15
+ // enabled()-off in this case skips rather than throws); `exportableNames` bounds
16
+ // the graph to printed sub-parts — a reference ghost neither counts nor holds.
17
+ export function heldCheck(facts, held, { subPartNames, exportableNames } = {}) {
18
+ if (held === undefined || held === null) return null;
19
+ if (held !== true && typeof held !== "string") {
20
+ throw new Error(`held: must be true or a sub-part name, got ${JSON.stringify(held)}`);
21
+ }
22
+ const base = {
23
+ scope: "view", subpart: null, metric: "held", kind: "gate",
24
+ expr: held === true ? "connected" : `connected to ${held}`,
25
+ };
26
+ const inView = facts.subparts.map((s) => s.name);
27
+ const known = subPartNames ? new Set(subPartNames) : new Set(inView);
28
+ if (typeof held === "string" && !known.has(held)) {
29
+ throw new Error(`held: unknown sub-part "${held}" (view has: ${inView.join(", ")})`);
30
+ }
31
+ const printed = exportableNames ? new Set(exportableNames) : null;
32
+ const nodes = inView.filter((n) => !printed || printed.has(n));
33
+ if (typeof held === "string" && !nodes.includes(held)) {
34
+ return { ...base, actual: null, status: "skip", pass: null, message: "sub-part disabled in this case" };
35
+ }
36
+ if (nodes.length < 2) {
37
+ return { ...base, actual: 0, status: "pass", pass: true, message: `${nodes.length} sub-part, nothing to hold` };
38
+ }
39
+ // Same rule as pairGapChecks' noReading: a deliberately skipped pass (quick lap)
40
+ // is unevaluated; a legacy result with no table stays an untagged skip.
41
+ if (!facts.gaps) {
42
+ return facts.measuredGaps === false
43
+ ? { ...base, actual: null, status: "skip", pass: null, unevaluated: true,
44
+ message: "not measured (quick check)", hint: "re-run this check without `quick` to measure pair distances" }
45
+ : { ...base, actual: null, status: "skip", pass: null, message: "unavailable" };
46
+ }
47
+ const adj = new Map(nodes.map((n) => [n, new Set()]));
48
+ const join = (a, b) => {
49
+ if (!adj.has(a) || !adj.has(b)) return;
50
+ adj.get(a).add(b);
51
+ adj.get(b).add(a);
52
+ };
53
+ for (const o of facts.overlaps ?? []) join(o.a, o.b);
54
+ for (const g of facts.gaps) if (g.distance <= CONTACT_EPS) join(g.a, g.b);
55
+ const rowOf = (n) => facts.subparts.find((s) => s.name === n);
56
+ const zOf = (n) => rowOf(n)?.bounds?.min?.[2];
57
+ const root = held === true
58
+ ? nodes.reduce((best, n) => (zOf(n) != null && (zOf(best) == null || zOf(n) < zOf(best)) ? n : best), nodes[0])
59
+ : held;
60
+ const seen = new Set([root]);
61
+ const queue = [root];
62
+ while (queue.length) {
63
+ const n = queue.shift();
64
+ for (const m of adj.get(n)) if (!seen.has(m)) { seen.add(m); queue.push(m); }
65
+ }
66
+ const floating = nodes.filter((n) => !seen.has(n));
67
+ if (floating.length === 0) {
68
+ return { ...base, actual: 0, status: "pass", pass: true, message: `${nodes.length} sub-parts connected to ${root}` };
69
+ }
70
+ const first = rowOf(floating[0]);
71
+ const location = first?.centerOfMass
72
+ ?? (first?.bounds ? first.bounds.min.map((v, i) => (v + first.bounds.max[i]) / 2) : null);
73
+ return {
74
+ ...base, actual: floating.length, status: "fail", pass: false,
75
+ message: `${floating.join(", ")} not connected to ${root}`,
76
+ hint: HELD_HINT, pattern: "floating-subpart",
77
+ ...(location ? { location } : {}),
78
+ };
79
+ }
@@ -1,6 +1,7 @@
1
1
  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
+ import { heldCheck } from "./held.js";
4
5
  import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
5
6
  import { expandExpectations, partGatesMinWall } from "./gates.js";
6
7
  import { relevanceHash } from "../param-deps.js";
@@ -270,11 +271,11 @@ function sheetRowChecks(s, declaredExp) {
270
271
  // `overhang` is the angle the part opted into (dfm-profiles.js overhangAngleFor),
271
272
  // or null/undefined: only then does the profile's overhang rule apply, as a
272
273
  // 1 mm² warning floor — a chamfer sitting exactly on the angle sheds slivers.
273
- export function evaluateCase(facts, { profile, expect, subPartNames, overhang = null }) {
274
+ export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null }) {
274
275
  const checks = [];
275
- // contacts/clearance are per-pair, not scalar view metrics — peel them off
276
- // before the registry loop and hand them to pairGapChecks.
277
- const { contacts, clearance, ...viewScalarExp } = expect?._view ?? {};
276
+ // contacts/clearance/held are pair-derived, not scalar view metrics — peel
277
+ // them off before the registry loop. held.js explains the exportable bound.
278
+ const { contacts, clearance, held, ...viewScalarExp } = expect?._view ?? {};
278
279
  // No sheet part in the view: every line below runs exactly as it did before sheet
279
280
  // parts existed (test/verify-golden.test.js). With one, the bed moves from the view
280
281
  // to each printed sub-part — see the sheet-parts block above.
@@ -285,6 +286,8 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
285
286
  };
286
287
  for (const [metric, expr] of Object.entries(viewExp)) checks.push(...checksFor("view", null, metric, expr, VIEW_METRICS, facts));
287
288
  checks.push(...pairGapChecks(facts, { contacts, clearance }, subPartNames));
289
+ const heldResult = heldCheck(facts, held, { subPartNames, exportableNames });
290
+ if (heldResult) checks.push(heldResult);
288
291
 
289
292
  // Same rule as gapsSkipped above, one metric over: a min-wall gate with no reading
290
293
  // is a warn ("min wall unavailable"), which does not move `ok` — right when the
@@ -437,6 +440,9 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
437
440
  };
438
441
 
439
442
  const subPartNames = Object.keys(part.parts);
443
+ // A reference ghost (`exportable: false`) is never printed, so it neither counts
444
+ // as held nor holds anything — the same exclusion measure applies to overhang.
445
+ const exportableNames = subPartNames.filter((n) => part.parts[n]?.exportable !== false);
440
446
  // An unmeasured case gets ONE check standing for the whole case rather than a
441
447
  // silent absence: "this preset was not checked" has to be visible in the same
442
448
  // list every other verdict lives in, or a reader counting passes sees a shorter
@@ -449,7 +455,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
449
455
  }];
450
456
  const caseResults = expanded.map(({ name, params, expect }) => {
451
457
  const facts = measureCase(params);
452
- return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, overhang: overhangAngle }) : notMeasured(name) };
458
+ return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle }) : notMeasured(name) };
453
459
  });
454
460
  // VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
455
461
  // `expect` — used to come back `ok: true` with zero checks, and every reader
@@ -0,0 +1,76 @@
1
+ // The preview's play/pause/replay button (mount-preview.js): lets a visitor stop
2
+ // a cached preview's baked autoplay animation while they read the page. A plain
3
+ // `#viewbar button`, inserted directly before the view style button (#view-style,
4
+ // after its divider), so it inherits the toolbar's sizing and hover styles.
5
+ //
6
+ // Three states, read off the player's onChange notifications (never polled):
7
+ // - Pause while playing or during the intro camera tween;
8
+ // - Play while paused — resumes from the pose it froze at;
9
+ // - Replay once a one-time animation has finished at its end pose — plays it
10
+ // again from the start, camera cues included. A looping animation
11
+ // never gets here.
12
+ // The label (aria-label, and the tooltip, which reads it each time it shows)
13
+ // names what a click does and so carries the state; there is deliberately no
14
+ // aria-pressed (WAI-ARIA: a toggle whose label changes must not also report a
15
+ // pressed state). A label change hides a tooltip that is showing, so it never
16
+ // reads the old action; it comes back with the new text on the next hover.
17
+ //
18
+ // Made only for a player that plays: mount-preview never creates it under
19
+ // reduced motion (the player never starts), for a still file, with
20
+ // animate: false, or for a host with no #viewbar. Nothing is remembered: a
21
+ // remount starts fresh.
22
+ import { attachButtonTooltips } from "./tooltip.js";
23
+
24
+ const svg = (body) => `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
25
+ // lucide "pause", "play" and "rotate-ccw".
26
+ const STATES = {
27
+ pause: { label: "Pause animation", icon: svg(`<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>`) },
28
+ play: { label: "Play animation", icon: svg(`<polygon points="6 3 20 12 6 21 6 3"/>`) },
29
+ replay: { label: "Replay animation", icon: svg(`<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>`) },
30
+ };
31
+
32
+ // What a click does for a given player status.
33
+ const actionFor = (status) => (status === "paused" ? "play" : status === "done" ? "replay" : "pause");
34
+
35
+ export function attachPreviewPlayButton(player, { toolbar, before = null }, { tooltip = null } = {}) {
36
+ const button = document.createElement("button");
37
+ button.type = "button";
38
+ button.id = "pf-preview-play";
39
+ button.className = "pf-preview-play-button";
40
+ let action = null;
41
+ let tooltipBinding = null;
42
+
43
+ function render(status) {
44
+ const next = actionFor(status);
45
+ if (next === action) return;
46
+ action = next;
47
+ const { label, icon } = STATES[next];
48
+ button.innerHTML = icon;
49
+ button.setAttribute("aria-label", label);
50
+ if (!tooltip) button.title = label;
51
+ tooltipBinding?.hide(); // a showing tooltip still names the old action
52
+ }
53
+ render(player.state().status);
54
+
55
+ const anchor = before && before.parentElement === toolbar ? before : [...toolbar.children].find((c) => c.id === "theme") ?? null;
56
+ toolbar.insertBefore(button, anchor);
57
+ tooltipBinding = tooltip ? attachButtonTooltips(tooltip, [{ element: button }]) : null;
58
+
59
+ const onClick = () => {
60
+ if (action === "pause") player.pause();
61
+ else if (action === "play") player.resume();
62
+ else player.replay();
63
+ };
64
+ button.addEventListener("click", onClick);
65
+ const off = player.onChange((s) => render(s.status));
66
+
67
+ return {
68
+ element: button,
69
+ detach() {
70
+ off();
71
+ button.removeEventListener("click", onClick);
72
+ tooltipBinding?.detach();
73
+ button.remove();
74
+ },
75
+ };
76
+ }
@@ -18,6 +18,14 @@
18
18
  // opacity, no ground hold, no cue — so the visitor sees exactly what the
19
19
  // file draws without a player (the opening pose, every sub-part opaque; an
20
20
  // opacity fade-in must not leave a sub-part hidden for good).
21
+ //
22
+ // A host can pause, resume and — once a one-time animation has finished —
23
+ // replay it (the preview's play/pause button, preview-play-button.js). Pause
24
+ // stops any camera move where it is (viewer.cancelCameraTween, as the live
25
+ // driver does): a cancelled tween never fires its onComplete, and createPlayback
26
+ // re-honors an abandoned intro or sweep on the next play(). An orbit while
27
+ // paused stays paused. onChange(cb) reports every status change, so a control
28
+ // never polls.
21
29
  import { createPlayback } from "./animation.js";
22
30
  import { invertRigid, mulMat4 } from "./geometry/pose.js";
23
31
 
@@ -99,6 +107,21 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
99
107
  const restInverse = Object.fromEntries(Object.keys(tracks).map((name) => [name, invertRigid(rest[name])]));
100
108
 
101
109
  let offFrame = null, offOrbit = null, started = false, disposed = false;
110
+ const listeners = new Set();
111
+ let lastStatus = playback.state().status;
112
+
113
+ function snapshotState() {
114
+ const { status, t } = playback.state();
115
+ return { status, t };
116
+ }
117
+ function notify() {
118
+ const now = snapshotState();
119
+ if (now.status === lastStatus) return;
120
+ lastStatus = now.status;
121
+ for (const cb of [...listeners]) {
122
+ try { cb(now); } catch (error) { console.warn("partforge: preview animation listener failed", error); }
123
+ }
124
+ }
102
125
 
103
126
  function apply(t) {
104
127
  const seconds = t * duration;
@@ -111,7 +134,7 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
111
134
  if (snap.status !== "intro" || !snap.cue) return;
112
135
  viewer.tweenCameraTo(snap.cue.view, {
113
136
  duration: INTRO_TWEEN_SECONDS,
114
- onComplete: () => { if (!disposed) playback.introDone(); },
137
+ onComplete: () => { if (!disposed) { playback.introDone(); notify(); } },
115
138
  });
116
139
  }
117
140
 
@@ -120,11 +143,12 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
120
143
  if (!snap) return; // not playing: intro, paused, or done — the last poses stand
121
144
  apply(snap.t);
122
145
  if (snap.cue) viewer.tweenCameraTo(snap.cue.view, { duration: snap.cue.sweep ?? INTRO_TWEEN_SECONDS });
146
+ notify();
123
147
  }
124
148
 
125
149
  function onOrbit() {
126
150
  playback.disarmCues();
127
- if (playback.state().status === "intro") playback.introDone();
151
+ if (playback.state().status === "intro") { playback.introDone(); notify(); }
128
152
  }
129
153
 
130
154
  return {
@@ -137,25 +161,44 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
137
161
  offFrame = viewer.onFrame(onFrame);
138
162
  offOrbit = viewer.onCameraStart(onOrbit);
139
163
  honorIntro(playback.play());
164
+ notify();
140
165
  },
141
166
  pause() {
142
- if (!disposed) playback.pause();
167
+ if (disposed || !offFrame) return;
168
+ const { status } = playback.state();
169
+ if (status !== "playing" && status !== "intro") return;
170
+ viewer.cancelCameraTween(); // an intro tween or a cue's sweep stops where it is
171
+ playback.pause();
172
+ notify();
143
173
  },
144
174
  resume() {
145
175
  if (disposed || playback.state().status !== "paused") return;
146
176
  honorIntro(playback.play());
177
+ notify();
178
+ },
179
+ // From `done` only (a one-time animation resting at its end pose): play it
180
+ // again exactly as on arrival — t = 0 pose and opacity first, then the
181
+ // re-armed intro cue (createPlayback's play() from done resets t and cues).
182
+ replay() {
183
+ if (disposed || playback.state().status !== "done") return;
184
+ apply(0);
185
+ honorIntro(playback.play());
186
+ notify();
187
+ },
188
+ onChange(cb) {
189
+ if (disposed) return () => {};
190
+ listeners.add(cb);
191
+ return () => listeners.delete(cb);
147
192
  },
148
193
  dispose() {
149
194
  if (disposed) return;
150
195
  disposed = true;
196
+ listeners.clear();
151
197
  if (!offFrame) return; // never played (not started, or reduced motion): nothing to undo
152
198
  offFrame(); offOrbit();
153
199
  offFrame = offOrbit = null;
154
200
  viewer.holdGround(false);
155
201
  },
156
- state() {
157
- const { status, t } = playback.state();
158
- return { status, t };
159
- },
202
+ state: snapshotState,
160
203
  };
161
204
  }
package/types/index.d.ts CHANGED
@@ -809,6 +809,8 @@ export interface PreviewRuntime {
809
809
  state(): { status: "idle" | "intro" | "playing" | "paused" | "done"; t: number };
810
810
  pause(): void;
811
811
  resume(): void;
812
+ /** Play a finished one-time animation again from the start (its camera cues included); a no-op unless `state().status` is `"done"`. */
813
+ replay(): void;
812
814
  } | null;
813
815
  renderMode: {
814
816
  get(): RenderMode;
package/types/part.d.ts CHANGED
@@ -491,6 +491,17 @@ export interface DfmProfile {
491
491
  overhang?: number | null;
492
492
  /** Carried for a future gap check; not enforced yet. */
493
493
  clearance?: number;
494
+ /**
495
+ * Diametral fit offsets in mm, negative = interference: `press` (never comes
496
+ * out), `push` (pressed on by hand), `slip` (lifts off), `moving` (a pin or
497
+ * axle), `printInPlace` (printed assembled). Guide defaults; an inline profile
498
+ * replaces the whole table.
499
+ */
500
+ fits?: { press: number; push: number; slip: number; moving: number; printInPlace: number };
501
+ /** Added to a horizontal bore's diameter — a printed arch sags inward. */
502
+ holeUndersize?: number;
503
+ /** Longest flat span printed unsupported, mm. Absent on a process that prints on supports. */
504
+ bridge?: number;
494
505
  /** Inherit from a named profile and override the rest. */
495
506
  base?: DfmProfileName | DfmProfile;
496
507
  }
@@ -600,6 +611,11 @@ export interface ViewExpectations {
600
611
  contacts?: Array<[string, string]>;
601
612
  /** Intended free fits, keyed `"a×b"` (order-insensitive). */
602
613
  clearance?: Record<string, Expectation>;
614
+ /**
615
+ * Every exportable sub-part must be connected, through contact or overlap, to
616
+ * the base: the lowest sub-part (`true`) or the named one. Topological only.
617
+ */
618
+ held?: true | string;
603
619
  }
604
620
 
605
621
  /** `verify.expect`: sub-part names plus the reserved `_view` key. */