partforge 0.156.0 → 0.158.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.
@@ -0,0 +1,161 @@
1
+ // The preview player: plays the autoplay animation a preview GLB carries
2
+ // (decoded by gltf/decode-preview.js) off the viewer's own frame loop, with no
3
+ // part code running. The live driver (animation-controls.js) moves params and
4
+ // rebuilds; here every animated sub-part's motion is already baked as
5
+ // model-frame keyframes, so a frame is a pose per sub-part and nothing more.
6
+ //
7
+ // Timing, cues and looping are not reimplemented: createPlayback (animation.js)
8
+ // runs on a synthetic normalized animation with no tracked params and no
9
+ // opacity keys, so its snapshot contributes only status, t and the cue to fire.
10
+ // The player turns that t into poses and opacities itself.
11
+ //
12
+ // Three rules (spec §3):
13
+ // - A looping animation wraps; a once-through one rests at its end pose and
14
+ // the player does nothing further each frame.
15
+ // - A user orbit disarms the remaining camera cues and never pauses the
16
+ // motion (mid-intro it releases the intro gate, so playback still starts).
17
+ // - Reduced motion means no player: start() writes nothing — no pose, no
18
+ // opacity, no ground hold, no cue — so the visitor sees exactly what the
19
+ // file draws without a player (the opening pose, every sub-part opaque; an
20
+ // opacity fade-in must not leave a sub-part hidden for good).
21
+ import { createPlayback } from "./animation.js";
22
+ import { invertRigid, mulMat4 } from "./geometry/pose.js";
23
+
24
+ // A frame after a stall (a hidden tab, a long GC) advances at most this many
25
+ // seconds, so the motion resumes where it was rather than jumping.
26
+ export const MAX_FRAME_DT = 0.1;
27
+ const INTRO_TWEEN_SECONDS = 0.6;
28
+
29
+ // Spherical interpolation between two unit quaternions [x, y, z, w].
30
+ export function slerp(a, b, u) {
31
+ let [bx, by, bz, bw] = b;
32
+ let cos = a[0] * bx + a[1] * by + a[2] * bz + a[3] * bw;
33
+ if (cos < 0) { cos = -cos; bx = -bx; by = -by; bz = -bz; bw = -bw; } // shortest arc
34
+ let wa = 1 - u, wb = u;
35
+ if (cos < 0.9995) {
36
+ const theta = Math.acos(cos), s = Math.sin(theta);
37
+ wa = Math.sin((1 - u) * theta) / s;
38
+ wb = Math.sin(u * theta) / s;
39
+ }
40
+ const q = [wa * a[0] + wb * bx, wa * a[1] + wb * by, wa * a[2] + wb * bz, wa * a[3] + wb * bw];
41
+ const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
42
+ return q.map((c) => c / len);
43
+ }
44
+
45
+ // Column-major mat16 for rotation q followed by translation p.
46
+ export function quatToMat4([x, y, z, w], [px, py, pz]) {
47
+ const xx = x * x, yy = y * y, zz = z * z, xy = x * y, xz = x * z, yz = y * z, wx = w * x, wy = w * y, wz = w * z;
48
+ return [
49
+ 1 - 2 * (yy + zz), 2 * (xy + wz), 2 * (xz - wy), 0,
50
+ 2 * (xy - wz), 1 - 2 * (xx + zz), 2 * (yz + wx), 0,
51
+ 2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (xx + yy), 0,
52
+ px, py, pz, 1,
53
+ ];
54
+ }
55
+
56
+ // The keyframed transform at `seconds`: translation lerped, rotation slerped
57
+ // between the bracketing keyframes, held before the first and after the last.
58
+ export function sampleTrack({ times, translations, rotations }, seconds) {
59
+ const n = times.length;
60
+ const tr = (i) => [translations[i * 3], translations[i * 3 + 1], translations[i * 3 + 2]];
61
+ const rot = (i) => [rotations[i * 4], rotations[i * 4 + 1], rotations[i * 4 + 2], rotations[i * 4 + 3]];
62
+ if (n === 1 || seconds <= times[0]) return quatToMat4(rot(0), tr(0));
63
+ if (seconds >= times[n - 1]) return quatToMat4(rot(n - 1), tr(n - 1));
64
+ let i = 0;
65
+ while (i < n - 2 && seconds >= times[i + 1]) i++;
66
+ const u = (seconds - times[i]) / (times[i + 1] - times[i] || 1);
67
+ const a = tr(i), b = tr(i + 1);
68
+ return quatToMat4(slerp(rot(i), rot(i + 1), u), [0, 1, 2].map((k) => a[k] + (b[k] - a[k]) * u));
69
+ }
70
+
71
+ // Linear opacity over [[t, v], …] keyframes in normalized t, clamped at the ends.
72
+ export function sampleOpacity(keys, t) {
73
+ if (t <= keys[0][0]) return keys[0][1];
74
+ for (let i = 1; i < keys.length; i++) {
75
+ const [t1, v1] = keys[i];
76
+ if (t <= t1) {
77
+ const [t0, v0] = keys[i - 1];
78
+ return v0 + (v1 - v0) * ((t - t0) / (t1 - t0 || 1));
79
+ }
80
+ }
81
+ return keys[keys.length - 1][1];
82
+ }
83
+
84
+ export function createPreviewPlayer(viewer, animation, { reducedMotion = false } = {}) {
85
+ const { duration, tracks, rest, opacity = {} } = animation;
86
+ const steps = animation.steps ?? [];
87
+ const playback = createPlayback({
88
+ name: animation.name,
89
+ loop: animation.loop,
90
+ totalDuration: duration,
91
+ cues: animation.cues ?? [],
92
+ steps: steps.map((s) => ({ label: s.label, duration: s.duration, easing: "linear", tracks: {}, opacity: {}, camera: null })),
93
+ stepStarts: steps.map((s) => s.start),
94
+ trackedKeys: [],
95
+ opacityKeys: [],
96
+ });
97
+ // Pose = keyframed transform with the rest transform taken out: the mesh
98
+ // already sits at its t = 0 placement, so the identity pose is the rest pose.
99
+ const restInverse = Object.fromEntries(Object.keys(tracks).map((name) => [name, invertRigid(rest[name])]));
100
+
101
+ let offFrame = null, offOrbit = null, started = false, disposed = false;
102
+
103
+ function apply(t) {
104
+ const seconds = t * duration;
105
+ for (const [name, track] of Object.entries(tracks)) viewer.setSubPose(name, mulMat4(sampleTrack(track, seconds), restInverse[name]));
106
+ for (const [name, keys] of Object.entries(opacity)) if (keys?.length) viewer.setSubPartOpacity(name, sampleOpacity(keys, t));
107
+ }
108
+
109
+ // A snapshot from play(): an intro cue tweens and gates playback until it lands.
110
+ function honorIntro(snap) {
111
+ if (snap.status !== "intro" || !snap.cue) return;
112
+ viewer.tweenCameraTo(snap.cue.view, {
113
+ duration: INTRO_TWEEN_SECONDS,
114
+ onComplete: () => { if (!disposed) playback.introDone(); },
115
+ });
116
+ }
117
+
118
+ function onFrame(dt) {
119
+ const snap = playback.tick(Math.min(dt, MAX_FRAME_DT));
120
+ if (!snap) return; // not playing: intro, paused, or done — the last poses stand
121
+ apply(snap.t);
122
+ if (snap.cue) viewer.tweenCameraTo(snap.cue.view, { duration: snap.cue.sweep ?? INTRO_TWEEN_SECONDS });
123
+ }
124
+
125
+ function onOrbit() {
126
+ playback.disarmCues();
127
+ if (playback.state().status === "intro") playback.introDone();
128
+ }
129
+
130
+ return {
131
+ start() {
132
+ if (started || disposed) return;
133
+ started = true;
134
+ if (reducedMotion) return;
135
+ apply(0);
136
+ viewer.holdGround(true);
137
+ offFrame = viewer.onFrame(onFrame);
138
+ offOrbit = viewer.onCameraStart(onOrbit);
139
+ honorIntro(playback.play());
140
+ },
141
+ pause() {
142
+ if (!disposed) playback.pause();
143
+ },
144
+ resume() {
145
+ if (disposed || playback.state().status !== "paused") return;
146
+ honorIntro(playback.play());
147
+ },
148
+ dispose() {
149
+ if (disposed) return;
150
+ disposed = true;
151
+ if (!offFrame) return; // never played (not started, or reduced motion): nothing to undo
152
+ offFrame(); offOrbit();
153
+ offFrame = offOrbit = null;
154
+ viewer.holdGround(false);
155
+ },
156
+ state() {
157
+ const { status, t } = playback.state();
158
+ return { status, t };
159
+ },
160
+ };
161
+ }
@@ -1,4 +1,5 @@
1
- // CNC routing — process module #2. Flat stock machined from ONE face: through cuts
1
+ // CNC routing — process module #2. Flat stock machined from either face — the near
2
+ // face (z = t, the default) or the far face (z = 0, per op `face: "far"`): through cuts
2
3
  // (profile), flat-floored pockets at a depth, drilled holes at a depth, and `extra`
