@rulecad/cad 0.0.0-stage → 0.1.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,308 @@
1
+ // Assemblies: objects placed together, in projects/<project>/assemblies/<name>.json.
2
+ // kind "fit": to check how parts go together (references allowed: the switch a keycap fits on). Supports are
3
+ // left out: they're removed before parts go together.
4
+ // kind "plate": a print plate, everything laid out on the bed and exported as one 3MF
5
+ // {
6
+ // "title": "Keycap on a switch", "kind": "fit", "uses": ["mx"],
7
+ // "requires": { "keycap": "^0.2.0" }, versions each object must be (^ same major, ~ same minor)
8
+ // "items": [ { "id": "switch", "object": "mx-switch" },
9
+ // { "id": "cap", "object": "keycap", "at": [0, 0, "switch.housing_top - cap.lift"], "rotate": [0, 0, 90] } ],
10
+ // "fits": [ { "a": "cap.cap", "b": "switch.stem", "min": 0, "max": "x1c.fit", "note": "slot over the stem" } ]
11
+ // }
12
+ // Positions are expressions over rules (mx.pitch) and items' variables (cap.lift); rotate is degrees about x, y, z,
13
+ // applied before at (a repeated item turns as a whole, its copies with it). An item can pin a release: "object": "keycap@0.2.1". fits measure the closest gap between two
14
+ // items or item.parts; a negative min allows a press fit to overlap.
15
+ import fs from 'node:fs';
16
+ import crypto from 'node:crypto';
17
+ import path from 'node:path';
18
+ import { evaluate, loadRuleSet } from './rules.mjs';
19
+ import { paths, printerOf } from './workspace.mjs';
20
+ import { locate, build, readObject, jscad } from './design.mjs';
21
+ import { materialise } from './versions.mjs';
22
+ import { satisfies } from '@rulecad/versions';
23
+ import { bounds } from '@rulecad/printcheck';
24
+ import { closest } from '@rulecad/fit';
25
+ import { keys as keyCentres, readLayout, hasLayout } from './layout.mjs';
26
+
27
+ export const assemblyPath = (ref) => { const [project, name] = String(ref).split('/'); return path.join(paths.project(project || ''), 'assemblies', `${name || ''}.json`); };
28
+ export const isAssembly = (ref) => String(ref).includes('/') && fs.existsSync(assemblyPath(ref.split('@')[0]));
29
+
30
+ export { satisfies }; // from @rulecad/versions
31
+
32
+ // buildAssembly('keyboard/keycap-on-switch') -> { a, project, name, items, parts, objects, fits, requires, values, num }
33
+ // parts: [{ name: 'cap.cap', item, leaf, role, label, geometry, polygons, triangles, shape, m }], placed. item: the
34
+ // top-level item it belongs to; leaf: the object instance (key-0-0.cap), for overlap checks inside components too;
35
+ // shape: which built object part it is (the same for every copy), with its faces unmoved in local and localTriangles,
36
+ // and m its placement (a JSCAD mat4), so repeated shapes can be sent once and placed (shapes()).
37
+ // objects: every object instance, nested ones included; fits and requires: this assembly's and its components'.
38
+ // An item is an object ("object": "keycap") or another assembly ("assembly": "keycap-on-switch"), placed by at and
39
+ // rotate. "repeat": { "x": { "count": "plate.cols", "step": "mx.pitch" }, "y": { … } } makes a grid of copies, with
40
+ // ids like key-0-2 (column, row) and at as the first copy's place.
41
+ // "repeat": { "layout": "plate/staggered-54", "step": "mx.pitch", "width": "cap.units" } places a copy at each key of
42
+ // an object's layout (layouts in plate.json), centred on at, with ids like key-0-11 (row, key; row 0 at the back).
43
+ // width names a variable set to each key's width in units: in a component, <item>.<variable>. "rows": { "cap.row":
44
+ // ["R1", "R2", …] } sets a variable to one value per row, and "each": { "cap.profile": "oem" } one value for every copy.
45
+ // With a layout file ("layout": "staggered", in projects/<project>/layouts/), "per": { "cap.legend": "legend",
46
+ // "cap.row": "row" } sets inputs from each key's own properties: the layout says what each key is.
47
+ // "place": false builds an item only for its values (what other items' from and positions read): nothing of it is placed,
48
+ // checked or printed.
49
+ // An object item can be a variant: "object": "keycap:oem-r3" (or "variant": "oem-r3"), and pin a release: "keycap@0.4.0".
50
+ // overrides: { itemId: { variable: value } }, set on this assembly's items by the assembly that uses it.
51
+ // opts: { mirror: true } builds each object with its mirror settings (cad show).
52
+ export async function buildAssembly(ref, stack = [], overrides = {}, opts = {}) {
53
+ const [project, name] = ref.split('/');
54
+ if (stack.includes(ref)) throw new Error(`assembly ${ref} contains itself (${[...stack, ref].join(' → ')})`);
55
+ const a = JSON.parse(fs.readFileSync(assemblyPath(ref), 'utf8'));
56
+ const printer = printerOf(project);
57
+ if (a.kind === 'plate' && !(a.uses || []).includes(printer)) a.uses = [...(a.uses || []), printer]; // a plate is checked as a print
58
+ const values = Object.fromEntries((a.uses || []).map((u) => { const s = loadRuleSet(u); return [u, Object.fromEntries(Object.entries(s.rules).map(([k, r]) => [k, r.value]))]; }));
59
+
60
+ // Names in positions: rule sets (mx.pitch), items' variables (cap.lift), or into components (key-0-0.cap.lift).
61
+ // Rule values may still be expressions (rules referring to rules): resolved on demand.
62
+ const seen = new Set(), ids = [];
63
+ const lookup = (n) => {
64
+ const path = n.split('.');
65
+ if ((a.uses || []).includes(path[0])) seen.add(n);
66
+ if (!(path[0] in values)) throw new Error(`"${n}": no item or rule set "${path[0]}" (items: ${ids.join(', ') || 'none yet'}; uses: ${(a.uses || []).join(', ') || 'none'})`);
67
+ let v = values;
68
+ for (const k of path) v = v?.[k];
69
+ if (typeof v === 'string') v = evaluate(v, (k) => lookup(k.includes('.') ? k : `${path.slice(0, -1).join('.')}.${k}`));
70
+ if (typeof v !== 'number') throw new Error(`"${n}" isn't a number`);
71
+ return v;
72
+ };
73
+ const num = (x) => evaluate(x, lookup);
74
+ const { translate, rotate, transform } = jscad.transforms, deg = Math.PI / 180;
75
+
76
+ const items = [], parts = [], objects = [], fits = [], requires = [];
77
+ for (const [k, r] of Object.entries(a.requires || {})) requires.push({ object: k, range: r, from: ref });
78
+ const built = new Map(); // each object or component is built once per set of values, however many copies are placed
79
+ const source = async (it, extra) => {
80
+ // from: inputs worked out from items built before this one ({ "usb_y": "board.pcb.pico_y" }): numbers, in this
81
+ // assembly's names, so a part can take a value only another part knows once it's built
82
+ const from = Object.fromEntries(Object.entries(it.from || {}).map(([k, x]) => [k, num(x)]));
83
+ const set = { ...(it.set || {}), ...from, ...(overrides[it.id] || {}), ...(it.assembly ? {} : extra) };
84
+ // Built for the mirror, an object's mirror settings win (no legends), so copies that differ only there are one shape
85
+ if (opts.mirror && it.object) for (const k of Object.keys(readObject(locate(`${project}/${String(it.object).split(/[:@]/)[0]}`)).mirror || {})) delete set[k];
86
+ const sub = it.assembly ? extra : {}; // a component's values go to its items: { cap: { units: 1.5 } }
87
+ const key = JSON.stringify([it.object, it.variant, it.assembly, set, sub]);
88
+ if (!built.has(key)) {
89
+ if (it.assembly) built.set(key, { key, sub: await buildAssembly(`${project}/${it.assembly}`, [...stack, ref], sub, opts) });
90
+ else {
91
+ const [ref, named] = String(it.object).split(':'), [objName, pin] = ref.split('@'), variant = named || it.variant;
92
+ const d = locate(`${project}/${objName}`), at = { ...(pin ? materialise(d, pin) : d), ...(variant ? { variant } : {}) };
93
+ const b = await build(at, set, opts);
94
+ built.set(key, { key, objName, b, version: pin || b.object.version, variant, role: ['reference', 'board'].includes(b.object.role) ? 'reference' : 'part' }); // a board is made by a fab: checked, never printed
95
+ }
96
+ }
97
+ return built.get(key);
98
+ };
99
+ const placing = [];
100
+ for (const it of a.items || []) {
101
+ if (!/^[a-z][\w-]*$/.test(it.id || '')) throw new Error(`each item needs an id (letters, digits, - or _): got ${JSON.stringify(it.id)}`);
102
+ if (!it.object === !it.assembly) throw new Error(`item ${it.id}: give either "object" or "assembly"`);
103
+ // Copies: one, a grid from repeat x/y, or a layout's keys. Each: { id, offset (from at), extra (values to set) }
104
+ const rx = it.repeat?.x, ry = it.repeat?.y, rl = it.repeat?.layout, copies = [];
105
+ if (rl) {
106
+ // A layout file in the project's layouts/, or (older assemblies) object/name: a layout kept in an object's .json
107
+ const [lp, ln] = String(rl).includes('/') ? String(rl).split('/') : [null, rl];
108
+ const data = lp ? readObject(locate(`${project}/${lp}`)).layouts?.[ln] : readLayout(project, ln);
109
+ if (!data) throw new Error(`item ${it.id}: no layout "${ln}" in ${lp}.json`);
110
+ const step = num(it.repeat.step);
111
+ // Values each copy sets: its width, a value for its row (rows: { "cap.row": ["R1", …] }) and constants (each)
112
+ // per: { "cap.legend": "legend" } sets an input from each key's own property (skipped where the key has none)
113
+ const per = (k) => Object.fromEntries(Object.entries(it.repeat.per || {}).filter(([, f]) => k[f] != null).map(([n, f]) => [n, k[f]]));
114
+ const values = (k) => ({ ...(it.repeat.width ? { [it.repeat.width]: k.units } : {}), ...(it.repeat.each || {}), ...per(k),
115
+ ...Object.fromEntries(Object.entries(it.repeat.rows || {}).map(([n, list]) => {
116
+ if (k.index >= list.length) throw new Error(`item ${it.id}: repeat.rows ${n} has ${list.length} values, the layout ${rows(data)} rows`);
117
+ return [n, list[k.index]];
118
+ })) });
119
+ const rows = (l) => (l.rows || []).length;
120
+ for (const k of keyCentres(data)) {
121
+ const extra = {};
122
+ for (const [n, val] of Object.entries(values(k))) {
123
+ if (!it.assembly) { extra[n] = val; continue; }
124
+ const [item, name] = n.split('.'); // in a component: <item>.<variable>
125
+ if (!name) throw new Error(`item ${it.id}: name a component's variable as <item>.<variable>, e.g. cap.units (got ${n})`);
126
+ (extra[item] ||= {})[name] = val;
127
+ }
128
+ copies.push({ id: `${it.id}-${k.index}-${k.col}`, offset: [k.x * step, k.y * step], extra, label: k.label });
129
+ }
130
+ } else {
131
+ const nx = rx ? Math.round(num(rx.count)) : 1, ny = ry ? Math.round(num(ry.count)) : 1;
132
+ for (let i = 0; i < nx; i++) for (let j = 0; j < ny; j++) {
133
+ copies.push({ id: it.repeat ? `${it.id}-${i}${ry ? `-${j}` : ''}` : it.id, offset: [rx ? i * num(rx.step) : 0, ry ? j * num(ry.step) : 0], extra: {} });
134
+ }
135
+ }
136
+ const first = await source(it, copies[0].extra);
137
+ values[it.id] = first.sub ? first.sub.values : first.b.values; // a repeated item's own name works too: cap.base, not only cap-0-0
138
+ for (const c of copies) { c.src = await source(it, c.extra); values[c.id] = c.src.sub ? c.src.sub.values : c.src.b.values; }
139
+ placing.push([it, copies]);
140
+ }
141
+ // Then place them: every item is built by now, so a position can refer to any item, before or after it (a board
142
+ // placed under the case's top, the case built with the board's Pico position)
143
+ for (const [it, copies] of placing) {
144
+ if (it.place === false) { // built only for its values ("place": false): a board a print plate's case is sized from
145
+ for (const c of copies) if (!c.src.sub) objects.push({ id: c.id, objName: c.src.objName, version: c.src.version, built: c.src.b, role: c.src.role, unplaced: true });
146
+ continue;
147
+ }
148
+ for (const c of copies) {
149
+ const src = c.src, id = c.id, key = src.key;
150
+ const base = (it.at || [0, 0, 0]).map(num), rot = (it.rotate || [0, 0, 0]).map((r) => num(r) * deg);
151
+ // A repeated item turns as a whole about its at: each copy's offset turns with it (keys on a tilted plate)
152
+ // Faces are moved as they are, not re-triangulated in place: triangulating again after a move rounds differently
153
+ const m = translate(base, rotate(rot, translate([c.offset[0], c.offset[1], 0], jscad.geometries.geom3.create()))).transforms;
154
+ const move = (polys) => polys.map((poly) => poly.map((v) => jscad.maths.vec3.transform([0, 0, 0], v, m)));
155
+ ids.push(id);
156
+ if (src.sub) {
157
+ const sub = src.sub;
158
+ for (const p of sub.parts) {
159
+ if (p.role === 'support' && a.kind !== 'plate') continue;
160
+ parts.push({ ...p, name: `${id}.${p.name}`, item: id, leaf: `${id}.${p.leaf}`, label: `${id} ${p.label}`,
161
+ geometry: transform(m, p.geometry), polygons: move(p.polygons), triangles: move(p.triangles), m: jscad.maths.mat4.multiply(jscad.maths.mat4.create(), m, p.m) });
162
+ }
163
+ for (const o of sub.objects) objects.push({ ...o, id: `${id}.${o.id}` });
164
+ for (const x of sub.fits) fits.push({ ...x, a: `${id}.${x.a}`, b: `${id}.${x.b}`, label: x.label && `${x.label} (${id})` });
165
+ requires.push(...sub.requires);
166
+ for (const n of sub.seen) seen.add(n);
167
+ items.push({ ...it, id, sub, role: 'part' });
168
+ } else {
169
+ const { b } = src;
170
+ for (const p of b.parts) {
171
+ if (p.role === 'support' && a.kind !== 'plate') continue; // supports are snipped off before parts go together
172
+ parts.push({ name: `${id}.${p.name}`, item: id, leaf: id, object: src.objName, role: src.role === 'reference' ? 'reference' : p.role, label: `${id}: ${p.label}`,
173
+ ...(p.filament ? { filament: p.filament } : {}), ...(p.mirror === false ? { mirror: false } : {}), part: p.name,
174
+ geometry: transform(m, p.geometry), polygons: move(p.polygons), triangles: move(p.triangles),
175
+ shape: `${key}:${p.name}`, hash: `${b.hash}:${p.name}`, local: p.polygons, localTriangles: p.triangles, m });
176
+ }
177
+ objects.push({ id, objName: src.objName, version: src.version, built: b, role: src.role });
178
+ items.push({ ...it, id, objName: src.objName, built: b, version: src.version, role: src.role });
179
+ }
180
+ }
181
+ }
182
+ // A fit with * applies to every copy: "key-*.switch.bottom" becomes one fit for key-0-0, key-0-1, …
183
+ // With * on both sides, each copy is checked against all the others: { "a": "key-*.cap", "b": "key-*.cap" }
184
+ for (const x of a.fits || []) {
185
+ const star = [x.a, x.b].find((n) => n.includes('*'));
186
+ if (!star) { fits.push({ ...x, num }); continue; }
187
+ const match = (n) => { const re = new RegExp(`^${n.split('.')[0].replace(/[-]/g, '\\-').replace('*', '([\\w-]+)')}$`); return ids.filter((id) => re.test(id)); };
188
+ const copies = match(star), at = (id, n) => [id, ...n.split('.').slice(1)].join('.');
189
+ if (!copies.length) throw new Error(`fit ${x.a} / ${x.b}: nothing matches ${star}`);
190
+ if (x.a.includes('*') && x.b.includes('*')) {
191
+ const others = match(x.b);
192
+ for (const id of copies) fits.push({ ...x, a: at(id, x.a), b: others.filter((o) => o !== id).map((o) => at(o, x.b)), num });
193
+ continue;
194
+ }
195
+ for (const id of copies) { const swap = (n) => (n === star ? at(id, n) : n); fits.push({ ...x, a: swap(x.a), b: swap(x.b), num }); }
196
+ }
197
+ return { a, project, name, items, parts, objects, fits, requires, values, num, seen,
198
+ polygons: parts.flatMap((p) => p.polygons), triangles: parts.flatMap((p) => p.triangles) };
199
+ }
200
+
201
+ // keyCentres(layout): each key's centre, from lib/layout.mjs (kept under this name for older callers)
202
+ export { keyCentres };
203
+
204
+ // ---------- checks ----------
205
+ export { closest };
206
+
207
+ // How much two parts overlap (mm³), from JSCAD's intersection.
208
+ export function overlap(ga, gb) {
209
+ const v = jscad.measurements.measureVolume(jscad.booleans.intersect(ga, gb));
210
+ return Math.abs(v) < 1e-6 ? 0 : Math.abs(v);
211
+ }
212
+
213
+ // Overlaps between placed parts, remembered across runs (.cache/overlaps.json): a part is its build (the build cache's
214
+ // hash: everything it's made from) and where it's placed, so after an edit only the pairs it touches are measured again
215
+ const OVERLAPS = paths.cache('overlaps.json');
216
+ let remembered = null, fresh = 0;
217
+ function pairOverlap(pa, pb) {
218
+ if (!pa.hash || !pb.hash) return overlap(pa.geometry, pb.geometry);
219
+ remembered ||= (() => { try { return JSON.parse(fs.readFileSync(OVERLAPS, 'utf8')); } catch { return {}; } })();
220
+ const at = (p) => `${p.hash}@${Array.from(p.m || []).map((x) => Math.round(x * 1e6) / 1e6).join(',')}`;
221
+ const [x, y] = [at(pa), at(pb)].sort(), k = crypto.createHash('sha1').update(`${x}|${y}`).digest('hex');
222
+ if (!(k in remembered)) { remembered[k] = overlap(pa.geometry, pb.geometry); fresh++; }
223
+ return remembered[k];
224
+ }
225
+ function saveOverlaps() {
226
+ if (!fresh) return;
227
+ try { fs.mkdirSync(path.dirname(OVERLAPS), { recursive: true }); fs.writeFileSync(OVERLAPS, JSON.stringify(remembered)); fresh = 0; } catch {} // only slower without it
228
+ }
229
+
230
+ // checkAssembly(built) -> findings, as @rulecad/printcheck: { level, text, card? } (card: a short line for the mirror)
231
+ export function checkAssembly(s) {
232
+ const out = [], f = (level, text, card) => out.push({ level, text, ...(card && { card }) }), mm = (n) => `${Number(n.toFixed(3))}`;
233
+ // Versions: every pin, from this assembly and its components, against every copy of that object
234
+ const pins = new Map();
235
+ for (const r of s.requires) pins.set(`${r.object} ${r.range}`, r);
236
+ for (const { object, range } of pins.values()) {
237
+ const copies = s.objects.filter((o) => o.objName === object);
238
+ if (!copies.length) { f('fail', `requires ${object} ${range}, but nothing uses it`); continue; }
239
+ const bad = copies.filter((o) => !satisfies(o.version, range));
240
+ f(bad.length ? 'fail' : 'pass', bad.length ? `${object} ${bad[0].version} doesn't meet ${range} (${bad.map((o) => o.id).join(', ')})`
241
+ : `${object} ${copies[0].version} meets ${range}${copies.length > 1 ? ` (${copies.length} copies)` : ''}`);
242
+ }
243
+ // Overlaps between object instances, inside components too. Bounding boxes first: most pairs can't touch.
244
+ const named = (p, n) => p.name === n || p.leaf === n || p.item === n || p.name.startsWith(`${n}.`);
245
+ const allowed = (a, b) => s.fits.some((x) => x.min != null && x.num(x.min) < 0 && ((named(a, x.a) && named(b, x.b)) || (named(a, x.b) && named(b, x.a))));
246
+ const box = new Map(), bx = (p) => box.get(p) || (box.set(p, bounds(p.triangles)), box.get(p));
247
+ const touch = (a, b) => [0, 1, 2].every((k) => bx(a).lo[k] <= bx(b).hi[k] + 1e-6 && bx(b).lo[k] <= bx(a).hi[k] + 1e-6);
248
+ const clashes = [];
249
+ for (let i = 0; i < s.parts.length; i++) for (let j = i + 1; j < s.parts.length; j++) {
250
+ const pa = s.parts[i], pb = s.parts[j];
251
+ if (pa.leaf === pb.leaf || !touch(pa, pb) || allowed(pa, pb)) continue;
252
+ const v = pairOverlap(pa, pb);
253
+ if (v > 0.01) clashes.push(`${pa.name} and ${pb.name} overlap by ${mm(v)} mm³`);
254
+ }
255
+ if (clashes.length) f('fail', clashes.slice(0, 5).join('; ') + (clashes.length > 5 ? `; and ${clashes.length - 5} more` : ''), `${clashes.length} overlaps`);
256
+ else f('pass', `no parts overlap (${new Set(s.parts.map((p) => p.leaf)).size} object instances)`, 'No parts overlap');
257
+ // Fits: the closest gap between two named things, against a range. Copies of one fit are reported together, worst first.
258
+ const pick = (n) => (Array.isArray(n) ? n.flatMap(pick) : s.parts.filter((p) => named(p, n)));
259
+ const groups = new Map();
260
+ for (const x of s.fits) {
261
+ const A = pick(x.a), key = x.note || `${x.a} / ${x.b}`;
262
+ const lo = x.min != null ? x.num(x.min) : -Infinity, hi = x.max != null ? x.num(x.max) : Infinity;
263
+ const reach = Math.max(2, ...[lo, hi].filter(Number.isFinite)) + 1; // look past the range, so a gap near it is measured
264
+ let B = pick(x.b);
265
+ if (Array.isArray(x.b) && A.length) { // against many others: only those within reach
266
+ const lo3 = [0, 1, 2].map((k) => Math.min(...A.map((p) => bx(p).lo[k]))), hi3 = [0, 1, 2].map((k) => Math.max(...A.map((p) => bx(p).hi[k])));
267
+ B = B.filter((p) => [0, 1, 2].every((k) => bx(p).lo[k] <= hi3[k] + reach && lo3[k] <= bx(p).hi[k] + reach));
268
+ }
269
+ if (!A.length || (!B.length && !Array.isArray(x.b))) { f('fail', `fit ${x.a} / ${x.b}: no such ${A.length ? x.b : x.a}`); continue; }
270
+ const vol = A.reduce((t, pa) => t + B.reduce((u, pb) => u + (touch(pa, pb) ? pairOverlap(pa, pb) : 0), 0), 0);
271
+ const gap = !B.length ? Infinity : vol > 0.01 ? -vol : closest(A.flatMap((p) => p.polygons), B.flatMap((p) => p.polygons), reach); // negative: overlap, mm³
272
+ const ok = vol > 0.01 ? lo < 0 : gap >= lo && gap <= hi;
273
+ if (!groups.has(key)) groups.set(key, { x, lo, hi, results: [] });
274
+ groups.get(key).results.push({ ok, gap, id: x.a });
275
+ }
276
+ for (const [key, { x, lo, hi, results }] of groups) {
277
+ const worst = results.reduce((w, r) => (!r.ok && w.ok ? r : r.ok === w.ok && r.gap < w.gap ? r : w), results[0]);
278
+ const say = (g) => (g < 0 ? `press fit, ${mm(-g)} mm³ overlap` : g === Infinity ? 'not near each other' : `closest gap ${mm(g)} mm`);
279
+ const n = results.length > 1 ? ` (${results.filter((r) => r.ok).length} of ${results.length} in range)` : '';
280
+ const label = (x.label || x.a).replace(/ \(.*\)$/, '');
281
+ f(worst.ok ? 'pass' : 'warn', `${key}: ${say(worst.gap)}${n}${worst.ok ? '' : `, wanted ${lo === -Infinity ? '' : `${mm(lo)} `}to ${hi === Infinity ? 'any' : mm(hi)} mm (${results.filter((r) => !r.ok).map((r) => r.id).join(', ')})`}`,
282
+ `${label}: ${worst.gap < 0 ? `press fit ${mm(-worst.gap)} mm³` : worst.gap === Infinity ? 'apart' : `gap ${Number(worst.gap.toFixed(2))} mm`}${worst.ok ? '' : ' (out of range)'}`);
283
+ }
284
+ // Plates print everything: no references
285
+ if (s.a.kind === 'plate') {
286
+ const refs = s.objects.filter((o) => o.role === 'reference' && !o.unplaced); // an unplaced item only lends its numbers
287
+ if (refs.length) f('fail', `a plate can't print references: ${refs.map((o) => o.id).join(', ')}`);
288
+ }
289
+ // Rules that are only estimates, anywhere in the assembly or its components
290
+ const est = new Set();
291
+ for (const o of s.objects) for (const e of o.built.entries) if (e.kind === 'estimate' && (e.used || !e.name.includes('.'))) est.add(e.name);
292
+ for (const n of s.seen) { const [u, k] = n.split('.'); if (loadRuleSet(u).rules[k]?.kind === 'estimate') est.add(n); }
293
+ if (est.size) f('warn', `uses estimates, not measurements: ${[...est].join(', ')}. Measure them and record kind: measured`, `Estimate: ${[...est].join(', ')}`);
294
+ saveOverlaps();
295
+ return out;
296
+ }
297
+
298
+ // shapes(parts) -> { shapes: [{ part, polygons, triangles }], place: [[shape index, 12 numbers], …] }
299
+ // Each distinct shape once, then where each copy goes: a 3 × 4 transform by rows (x' = a·x + b·y + c·z + d, …).
300
+ export function shapes(parts) {
301
+ const index = new Map(), list = [], place = [];
302
+ for (const p of parts) {
303
+ if (!index.has(p.shape)) { index.set(p.shape, list.length); list.push({ part: p, polygons: p.local, triangles: p.localTriangles }); }
304
+ const m = p.m, r = (x) => Number(x.toFixed(6)); // JSCAD's mat4 is column-major
305
+ place.push([index.get(p.shape), ...[m[0], m[4], m[8], m[12], m[1], m[5], m[9], m[13], m[2], m[6], m[10], m[14]].map(r)]);
306
+ }
307
+ return { shapes: list, place };
308
+ }
package/lib/design.mjs ADDED
@@ -0,0 +1,197 @@
1
+ // Designs: projects/<project>/objects/<object>.js (the shape) and <object>.json (its variables and metadata).
2
+ // The .js exports main(v), where v holds the resolved variables (v.stem_arm) and rule sets (v.x1c.line). It
3
+ // should hold no measurements of its own: lint() flags number literals other than 0, 1 and 2.
4
+ // main() returns a shape, or named parts ({ cap, pillar }) described in the .json's "parts":
5
+ // { "pillar": { "role": "support", "label": "Breakaway pillar" } } role: part (default) or support (printed, then
6
+ // removed). Checks and print files use every part; the mirror shows only real parts.
7
+ // "filament": "legend" prints the part in another of the project's filaments (project.json "filaments"); "mirror":
8
+ // false leaves it off the on-screen copy (fine detail); "optional": true lets main() leave it out (a keycap with no legend).
9
+ // Variants: named sets of inputs in the .json ("variants": { "oem-r3": { "profile": "oem", "row": "R3" } }), built as
10
+ // keyboard/keycap:oem-r3. Versions are the design over time; variants are alternatives that exist side by side.
11
+ // Layouts: a "type": "layout" variable picks a file in the project's layouts/; the design gets every layout it can pick
12
+ // as v.layouts[name] = { …the file, keys (each key's centre and properties), width, depth } (lib/layout.mjs).
13
+ // Boards: "engine": "pcb" in the .json makes main(v) return a tscircuit board ({ board, matrix }, built with v.pcb, as
14
+ // @rulecad/pcb describes); the build then carries its Circuit JSON (circuit) and matrix, and one part: the bare board
15
+ // (its copper is in the 2D views).
16
+ // Builds are cached in .cache/builds/, by everything they're made from (CAD_NO_CACHE=1 to skip).
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { createRequire } from 'node:module';
20
+ import crypto from 'node:crypto';
21
+ import { resolve, loadRuleSet } from './rules.mjs';
22
+ import { WS, paths, printerOf } from './workspace.mjs';
23
+ import { readLayout, hasLayout, keys, width, depth } from './layout.mjs';
24
+ import * as shapes from '@rulecad/shapes';
25
+ import { engineId, packageVersion } from './versions.mjs';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const jscad = require('@jscad/modeling');
29
+
30
+ // "keyboard/keycap", "keyboard/keycap:oem-r3" (a variant) or a path to the .js or .json
31
+ // -> { project, name, js, json, dir, variant }
32
+ export function locate(ref) {
33
+ const [plain, variant] = String(ref).split(':');
34
+ ref = plain;
35
+ let base = ref.replace(/\.(js|json)$/, '');
36
+ if (!base.includes(path.sep) || !fs.existsSync(`${base}.json`)) {
37
+ const [project, name] = ref.split('/');
38
+ if (!project || !name) throw new Error(`name a design as <project>/<object>, e.g. keyboard/keycap (got "${ref}")`);
39
+ base = path.join(paths.project(project), 'objects', name);
40
+ }
41
+ if (!fs.existsSync(`${base}.json`) || !fs.existsSync(`${base}.js`)) throw new Error(`no design at ${path.relative(WS, base)}.{js,json}`);
42
+ const dir = path.dirname(path.dirname(base));
43
+ return { project: path.basename(dir), name: path.basename(base), js: `${base}.js`, json: `${base}.json`, dir, ...(variant ? { variant } : {}) };
44
+ }
45
+
46
+ // The layouts an object can pick, by name, from its layout variables' choices (built-ins like grid have no file)
47
+ export function layoutsOf(d, object) {
48
+ const out = {};
49
+ for (const v of Object.values(object.variables || {})) if (v.type === 'layout') {
50
+ for (const n of v.choice || [v.value]) if (hasLayout(d.project, n, d.layoutsDir)) out[n] = readLayout(d.project, n, d.layoutsDir);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // A variant's inputs, under what's set explicitly
56
+ export function variantSet(d, object, set = {}) {
57
+ if (!d.variant) return set;
58
+ const v = (object.variants || {})[d.variant];
59
+ if (!v) throw new Error(`${d.name} has no variant "${d.variant}" (variants: ${Object.keys(object.variants || {}).join(', ') || 'none'})`);
60
+ return { ...v, ...set };
61
+ }
62
+
63
+ const CACHE = paths.cache('builds'), CACHE_VERSION = 2;
64
+ const source = (f) => fs.readFileSync(new URL(f, import.meta.url), 'utf8');
65
+ // What every build is made with: this code, the shape helpers and JSCAD's version (an upgrade rebuilds everything);
66
+ // boards also with the board engine (engineId: its code and tscircuit's version), loaded only for boards
67
+ const own = source(import.meta.url) + source(import.meta.resolve('@rulecad/shapes')) + packageVersion('@jscad/modeling');
68
+ export const pcbEngine = () => import('@rulecad/pcb').catch((e) => {
69
+ if (e.code === 'ERR_MODULE_NOT_FOUND' && /@rulecad\/pcb/.test(e.message)) throw new Error('boards need the board engine: npm install @rulecad/pcb');
70
+ throw e;
71
+ });
72
+
73
+ // An object's on-screen settings are "display" ({ "smooth": 24 }), and a part left off screen is "display": false.
74
+ // Their first name, "mirror", still works: read here, the rest of the tool sees it as mirror either way.
75
+ export const readObject = (d) => {
76
+ const o = JSON.parse(fs.readFileSync(d.json, 'utf8'));
77
+ if (o.display && !o.mirror) o.mirror = o.display;
78
+ for (const p of Object.values(o.parts || {})) if (p.display === false) p.mirror = false;
79
+ return o;
80
+ };
81
+
82
+ // build(design, set, { mirror }) -> { object, values, entries, parts, polygons (for the mirror), triangles (for checks and export) }
83
+ // parts: [{ name, role, label, geometry, polygons, triangles }]; polygons and triangles: every part together
84
+ export async function build(d, set = {}, { mirror = false } = {}) {
85
+ const object = readObject(d);
86
+ set = variantSet(d, object, set);
87
+ const sets = d.rulesDir ? Object.fromEntries((object.uses || []).map((u) => [u, loadRuleSet(u, d.rulesDir)])) : {}; // a release's own rules
88
+ const display = mirror ? object.mirror || {} : {};
89
+ const { values, entries } = resolve(object, { set, sets, display }); // mirror: lighter for the display
90
+ const files = layoutsOf(d, object);
91
+ if (object.layouts) values.layouts = object.layouts; // older designs kept layouts in their .json
92
+ if (Object.keys(files).length) values.layouts = Object.fromEntries(Object.entries(files).map(([n, l]) => [n, { ...l, keys: keys(l), width: width(l), depth: depth(l) }]));
93
+
94
+ // The cache: everything the build is made from. A hit skips running the design (and its booleans) altogether
95
+ const ruleText = (object.uses || []).map((u) => JSON.stringify(sets[u] || loadRuleSet(u)));
96
+ const key = crypto.createHash('sha256').update(JSON.stringify([CACHE_VERSION, own, object.engine === 'pcb' ? engineId() : '', fs.readFileSync(d.js, 'utf8'), JSON.stringify(object), ruleText, files, set, display])).digest('hex');
97
+ const hit = path.join(CACHE, `${key}.json`);
98
+ if (!process.env.CAD_NO_CACHE && fs.existsSync(hit)) {
99
+ const c = JSON.parse(fs.readFileSync(hit, 'utf8'));
100
+ try { const now = new Date(); fs.utimesSync(hit, now, now); } catch {} // recently used: kept when the cache is pruned
101
+ for (const e of entries) if (c.used.includes(e.name)) e.used = true;
102
+ const parts = c.parts.map(({ solid: f, ...p }) => ({ ...p, geometry: fromFaces(f) }));
103
+ if (c.circuit) Object.assign(values, (await pcbEngine()).outputs(c.circuit, c.matrix));
104
+ return { object, values, entries, parts, polygons: parts.flatMap((p) => p.polygons), triangles: parts.flatMap((p) => p.triangles), cached: true, hash: key, ...(c.circuit ? { circuit: c.circuit, matrix: c.matrix } : {}) };
105
+ }
106
+
107
+ const mod = await import(`${d.js}?t=${fs.statSync(d.js).mtimeMs}`).catch((e) => { // a fresh copy after each edit
108
+ // Designs import JSCAD themselves, so it has to be installed where they are (it's the workspace's dependency)
109
+ if (e.code === 'ERR_MODULE_NOT_FOUND' && /@jscad\/modeling/.test(e.message)) throw new Error(`designs need JSCAD in the workspace: npm install @jscad/modeling (in ${WS})`);
110
+ if (/import statement outside a module/.test(e.message)) throw new Error(`designs are ES modules: set "type": "module" in ${path.join(WS, 'package.json')}`);
111
+ throw e;
112
+ });
113
+ if (typeof mod.main !== 'function') throw new Error(`${path.relative(WS, d.js)} has no exported main(v)`);
114
+ // Rules the code reads directly (v.mx.housing_top) count as used too, so estimates it relies on get flagged
115
+ const read = new Set(), watched = Object.fromEntries(Object.entries(values).map(([k, v]) => [k, v && typeof v === 'object'
116
+ ? new Proxy(v, { get: (t, r) => { if (typeof r === 'string' && r in t) read.add(`${k}.${r}`); return t[r]; } }) : v]));
117
+ const pcb = object.engine === 'pcb' ? await pcbEngine() : null;
118
+ watched.shapes = shapes; if (pcb) watched.pcb = pcb;
119
+ let made = await mod.main(watched), board = null;
120
+ if (object.engine === 'pcb') { // a circuit board: tscircuit builds it, and the bare board is its one part
121
+ const { board: b, matrix } = made && made.board ? made : { board: made };
122
+ board = { circuit: await pcb.render(b), ...(matrix ? { matrix } : {}) };
123
+ Object.assign(values, pcb.outputs(board.circuit, matrix)); // what it tells the parts around it: outline, Pico
124
+ made = pcb.solids(board.circuit, { thickness: values.thickness, smooth: values.smooth });
125
+ }
126
+ const parts = named(made, object.parts || {});
127
+ for (const e of entries) if (read.has(e.name)) e.used = true;
128
+ try {
129
+ fs.mkdirSync(CACHE, { recursive: true });
130
+ prune();
131
+ fs.writeFileSync(hit, JSON.stringify({ used: entries.filter((e) => e.used).map((e) => e.name), parts: parts.map(({ geometry, ...p }) => ({ ...p, solid: faces(geometry) })), ...(board || {}) }));
132
+ } catch {} // a cache that can't be written is only slower
133
+ return { object, values, entries, parts, polygons: parts.flatMap((p) => p.polygons), triangles: parts.flatMap((p) => p.triangles), hash: key, ...(board || {}) };
134
+ }
135
+
136
+ // The cache keeps its most recently used builds, about CACHE_KEEP of them (an old version's builds go first)
137
+ const CACHE_KEEP = 800;
138
+ function prune() {
139
+ if (Math.random() > 0.05) return; // now and then is enough
140
+ const files = fs.readdirSync(CACHE).map((f) => { const p = path.join(CACHE, f); return { p, t: fs.statSync(p).mtimeMs }; }).sort((a, b) => b.t - a.t);
141
+ for (const { p } of files.slice(CACHE_KEEP)) fs.rmSync(p, { force: true });
142
+ }
143
+
144
+ // A design's result as parts: named ({ cap, pillar }) or one unnamed shape ("body").
145
+ function named(result, meta) {
146
+ const isShape = (r) => Array.isArray(r) || jscad.geometries.geom3.isA(r) || jscad.geometries.geom2.isA(r);
147
+ const entries = isShape(result) ? [['body', result]] : Object.entries(result || {});
148
+ const empty = (g) => g == null || (Array.isArray(g) && !g.length);
149
+ for (const k of Object.keys(meta)) if (!meta[k].optional && !entries.some(([n, g]) => n === k && !empty(g))) throw new Error(`parts in the .json lists "${k}", but main() returns no such part`);
150
+ return entries.filter(([name, g]) => !(empty(g) && meta[name]?.optional)).map(([name, g]) => {
151
+ const m = meta[name] || {};
152
+ if (m.role && !['part', 'support'].includes(m.role)) throw new Error(`part ${name}: role is part or support`);
153
+ // The solid for booleans (fit checks) is JSCAD's own, exactly; a cached build restores the very same faces
154
+ const geometry = solid(g);
155
+ return { name, role: m.role || 'part', label: m.label || name, ...(m.filament ? { filament: m.filament } : {}), ...(m.mirror === false ? { mirror: false } : {}),
156
+ geometry, polygons: polygons(g), triangles: polygons(g, { triangulate: true }) };
157
+ });
158
+ }
159
+
160
+ // A part's result as one JSCAD solid with its transforms applied (several solids become one set of faces), and back from
161
+ // the exact faces a cache keeps: the same numbers, so booleans on a cached build agree with a fresh one.
162
+ const { geom3, poly3 } = jscad.geometries;
163
+ const solid = (g) => geom3.create([g].flat(Infinity).filter((x) => geom3.isA(x)).flatMap((x) => geom3.toPolygons(x)));
164
+ const faces = (g) => geom3.toPolygons(g).map((p) => p.vertices.map((v) => [v[0], v[1], v[2]]));
165
+ const fromFaces = (list) => geom3.create(list.map((vs) => poly3.create(vs)));
166
+
167
+ // The rules of the printer a build is checked against (the project's, else the workspace's), resolved
168
+ export const printer = (b, d) => { const n = printerOf(d.project); return b.values[n] || resolve({ uses: [n] }).values[n]; };
169
+
170
+ // Only some parts: --part cap (by name), the real parts without supports, or what the mirror shows (no supports, and
171
+ // no parts marked mirror: false).
172
+ export function pick(b, { part, noSupports = false, forMirror = false } = {}) {
173
+ let parts = b.parts;
174
+ if (part) { parts = parts.filter((p) => p.name === part); if (!parts.length) throw new Error(`no part "${part}" (parts: ${b.parts.map((p) => p.name).join(', ')})`); }
175
+ if (noSupports || forMirror) parts = parts.filter((p) => p.role !== 'support');
176
+ if (forMirror) parts = parts.filter((p) => p.mirror !== false);
177
+ return { ...b, parts, polygons: parts.flatMap((p) => p.polygons), triangles: parts.flatMap((p) => p.triangles) };
178
+ }
179
+
180
+ // Any JSCAD result -> polygons (corner lists), transforms applied. triangulate: also split faces at T-junctions (a
181
+ // corner of one face in the middle of a neighbour's edge, left by booleans) so every edge pairs up, as slicers want.
182
+ export function polygons(result, { triangulate = false } = {}) {
183
+ const { geom3, geom2 } = jscad.geometries;
184
+ const out = [];
185
+ for (const g of [result].flat(Infinity)) {
186
+ if (geom2.isA(g)) throw new Error("2D shapes can't be printed or shown: extrude them first");
187
+ if (!geom3.isA(g)) continue;
188
+ const fixed = jscad.modifiers.generalize({ snap: true, triangulate }, g);
189
+ for (const p of geom3.toPolygons(fixed)) out.push(p.vertices.map((v) => [v[0], v[1], v[2]]));
190
+ }
191
+ if (!out.length) throw new Error('the design has no 3D shapes');
192
+ return out;
193
+ }
194
+
195
+ export { lint } from '@rulecad/variables'; // number literals that should be variables
196
+
197
+ export { jscad };
package/lib/layout.mjs ADDED
@@ -0,0 +1,14 @@
1
+ // Layout files: projects/<project>/layouts/<name>.json, in the format @rulecad/keyboard-layout describes and reads.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { WS, paths } from './workspace.mjs';
5
+
6
+ export const layoutPath = (project, name, dir) => path.join(dir || path.join(paths.project(project), 'layouts'), `${name}.json`);
7
+ export const hasLayout = (project, name, dir) => fs.existsSync(layoutPath(project, name, dir));
8
+ export function readLayout(project, name, dir) {
9
+ const f = layoutPath(project, name, dir);
10
+ if (!fs.existsSync(f)) throw new Error(`no layout "${name}" (${path.relative(WS, f)})`);
11
+ return JSON.parse(fs.readFileSync(f, 'utf8'));
12
+ }
13
+
14
+ export { keys, width, depth, project } from '@rulecad/keyboard-layout';
@@ -0,0 +1,72 @@
1
+ <!doctype html>
2
+ <!-- Preview sheet: six views of a design in a dark style (black ground, light as the only material), for people
3
+ and for Claude to look at before printing. Rendered headlessly by lib/preview.mjs; data from /data.json. -->
4
+ <html><head><meta charset="utf-8"><title>preview</title>
5
+ <style>
6
+ html, body { margin: 0; background: #000; color: #b4b4b4; font: 400 22px -apple-system, "Helvetica Neue", sans-serif; }
7
+ canvas { display: block; }
8
+ .label { position: absolute; color: #5c5c5c; font-size: 18px; letter-spacing: 0.12em; text-transform: uppercase; }
9
+ #title { position: absolute; left: 32px; top: 24px; color: #fff; font-size: 30px; }
10
+ #meta { position: absolute; left: 32px; top: 64px; }
11
+ </style>
12
+ <script type="importmap">{ "imports": { "three": "/three/three.module.js" } }</script>
13
+ </head><body>
14
+ <div id="title"></div><div id="meta"></div>
15
+ <script type="module">
16
+ import * as THREE from 'three';
17
+ const W = 1800, H = 1200, V = 600; // three views across, two down
18
+ const data = await (await fetch('/data.json')).json();
19
+ document.getElementById('title').textContent = data.title;
20
+ document.getElementById('meta').textContent = data.meta;
21
+
22
+ // Each part as its own geometry, Z-up (JSCAD, the bed) turned Y-up. Exploded: supports drop below, and in an
23
+ // assembly each item rises above the one before, so what touches what can be seen.
24
+ const geos = data.parts.map((p) => { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(p.positions, 3)); g.rotateX(-Math.PI / 2); g.computeVertexNormals(); g.computeBoundingBox(); return g; });
25
+ if (data.explode) {
26
+ const all = new THREE.Box3(); geos.forEach((g) => all.union(g.boundingBox));
27
+ const gap = (all.max.y - all.min.y) * 0.8;
28
+ let drop = 0;
29
+ data.parts.forEach((p, i) => { if (p.role === 'support') { drop += gap; geos[i].translate(0, -drop, 0); geos[i].computeBoundingBox(); } });
30
+ const items = [...new Set(data.parts.map((p) => p.item).filter(Boolean))]; // assemblies: each item above the last
31
+ data.parts.forEach((p, i) => { const k = items.indexOf(p.item); if (k > 0) { geos[i].translate(0, k * gap, 0); geos[i].computeBoundingBox(); } });
32
+ }
33
+ const box = new THREE.Box3(); geos.forEach((g) => box.union(g.boundingBox));
34
+ const center = box.getCenter(new THREE.Vector3()), radius = box.getSize(new THREE.Vector3()).length() / 2;
35
+ geos.forEach((g) => g.translate(-center.x, -center.y, -center.z));
36
+ const geo = { boundingBox: box.translate(center.clone().negate()), computeBoundingBox() {} };
37
+
38
+ const scene = new THREE.Scene();
39
+ // Faces: dark, flat-lit, so shape reads without lighting up the screen. Creases: thin ink lines. Supports: faint
40
+ // lines only (ink-3), so the part they hold up shows through them; references (not printed) the same.
41
+ data.parts.forEach((p, i) => {
42
+ if (p.role === 'part') scene.add(new THREE.Mesh(geos[i], new THREE.MeshLambertMaterial({ color: 0x3a3a3a, flatShading: true, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 })));
43
+ scene.add(new THREE.LineSegments(new THREE.EdgesGeometry(geos[i], 25), new THREE.LineBasicMaterial({ color: p.role === 'part' ? 0xffffff : 0x5c5c5c })));
44
+ });
45
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
46
+ const sun = new THREE.DirectionalLight(0xffffff, 2.2); sun.position.set(-1, 2, 1.5); scene.add(sun);
47
+ const under = new THREE.DirectionalLight(0xffffff, 1.2); under.position.set(1, -2, 1); scene.add(under); // so views from below aren't black
48
+ // The bed as a faint grid, so up and down are clear
49
+ const grid = new THREE.GridHelper(radius * 4, 16, 0x2a2a2a, 0x1a1a1a);
50
+ grid.position.y = geo.boundingBox.min.y; scene.add(grid);
51
+
52
+ const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
53
+ renderer.setPixelRatio(1); renderer.setSize(W, H); renderer.setScissorTest(true); renderer.setClearColor(0x000000);
54
+ document.body.append(renderer.domElement);
55
+
56
+ const d = radius * 3.2;
57
+ const views = [
58
+ ['front', [0, 0, d]], ['side', [d, 0, 0]], ['3/4', [d * 0.62, d * 0.5, d * 0.62]],
59
+ ['top', [0, d, 0.0001]], ['bottom', [0, -d, 0.0001]], ['3/4 below', [d * 0.62, -d * 0.5, d * 0.62]],
60
+ ];
61
+ views.forEach(([name, pos], i) => {
62
+ const x = (i % 3) * V, y = i < 3 ? V : 0; // WebGL's y runs up: the first row is the top half
63
+ const cam = new THREE.OrthographicCamera(-radius * 1.25, radius * 1.25, radius * 1.25, -radius * 1.25, 0.01, d * 4);
64
+ cam.position.set(...pos); cam.lookAt(0, 0, 0);
65
+ renderer.setViewport(x, y, V, V); renderer.setScissor(x, y, V, V);
66
+ renderer.render(scene, cam);
67
+ const l = document.createElement('div'); l.className = 'label'; l.textContent = name;
68
+ l.style.left = `${x + 32}px`; l.style.top = `${(i < 3 ? 112 : V + 24)}px`; document.body.append(l);
69
+ });
70
+ document.body.dataset.ready = '1';
71
+ </script>
72
+ </body></html>