partforge 0.132.0 → 0.133.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,114 @@
1
+ // Sheet frames and poses — pure math, no kernel, no paper (lint and the oracle read it).
2
+ //
3
+ // A sheet part is authored in its CANONICAL frame: the profile in XY, the solid
4
+ // extruded over z ∈ [0, t], the laser face at z = t. A SheetPose { face, up, at } says
5
+ // where that piece sits in the assembly:
6
+ // face the laser face's outward normal, as an axis word ("+X", "-Y", …)
7
+ // up the drawing's +y, an axis word perpendicular to face
8
+ // at where drawing [0, 0] lands (on the laser face); the material extends along −face
9
+ // The drawing's +x maps to X = up × face, so R = [X | Y | F] (columns) is always a
10
+ // proper rotation (det +1) and an engraving can never come out mirrored. A canonical
11
+ // point [u, v, z] lands at at + u·X + v·Y + (z − t)·F.
12
+ import { AXIS_WORDS } from "./constants.js";
13
+
14
+ // A sheet part with no pose: no transform at all — the canonical solid stays put.
15
+ export const FLAT_POSE = null;
16
+
17
+ const AXIS_VEC = {
18
+ "+X": [1, 0, 0], "-X": [-1, 0, 0],
19
+ "+Y": [0, 1, 0], "-Y": [0, -1, 0],
20
+ "+Z": [0, 0, 1], "-Z": [0, 0, -1],
21
+ };
22
+ const WORDS = AXIS_WORDS.join(", ");
23
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
24
+ const finiteN = (v, n) => Array.isArray(v) && v.length === n && v.every((c) => typeof c === "number" && Number.isFinite(c));
25
+ // `+ 0` folds a -0 component to 0, so frames and steps serialize cleanly.
26
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1] + 0, a[2] * b[0] - a[0] * b[2] + 0, a[0] * b[1] - a[1] * b[0] + 0];
27
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
28
+
29
+ // null when `pose` is a valid SheetPose, else the reason (callers add their prefix).
30
+ export function validatePose(pose) {
31
+ if (!isPlainObject(pose)) return "pose must be a { face, up, at } object";
32
+ if (!AXIS_WORDS.includes(pose.face)) return `face must be one of ${WORDS}, got ${JSON.stringify(pose.face)}`;
33
+ if (!AXIS_WORDS.includes(pose.up)) return `up must be one of ${WORDS}, got ${JSON.stringify(pose.up)}`;
34
+ if (pose.up[1] === pose.face[1]) return `up (${pose.up}) must be perpendicular to face (${pose.face})`;
35
+ if (!finiteN(pose.at, 3)) return `at must be a finite [x, y, z], got ${JSON.stringify(pose.at)}`;
36
+ return null;
37
+ }
38
+
39
+ function checkPose(pose, prefix) {
40
+ const reason = validatePose(pose);
41
+ if (reason) throw new Error(`${prefix}: ${reason}`);
42
+ }
43
+
44
+ // The pose's world frame: X = up × face (drawing +x), Y = up (drawing +y), F = face.
45
+ export function poseFrame(pose) {
46
+ checkPose(pose, "sheet pose");
47
+ const F = [...AXIS_VEC[pose.face]], Y = [...AXIS_VEC[pose.up]];
48
+ return { X: cross(Y, F), Y, F, at: [...pose.at] };
49
+ }
50
+
51
+ // Axis-angle of R = [X | Y | F], or null for the identity. R is a signed permutation
52
+ // matrix, so the angle is 90°, 120° or 180°; the axis is scaled so its largest
53
+ // component is ±1 (the backends normalize it), which keeps basis rotations exact.
54
+ function axisAngle(X, Y, F) {
55
+ // R[i][j] = component i of column j
56
+ const R = [[X[0], Y[0], F[0]], [X[1], Y[1], F[1]], [X[2], Y[2], F[2]]];
57
+ const trace = R[0][0] + R[1][1] + R[2][2];
58
+ if (trace > 2.5) return null; // identity (trace 3)
59
+ const cos = Math.max(-1, Math.min(1, (trace - 1) / 2));
60
+ const deg = Math.round((Math.acos(cos) * 180) / Math.PI * 1e9) / 1e9;
61
+ let axis;
62
+ if (trace < -0.5) {
63
+ // 180°: R + I = 2·a·aᵀ — take the column with the largest diagonal entry
64
+ const i = [0, 1, 2].reduce((best, k) => (R[k][k] > R[best][best] ? k : best), 0);
65
+ axis = [0, 1, 2].map((r) => R[r][i] + (r === i ? 1 : 0));
66
+ } else {
67
+ axis = [R[2][1] - R[1][2], R[0][2] - R[2][0], R[1][0] - R[0][1]];
68
+ }
69
+ const m = Math.max(...axis.map(Math.abs));
70
+ return { deg, axis: axis.map((c) => c / m + 0) };
71
+ }
72
+
73
+ // The rigid steps that carry the canonical solid (z ∈ [0, t]) to the pose:
74
+ // translate [0, 0, −t] (laser face to z = 0), rotate by R about the origin (omitted
75
+ // when R = I), translate `at`. Exactly the step vocabulary the pose probe records
76
+ // (pose-probe-core.js), so a posed sheet part stays probe-trusted.
77
+ export function poseSteps(pose, t) {
78
+ const { X, Y, F, at } = poseFrame(pose);
79
+ const steps = [{ t: "translate", v: [0, 0, -t] }];
80
+ const rot = axisAngle(X, Y, F);
81
+ if (rot) steps.push({ t: "rotate", deg: rot.deg, center: [0, 0, 0], axis: rot.axis });
82
+ steps.push({ t: "translate", v: at });
83
+ return steps;
84
+ }
85
+
86
+ // The flat pose (FLAT_POSE, null) is the canonical frame itself — the same placement as
87
+ // { face: "+Z", up: "+Y", at: [0, 0, t] } — which a pose function may return for some
88
+ // params. The converters take it: worldToSheet needs nothing more, sheetToWorld needs
89
+ // the sheet's thickness, since the laser face then sits at z = t.
90
+ const flatFrame = (t) => ({ face: "+Z", up: "+Y", at: [0, 0, t] });
91
+
92
+ // Drawing [u, v] (on the laser face, `depth` mm into the material) → world [x, y, z].
93
+ // `thickness` is read only for the flat pose.
94
+ export function sheetToWorld(pose, uv, depth = 0, thickness) {
95
+ if (pose === FLAT_POSE && !(typeof thickness === "number" && Number.isFinite(thickness) && thickness > 0)) {
96
+ throw new Error("sheetToWorld: a flat (null) pose needs the sheet's thickness — sheetToWorld(null, [u, v], depth, t)");
97
+ }
98
+ if (pose === FLAT_POSE) pose = flatFrame(thickness);
99
+ checkPose(pose, "sheetToWorld");
100
+ if (!finiteN(uv, 2)) throw new Error(`sheetToWorld: point must be a finite [u, v], got ${JSON.stringify(uv)}`);
101
+ if (typeof depth !== "number" || !Number.isFinite(depth)) throw new Error(`sheetToWorld: depth must be a finite number (mm), got ${JSON.stringify(depth)}`);
102
+ const { X, Y, F, at } = poseFrame(pose);
103
+ return [0, 1, 2].map((i) => at[i] + uv[0] * X[i] + uv[1] * Y[i] - depth * F[i] + 0);
104
+ }
105
+
106
+ // World [x, y, z] → drawing [u, v]; the component along the face normal is dropped.
107
+ export function worldToSheet(pose, xyz) {
108
+ if (pose === FLAT_POSE) pose = flatFrame(0);
109
+ checkPose(pose, "worldToSheet");
110
+ if (!finiteN(xyz, 3)) throw new Error(`worldToSheet: point must be a finite [x, y, z], got ${JSON.stringify(xyz)}`);
111
+ const { X, Y, at } = poseFrame(pose);
112
+ const rel = [xyz[0] - at[0], xyz[1] - at[1], xyz[2] - at[2]];
113
+ return [dot(rel, X) + 0, dot(rel, Y) + 0];
114
+ }
@@ -0,0 +1,133 @@
1
+ // Resolve a sheet sub-part's declaration at (p, d) into shapes, and build its preview.
2
+ //
3
+ // PROBE-SAFE: everything here calls kernel and Shape2D METHODS only — no toContours,
4
+ // isEmpty, area, boundingBox or regions (the pose/parameter probes treat those as
5
+ // queries, poison trust and hand back dummies; pose-probe-core.js), and never branches
6
+ // on geometry. That is what keeps every sheet part's pose trusted, so a pose-only
7
+ // slider drag re-poses instead of rebuilding.
8
+ //
9
+ // Paper-free (the oracle imports this module — test/oracle-no-paper.test.js): a score
10
+ // line's groove is a plain butt-capped rectangle, never geometry/stroke-outline.js,
11
+ // which reaches paper through contour-ops.js.
12
+ import { EMPTY_MARK_RE, MARK_DEPTH, MARK_OVERCUT, SCORE_WIDTH, sheetGroup } from "./constants.js";
13
+ import { poseSteps, validatePose } from "./pose.js";
14
+ import { recoverArcs } from "../geometry/arc-fit.js";
15
+
16
+ const fail = (sp, problem) => new Error(`sheet part${sp.label ? ` "${sp.label}"` : ""}: ${problem}`);
17
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
18
+ const isLine = (e) => Array.isArray(e) && e.length === 2
19
+ && e.every((pt) => Array.isArray(pt) && pt.length === 2 && isNum(pt[0]) && isNum(pt[1]));
20
+
21
+ // Run `make`; a mark that turned out empty (an empty Shape2D extruded, an offset that
22
+ // collapsed) comes back null instead of failing the build. Matched by MESSAGE, never
23
+ // by asking the shape whether it is empty — that would be a probe query.
24
+ function orEmpty(make) {
25
+ try {
26
+ return make();
27
+ } catch (e) {
28
+ if (EMPTY_MARK_RE.test(e?.message ?? "")) return null;
29
+ throw e;
30
+ }
31
+ }
32
+
33
+ // A score line as a SCORE_WIDTH-wide rectangle, butt-capped at both ends (CCW).
34
+ function lineGroove(k, [a, b]) {
35
+ const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
36
+ const nx = (-(b[1] - a[1]) / len) * (SCORE_WIDTH / 2), ny = ((b[0] - a[0]) / len) * (SCORE_WIDTH / 2);
37
+ return k.shape2d([[a[0] - nx, a[1] - ny], [b[0] - nx, b[1] - ny], [b[0] + nx, b[1] + ny], [a[0] + nx, a[1] + ny]]);
38
+ }
39
+
40
+ // A closed score shape: every boundary grooved, SCORE_WIDTH wide, centred on it. A
41
+ // shape too small to keep an inside collapses under the inward offset; it is then
42
+ // scored solid (the outward offset alone).
43
+ function shapeGroove(shape) {
44
+ const outer = shape.offset(SCORE_WIDTH / 2);
45
+ return orEmpty(() => outer.cut(shape.offset(-SCORE_WIDTH / 2))) ?? outer;
46
+ }
47
+
48
+ // → ResolvedSheet: { process, material, thickness, group, label, pose, profile,
49
+ // score: { lines, shapes }, engrave, grooves, customBuild, trustedShape2d, recoverArcs }.
50
+ // Throws a `sheet part "<label>": …` error for a field that resolves to something
51
+ // unusable. `trustedShape2d` is the kernel's lift for machine-produced regions (no
52
+ // profile validation, arcs kept), or null where the kernel has none (a probe): the
53
+ // process checks rebuild the profile from its own rings with it, less the holes a width
54
+ // search can leave out (process/laser/descriptor.js). `recoverArcs` (geometry/arc-fit.js)
55
+ // reads a run of cubics that lies on one circle as that circle's arcs, the fit the cut
56
+ // files are drawn with; the checks run their width searches on those arcs. Both are
57
+ // handed over rather than imported, because a process descriptor imports nothing.
58
+ export function resolveSheet(k, sp, p, d) {
59
+ const s = sp.sheet;
60
+ const thickness = typeof s.thickness === "function" ? s.thickness(p, d) : s.thickness;
61
+ if (!isNum(thickness) || thickness <= 0) throw fail(sp, `thickness must be a finite number > 0 (mm), got ${thickness}`);
62
+ const material = typeof s.material === "function" ? s.material(p, d) : s.material;
63
+ if (typeof material !== "string" || material.trim() === "") throw fail(sp, `material must be a non-empty string, got ${JSON.stringify(material)}`);
64
+
65
+ let pose = null;
66
+ if (s.pose !== null) {
67
+ pose = typeof s.pose === "function" ? s.pose(p, d) : s.pose;
68
+ if (pose !== null) {
69
+ const reason = validatePose(pose);
70
+ if (reason) throw fail(sp, reason);
71
+ }
72
+ }
73
+
74
+ const drawn = s.profile(k, p, d);
75
+ if (drawn === null || drawn === undefined) throw fail(sp, "profile(k, p, d) returned nothing — return a Shape2D or profile");
76
+ const profile = k.shape2d(drawn);
77
+
78
+ const lines = [], shapes = [];
79
+ if (s.score) {
80
+ const entries = s.score(k, p, d);
81
+ if (entries !== null && entries !== undefined) {
82
+ if (!Array.isArray(entries)) throw fail(sp, "score(k, p, d) must return an array of lines [[x, y], [x, y]] and shapes");
83
+ for (const entry of entries) {
84
+ if (entry === null || entry === undefined) continue; // a mark switched off
85
+ if (!isLine(entry)) { shapes.push(k.shape2d(entry)); continue; } // a point list is a polygon
86
+ const [a, b] = entry;
87
+ // A zero-length line marks nothing. Dropped HERE, not just from the grooves, so
88
+ // the preview and the cut file (which draws score.lines) always agree.
89
+ if (Math.hypot(b[0] - a[0], b[1] - a[1]) > 1e-9) lines.push([[a[0], a[1]], [b[0], b[1]]]);
90
+ }
91
+ }
92
+ }
93
+ const engraved = s.engrave ? s.engrave(k, p, d) : null;
94
+ const engrave = engraved === null || engraved === undefined ? null : k.shape2d(engraved);
95
+
96
+ const grooves = [
97
+ ...lines.map((ln) => lineGroove(k, ln)),
98
+ ...shapes.map(shapeGroove),
99
+ ];
100
+
101
+ return {
102
+ process: s.process, material, thickness, group: sheetGroup(material, thickness),
103
+ label: sp.label ?? null, pose, profile, score: { lines, shapes }, engrave, grooves,
104
+ customBuild: sp.build !== s.generatedBuild,
105
+ trustedShape2d: typeof k.shape2d?.trusted === "function" ? k.shape2d.trusted : null,
106
+ recoverArcs,
107
+ };
108
+ }
109
+
110
+ // The default preview: the profile extruded to the thickness, the engrave pocketed and
111
+ // each groove cut MARK_DEPTH deep. Each mark tool spans z = t − MARK_DEPTH to
112
+ // t + MARK_OVERCUT, so its top face is never coplanar with the laser face; the removed
113
+ // volume is still MARK_DEPTH × area(marks ∩ profile).
114
+ export function sheetPreview(k, s) {
115
+ const t = s.thickness;
116
+ const body = s.profile.extrude({ h: t });
117
+ const tools = [s.engrave, ...s.grooves]
118
+ .filter(Boolean)
119
+ .map((mark) => orEmpty(() => mark.extrude({ h: MARK_DEPTH + MARK_OVERCUT }).translate([0, 0, t - MARK_DEPTH])))
120
+ .filter(Boolean);
121
+ return tools.length ? body.cutAll(tools) : body;
122
+ }
123
+
124
+ // Carry a canonical solid (z ∈ [0, t]) to its pose — translate and rotate only, so
125
+ // display and export differ by nothing and the probe keeps trusting the pose. A null
126
+ // pose is no transform.
127
+ export function applyPose(solid, pose, t) {
128
+ if (pose === null) return solid;
129
+ return poseSteps(pose, t).reduce(
130
+ (s, st) => (st.t === "translate" ? s.translate(st.v) : s.rotate(st.deg, st.center, st.axis)),
131
+ solid,
132
+ );
133
+ }
@@ -10,7 +10,11 @@
10
10
  // `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