3
4
  // operations the operator does with another tool after the flat milling. The
4
5
  // drawing is one layered DXF per piece, depth on the layer name and colour.
@@ -12,8 +13,9 @@ import {
12
13
  } from "../../sheet/constants.js";
13
14
  import { err } from "../../lint/finding.js";
14
15
 
15
- // The preview: the profile extruded to the thickness, each pocket cut `depth` down from
16
- // the machined face (z = t), each drill as a cylinder from the face to its depth —
16
+ // The preview: the profile extruded to the thickness, each pocket cut `depth` into the
17
+ // stock from its face (down from z = t on the near face, up from z = 0 on the far
18
+ // face), each drill as a cylinder from its face to its depth —
17
19
  // through holes and through pockets (depth = thickness) clear BOTH faces by MARK_OVERCUT so no cap is coplanar with a face
18
20
  // (OCCT's coplanar booleans are fragile) — each extra solid subtracted as given, and
19
21
  // the reference grooves cut like a laser score. A tool whose region turned out empty
@@ -29,10 +31,14 @@ function routedPreview(k, s) {
29
31
  const tools = [
30
32
  ...pockets.map((pk) => orEmpty(() => (pk.depth >= t
31
33
  ? pk.region.extrude({ h: t + 2 * MARK_OVERCUT }).translate([0, 0, -MARK_OVERCUT])
32
- : pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, t - pk.depth])))),
34
+ : pk.face === "far"
35
+ ? pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, -MARK_OVERCUT])
36
+ : pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, t - pk.depth])))),
33
37
  ...drills.map((dr) => (dr.through
34
38
  ? k.cylinder({ d: dr.d, h: t + 2 * MARK_OVERCUT }).translate([dr.at[0], dr.at[1], -MARK_OVERCUT])
35
- : k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], t - dr.depth]))),
39
+ : dr.face === "far"
40
+ ? k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], -MARK_OVERCUT])
41
+ : k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], t - dr.depth]))),
36
42
  ...extra.map((x) => x.solid),
