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.
- package/docs/AUTHORING-PARTS.md +41 -16
- package/package.json +1 -1
- package/src/framework/export/bundle.js +47 -26
- package/src/framework/export/drawing.js +35 -2
- package/src/framework/export/formats.js +10 -2
- package/src/framework/export/readme.js +20 -7
- package/src/framework/geometry/pose.js +20 -0
- package/src/framework/gltf/decode-preview.js +79 -7
- package/src/framework/gltf/encode-preview.js +196 -10
- package/src/framework/mount-preview.js +28 -2
- package/src/framework/mount.js +23 -6
- package/src/framework/oracle/build.js +30 -3
- package/src/framework/preview-animation.js +128 -0
- package/src/framework/preview-player.js +161 -0
- package/src/framework/process/cnc-router/descriptor.js +41 -19
- package/src/framework/process/cnc-router/export.js +54 -31
- package/src/framework/sheet/routed-resolve.js +16 -4
- package/src/oracle.js +3 -0
- package/types/geometry.d.ts +6 -4
- package/types/gltf.d.ts +14 -1
- package/types/index.d.ts +14 -0
- package/types/oracle.d.ts +78 -0
- package/types/part.d.ts +2 -0
- package/types/testing.d.ts +11 -0
|
@@ -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
|
|
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`
|
|
16
|
-
//
|
|
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.
|
|
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
|
-
:
|
|
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
|
|
100
|
-
//
|
|
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
|
|
103
|
-
const reach = Number.isFinite(
|
|
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
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const {
|
|
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
|
-
//
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
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}
|
|
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)
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
71
|
-
|
|
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
|
package/types/geometry.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
342
|
-
export
|
|
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. */
|