11
11
  // verify runner (oracle/verify.js) and the linter (partforge/lint) must agree on.
12
12
  // Keeping it here lets the linter import the vocabulary without reaching
13
- // measure.js, which pulls in the geometry kernels. Must stay import-free.
13
+ // measure.js, which pulls in the geometry kernels. Its one import is the process
14
+ // registry, whose closure is import-free in turn (the process descriptors,
15
+ // sheet/constants.js, lint/finding.js) — so the linter's stays kernel-free.
16
+ import { SHEET_METRICS } from "./process/registry.js";
17
+
14
18
  export const SUBPART_METRICS = {
15
19
  holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
16
20
  hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
@@ -78,6 +82,9 @@ export const SUBPART_METRICS = {
78
82
  hint: "rebuild volume differs from the reference import by more than the allowed percentage — a feature is missing, doubled, or mis-scaled vs the reference" },
79
83
  refBboxDelta: { kind: "gate", extract: (s) => s.deviation?.bboxDelta ?? null,
80
84
  hint: "the rebuild's bounding-box corners drift from the reference import — check overall dimensions and that the rebuild is aligned to the reference's coordinates" },
85
+ // Sheet parts (sheetPart(), partforge/geometry): each process's own checks. verify
86
+ // volunteers them on every sheet sub-part; an author may declare them like any other.
87
+ ...SHEET_METRICS,
81
88
  };