37
43
  ...s.grooves.map((g) => orEmpty(() => g.extrude({ h: MARK_DEPTH + MARK_OVERCUT }).translate([0, 0, t - MARK_DEPTH]))),
38
44
  ].filter(Boolean);
@@ -74,21 +80,21 @@ function routedFacts(s) {
74
80
  for (const pk of pockets) {
75
81
  if (pk.region.isEmpty()) { empty++; continue; }
76
82
  const [w, h] = sizeOf(pk.region);
77
- pocketRows.push({ label: pk.label, depth: pk.depth, w: round2(w), h: round2(h), area: round2(pk.region.area()) });
83
+ pocketRows.push({ label: pk.label, depth: pk.depth, w: round2(w), h: round2(h), area: round2(pk.region.area()), face: pk.face });
78
84
  noteOutside(pk.region);
79
85
  }
80
- // Drills grouped by (d, depth, through): one row per tool setting, labels joined.
86
+ // Drills grouped by (d, depth, through, face): one row per tool setting, labels joined.
81
87
  const drillGroups = new Map();
82
88
  for (const dr of drills) {
83
- const key = `${dr.d}|${dr.depth}|${dr.through}`;
84
- const g = drillGroups.get(key) ?? { labels: [], d: dr.d, depth: dr.depth, through: dr.through, count: 0 };
89
+ const key = `${dr.d}|${dr.depth}|${dr.through}|${dr.face}`;
90
+ const g = drillGroups.get(key) ?? { labels: [], d: dr.d, depth: dr.depth, through: dr.through, face: dr.face, count: 0 };
85
91
  g.count++;
86
92
  if (dr.label && !g.labels.includes(dr.label)) g.labels.push(dr.label);
87
93
  drillGroups.set(key, g);
88
94
  noteOutside(dr.shape);
89
95
  }
90
- const drillRows = [...drillGroups.values()].map(({ labels, d, depth, through, count }) =>
91
- ({ label: labels.length ? labels.join(", ") : null, d, depth, through, count }));
96
+ const drillRows = [...drillGroups.values()].map(({ labels, d, depth, through, count, face }) =>
97
+ ({ label: labels.length ? labels.join(", ") : null, d, depth, through, count, face }));
92
98
  // An extra op whose footprint resolved to nothing draws nothing: counted under
93
99
  // `empty` with the empty pockets, never a row (its size has no bounding box).
94
100
  const extraRows = [];
@@ -96,12 +102,25 @@ function routedFacts(s) {
96
102
  if (x.footprint.isEmpty()) { empty++; continue; }
97
103
  const [w, h] = sizeOf(x.footprint);
98
104
  noteOutside(x.footprint);
99
- // How deep the op reaches below the machined face (z = t), within the stock. An
100
- // empty solid has no bounding box and reaches nothing.
105
+ // How deep the op reaches into the stock from its face — below z = t on the near
106
+ // face, above z = 0 on the far face — within the stock. An empty solid has no
107
+ // bounding box and reaches nothing.
101
108
  const bb = x.solid.boundingBox();
102
- const bottom = bb?.min?.[2];
103
- const reach = Number.isFinite(bottom) ? t - bottom : 0;
104
- extraRows.push({ label: x.label, method: x.method, w: round2(w), h: round2(h), maxDepth: round2(Math.max(0, Math.min(t, reach))) });
109
+ const bound = x.face === "far" ? bb?.max?.[2] : bb?.min?.[2];
110
+ const reach = Number.isFinite(bound) ? (x.face === "far" ? bound : t - bound) : 0;
111
+ extraRows.push({ label: x.label, method: x.method, w: round2(w), h: round2(h), maxDepth: round2(Math.max(0, Math.min(t, reach))), face: x.face });
112
+ }
113
+
114
+ // Breakthrough: a near pocket and a far pocket whose regions overlap and whose depths
115
+ // together exceed the stock — the router breaks through between them. Depths adding
116
+ // up to exactly the thickness are a deliberate opening and never counted.
117
+ const live = pockets.filter((pk) => !pk.region.isEmpty());
118
+ const near = live.filter((pk) => pk.face !== "far"), far = live.filter((pk) => pk.face === "far");
119
+ let breakthrough = 0;
120
+ for (const a of near) {
121
+ for (const b of far) {
122
+ if (a.depth + b.depth > t + 1e-6 && a.region.intersect(b.region).area() > LOSS_TOL_MM2) breakthrough++;
123
+ }
105
124
  }
106
125
 
107
126
  return {
@@ -112,7 +131,7 @@ function routedFacts(s) {
112
131
  at: { bridge: null, gap: null, marks: null, outside: null },
113
132
  readErrors: { bridge: null, gap: null, marks: null, marksArea: null },
114
133
  evaluated: true,
115
- routed: { profile: { w: round2(flat[0]), h: round2(flat[1]) }, pockets: pocketRows, drills: drillRows, extra: extraRows, outside, empty },
134
+ routed: { profile: { w: round2(flat[0]), h: round2(flat[1]) }, pockets: pocketRows, drills: drillRows, extra: extraRows, outside, empty, breakthrough },
116
135
  };
117
136
  }
118
137
 
@@ -130,10 +149,13 @@ const METRICS = {
130
149
  routedPieces: { kind: "warn", doc: ROUTED_DOC_ID,
131
150
  extract: (s) => s.sheet?.pieces ?? null,
132
151
  hint: "the profile is not exactly one piece — a routed part must cut out as one region; join the pieces or split them into separate routedPart sub-parts" },
152
+ routedBreakthrough: { kind: "warn", doc: ROUTED_DOC_ID,
153
+ extract: (s) => s.sheet?.routed?.breakthrough ?? null,
154
+ hint: "a pocket on the near face and one on the far face overlap and together go deeper than the stock, so the router breaks through between them — make the overlap a through cut in `profile`, or lower one depth; if the opening is intended, set the two depths to add up to exactly the thickness" },
133
155
  };
134
156
 
135
157
  // ── lint ──────────────────────────────────────────────────────────────────────
136
- const DRILL_DEPTH_HINT = "A drill's depth is measured from the machined face; a depth at or past the stock thickness is a through hole. Lower the depth, raise the stock thickness control, or set depth to the thickness to drill through.";
158
+ const DRILL_DEPTH_HINT = "A drill's depth is measured from the op's own face (near or far); a depth at or past the stock thickness is a through hole. Lower the depth, raise the stock thickness control, or set depth to the thickness to drill through.";
137
159
  const ROUTER_RULES = [{
138
160
  id: "routed-drill-depth",
139
161
  run: ({ part, p, d, deriveError }) => {
@@ -168,7 +190,7 @@ export const ROUTER = {
168
190
  preview: routedPreview,
169
191
  destinations: [{ id: "cnc-router", cutFormat: "dxf" }],
170
192
  facts: routedFacts,
171
- checks: () => ({ routedOutside: "0", routedEmpty: "0", routedPieces: "1" }),
193
+ checks: () => ({ routedOutside: "0", routedEmpty: "0", routedPieces: "1", routedBreakthrough: "0" }),
172
194
  metrics: METRICS,
173
195
  lintRules: ROUTER_RULES,
174
196
  };
@@ -6,10 +6,11 @@
6
6
  //
7
7
  // Colours: `ctx.palette` (drawing.js routedPalette, built by the kit over EVERY routed
8
8
  // piece) when given, so one depth is one colour across the kit; without it — a direct
9
- // call — the piece ranks its own depths. Each named layer also carries its `depth` (mm,
9
+ // call — routedPalette over the piece alone. Each named layer also carries its `depth` (mm,
10
10
  // two decimals) and drills `through`, which the README's layer legend reads.
11
11
  import {
12
12
  refitRing, ringsOf, cutRings, drawingBounds, routedLayerName, depthToken, drillKey, ROUTED_COLORS,
13
+ routedPalette, FLIP_AXIS, mirrorRings,
13
14
  } from "../../export/drawing.js";
14
15
  import { KIT_OPTIONS_ERROR } from "../../export/formats.js";
15
16
 
@@ -18,57 +19,79 @@ const refitRegions = (regions) => regions.map((rg) => ({ outer: refitRing(rg.out
18
19
  const allRings = (regions) => { const r = ringsOf(regions); return [...r.outer, ...r.holes]; };
19
20
  const ringsOfShape = (shape) => allRings(refitRegions(shape.toContours())).map(closedPath);
20
21
 
21
- function drawing(s, o, k, ctx = {}) {
22
- const label = ctx.label ?? s.label ?? "routed part";
23
- if (o?.kerf > 0) throw new Error(`${KIT_OPTIONS_ERROR} kerf does not apply to a CNC router — the CAM applies the tool offset; set kerf to 0 ("${label}")`);
24
- const nominal = refitRegions(s.profile.toContours());
25
- if (nominal.length === 0) throw new Error(`cut kit: "${label}" has an empty profile — nothing to cut`);
26
- const cut = cutRings(nominal);
27
- const { pockets, drills, extra } = s.routed;
28
-
22
+ // One face's operation layers: SECONDARY, then drills grouped by (d, depth, through) in
23
+ // first-seen order, then pockets grouped by two-decimal depth, shallowest first. `place`
24
+ // maps each layer's paths (identity for the near face, the flip's mirror for the far);
25
+ // a far layer's id is prefixed `far-` and it carries `face: "far"` — a near layer has
26
+ // no face key, so a one-sided piece draws exactly as before two-sided parts existed.
27
+ function opLayers({ pockets, drills, extra }, palette, place) {
28
+ const tag = place ? { face: "far" } : {};
29
+ const id = (base) => (place ? `far-${base}` : base);
30
+ const at = place ?? ((paths) => paths);
29
31
  const layers = [];
30
- const reference = [
31
- ...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
32
- ...s.score.shapes.flatMap(ringsOfShape),
33
- ];
34
- if (reference.length) layers.push({ id: "reference", name: "REFERENCE", ...ROUTED_COLORS.reference, paths: reference });
35
32
  const secondary = extra.flatMap((x) => ringsOfShape(x.footprint));
36
- if (secondary.length) layers.push({ id: "secondary", name: "SECONDARY", ...ROUTED_COLORS.secondary, paths: secondary });
33
+ if (secondary.length) layers.push({ id: id("secondary"), name: place ? "FAR_SECONDARY" : "SECONDARY", ...ROUTED_COLORS.secondary, ...tag, paths: at(secondary) });
37
34
 
38
- // Drills grouped by (d, depth, through) in first-seen order; each hole a closed ring
39
- // of two exact arcs, which dxf.js writes as a CIRCLE.
40
- const palette = ctx.palette ?? null;
35
+ // Each hole a closed ring of two exact arcs, which dxf.js writes as a CIRCLE.
41
36
  const drillGroups = new Map();
42
37
  drills.forEach((dr) => {
43
38
  const key = drillKey(dr);
44
- const colour = palette?.drill.get(key) ?? ROUTED_COLORS.drill[drillGroups.size % ROUTED_COLORS.drill.length];
45
- const g = drillGroups.get(key) ?? { id: `drill-${key}`, name: routedLayerName("drill", dr), ...colour,
46
- depth: Number(depthToken(dr.depth)), through: dr.through, paths: [] };
47
- g.paths.push(...ringsOfShape(dr.shape));
39
+ const g = drillGroups.get(key) ?? { id: id(`drill-${key}`), name: routedLayerName("drill", dr), ...palette.drill.get(key),
40
+ depth: Number(depthToken(dr.depth)), through: dr.through, ...tag, paths: [] };
41
+ g.paths.push(...at(ringsOfShape(dr.shape)));
48
42
  drillGroups.set(key, g);
49
43
  });
50
44
  layers.push(...drillGroups.values());
51
45
 
52
- // Pockets grouped by two-decimal depth, shallowest layer first; an empty region draws nothing.
46
+ // An empty region draws nothing.
53
47
  const pocketGroups = new Map();
54
48
  for (const pk of pockets) {
55
49
  const paths = ringsOfShape(pk.region);
56
50
  if (paths.length === 0) continue;
57
51
  const key = depthToken(pk.depth);
58
- const g = pocketGroups.get(key) ?? { id: `pocket-${key}`, name: routedLayerName("pocket", pk), depth: Number(key), paths: [] };
59
- g.paths.push(...paths);
52
+ const g = pocketGroups.get(key) ?? { id: id(`pocket-${key}`), name: routedLayerName("pocket", pk), depth: Number(key), ...tag, paths: [] };
53
+ g.paths.push(...at(paths));
60
54
  pocketGroups.set(key, g);
61
55
  }
62
- [...pocketGroups.values()].sort((a, b) => a.depth - b.depth).forEach((g, i) => {
63
- const colour = palette?.pocket.get(depthToken(g.depth)) ?? ROUTED_COLORS.pocket[i % ROUTED_COLORS.pocket.length];
64
- layers.push({ ...g, ...colour });
65
- });
56
+ [...pocketGroups.values()].sort((a, b) => a.depth - b.depth)
57
+ .forEach((g) => layers.push({ ...g, ...palette.pocket.get(depthToken(g.depth)) }));
58
+ return layers;
59
+ }
60
+
61
+ function drawing(s, o, k, ctx = {}) {
62
+ const label = ctx.label ?? s.label ?? "routed part";
63
+ if (o?.kerf > 0) throw new Error(`${KIT_OPTIONS_ERROR} kerf does not apply to a CNC router — the CAM applies the tool offset; set kerf to 0 ("${label}")`);
64
+ const nominal = refitRegions(s.profile.toContours());
65
+ if (nominal.length === 0) throw new Error(`cut kit: "${label}" has an empty profile — nothing to cut`);
66
+ const cut = cutRings(nominal);
67
+ const { pockets, drills, extra } = s.routed;
68
+
69
+ const layers = [];
70
+ const reference = [
71
+ ...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
72
+ ...s.score.shapes.flatMap(ringsOfShape),
73
+ ];
74
+ if (reference.length) layers.push({ id: "reference", name: "REFERENCE", ...ROUTED_COLORS.reference, paths: reference });
75
+ // Near ops as they are; far ops (cut after the piece is turned over by the kit's flip)
76
+ // on their own `far-` layers, every ring mirrored about the piece's centre so the far
77
+ // face is drawn as the router sees it. One palette for both faces: a depth or drill
78
+ // setting is one colour whichever face it is on.
79
+ const palette = ctx.palette ?? routedPalette([s]);
80
+ const nb = drawingBounds([{ id: "cut-outer", paths: ringsOf(nominal).outer.map(closedPath) }]);
81
+ const axis = FLIP_AXIS[o?.flip ?? "left-right"];
82
+ const centre = axis === "x" ? (nb.min[0] + nb.max[0]) / 2 : (nb.min[1] + nb.max[1]) / 2;
83
+ const isFar = (op) => op.face === "far";
84
+ const near = opLayers({ pockets: pockets.filter((op) => !isFar(op)), drills: drills.filter((op) => !isFar(op)), extra: extra.filter((op) => !isFar(op)) }, palette);
85
+ const far = opLayers({ pockets: pockets.filter(isFar), drills: drills.filter(isFar), extra: extra.filter(isFar) }, palette,
86
+ (paths) => mirrorRings(paths, axis, centre));
87
+ layers.push(...near, ...far);
66
88
 
67
89
  if (cut.inner.length) layers.push({ id: "cut-inner", paths: cut.inner.map(closedPath) });
68
90
  layers.push({ id: "cut-outer", paths: cut.outer.map(closedPath) });
69
91
 
70
- const nb = drawingBounds([{ id: "cut-outer", paths: ringsOf(nominal).outer.map(closedPath) }]);
71
- return { layers, bounds: drawingBounds(layers), nominal: [nb.max[0] - nb.min[0], nb.max[1] - nb.min[1]], kerf: 0 };
92
+ const out = { layers, bounds: drawingBounds(layers), nominal: [nb.max[0] - nb.min[0], nb.max[1] - nb.min[1]], kerf: 0 };
93
+ if (far.length) out.farOutline = mirrorRings(ringsOf(nominal).outer, axis, centre).map(closedPath);
94
+ return out;
72
95
  }
73
96
 
74
97
  export default { drawing };
@@ -16,6 +16,13 @@ const depthOf = (sp, entry, who) => {
16
16
  if (!isNum(entry.depth) || entry.depth <= 0) throw fail(sp, `${who} depth must be a finite number > 0 (mm), got ${JSON.stringify(entry.depth)}`);
17
17
  return entry.depth;
18
18
  };
19
+ // Which face an op is machined from: "near" (z = thickness, the default) or "far" (z = 0).
20
+ const faceOf = (sp, entry, who) => {
21
+ const f = entry.face;
22
+ if (f === undefined || f === null) return "near";
23
+ if (f === "near" || f === "far") return f;
24
+ throw fail(sp, `${who} face must be "near" or "far", got ${JSON.stringify(f)}`);
25
+ };
19
26
  const labelOf = (entry) => (typeof entry.label === "string" && entry.label.trim() ? entry.label.trim() : null);
20
27
  const list = (sp, fn, args, what, shape) => {
21
28
  if (!fn) return [];
@@ -26,7 +33,9 @@ const list = (sp, fn, args, what, shape) => {
26
33
  };
27
34
 
28
35
  // `base` is resolveSheet's ResolvedSheet so far (profile, thickness, score…); the
29
- // return value is merged onto it as `routed`.
36
+ // return value is merged onto it as `routed`. Every op carries `face`: "near" (machined
37
+ // from z = thickness, the default) or "far" (from z = 0, the stock flipped over); depth
38
+ // is measured from that face either way.
30
39
  export function resolveRouted(k, sp, p, d, base) {
31
40
  const s = sp.sheet;
32
41
  const t = base.thickness;
@@ -35,8 +44,9 @@ export function resolveRouted(k, sp, p, d, base) {
35
44
  const who = name(e, i, "pocket");
36
45
  if (!e || typeof e !== "object" || e.region === null || e.region === undefined) throw fail(sp, `${who} needs a region (a Shape2D or profile)`);
37
46
  const depth = depthOf(sp, e, who);
47
+ const face = faceOf(sp, e, who);
38
48
  if (depth > t) throw fail(sp, `${who} is ${e.depth} mm deep in ${t} mm stock — a pocket may be at most the thickness; make it a through cut in \`profile\` if it should go through`);
39
- return { region: k.shape2d(e.region), depth, label: labelOf(e) };
49
+ return { region: k.shape2d(e.region), depth, label: labelOf(e), face };
40
50
  });
41
51
 
42
52
  const drills = list(sp, s.drills, [p, d], "drills(p, d)", "{ at, d, depth }").map((e, i) => {
@@ -44,18 +54,20 @@ export function resolveRouted(k, sp, p, d, base) {
44
54
  if (!e || typeof e !== "object" || !isPoint(e.at)) throw fail(sp, `${who} needs at: [x, y]`);
45
55
  if (!isNum(e.d) || e.d <= 0) throw fail(sp, `${who} d must be a finite number > 0 (mm), got ${JSON.stringify(e.d)}`);
46
56
  const depth = depthOf(sp, e, who);
57
+ const face = faceOf(sp, e, who);
47
58
  const through = depth >= t;
48
- return { shape: k.shape2d(circleRing(e.at, e.d / 2)), at: [e.at[0], e.at[1]], d: e.d, depth: through ? t : depth, through, label: labelOf(e) };
59
+ return { shape: k.shape2d(circleRing(e.at, e.d / 2)), at: [e.at[0], e.at[1]], d: e.d, depth: through ? t : depth, through, label: labelOf(e), face };
49
60
  });
50
61
 
51
62
  const extra = list(sp, s.extra, [k, p, d], "extra(k, p, d)", "{ solid, footprint, label, method }").map((e, i) => {
52
63
  const label = labelOf(e);
53
64
  if (!label) throw fail(sp, `extra op ${i + 1} needs a label — say what it is ("12° domino slot")`);
54
65
  const who = `extra op "${label}"`;
66
+ const face = faceOf(sp, e, who);
55
67
  if (typeof e.method !== "string" || !e.method.trim()) throw fail(sp, `${who} needs a method — say how the operator makes it ("table saw, blade tilted 12°")`);
56
68
  if (!isSolid(e.solid)) throw fail(sp, `${who} needs a solid — the geometry it removes, for the preview and the STEP`);
57
69
  if (e.footprint === null || e.footprint === undefined) throw fail(sp, `${who} needs a footprint — its outline on the machined face, for the drawing`);
58
- return { solid: e.solid, footprint: k.shape2d(e.footprint), label, method: e.method.trim() };
70
+ return { solid: e.solid, footprint: k.shape2d(e.footprint), label, method: e.method.trim(), face };
59
71
  });
60
72
 
61
73
  return { pockets, drills, extra };
package/src/oracle.js CHANGED
@@ -17,6 +17,9 @@
17
17
  export { assemblyGaps, meshGaps } from "./framework/oracle/gaps.js";
18
18
  export { meshVolume, bboxSize, bounds, meshArea } from "./framework/oracle/mesh.js";
19
19
  export { buildView } from "./framework/oracle/build.js";
20
+ // Preview animation — whether a view's autoplay animation is place-only, and its
21
+ // sampled place() matrices, for the cached preview GLB (partforge/gltf) to bake.
22
+ export { samplePreviewAnimation, PREVIEW_SAMPLE_HZ, PREVIEW_SAMPLE_CAP } from "./framework/preview-animation.js";
20
23
  export { measure } from "./framework/oracle/measure.js";
21
24
  export { verify } from "./framework/oracle/verify.js";
22
25
  // Change tracking — the `inspect` job's opt-in "what changed since my last
@@ -338,12 +338,14 @@ export function sheetPart<P = ResolvedParams, D = Derived>(spec: SheetPartSpec<P
338
338
 
339
339
  // --- routed parts (docs/AUTHORING-PARTS.md "Routed parts") --------------------
340
340
 
341
- /** A flat-floored pocket machined `depth` mm down from the machined face. */
342
- export interface RoutedPocket { region: Shape2D | ProfileInput; depth: number; label?: string }
341
+ /** Which face of the stock an op is machined from: `"near"` (z = thickness, the default) or `"far"` (z = 0). */
342
+ export type RoutedFace = "near" | "far";
343
+ /** A flat-floored pocket machined `depth` mm into the stock from its face. */
344
+ export interface RoutedPocket { region: Shape2D | ProfileInput; depth: number; label?: string; face?: RoutedFace }
343
345
  /** A drilled hole; `depth` equal to the stock thickness drills through — deeper is a `routed-drill-depth` lint error. */
344
- export interface RoutedDrill { at: Point2; d: number; depth: number; label?: string }
346
+ export interface RoutedDrill { at: Point2; d: number; depth: number; label?: string; face?: RoutedFace }
345
347
  /** An operation done with another tool after the flat milling: the solid is cut in the preview and the STEP, the footprint is drawn, label and method go in the README. */
346
- export interface RoutedExtra { solid: Solid; footprint: Shape2D | ProfileInput; label: string; method: string }
348
+ export interface RoutedExtra { solid: Solid; footprint: Shape2D | ProfileInput; label: string; method: string; face?: RoutedFace }
347
349
 
348
350
  /**
349
351
  * What `routedPart` accepts: `sheetPart`'s stock, profile and pose, plus pockets,
package/types/gltf.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  // partforge/gltf — the cached-preview writer. Worker-safe: no three.js, no DOM.
2
2
 
3
3
  import type { SubPartDefinition } from "./part.js";
4
+ import type { PreviewAnimationInput } from "./oracle.js";
5
+
6
+ export type { PreviewAnimationInput };
4
7
 
5
8
  /** The slice of a part `encodePreviewGlb` reads; any `PartDefinition` satisfies it, so does a plain object. */
6
9
  export interface PreviewPart {
@@ -38,9 +41,19 @@ export interface EncodePreviewInput {
38
41
  part?: PreviewPart | null;
39
42
  /** The view these meshes show; names the scene and lands in the file's extras as `partforgeView`. */
40
43
  view?: string | null;
44
+ /**
45
+ * The view's autoplay animation as `samplePreviewAnimation` returns it (encoder
46
+ * version 4). Each sampled sub-part's node gets a rest transform `rel(0)` and,
47
+ * when it moves, LINEAR `translation` + `rotation` channels in seconds, where
48
+ * `rel(t) = M(t) · base⁻¹`; the top-level extras gain `partforgeAnimation`
49
+ * `{ name, label, loop, duration, cues, opacity, steps, animated }`. Refused
50
+ * (throws) for a non-rigid pose, samples not running from t = 0 to t = 1, a
51
+ * sample missing a sub-part another has, or a sub-part with no mesh.
52
+ */
53
+ animation?: PreviewAnimationInput | null;
41
54
  }
42
55
 
43
- /** Bumped whenever the encoder writes different bytes for the same input (2: feature edges; 3: edges as indices into the surface's vertices). */
56
+ /** Bumped whenever the encoder writes different bytes for the same input (2: feature edges; 3: edges as indices into the surface's vertices; 4: animation channels + partforgeAnimation extras). */
44
57
  export const PREVIEW_ENCODER_VERSION: number;
45
58
 
46
59
  /** Encode one view's posed meshes as a welded, quantized, meshopt-compressed GLB (Y-up, millimetres). Deterministic. */
package/types/index.d.ts CHANGED
@@ -67,6 +67,10 @@ export interface KitOptions {
67
67
  printFormat?: "stl" | "3mf";
68
68
  /** Whole number, 1–20. Default 1. */
69
69
  sets?: number;
70
+ /** Which way a two-sided board is turned over between sides — the axis far-side layers are mirrored about. Default `"left-right"`. */
71
+ flip?: "left-right" | "end-over-end";
72
+ /** Two-sided pieces: one layered file per piece (`"one-file"`, default), or a near file plus a `-far` file (`"two-files"`). */
73
+ twoSided?: "one-file" | "two-files";
70
74
  }
71
75
 
72
76
  /**
@@ -776,6 +780,8 @@ export interface MountPreviewOptions {
776
780
  elements?: { chrome?: { reframe?: HTMLElement | null; theme?: HTMLElement | null } };
777
781
  /** A previous runtime's `getViewerState()`; `cutaway` is ignored (a preview has no live solid). */
778
782
  viewerState?: ViewerState | null;
783
+ /** Play the file's baked animation once drawn (default true). The editor's own boot preview goes through mount(), not here. */
784
+ animate?: boolean;
779
785
  }
780
786
 
781
787
  /** The handle `mountPreview()` returns. Every method is safe before `ready` settles. */
@@ -798,6 +804,12 @@ export interface PreviewRuntime {
798
804
  setHostPane(pane: HostPane): void;
799
805
  /** Accepted and ignored — there is no rail. */
800
806
  setRailLayout(layout: HostRailLayout): void;
807
+ /** The baked animation's controls: `null` before `ready`, for a still file, or with `animate: false`. */
808
+ readonly animation: {
809
+ state(): { status: "idle" | "intro" | "playing" | "paused" | "done"; t: number };
810
+ pause(): void;
811
+ resume(): void;
812
+ } | null;
801
813
  renderMode: {
802
814
  get(): RenderMode;
803
815
  set(mode: RenderMode): Promise<RenderMode>;
@@ -830,6 +842,8 @@ export const KIT_DEFAULTS: Readonly<{
830
842
  spacing: number;
831
843
  printFormat: "stl";
832
844
  sets: number;
845
+ flip: "left-right";
846
+ twoSided: "one-file";
833
847
  }>;
834
848
 
835
849
  /** Normalise kit options — defaults filled, `cutFormat` derived — or throw a `"cut kit options: …"` error. */