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.
- package/bin/cli.js +12 -2
- package/docs/AUTHORING-PARTS.md +304 -1
- package/docs/KERNEL-CONTRACT.md +40 -10
- package/package.json +1 -1
- package/src/framework/geometry/arc-fit.js +169 -36
- package/src/framework/geometry/arc-math.js +15 -7
- package/src/framework/geometry/contour-offset.js +132 -5
- package/src/framework/geometry/polygon.js +7 -0
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-schema.js +2 -1
- package/src/framework/lint/rules-shape.js +10 -3
- package/src/framework/lint/rules-sheet.js +172 -0
- package/src/framework/lint/rules-verify.js +10 -0
- package/src/framework/materials/environments.js +1 -1
- package/src/framework/materials/presets.js +2 -2
- package/src/framework/oracle/gates.js +13 -0
- package/src/framework/oracle/measure.js +129 -5
- package/src/framework/oracle/verify.js +113 -5
- package/src/framework/process/laser/descriptor.js +1089 -0
- package/src/framework/process/registry.js +24 -0
- package/src/framework/sheet/constants.js +104 -0
- package/src/framework/sheet/joinery.js +356 -0
- package/src/framework/sheet/part.js +98 -0
- package/src/framework/sheet/pose.js +114 -0
- package/src/framework/sheet/resolve.js +133 -0
- package/src/framework/verify-metrics.js +8 -1
- package/src/parts/laser-box.js +108 -0
- package/src/parts/material-swatches.js +23 -1
- package/types/geometry.d.ts +115 -0
- package/types/lint.d.ts +4 -1
- package/types/oracle.d.ts +1 -1
- package/types/part.d.ts +57 -1
- 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
|
+
}
|