82
89
  export const VIEW_METRICS = {
83
90
  bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
@@ -0,0 +1,108 @@
1
+ // Finger-jointed plywood box: five laser-cut panels from fingerBox, a laser-cut lid,
2
+ // and two print-in-place hinges whose tongues key into slots in the back panel and
3
+ // the lid; an engraved label and a score line on the front. A hinge prints flat and
4
+ // flat is 90° open, so the box is shown with its lid standing up.
5
+ import { sheetPart, fingerBox, printedTab, worldToSheet } from "partforge/geometry";
6
+
7
+ // Hinge in its PRINT pose: leaves flat on the bed, knuckle axis along X at y = 0,
8
+ // z = R, tongues up (+Z). Fixed leaf (y < 0) → back panel; lid leaf (y > 0) → lid.
9
+ const HINGE = { width: 24, leaf: 18, leafT: 3, R: 3, gap: 0.4, pinR: 1.2, lift: 0.25,
10
+ tongue: [8, 3], tongueX: 6, tongueY: 11 };
11
+
12
+ // ONE spec for a tongue and its slot; the slot carries the clearance.
13
+ const tab = (p) => printedTab({ size: HINGE.tongue, thickness: p.t, clearance: p.printFit });
14
+
15
+ function hinge(k, p, d) {
16
+ const { width: w, leaf, leafT, R, gap, pinR, tongueX } = HINGE, { tongueY } = d;
17
+ const seg = (w - 2 * gap) / 3; // three knuckles, `gap` apart
18
+ const x0 = -w / 2, x1 = x0 + seg, x2 = x1 + gap, x3 = x2 + seg, x4 = x3 + gap, x5 = w / 2;
19
+ const box = (min, max) => k.box({ min, max });
20
+ const barrel = (a, b, r = R) => k.cylinder({ r, h: b - a }).along("+X").at([a, 0, R]);
21
+ const tongues = (y) => [-tongueX, tongueX].map((x) => k.box({ size: tab(p).tongue }).at([x, y, leafT]));
22
+ const fixed = k.union([
23
+ box([x0, -leaf, 0], [x5, -(R + gap), leafT]),
24
+ box([x0, -(R + gap), 0], [x1, 0, leafT]), box([x4, -(R + gap), 0], [x5, 0, leafT]),
25
+ barrel(x0, x1), barrel(x4, x5), barrel(x0, x5, pinR), // outer knuckles + pin
26
+ ...tongues(-tongueY),
27
+ ]).label("Fixed leaf");
28
+ const moving = k.union([
29
+ box([x0, R + gap, 0], [x5, leaf, leafT]), box([x2, 0, 0], [x3, R + gap, leafT]),
30
+ barrel(x2, x3), ...tongues(tongueY),
31
+ ]).cut(barrel(x2 - 1, x3 + 1, pinR + gap)).label("Lid leaf"); // running clearance on the pin
32
+ return k.union([fixed, moving]);
33
+ }
34
+
35
+ // Display: flat hinge against the back panel's outside face, knuckles above the wall.
36
+ // Export: the print pose, untouched — both hinges export the same solid.
37
+ const hingePlace = (i) => (s, { purpose, p, d }) => (purpose === "export" ? s
38
+ : s.rotateX(90).at([d.hingeX[i], p.depth / 2 + HINGE.leafT, d.axisZ]));
39
+
40
+ // Tongue slots for both hinges, cut where the tongues really land (world → sheet).
41
+ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
42
+ [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
43
+ k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
44
+
45
+ const PLY = { views: ["box"], display: { material: "oak" }, material: "birch plywood", thickness: (p) => p.t };
46
+ const panel = (name, label, extra = {}) => sheetPart({
47
+ ...PLY, label,
48
+ profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
49
+ pose: (p, d) => d.box[name].pose, // fingerBox knows where it goes
50
+ ...extra,
51
+ });
52
+
53
+ export default {
54
+ meta: { title: "Plywood box with printed hinges", units: "mm" },
55
+ parameters: [
56
+ { id: "box", title: "Box", description: "Outside size, lid excluded.", controls: [
57
+ { key: "width", label: "Width", unit: "mm", min: 100, max: 400, step: 1, description: "Outside width." },
58
+ { key: "depth", label: "Depth", unit: "mm", min: 80, max: 300, step: 1, description: "Hinges on the back." },
59
+ { key: "height", label: "Height", unit: "mm", min: 50, max: 200, step: 1, description: "Wall height." },
60
+ { key: "label", type: "text", label: "Label", description: "Engraved on the front; empty for none." },
61
+ ] },
62
+ { id: "stock", title: "Stock & fit", description: "Measure your sheet. Kerf is chosen when you download the kit.", controls: [
63
+ { key: "t", label: "Sheet thickness", unit: "mm", min: 2, max: 6.5, step: 0.05, description: "MEASURED — '3 mm' ply is often 2.7–3.3." },
64
+ { key: "fit", label: "Finger clearance", unit: "mm", min: 0, max: 0.4, step: 0.02, description: "Total play per finger joint." },
65
+ { key: "printFit", label: "Hinge tab clearance", unit: "mm", min: 0, max: 0.8, step: 0.05, description: "Slot minus printed tongue." },
66
+ ] },
67
+ ],
68
+ defaults: { width: 160, depth: 110, height: 80, label: "TOOLS", t: 3, fit: 0.1, printFit: 0.3 },
69
+ derive: (p) => {
70
+ const rise = Math.max(0, p.t - HINGE.R); // thick stock: the shut lid clears the walls
71
+ const axisZ = p.height + HINGE.lift + HINGE.R + rise; // knuckle axis, above the back wall
72
+ const lidZ = axisZ + HINGE.lift + HINGE.R; // hinge edge of the open lid
73
+ return {
74
+ box: fingerBox({ width: p.width, depth: p.depth, height: p.height, thickness: p.t, clearance: p.fit }),
75
+ hingeX: [-(p.width / 2 - 25), p.width / 2 - 25],
76
+ axisZ,
77
+ tongueY: HINGE.tongueY + rise, // slots stay put
78
+ // Open 90°: laser face to the back, the drawing's front edge (v = 0) on top.
79
+ lidPose: { face: "+Y", up: "-Z", at: [-p.width / 2, p.depth / 2, lidZ + p.depth] },
80
+ };
81
+ },
82
+ parts: {
83
+ bottom: panel("bottom", "Bottom"),
84
+ left: panel("left", "Left"),
85
+ right: panel("right", "Right"),
86
+ front: panel("front", "Front", { // u across, v up
87
+ engrave: (k, p) => (p.label?.trim() ? k.text2d(p.label, { size: 14 }).translate([p.width / 2, p.height * 0.55]) : null),
88
+ score: (k, p) => [[[12, p.height * 0.35], [p.width - 12, p.height * 0.35]]], // a two-point line
89
+ }),
90
+ back: panel("back", "Back", {
91
+ profile: (k, p, d) => k.shape2d(d.box.back.outline)
92
+ .cutAll(hingeSlots(k, p, d, d.box.back.pose, d.axisZ - d.tongueY)),
93
+ }),
94
+ lid: sheetPart({
95
+ ...PLY, label: "Lid",
96
+ profile: (k, p, d) => k.shape2d([[0, 0], [p.width, 0], [p.width, p.depth], [0, p.depth]])
97
+ .cutAll(hingeSlots(k, p, d, d.lidPose, d.axisZ + d.tongueY)),
98
+ pose: (p, d) => d.lidPose,
99
+ }),
100
+ hingeL: { label: "Hinge (left)", views: ["box"], display: { material: "pla-print" },
101
+ export: { name: "hinge" }, build: hinge, place: hingePlace(0) },
102
+ hingeR: { label: "Hinge (right)", views: ["box"], display: { material: "pla-print" },
103
+ build: hinge, place: hingePlace(1) }, // identical solid: the kit prints "hinge" ×2
104
+ },
105
+ views: { box: { label: "Box" } },
106
+ // `process` = the PRINTED parts' profile; sheet parts get the laser checks instead.
107
+ verify: { process: "fdm-pla", expect: { _view: { overlaps: 0 } } },
108
+ };
@@ -6,13 +6,23 @@ import { PRESETS } from "../framework/materials/presets.js";
6
6
  const ids = Object.keys(PRESETS);
7
7
  const COLS = 6, PITCH = 40;
8
8
 
9
+ // Common filament colours for the "PLA colours" and "PETG colours" views: how
10
+ // each print finish reads across light, dark and saturated tints, since a preset
11
+ // tuned on one colour can wash out another. The last is the no-material blue-grey.
12
+ const FILAMENT_COLOURS = [
13
+ ["White", 0xf2f0eb], ["Cream (preset)", PRESETS["pla-print"].color], ["Light grey", 0xa3a6a8],
14
+ ["Charcoal", 0x4a4c50], ["Black", 0x1e1f21], ["No-material blue-grey", 0x9fb4cc],
15
+ ["Red", 0xc8102e], ["Orange", 0xe0592a], ["Yellow", 0xf2c500],
16
+ ["Green", 0x2e8b3d], ["Blue", 0x1f5fbf], ["Purple", 0x6b3fa0],
17
+ ];
18
+
9
19
  const withHole = (k) =>
10
20
  k.box({ size: [30, 30, 12] }).fillet({ r: 3 }).cut(k.cylinder({ r: 5, h: 20 }).translate([0, 0, -4]));
11
21
 
12
22
  export default {
13
23
  meta: { title: "Material swatches", units: "mm", environment: "studio" },
14
24
  defaults: {},
15
- views: { all: { label: "All presets", default: true }, print: { label: "Layer lines" } },
25
+ views: { all: { label: "All presets", default: true }, colours: { label: "PLA colours" }, petg: { label: "PETG colours" }, print: { label: "Layer lines" } },
16
26
  parts: {
17
27
  ...Object.fromEntries(ids.map((id, i) => [id.replaceAll("-", "_"), {
18
28
  label: PRESETS[id].label,
@@ -27,6 +37,18 @@ export default {
27
37
  views: ["all"],
28
38
  build: (k) => withHole(k).translate([(ids.length % COLS) * PITCH, -Math.floor(ids.length / COLS) * PITCH, 0]),
29
39
  },
40
+ ...Object.fromEntries(FILAMENT_COLOURS.map(([label, color], i) => [`pla_${i}`, {
41
+ label: `PLA — ${label}`,
42
+ views: ["colours"],
43
+ build: (k) => withHole(k).translate([(i % COLS) * PITCH, -Math.floor(i / COLS) * PITCH, 0]),
44
+ display: { material: "pla-print", color },
45
+ }])),
46
+ ...Object.fromEntries(FILAMENT_COLOURS.map(([label, color], i) => [`petg_${i}`, {
47
+ label: `PETG — ${label}`,
48
+ views: ["petg"],
49
+ build: (k) => withHole(k).translate([(i % COLS) * PITCH, -Math.floor(i / COLS) * PITCH, 0]),
50
+ display: { material: "petg-print", color },
51
+ }])),
30
52
  upright_pla: {
31
53
  label: "PLA, displayed upright, printed flat",
32
54
  views: ["print"],
@@ -8,15 +8,27 @@ import type {
8
8
  Contour,
9
9
  Corner2D,
10
10
  CornerSelector,
11
+ GeometryKernel,
11
12
  MirrorAxis2,
12
13
  Point2,
13
14
  Point3,
14
15
  PointsContour,
15
16
  Region2D,
17
+ Shape2D,
16
18
  Solid,
17
19
  } from "./kernel.js";
20
+ import type {
21
+ AxisWord,
22
+ Derived,
23
+ PlaceContext,
24
+ ResolvedParams,
25
+ SheetPose,
26
+ SheetScoreEntry,
27
+ SubPartDefinition,
28
+ } from "./part.js";
18
29
 
19
30
  export type { ArcContour, Contour, Corner2D, CornerSelector, MirrorAxis2, Point2, Point3, PointsContour, Region2D, Solid };
31
+ export type { AxisWord, SheetPose };
20
32
 
21
33
  /**
22
34
  * Anything the 2-D editing ops below accept: a plain point list, a curve-native
@@ -282,3 +294,106 @@ export interface ProfileIssue {
282
294
  * geometric badness — only an unrecognized `input` shape throws.
283
295
  */
284
296
  export function validateProfile(input: ProfileInput): { ok: boolean; issues: ProfileIssue[] };
297
+
298
+ // --- sheet parts (docs/AUTHORING-PARTS.md "Sheet parts") --------------------
299
+
300
+ /**
301
+ * What `sheetPart` accepts. A field needing the kernel is `(k, p, d)`; a plain value
302
+ * is a literal or `(p, d)`. Unknown keys throw at load and name the fix (`kerf` is
303
+ * chosen at download, `outline`/`cut` are `profile`, `build` is supplied).
304
+ */
305
+ export interface SheetPartSpec<P = ResolvedParams, D = Derived> {
306
+ /** Stock label ("birch plywood"); groups pieces in the kit. The LOOK is `display.material`. */
307
+ material: string | ((p: P, d: D) => string);
308
+ /** The MEASURED sheet thickness in mm: the extrusion depth and every joint's depth. */
309
+ thickness: number | ((p: P, d: D) => number);
310
+ /** The CUT layer: outline plus holes, seen from the laser face. */
311
+ profile: (k: GeometryKernel, p: P, d: D) => Shape2D | ProfileInput;
312
+ /** Exactly two points is a line; any other entry is a shape whose boundaries are scored. */
313
+ score?: ((k: GeometryKernel, p: P, d: D) => SheetScoreEntry[] | null) | null;
314
+ /** Filled regions burned into the laser face (`k.text2d`, `k.vector2d`, a Shape2D). */
315
+ engrave?: ((k: GeometryKernel, p: P, d: D) => Shape2D | ProfileInput | null) | null;
316
+ /** Where the piece sits, for display AND export. Omitted: flat, no transform. */
317
+ pose?: SheetPose | ((p: P, d: D) => SheetPose | null) | null;
318
+ process?: "laser";
319
+ label?: string;
320
+ views: string[];
321
+ display?: SubPartDefinition<P, D>["display"];
322
+ export?: { name: string };
323
+ enabled?: (p: P) => unknown;
324
+ exportable?: boolean;
325
+ reference?: string;
326
+ /** Your own placement; runs AFTER the pose. */
327
+ place?: (solid: Solid, ctx: PlaceContext<P, D>) => Solid;
328
+ }
329
+
330
+ /** A sub-part cut from flat stock: an ordinary sub-part with `build`, `place` and `sheet` filled in. */
331
+ export function sheetPart<P = ResolvedParams, D = Derived>(spec: SheetPartSpec<P, D>): SubPartDefinition<P, D>;
332
+
333
+ /** A metric screw size `tSlots` knows. */
334
+ export type ScrewSize = "M2.5" | "M3" | "M4";
335
+
336
+ /** One edge's joint — plain, JSON-safe data (it lives in `derive()` output). A plain edge is `undefined`. */
337
+ export type EdgeJoint =
338
+ | { joint: "fingers"; thickness: number; clearance: number; finger: number; side: "outer" | "inner" }
339
+ | { joint: "tabs"; thickness: number; count: number; width: number }
340
+ | { joint: "tslots"; thickness: number; screw: ScrewSize; screwLength: number; at: number[]; clearance: number };
341
+
342
+ /** Finger joint. `clearance` is the TOTAL play per finger; outer fingers protrude, inner ones notch. */
343
+ export function fingers(opts: { thickness: number; clearance?: number; finger?: number; side?: "outer" | "inner" }): Extract<EdgeJoint, { joint: "fingers" }>;
344
+
345
+ /** Tongues protruding `thickness` from the edge, `count` of them, each `width` wide. */
346
+ export function tabs(opts: { thickness: number; count?: number; width?: number }): Extract<EdgeJoint, { joint: "tabs" }>;
347
+
348
+ /** A screw-and-nut joint: a shank slot crossed by a nut trap, at fractions `at` along the edge. */
349
+ export function tSlots(opts: { thickness: number; screw?: ScrewSize; screwLength?: number; at?: number[]; clearance?: number }): Extract<EdgeJoint, { joint: "tslots" }>;
350
+
351
+ /** A panel's four edges, in CCW order; an omitted edge is plain. */
352
+ export interface SheetPanelEdges {
353
+ bottom?: EdgeJoint;
354
+ right?: EdgeJoint;
355
+ top?: EdgeJoint;
356
+ left?: EdgeJoint;
357
+ }
358
+
359
+ /** A panel outline: nominal box [0, W] × [0, H] with each edge's joint; `size` includes protrusions. */
360
+ export function sheetPanel(opts: { width: number; height: number; edges?: SheetPanelEdges }): { outline: PointsContour; size: [number, number] };
361
+
362
+ /** The holes the OTHER panel needs for a tabs or T-slot edge; `line` is the tabbed panel's mid-plane in this panel's frame. */
363
+ export function matchingSlots(joint: EdgeJoint, opts: { line: [Point2, Point2]; clearance?: number }): Contour[];
364
+
365
+ /** One `fingerBox` panel: its outline in its own frame (bbox [0, 0]–size) and where it goes. */
366
+ export interface FingerBoxPanel {
367
+ outline: PointsContour;
368
+ size: [number, number];
369
+ pose: SheetPose;
370
+ }
371
+
372
+ /** An open-top finger-jointed box, W × D × H outside: five panels, every corner owned once. */
373
+ export function fingerBox(opts: {
374
+ width: number;
375
+ depth: number;
376
+ height: number;
377
+ thickness: number;
378
+ clearance?: number;
379
+ finger?: number;
380
+ }): Record<"bottom" | "front" | "back" | "left" | "right", FingerBoxPanel>;
381
+
382
+ /** A slot and the printed tongue that keys into it, from one spec; all the play is on the slot. */
383
+ export function printedTab(opts: { size: [number, number]; thickness: number; clearance?: number }): { slot: PointsContour; tongue: [number, number, number] };
384
+
385
+ /** An arc-exact round hole (a CCW circle of two arcs) for a sheet part's profile: `circleProfile(d / 2, at)`, named by diameter. */
386
+ export function sheetHole(opts: { d: number; at: Point2 }): ArcContour;
387
+
388
+ /** Clearance holes (ISO 273 medium) and hex nuts (ISO 4032) for `tSlots`, mm. */
389
+ export const JOINERY_SCREWS: Readonly<Record<ScrewSize, { readonly hole: number; readonly nut: { readonly flats: number; readonly height: number } }>>;
390
+
391
+ /**
392
+ * A drawing point on a posed sheet (`depth` mm into the material) → world [x, y, z].
393
+ * `pose` null (`FLAT_POSE`) is the canonical frame; it needs the sheet's `thickness`,
394
+ * since the laser face then sits at z = thickness. Otherwise `thickness` is unused.
395
+ */
396
+ export function sheetToWorld(pose: SheetPose | null, uv: Point2, depth?: number, thickness?: number): [number, number, number];
397
+
398
+ /** A world point → where it lands on a posed sheet's drawing, [u, v]. `pose` null is the canonical frame. */
399
+ export function worldToSheet(pose: SheetPose | null, xyz: Point3): [number, number];
package/types/lint.d.ts CHANGED
@@ -39,7 +39,10 @@ export interface Finding {
39
39
  * within `file`.
40
40
  */
41
41
  line?: number;
42
- /** A stable ERROR-PATTERNS.md entry id, when one applies. */
42
+ /**
43
+ * A stable ERROR-PATTERNS.md entry id, when one applies — or `"sheet-parts"`,
44
+ * the authoring guide's "Sheet parts" section, on a sheet-part finding.
45
+ */
43
46
  pattern?: string;
44
47
  }
45
48
 
package/types/oracle.d.ts CHANGED
@@ -9,7 +9,7 @@ export type { GeometryKernel, Mesh, PartDefinition, ResolvedParams, Solid } from
9
9
  export {
10
10
  // measurement + verification
11
11
  measure, verify, buildView,
12
- type MeasureReport, type SubPartFacts, type AggregateFacts, type BuiltSubPart,
12
+ type MeasureReport, type SubPartFacts, type SheetFacts, type AggregateFacts, type BuiltSubPart,
13
13
  type VerifyReport, type VerifyCaseResult, type VerifyCheck, type CheckStatus,
14
14
  // mesh facts, gaps, BVH, min wall
15
15
  meshVolume, bboxSize, bounds, meshArea, assemblyGaps, meshGaps, buildBVH, meshTriangles, minWall, overhang,
package/types/part.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // src/framework/oracle/verify.js + src/framework/verify-metrics.js (the `verify`
10
10
  // block's metric vocabulary).
11
11
 
12
- import type { BackendName, GeometryKernel, Solid } from "./kernel.js";
12
+ import type { BackendName, GeometryKernel, Point2, Point3, ProfileInput, Solid } from "./kernel.js";
13
13
 
14
14
  /**
15
15
  * A value the control panel can hold. Sliders and number boxes write numbers;
@@ -400,6 +400,47 @@ export interface SubPartDefinition<P = ResolvedParams, D = Derived> {
400
400
  };
401
401
  /** Filename / object name on export; defaults to the key. */
402
402
  export?: { name: string };
403
+ /**
404
+ * Set by `sheetPart()` (partforge/geometry) on a sub-part cut from flat stock: its
405
+ * cut/score/engrave declaration, and the one marker framework code recognizes a
406
+ * sheet part by. Never written by hand.
407
+ */
408
+ sheet?: SheetDeclaration<P, D>;
409
+ }
410
+
411
+ // --- sheet parts ------------------------------------------------------------
412
+
413
+ /** An axis direction, as a sheet pose names its face and up. */
414
+ export type AxisWord = "+X" | "-X" | "+Y" | "-Y" | "+Z" | "-Z";
415
+
416
+ /**
417
+ * Where a sheet part sits in the assembly (docs/AUTHORING-PARTS.md "Sheet parts").
418
+ * The part is drawn in its own frame — profile in XY, material over z ∈ [0, t], the
419
+ * laser face at z = t. `face` is the laser face's outward normal, `up` the drawing's
420
+ * +y (perpendicular to `face`), `at` where drawing [0, 0] lands; drawing +x is
421
+ * `up × face`, so a pose is always a proper rotation and never mirrors.
422
+ */
423
+ export interface SheetPose {
424
+ face: AxisWord;
425
+ up: AxisWord;
426
+ at: Point3;
427
+ }
428
+
429
+ /** One `score` entry: exactly two points is a LINE; anything else is a shape whose boundaries are all scored. */
430
+ export type SheetScoreEntry = [Point2, Point2] | ProfileInput | null;
431
+
432
+ /** The plain-data declaration `sheetPart()` attaches as `sub.sheet`. */
433
+ export interface SheetDeclaration<P = ResolvedParams, D = Derived> {
434
+ process: string;
435
+ material: string | ((p: P, d: D) => string);
436
+ thickness: number | ((p: P, d: D) => number);
437
+ profile: (k: GeometryKernel, p: P, d: D) => ProfileInput;
438
+ score: ((k: GeometryKernel, p: P, d: D) => SheetScoreEntry[] | null) | null;
439
+ engrave: ((k: GeometryKernel, p: P, d: D) => ProfileInput | null) | null;
440
+ /** `null` = flat: no transform. */
441
+ pose: SheetPose | ((p: P, d: D) => SheetPose | null) | null;
442
+ /** The build `sheetPart` made; `sub.build !== sub.sheet.generatedBuild` is a custom build. */
443
+ generatedBuild: (k: GeometryKernel, p: P, d: D) => Solid;
403
444
  }
404
445
 
405
446
  export interface ViewDefinition {
@@ -489,6 +530,21 @@ export interface SubPartExpectations {
489
530
  refVolumeDeltaPct?: Expectation;
490
531
  /** Bounding-box corner drift `[dx, dy, dz]` vs. the sub-part's declared `reference` import. */
491
532
  refBboxDelta?: Expectation;
533
+ /**
534
+ * Sheet parts only (`sheetPart()`): the narrowest web or finger in the cut
535
+ * profile, mm. verify checks it on every sheet part at `>=` max(half the
536
+ * thickness, 0.5 mm) as a volunteered warning; declare it to make it count.
537
+ * Nothing narrower than twice that floor reads as the ceiling, with a note.
538
+ */
539
+ sheetBridge?: Expectation;
540
+ /** Sheet parts only: the narrowest hole, slot or notch in the cut profile, mm. Same floor as `sheetBridge`. */
541
+ sheetGap?: Expectation;
542
+ /** Sheet parts only: engrave/score mark regions lying outside the cut (volunteered check: `0`). */
543
+ sheetMarks?: Expectation;
544
+ /** Sheet parts only: separate regions in the cut profile (volunteered check: `1`). */
545
+ sheetPieces?: Expectation;
546
+ /** Sheet parts with a custom `build` only: % volume drift from profile area × thickness minus the marks (volunteered check: `<=2`). */
547
+ sheetSolidMatch?: Expectation;
492
548
  }
493
549
 
494
550
  /**