partforge 0.132.1 → 0.133.1

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.
Files changed (34) hide show
  1. package/bin/cli.js +12 -2
  2. package/docs/AUTHORING-PARTS.md +311 -6
  3. package/docs/KERNEL-CONTRACT.md +41 -11
  4. package/package.json +1 -1
  5. package/src/framework/geometry/arc-fit.js +169 -36
  6. package/src/framework/geometry/arc-math.js +15 -7
  7. package/src/framework/geometry/contour-offset.js +132 -5
  8. package/src/framework/geometry/kernel.js +1 -1
  9. package/src/framework/geometry/occt-backend.js +6 -2
  10. package/src/framework/geometry/occt-step.js +70 -0
  11. package/src/framework/geometry/polygon.js +7 -0
  12. package/src/framework/jobs.js +1 -1
  13. package/src/framework/lint/index.js +2 -1
  14. package/src/framework/lint/rules-schema.js +2 -1
  15. package/src/framework/lint/rules-shape.js +10 -3
  16. package/src/framework/lint/rules-sheet.js +172 -0
  17. package/src/framework/lint/rules-verify.js +10 -0
  18. package/src/framework/oracle/gates.js +13 -0
  19. package/src/framework/oracle/measure.js +129 -5
  20. package/src/framework/oracle/verify.js +113 -5
  21. package/src/framework/process/laser/descriptor.js +1089 -0
  22. package/src/framework/process/registry.js +24 -0
  23. package/src/framework/sheet/constants.js +104 -0
  24. package/src/framework/sheet/joinery.js +356 -0
  25. package/src/framework/sheet/part.js +98 -0
  26. package/src/framework/sheet/pose.js +114 -0
  27. package/src/framework/sheet/resolve.js +133 -0
  28. package/src/framework/verify-metrics.js +8 -1
  29. package/src/parts/laser-box.js +108 -0
  30. package/types/geometry.d.ts +115 -0
  31. package/types/lint.d.ts +4 -1
  32. package/types/oracle.d.ts +1 -1
  33. package/types/part.d.ts +57 -1
  34. package/types/testing.d.ts +81 -1
@@ -0,0 +1,24 @@
1
+ // The process registry: every manufacturing process a sheet part can name. Adding a
2
+ // process is one folder under process/, one line here and one line in
3
+ // process/exporters.js.
4
+ //
5
+ // Import-free apart from the descriptors and sheet/constants.js, for the same reason
6
+ // the descriptors are: lint and the oracle read this file.
7
+ import { isSheetPart } from "../sheet/constants.js";
8
+ import { LASER } from "./laser/descriptor.js";
9
+
10
+ export const PROCESSES = [LASER];
11
+ export const PROCESS_IDS = PROCESSES.map((d) => d.id);
12
+
13
+ export const processById = (id) => PROCESSES.find((d) => d.id === id) ?? null;
14
+
15
+ // The process a sub-part is made by, or null for a printed (non-sheet) sub-part.
16
+ export const processFor = (sp) => (isSheetPart(sp) ? processById(sp.sheet.process) : null);
17
+
18
+ // Every process's verify metrics as one flat vocabulary; verify-metrics.js spreads it
19
+ // into SUBPART_METRICS. Process metric names must not collide with each other or with
20
+ // a print metric (test/verify-metrics.test.js).
21
+ export const SHEET_METRICS = Object.assign({}, ...PROCESSES.map((d) => d.metrics));
22
+
23
+ // Every process's own lint rules; lint/rules-sheet.js appends them to SHEET_RULES.
24
+ export const PROCESS_LINT_RULES = PROCESSES.flatMap((d) => d.lintRules);
@@ -0,0 +1,104 @@
1
+ // The sheet-part vocabulary: names, thresholds and tiny pure helpers shared by the
2
+ // authoring layer (sheet/part.js, sheet/joinery.js), lint (lint/rules-sheet.js), the
3
+ // oracle (oracle/measure.js, oracle/verify.js) and the kit export (export/*).
4
+ //
5
+ // IMPORT-FREE, and that is load-bearing: lint's closure must stay free of bare
6
+ // dependencies (test/lint-purity.test.js) and the oracle's free of paper
7
+ // (test/oracle-no-paper.test.js), and all three read this file. Anything that needs
8
+ // a kernel, a Shape2D or paper belongs in resolve.js / joinery.js instead.
9
+ //
10
+ // Recognition is PLAIN DATA: a sheet sub-part is one whose `sheet` is an object with a
11
+ // string `process` (isSheetPart below). Never instanceof, a module-scoped Symbol() or
12
+ // a WeakMap — in partforge-cloud's part worker the part's `partforge/geometry` and the
13
+ // framework are separate module instances, so an identity marker silently never
14
+ // matches there.
15
+
16
+ // The guide section id ("## Sheet parts" in docs/AUTHORING-PARTS.md). Every sheet
17
+ // lint finding and DFM check carries it as `pattern`, which survives the host's
18
+ // truncation of message/hint.
19
+ export const SHEET_DOC_ID = "sheet-parts";
20
+
21
+ // The six axis words a SheetPose names its face and up with.
22
+ export const AXIS_WORDS = Object.freeze(["+X", "-X", "+Y", "-Y", "+Z", "-Z"]);
23
+
24
+ // sheetPart's own keys, and the ordinary sub-part keys it passes through untouched.
25
+ export const SHEET_KEYS = Object.freeze(["material", "thickness", "profile", "score", "engrave", "pose", "process"]);
26
+ export const SUBPART_PASSTHROUGH_KEYS = Object.freeze(["label", "views", "display", "export", "enabled", "exportable", "reference", "place"]);
27
+
28
+ // Keys an author reaches for by another name, and keys held for future processes
29
+ // (sheet-metal bends, papercraft folds, grain direction). Each gets its own error.
30
+ export const RENAMED_KEYS = Object.freeze({ outline: "profile", cut: "profile" });
31
+ export const RESERVED_KEYS = Object.freeze(["folds", "bends", "grain"]);
32
+
33
+ // The preview build's marks (mm): engrave and score are cut MARK_DEPTH into the laser
34
+ // face by tools that rise MARK_OVERCUT above it (never coplanar with the face — OCCT's
35
+ // coplanar booleans are fragile), and a score is a SCORE_WIDTH-wide groove. The
36
+ // depths exist only in the preview; the cut files carry vectors.
37
+ export const MARK_DEPTH = 0.2;
38
+ export const MARK_OVERCUT = 0.1;
39
+ export const SCORE_WIDTH = 0.3;
40
+
41
+ // The laser DFM thresholds (read by the checks in P1b). A web, finger, hole or slot
42
+ // narrower than widthFloor(t) = max(0.5·t, 0.5 mm) — SendCutSend's floor — is flagged;
43
+ // widths are found by bisection to WIDTH_RESOLUTION mm, an area change under
44
+ // LOSS_TOL_MM2 is not a loss, and a custom build may drift SOLID_MATCH_PCT % from its
45
+ // profile. All of a measure()/kit call's 2-D checks share SHEET_CHECK_BUDGET_MS.
46
+ export const WIDTH_FLOOR_FRACTION = 0.5;
47
+ export const WIDTH_FLOOR_MIN = 0.5;
48
+ export const WIDTH_RESOLUTION = 0.05;
49
+ export const LOSS_TOL_MM2 = 0.01;
50
+ export const SOLID_MATCH_PCT = 2;
51
+ export const SHEET_CHECK_BUDGET_MS = 1500;
52
+ export const LASER_THICKNESS_RANGE = Object.freeze([0.5, 12]);
53
+
54
+ // The two standing texts verify gives the laser checks (oracle/verify.js). They live here,
55
+ // import-free, so every surface that repeats them — the kit's README, which may not import
56
+ // oracle/* — reads one text; verify.js imports and re-exports them.
57
+ // Standing in for a sheet's budget-gated volunteered checks when its 2-D budget ran out
58
+ // (measure()'s `sheet.evaluated` false): one warning, never `unevaluated`.
59
+ export const SHEET_CHECKS_NOTICE = Object.freeze({
60
+ metric: "sheetChecks", kind: "warn", expr: "evaluated", actual: null, status: "warn", pass: null, volunteered: true,
61
+ message: "2-D sheet checks not evaluated (time budget)",
62
+ hint: "The laser checks (narrow webs and gaps, stray marks) did not fit their time budget, or the profile was too complex to start them. They are warnings only and never decide verify.ok. What they spend time on is edges, curved ones most: round holes crowded close to each other or to an edge, and corners rounded tighter than about the sheet thickness, cost the most.",
63
+ pattern: SHEET_DOC_ID,
64
+ });
65
+ // Why a sheet reading the geometry engine could not take (SheetFacts.readErrors) has no
66
+ // verdict. On a valid profile a read error is the checker's own limit, so it never blames the
67
+ // contour first.
68
+ export const SHEET_READ_ERROR_HINT = "The 2-D laser check could not finish on this profile (the engine stopped at the width in the message), so this reading was not taken; the part still builds. On a valid profile this is a limit of the checker, often near rounded corners or booleaned round holes — do not reshape the part for it; check widths near the one named by eye. Only if the profile really has overlapping or self-touching contours or a sliver, fix those and re-run.";
69
+
70
+ // The two errors a mark that turned out empty can raise: extruding an empty Shape2D
71
+ // (geometry/op-options.js, geometry/occt-backend.js) and an offset that collapses a
72
+ // shape (geometry/contour-offset.js). The preview build drops such a mark instead of
73
+ // failing — matched by MESSAGE so the build never queries geometry (see resolve.js).
74
+ export const EMPTY_MARK_RE = /is empty — nothing to build|collapses the shape/;
75
+
76
+ export const isSheetPart = (sp) =>
77
+ !!sp && typeof sp.sheet === "object" && sp.sheet !== null && typeof sp.sheet.process === "string";
78
+
79
+ // Stock grouping: pieces cut from the same material at the same thickness share
80
+ // sheets. The key is "<normalized material>|<thickness to 0.01 mm>".
81
+ export const normalizeMaterial = (m) => String(m).trim().toLowerCase().replace(/\s+/g, " ");
82
+ export const sheetGroup = (material, thickness) => `${normalizeMaterial(material)}|${thickness.toFixed(2)}`;
83
+
84
+ // Millimetres for messages: at most two decimals, no trailing zeros (3 → "3").
85
+ export const fmtMm = (x) => String(Number(x.toFixed(2)));
86
+
87
+ export const widthFloor = (t) => Math.max(WIDTH_FLOOR_FRACTION * t, WIDTH_FLOOR_MIN);
88
+
89
+ // A sheet sub-part's stock at these params — { process, material, thickness, group } —
90
+ // or null when the sub-part is not a sheet, a field is invalid, or evaluating one
91
+ // throws. `material` is returned as authored; `group` is the normalized key.
92
+ export function sheetMeta(sp, p, d) {
93
+ if (!isSheetPart(sp)) return null;
94
+ try {
95
+ const { process, material: m, thickness: t } = sp.sheet;
96
+ const material = typeof m === "function" ? m(p, d) : m;
97
+ const thickness = typeof t === "function" ? t(p, d) : t;
98
+ if (typeof material !== "string" || material.trim() === "") return null;
99
+ if (typeof thickness !== "number" || !Number.isFinite(thickness) || thickness <= 0) return null;
100
+ return { process, material, thickness, group: sheetGroup(material, thickness) };
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
@@ -0,0 +1,356 @@
1
+ // Joinery helpers for sheet parts — pure, kernel-free, plain data in and out. They
2
+ // belong in derive(): a joint is JSON-safe data, an outline is a plain CCW point list
3
+ // (no closing duplicate), a hole is an exact two-arc contour. Re-exported from
4
+ // partforge/geometry (geometry/polygon.js).
5
+ //
6
+ // Three numbers, never mixed:
7
+ // thickness the depth of a joint's band — the MATING sheet's measured thickness
8
+ // clearance the finished joint's TOTAL play: fingers split it between the two
9
+ // panels (c/4 per side each), tabs, nut traps and printed tongues put it
10
+ // all on the slot
11
+ // kerf never an argument — outputs are nominal material regions and the kit
12
+ // offsets the cut layer once at download
13
+ //
14
+ // sheetPanel's nominal edge lines are the box [0, W] × [0, H], edges in CCW order
15
+ // bottom → right → top → left, each measured from its own start. A joint lives in a
16
+ // band `thickness` wide along its edge: OUTER fingers and tab tongues protrude OUTSIDE
17
+ // the box, INNER fingers are notches INSIDE it. Plain, tab and T-slot edges have no
18
+ // band. Corner ownership belongs to the helper: where two banded edges meet, the
19
+ // corner square is material iff both edges own their ends — outer fingers own theirs,
20
+ // inner fingers do not — so outer+outer fills the square, inner+inner notches it, and
21
+ // a mixed corner leaves it empty. A band next to an inner band starts `thickness` in
22
+ // from the corner, which is what lets two panels' finger cells line up.
23
+ import { fmtMm } from "./constants.js";
24
+
25
+ // Metric screws for tSlots(): clearance hole (ISO 273, medium series) and hex nut
26
+ // across-flats / height (ISO 4032), mm.
27
+ export const JOINERY_SCREWS = Object.freeze({
28
+ "M2.5": Object.freeze({ hole: 2.9, nut: Object.freeze({ flats: 5.0, height: 2.0 }) }),
29
+ "M3": Object.freeze({ hole: 3.4, nut: Object.freeze({ flats: 5.5, height: 2.4 }) }),
30
+ "M4": Object.freeze({ hole: 4.5, nut: Object.freeze({ flats: 7.0, height: 3.2 }) }),
31
+ });
32
+
33
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
34
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
35
+ const isPt = (p) => Array.isArray(p) && p.length === 2 && isNum(p[0]) && isNum(p[1]);
36
+
37
+ function checkOptions(helper, o, keys, example) {
38
+ if (!isPlainObject(o)) throw new Error(`${helper}: expected an options object — ${example}`);
39
+ for (const key of Object.keys(o)) {
40
+ if (key === "kerf") throw new Error(`${helper}: kerf is not a joinery option — joinery is drawn nominal and kerf is chosen when you download the kit`);
41
+ if (!keys.includes(key)) throw new Error(`${helper}: unknown option "${key}" — the options are ${keys.join(", ")}`);
42
+ }
43
+ return o;
44
+ }
45
+
46
+ function positive(helper, key, v) {
47
+ if (!isNum(v) || v <= 0) throw new Error(`${helper}: ${key} must be a number > 0 (mm), got ${JSON.stringify(v)}`);
48
+ return v;
49
+ }
50
+
51
+ function clearanceFor(helper, v, thickness) {
52
+ if (!isNum(v) || v < 0 || v >= thickness)
53
+ throw new Error(`${helper}: clearance must be a number from 0 to less than the thickness (mm), got ${JSON.stringify(v)}`);
54
+ return v;
55
+ }
56
+
57
+ // --- joints (plain data) ---------------------------------------------------------
58
+
59
+ export function fingers(opts) {
60
+ const o = checkOptions("fingers", opts, ["thickness", "clearance", "finger", "side"], "fingers({ thickness, clearance, finger, side })");
61
+ const thickness = positive("fingers", "thickness", o.thickness);
62
+ const clearance = clearanceFor("fingers", o.clearance ?? 0.1, thickness);
63
+ const finger = positive("fingers", "finger", o.finger ?? 2 * thickness);
64
+ if (finger < 2 * thickness - 1e-9)
65
+ throw new Error(`fingers: finger must be at least 2 × thickness (${fmtMm(2 * thickness)} mm), got ${fmtMm(finger)}`);
66
+ const side = o.side ?? "outer";
67
+ if (side !== "outer" && side !== "inner") throw new Error(`fingers: side must be "outer" or "inner", got ${JSON.stringify(side)}`);
68
+ return { joint: "fingers", thickness, clearance, finger, side };
69
+ }
70
+
71
+ export function tabs(opts) {
72
+ const o = checkOptions("tabs", opts, ["thickness", "count", "width"], "tabs({ thickness, count, width })");
73
+ const thickness = positive("tabs", "thickness", o.thickness);
74
+ const count = o.count ?? 2;
75
+ if (!Number.isInteger(count) || count < 1 || count > 50)
76
+ throw new Error(`tabs: count must be a whole number from 1 to 50, got ${JSON.stringify(count)}`);
77
+ const width = positive("tabs", "width", o.width ?? 3 * thickness);
78
+ return { joint: "tabs", thickness, count, width };
79
+ }
80
+
81
+ export function tSlots(opts) {
82
+ const o = checkOptions("tSlots", opts, ["thickness", "screw", "screwLength", "at", "clearance"], "tSlots({ thickness, screw, screwLength, at, clearance })");
83
+ const thickness = positive("tSlots", "thickness", o.thickness);
84
+ const screw = o.screw ?? "M3";
85
+ if (typeof screw !== "string" || !Object.hasOwn(JOINERY_SCREWS, screw))
86
+ throw new Error(`tSlots: screw must be one of ${Object.keys(JOINERY_SCREWS).join(", ")}, got ${JSON.stringify(screw)}`);
87
+ const screwLength = positive("tSlots", "screwLength", o.screwLength ?? 12);
88
+ const at = o.at ?? [0.5];
89
+ if (!Array.isArray(at) || at.length === 0 || !at.every((f) => isNum(f) && f > 0 && f < 1))
90
+ throw new Error(`tSlots: at must be an array of fractions between 0 and 1, got ${JSON.stringify(at)}`);
91
+ const clearance = clearanceFor("tSlots", o.clearance ?? 0.2, thickness);
92
+ const { nut } = JOINERY_SCREWS[screw];
93
+ const reach = screwLength - thickness;
94
+ const need = nut.height + clearance / 2 + 1.5; // nut trap + 1 mm of material before it
95
+ if (reach < need)
96
+ throw new Error(`tSlots: an ${screw} × ${fmtMm(screwLength)} mm screw reaches ${reach.toFixed(2)} mm past a ${thickness.toFixed(2)} mm panel — the nut trap needs at least ${need.toFixed(2)} mm; use a longer screw`);
97
+ return { joint: "tslots", thickness, screw, screwLength, at: [...at].sort((a, b) => a - b), clearance };
98
+ }
99
+
100
+ // --- panels ----------------------------------------------------------------------
101
+
102
+ const EDGE_NAMES = ["bottom", "right", "top", "left"];
103
+ const isJoint = (j) => isPlainObject(j) && (j.joint === "fingers" || j.joint === "tabs" || j.joint === "tslots");
104
+ const bandInside = (j) => j?.joint === "fingers" && j.side === "inner";
105
+
106
+ // One edge's boundary in its own frame: [s, n] points from s = 0 to s = L, where s
107
+ // runs along the edge from its start and n is the offset OUTWARD from the nominal
108
+ // line (+ protrudes, − notches). `head`/`tail` are how far the band starts in from
109
+ // each corner (a neighbouring inner band's thickness, else 0).
110
+ function fingerPoints(j, L, head, tail) {
111
+ const t = j.thickness, q = j.clearance / 4;
112
+ const span = L - head - tail;
113
+ let n = Math.floor(span / j.finger + 1e-9);
114
+ if (n % 2 === 0) n -= 1;
115
+ n = Math.max(n, 3);
116
+ const cell = span / n;
117
+ if (cell < 2 * t - 1e-9)
118
+ throw new Error(`fingers: a ${span.toFixed(2)} mm edge is too short — 3 fingers of at least ${(2 * t).toFixed(2)} mm (2 × thickness) need ${(6 * t).toFixed(2)} mm`);
119
+ const outer = j.side === "outer";
120
+ // Cell k (0-based) carries this panel's material iff (k even) === outer; the
121
+ // interior boundary between two cells moves q = c/4 INTO the material cell, so a
122
+ // finger is c/4 narrower and a notch c/4 wider per interior side.
123
+ const isMaterial = (k) => (k % 2 === 0) === outer;
124
+ const offset = (k) => (outer ? (isMaterial(k) ? t : 0) : (isMaterial(k) ? 0 : -t));
125
+ const cuts = [];
126
+ for (let k = 1; k < n; k++) cuts.push(head + k * cell + (isMaterial(k) ? q : -q));
127
+ const runs = []; // [from, to, n] covering [0, L]
128
+ if (head > 0) runs.push([0, head, outer ? 0 : -t]); // outer: empty mixed corner; inner: notched square
129
+ const bounds = [head, ...cuts, L - tail];
130
+ for (let k = 0; k < n; k++) runs.push([bounds[k], bounds[k + 1], offset(k)]);
131
+ if (tail > 0) runs.push([L - tail, L, outer ? 0 : -t]);
132
+ return runsToPoints(runs);
133
+ }
134
+
135
+ function runsToPoints(runs) {
136
+ const merged = [];
137
+ for (const r of runs) {
138
+ const last = merged[merged.length - 1];
139
+ if (last && last[2] === r[2]) last[1] = r[1];
140
+ else merged.push([...r]);
141
+ }
142
+ const pts = [[merged[0][0], merged[0][2]]];
143
+ for (let i = 0; i < merged.length; i++) {
144
+ pts.push([merged[i][1], merged[i][2]]);
145
+ if (i + 1 < merged.length) pts.push([merged[i][1], merged[i + 1][2]]);
146
+ }
147
+ return pts;
148
+ }
149
+
150
+ // Tongues centred at (i + 0.5)·L/count, each `width` wide, protruding `thickness`;
151
+ // they must stay apart and clear of a neighbouring inner band (head/tail).
152
+ function tabPoints(j, L, head, tail) {
153
+ const pitch = L / j.count;
154
+ if (j.width >= pitch - 1e-9 || pitch / 2 - j.width / 2 < head - 1e-9 || pitch / 2 - j.width / 2 < tail - 1e-9)
155
+ throw new Error(`tabs: ${j.count} tabs of ${fmtMm(j.width)} mm do not fit a ${L.toFixed(2)} mm edge`);
156
+ const pts = [[0, 0]];
157
+ for (let i = 0; i < j.count; i++) {
158
+ const c = (i + 0.5) * pitch, a = c - j.width / 2, b = c + j.width / 2;
159
+ pts.push([a, 0], [a, j.thickness], [b, j.thickness], [b, 0]);
160
+ }
161
+ pts.push([L, 0]);
162
+ return pts;
163
+ }
164
+
165
+ // Each T-slot, into the panel from the nominal edge: a shank slot `hole` wide and
166
+ // reach + 1 deep (reach = screwLength − thickness, the screw's length past the mating
167
+ // panel), crossed by a nut trap (flats + c) along the edge × (nut height + c) deep whose
168
+ // far face sits 0.5 mm short of the screw tip.
169
+ function tSlotPoints(j, L, name, head, tail) {
170
+ const { hole, nut } = JOINERY_SCREWS[j.screw];
171
+ const c = j.clearance, reach = j.screwLength - j.thickness;
172
+ const near = reach - nut.height - 0.5 - c / 2; // trap's near face, depth from the edge
173
+ const far = reach - 0.5 + c / 2; // trap's far face
174
+ const shank = reach + 1; // shank slot depth
175
+ const ws = hole / 2, wt = (nut.flats + c) / 2;
176
+ const pts = [[0, 0]];
177
+ let prevEnd = head;
178
+ for (const f of j.at) {
179
+ const s = f * L;
180
+ if (s - wt < prevEnd + 1e-9 || s + wt > L - tail - 1e-9)
181
+ throw new Error(`sheetPanel: edges.${name} has a T-slot at ${f} that does not fit its ${L.toFixed(2)} mm edge`);
182
+ prevEnd = s + wt;
183
+ pts.push(
184
+ [s - ws, 0], [s - ws, -near], [s - wt, -near], [s - wt, -far], [s - ws, -far], [s - ws, -shank],
185
+ [s + ws, -shank], [s + ws, -far], [s + wt, -far], [s + wt, -near], [s + ws, -near], [s + ws, 0],
186
+ );
187
+ }
188
+ pts.push([L, 0]);
189
+ return pts;
190
+ }
191
+
192
+ // Drop repeated points and the middle of straight runs (cyclic).
193
+ function cleanRing(pts) {
194
+ const eq = (a, b) => Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
195
+ let ring = pts.filter((p, i) => !eq(p, pts[(i + 1) % pts.length]));
196
+ let changed = true;
197
+ while (changed && ring.length > 3) {
198
+ changed = false;
199
+ for (let i = 0; i < ring.length; i++) {
200
+ const a = ring[(i - 1 + ring.length) % ring.length], b = ring[i], c = ring[(i + 1) % ring.length];
201
+ const cross = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]);
202
+ const dot = (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]);
203
+ if (Math.abs(cross) < 1e-9 && dot > 0) { ring.splice(i, 1); changed = true; break; }
204
+ }
205
+ }
206
+ return ring;
207
+ }
208
+
209
+ // Every outline is rectilinear, so "does it cross itself" is a cheap exact test on
210
+ // axis-aligned segments — the only way two edges' joints can collide (a T-slot or a
211
+ // tongue crowding a corner, a T-slot deeper than the panel).
212
+ function assertSimple(ring) {
213
+ const n = ring.length;
214
+ const seg = (i) => [ring[i], ring[(i + 1) % n]];
215
+ const lo = (a, b) => Math.min(a, b), hi = (a, b) => Math.max(a, b);
216
+ for (let i = 0; i < n; i++) {
217
+ for (let k = i + 2; k < n; k++) {
218
+ if (i === 0 && k === n - 1) continue; // the closing pair shares a vertex
219
+ const [a, b] = seg(i), [c, d] = seg(k);
220
+ const ox = lo(hi(a[0], b[0]), hi(c[0], d[0])) - hi(lo(a[0], b[0]), lo(c[0], d[0]));
221
+ const oy = lo(hi(a[1], b[1]), hi(c[1], d[1])) - hi(lo(a[1], b[1]), lo(c[1], d[1]));
222
+ if (ox > -1e-9 && oy > -1e-9)
223
+ throw new Error(`sheetPanel: the joints collide — the outline crosses itself near [${fmtMm(a[0])}, ${fmtMm(a[1])}]; move the tabs or T-slots away from each other and from the corners`);
224
+ }
225
+ }
226
+ }
227
+
228
+ export function sheetPanel(opts) {
229
+ const o = checkOptions("sheetPanel", opts, ["width", "height", "edges"], "sheetPanel({ width, height, edges: { bottom, right, top, left } })");
230
+ const W = positive("sheetPanel", "width", o.width);
231
+ const H = positive("sheetPanel", "height", o.height);
232
+ const edges = o.edges ?? {};
233
+ if (!isPlainObject(edges)) throw new Error("sheetPanel: edges must be an object — { bottom, right, top, left }, each a joint or omitted");
234
+ for (const [key, j] of Object.entries(edges)) {
235
+ if (!EDGE_NAMES.includes(key)) throw new Error(`sheetPanel: unknown edge "${key}" — the edges are ${EDGE_NAMES.join(", ")}`);
236
+ if (j !== undefined && !isJoint(j)) throw new Error(`sheetPanel: edges.${key} is not a joint — build it with fingers(), tabs() or tSlots()`);
237
+ }
238
+ // CCW: start, direction, outward normal, nominal length.
239
+ const frames = [
240
+ { name: "bottom", S: [0, 0], dir: [1, 0], out: [0, -1], L: W },
241
+ { name: "right", S: [W, 0], dir: [0, 1], out: [1, 0], L: H },
242
+ { name: "top", S: [W, H], dir: [-1, 0], out: [0, 1], L: W },
243
+ { name: "left", S: [0, H], dir: [0, -1], out: [-1, 0], L: H },
244
+ ];
245
+ const joints = frames.map((f) => edges[f.name]);
246
+ const local = frames.map((f, i) => {
247
+ const j = joints[i], prev = joints[(i + 3) % 4], next = joints[(i + 1) % 4];
248
+ if (!j) return [[0, 0], [f.L, 0]];
249
+ const head = bandInside(prev) ? prev.thickness : 0, tail = bandInside(next) ? next.thickness : 0;
250
+ if (j.joint === "fingers") return fingerPoints(j, f.L, head, tail);
251
+ if (j.joint === "tabs") return tabPoints(j, f.L, head, tail);
252
+ return tSlotPoints(j, f.L, f.name, head, tail);
253
+ });
254
+ const toWorld = (f, [s, n]) => [f.S[0] + s * f.dir[0] + n * f.out[0], f.S[1] + s * f.dir[1] + n * f.out[1]];
255
+ const ring = [];
256
+ frames.forEach((f, i) => {
257
+ const pf = frames[(i + 3) % 4], prevPts = local[(i + 3) % 4], pts = local[i];
258
+ const nPrev = prevPts[prevPts.length - 1][1], nThis = pts[0][1];
259
+ // The corner join: where the previous edge's last offset line meets this edge's first.
260
+ ring.push([f.S[0] + nPrev * pf.out[0] + nThis * f.out[0], f.S[1] + nPrev * pf.out[1] + nThis * f.out[1]]);
261
+ for (const p of pts.slice(1, -1)) ring.push(toWorld(f, p));
262
+ });
263
+ const outline = cleanRing(ring).map(([x, y]) => [x + 0, y + 0]);
264
+ assertSimple(outline);
265
+ const xs = outline.map((p) => p[0]), ys = outline.map((p) => p[1]);
266
+ return { outline, size: [Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)] };
267
+ }
268
+
269
+ // --- mates -----------------------------------------------------------------------
270
+
271
+ // The holes the OTHER panel needs for a tabs or T-slot edge. `line` is the tabbed
272
+ // panel's MID-PLANE line drawn in the slotted panel's frame, line[0] matching the
273
+ // tabbed edge's start (positions along it are fractions of its length, so it should be
274
+ // as long as the tabbed edge). Tabs → (width + c) × (thickness + c) rectangles centred
275
+ // on the line (c defaults to 0.1); T-slots → sheetHole({ d: hole + c }) at the same
276
+ // fractions (c defaults to 0).
277
+ export function matchingSlots(joint, opts) {
278
+ if (!isJoint(joint)) throw new Error("matchingSlots: the first argument must be a joint from fingers(), tabs() or tSlots()");
279
+ if (joint.joint === "fingers")
280
+ throw new Error('matchingSlots: fingers mate edge to edge and need no slots — give the other panel\'s edge fingers({ …, side: "inner" }) (or "outer")');
281
+ const o = checkOptions("matchingSlots", opts, ["line", "clearance"], "matchingSlots(joint, { line: [[x0, y0], [x1, y1]], clearance })");
282
+ if (!Array.isArray(o.line) || o.line.length !== 2 || !o.line.every(isPt)) throw new Error("matchingSlots: line must be two finite [x, y] points");
283
+ const [p0, p1] = o.line;
284
+ const L = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
285
+ if (L < 1e-9) throw new Error("matchingSlots: line must be two finite [x, y] points");
286
+ const dir = [(p1[0] - p0[0]) / L, (p1[1] - p0[1]) / L], nrm = [-dir[1], dir[0]];
287
+ const along = (f) => [p0[0] + dir[0] * f * L, p0[1] + dir[1] * f * L];
288
+ if (joint.joint === "tabs") {
289
+ const c = clearanceFor("matchingSlots", o.clearance ?? 0.1, joint.thickness);
290
+ const a = (joint.width + c) / 2, b = (joint.thickness + c) / 2;
291
+ return Array.from({ length: joint.count }, (_, i) => {
292
+ const m = along((i + 0.5) / joint.count);
293
+ return [[-a, -b], [a, -b], [a, b], [-a, b]].map(([u, v]) => [m[0] + u * dir[0] + v * nrm[0] + 0, m[1] + u * dir[1] + v * nrm[1] + 0]);
294
+ });
295
+ }
296
+ const c = clearanceFor("matchingSlots", o.clearance ?? 0, joint.thickness);
297
+ return joint.at.map((f) => sheetHole({ d: JOINERY_SCREWS[joint.screw].hole + c, at: along(f) }));
298
+ }
299
+
300
+ // A hole for a printed tongue, and the tongue itself, from ONE spec. The slot is a
301
+ // CCW (w + c) × (h + c) rectangle centred at the origin (w along x) — all the play is
302
+ // on the slot; the tongue [w, h, thickness] is k.box({ size: tongue }) (centred in XY,
303
+ // base at z = 0).
304
+ export function printedTab(opts) {
305
+ const o = checkOptions("printedTab", opts, ["size", "thickness", "clearance"], "printedTab({ size: [w, h], thickness, clearance })");
306
+ if (!Array.isArray(o.size) || o.size.length !== 2 || !o.size.every((v) => isNum(v) && v > 0))
307
+ throw new Error(`printedTab: size must be two numbers > 0 (mm), got ${JSON.stringify(o.size)}`);
308
+ const thickness = positive("printedTab", "thickness", o.thickness);
309
+ const c = clearanceFor("printedTab", o.clearance ?? 0.3, thickness);
310
+ const [w, h] = o.size, a = (w + c) / 2, b = (h + c) / 2;
311
+ return { slot: [[-a, -b], [a, -b], [a, b], [-a, b]], tongue: [w, h, thickness] };
312
+ }
313
+
314
+ // An arc-exact round hole: a CCW circle of diameter d as two three-point arcs — the
315
+ // contour circleProfile(d / 2, at) returns, named by diameter. Prefer either to
316
+ // circlePolygon (a 48-gon) on a sheet part — the cut file keeps a true circle.
317
+ export function sheetHole(opts) {
318
+ const o = checkOptions("sheetHole", opts, ["d", "at"], "sheetHole({ d, at: [x, y] })");
319
+ if (!isNum(o.d) || o.d <= 0) throw new Error(`sheetHole: d must be a number > 0 (mm), got ${JSON.stringify(o.d)}`);
320
+ if (!isPt(o.at)) throw new Error("sheetHole: at must be a finite [x, y]");
321
+ const r = o.d / 2, [x, y] = o.at;
322
+ return { start: [x + r, y], segments: [{ to: [x - r, y], via: [x, y + r] }, { to: [x + r, y], via: [x, y - r] }] };
323
+ }
324
+
325
+ // --- the five-panel box ------------------------------------------------------------
326
+
327
+ // An open-top finger-jointed box, W × D × H outside (bottom included). Every outline is
328
+ // in its own drawing frame, seen from OUTSIDE, with [0, 0] at the box's outer corner
329
+ // named by its pose's `at` and bbox [0, 0]–size. Joints: front/back vertical edges
330
+ // outer, left/right vertical edges inner, wall bottom edges outer, all four bottom
331
+ // edges inner, top edges plain — so every corner cube has exactly one owner.
332
+ export function fingerBox(opts) {
333
+ const o = checkOptions("fingerBox", opts, ["width", "depth", "height", "thickness", "clearance", "finger"], "fingerBox({ width, depth, height, thickness, clearance, finger })");
334
+ const W = positive("fingerBox", "width", o.width);
335
+ const D = positive("fingerBox", "depth", o.depth);
336
+ const H = positive("fingerBox", "height", o.height);
337
+ const t = positive("fingerBox", "thickness", o.thickness);
338
+ const outer = fingers({ thickness: t, clearance: o.clearance ?? 0.1, finger: o.finger ?? 2 * t, side: "outer" });
339
+ const inner = { ...outer, side: "inner" };
340
+ const panel = (name, width, height, edges, [dx, dy], pose) => {
341
+ let built;
342
+ try { built = sheetPanel({ width, height, edges }); }
343
+ catch (e) { throw new Error(`fingerBox: the ${name} panel — ${e.message}`, { cause: e }); }
344
+ return { outline: built.outline.map(([x, y]) => [x + dx + 0, y + dy + 0]), size: built.size, pose };
345
+ };
346
+ const wall = { bottom: outer, right: outer, left: outer };
347
+ const side = { bottom: outer, right: inner, left: inner };
348
+ return {
349
+ bottom: panel("bottom", W, D, { bottom: inner, right: inner, top: inner, left: inner }, [0, 0],
350
+ { face: "-Z", up: "+Y", at: [W / 2, -D / 2, 0] }),
351
+ front: panel("front", W - 2 * t, H - t, wall, [t, t], { face: "-Y", up: "+Z", at: [-W / 2, -D / 2, 0] }),
352
+ back: panel("back", W - 2 * t, H - t, wall, [t, t], { face: "+Y", up: "+Z", at: [W / 2, D / 2, 0] }),
353
+ left: panel("left", D, H - t, side, [0, t], { face: "-X", up: "+Z", at: [-W / 2, D / 2, 0] }),
354
+ right: panel("right", D, H - t, side, [0, t], { face: "+X", up: "+Z", at: [W / 2, -D / 2, 0] }),
355
+ };
356
+ }
@@ -0,0 +1,98 @@
1
+ // sheetPart(spec) — a sub-part cut from flat stock (laser-cut plywood, acrylic, MDF).
2
+ // It returns an ORDINARY sub-part: `build` and (when there is a pose or an author
3
+ // place) `place` are filled in, and a plain-data `sheet` declaration is attached. So
4
+ // `build` stays required everywhere, and nothing that reads `sp.build` has to change.
5
+ //
6
+ // `sp.sheet` is the ONLY marker (isSheetPart in constants.js): a shallow-frozen plain
7
+ // object, never a class instance, Symbol() or WeakMap entry — the cloud part worker
8
+ // has two module instances of this code and identity would never match across them.
9
+ // `sp.sheet.generatedBuild` is the build made here; an author who replaces `build`
10
+ // afterwards is detectable as `sp.build !== sp.sheet.generatedBuild`.
11
+ //
12
+ // Placement (decision 10): the pose applies for display AND export, then the author's
13
+ // own `place` — so STEP/3MF/STL files of a sheet forge come out assembled.
14
+ import { RENAMED_KEYS, RESERVED_KEYS, SHEET_KEYS, SUBPART_PASSTHROUGH_KEYS } from "./constants.js";
15
+ import { validatePose } from "./pose.js";
16
+ import { applyPose, resolveSheet, sheetPreview } from "./resolve.js";
17
+ import { PROCESSES, PROCESS_IDS, processById } from "../process/registry.js";
18
+
19
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
20
+ const KNOWN = [...SHEET_KEYS, ...SUBPART_PASSTHROUGH_KEYS];
21
+ const KEY_LIST = `${SHEET_KEYS.join(", ")}, plus the sub-part keys ${SUBPART_PASSTHROUGH_KEYS.join(", ")}`;
22
+ const optionalFn = (v) => v === undefined || v === null || typeof v === "function";
23
+
24
+ // Every mistake throws here, at module load, naming the fix — in the order a reader
25
+ // would want them: the wrong-word keys first, then each field.
26
+ function checkSpec(spec) {
27
+ if (!isPlainObject(spec)) throw new Error("sheetPart: expected an options object — sheetPart({ material, thickness, profile, views, … })");
28
+ const keys = Object.keys(spec);
29
+ if (keys.includes("kerf")) throw new Error("sheetPart: kerf is chosen when you download the kit — use a clearance control");
30
+ const renamed = keys.find((key) => Object.hasOwn(RENAMED_KEYS, key));
31
+ if (renamed) throw new Error(`sheetPart: "${renamed}" is not a sheetPart key — use ${RENAMED_KEYS[renamed]}`);
32
+ if (keys.includes("quantity")) throw new Error('sheetPart: "quantity" is not a sheetPart key — the kit counts identical pieces');
33
+ if (keys.includes("build")) throw new Error("sheetPart: sheetPart supplies build — draw the part with profile, score and engrave");
34
+ const reservedKeys = [...RESERVED_KEYS, ...PROCESSES.flatMap((proc) => proc.reservedKeys ?? [])];
35
+ const reserved = keys.find((key) => reservedKeys.includes(key));
36
+ if (reserved) throw new Error(`sheetPart: "${reserved}" is reserved for a future process and not supported yet`);
37
+ const unknown = keys.find((key) => !KNOWN.includes(key));
38
+ if (unknown) throw new Error(`sheetPart: unknown key "${unknown}" — the keys are ${KEY_LIST}`);
39
+
40
+ const process = spec.process ?? "laser";
41
+ if (!processById(process)) throw new Error(`sheetPart: unknown process ${JSON.stringify(process)} — valid: ${PROCESS_IDS.join(", ")}`);
42
+ const { material: m, thickness: t } = spec;
43
+ if (!(typeof m === "function" || (typeof m === "string" && m.trim() !== "")))
44
+ throw new Error('sheetPart: material must be a non-empty string or a (p, d) => string function — a stock label like "birch plywood"');
45
+ if (!(typeof t === "function" || (typeof t === "number" && Number.isFinite(t) && t > 0)))
46
+ throw new Error("sheetPart: thickness must be a number > 0 or a (p, d) => number function — the MEASURED sheet thickness in mm");
47
+ if (typeof spec.profile !== "function") throw new Error("sheetPart: profile must be a function (k, p, d) => Shape2D or profile");
48
+ if (!optionalFn(spec.score)) throw new Error("sheetPart: score must be a function (k, p, d) => [[x, y], [x, y]] lines and shapes, or omitted");
49
+ if (!optionalFn(spec.engrave)) throw new Error("sheetPart: engrave must be a function (k, p, d) => Shape2D or profile (or null), or omitted");
50
+ const pose = spec.pose;
51
+ if (pose !== undefined && pose !== null && typeof pose !== "function") {
52
+ if (!isPlainObject(pose)) throw new Error("sheetPart: pose must be a { face, up, at } object or a (p, d) => pose function");
53
+ const reason = validatePose(pose);
54
+ if (reason) throw new Error(`sheetPart: ${reason}`);
55
+ }
56
+ if (spec.place !== undefined && typeof spec.place !== "function") throw new Error("sheetPart: place must be a function (solid, ctx) => solid");
57
+ return process;
58
+ }
59
+
60
+ export function sheetPart(spec) {
61
+ const process = checkSpec(spec);
62
+ const sub = {};
63
+ for (const key of SUBPART_PASSTHROUGH_KEYS)
64
+ if (key !== "place" && spec[key] !== undefined) sub[key] = spec[key];
65
+
66
+ const generatedBuild = (k, p, d) => {
67
+ const s = resolveSheet(k, sub, p, d);
68
+ return (processById(s.process).preview ?? sheetPreview)(k, s);
69
+ };
70
+ const sheet = Object.freeze({
71
+ process,
72
+ material: spec.material,
73
+ thickness: spec.thickness,
74
+ profile: spec.profile,
75
+ score: spec.score ?? null,
76
+ engrave: spec.engrave ?? null,
77
+ pose: spec.pose ?? null,
78
+ generatedBuild,
79
+ });
80
+ sub.build = generatedBuild;
81
+
82
+ const authorPlace = spec.place;
83
+ if (sheet.pose !== null || authorPlace) {
84
+ sub.place = (solid, ctx) => {
85
+ const pose = typeof sheet.pose === "function" ? sheet.pose(ctx.p, ctx.d) : sheet.pose;
86
+ let posed = solid;
87
+ if (pose !== null) {
88
+ const reason = validatePose(pose);
89
+ if (reason) throw new Error(`sheet pose: ${reason}`);
90
+ const t = typeof sheet.thickness === "function" ? sheet.thickness(ctx.p, ctx.d) : sheet.thickness;
91
+ posed = applyPose(solid, pose, t);
92
+ }
93
+ return authorPlace ? authorPlace(posed, ctx) : posed;
94
+ };
95
+ }
96
+ sub.sheet = sheet;
97
+ return sub;
98
+ }