@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.
package/bin/cad.mjs ADDED
@@ -0,0 +1,505 @@
1
+ #!/usr/bin/env node
2
+ // cad: make, check, preview, export and show rule-driven designs. Run `cad help`.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { fmt, loadRuleSet } from '../lib/rules.mjs';
7
+ import { WS, TOOL, paths, config, printerOf } from '../lib/workspace.mjs';
8
+ import { target, hasTarget } from '../lib/targets.mjs';
9
+ import { locate, build, pick, lint, readObject, printer, pcbEngine } from '../lib/design.mjs';
10
+ import { git, tags, bump, bigger, newer, compare, snapshot, materialise, files, prints, printsFile, engineId } from '../lib/versions.mjs';
11
+ import { check } from '@rulecad/printcheck';
12
+ import { preview } from '../lib/preview.mjs';
13
+ import { isAssembly, buildAssembly, checkAssembly, shapes } from '../lib/assembly.mjs';
14
+ import { fromParts, fromShapes, encode } from '@rulecad/model-link';
15
+ import { threemf, slotOf } from '../lib/threemf.mjs';
16
+
17
+ // The project's filaments: { base: { slot: 1, … }, legend: { slot: 2, … } } in project.json
18
+ const filaments = (project) => JSON.parse(fs.readFileSync(path.join(paths.project(project), 'project.json'), 'utf8')).filaments || {};
19
+ // The filaments in AMS slot order, for the print file: [{ colour }, …], slot 1 first
20
+ const slots = (fil) => { const out = []; for (const f of Object.values(fil)) out[(f.slot || 1) - 1] = f; return Array.from(out, (f) => f || {}); };
21
+ // What the print file asks of the AMS: which filament each kind of part prints in
22
+ function filamentNotes(parts, fil) {
23
+ const used = [...new Set(parts.map((p) => p.filament || 'base'))];
24
+ const missing = used.filter((f) => f !== 'base' && !fil[f]);
25
+ if (missing.length) return [{ level: 'fail', text: `parts print in ${missing.join(', ')}, which project.json's filaments don't list` }];
26
+ return [{ level: 'pass', text: used.length > 1 ? `${used.length} filaments: ${used.map((f) => `${f} in AMS slot ${slotOf(fil, f)}${fil[f]?.name ? ` (${fil[f].name})` : ''}`).join(', ')}` : 'one filament' }];
27
+ }
28
+
29
+ // --open: the 3MF in Bambu Studio (macOS), else whatever opens .3mf files
30
+ const openInSlicer = (f) => (process.platform === 'darwin' ? execFileSync('open', ['-a', 'Bambu Studio', f])
31
+ : execFileSync(process.platform === 'win32' ? 'explorer' : 'xdg-open', [f]));
32
+ const VIEW = process.env.CAD_VIEW_URL || config.viewUrl;
33
+
34
+ const HELP = `cad: JSCAD designs you can trace, check, share and print (rulecad)
35
+
36
+ cad new <project>/<object> start a design from the template (makes the project if needed)
37
+ cad rules <project>/<object> every variable: value, the rule behind it, its source
38
+ cad check <project>/<object> raw numbers in the design, then printability (exit 1 on a failure)
39
+ cad preview <project>/<object> four views as a PNG in .preview/ (prints the path)
40
+ cad export <project>/<object> a 3MF for Bambu Studio in exports/ [--open: open it in Bambu Studio]
41
+ cad show <project>/<object> the model on display through your show target, with its rules [--ttl 10m]
42
+ cad link <project>/<object> links to open the model in a browser, and to download its STL or 3MF
43
+ [--md: one labelled Markdown link, for chat (the page downloads any parts): short unless --long]
44
+ [--short: the model's data in a secret GitHub Gist, so links are ~80 characters]
45
+
46
+ cad status [project] each object's version, and whether it has changes since its last release
47
+ cad release <project>/<object> -m "what changed" [--bump major|minor|patch] [--dry-run] [--push]
48
+ check it, compare with the last release, suggest the bump, then commit, tag and log it in CHANGELOG.md
49
+ cad release <project> -m "…" a project release: records the version of each object
50
+ cad history <project>[/<object>] releases, with what changed and what was printed
51
+ cad diff <project>/<object> <from> [<to>] what changed between two releases (or a release and now)
52
+ cad printed <project>/<object> --result ok|failed [--note "…"] [--material PETG] [--version 0.1.0]
53
+ cad release <project> --all -m "…" release every object that changed (each with this message), then the project
54
+
55
+ cad variants <project>/<object> the object's variants: named sets of inputs, built as <project>/<object>:<variant>
56
+ cad compare <project>/<object> <a> <b> two variants (or releases, 1.2.0) side by side in one preview
57
+
58
+ <project>/<object>:<variant> any command works on a variant: cad export keyboard/keycap:legend-f
59
+
60
+ <project>/<object>@<version> any command but release works on a release: cad export keyboard/keycap@0.1.0
61
+ --set name=value,name=value change variables that have a tweak range (any command)
62
+ --part <name> only that part (check, preview, export, link): e.g. --part cap
63
+ --explode preview: parts moved apart, so each can be seen
64
+ --trailer "Co-Authored-By: …" added to release commits (for agents)`;
65
+
66
+ const args = process.argv.slice(2);
67
+ // --name value, or -m for one-letter names
68
+ const opt = (k) => { const i = args.findIndex((a) => a === `--${k}` || (k.length === 1 && a === `-${k}`)); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : undefined; };
69
+ const sets = () => Object.fromEntries(String(opt('set') || '').split(',').filter(Boolean).map((p) => p.split('=').map((s) => s.trim())));
70
+ const suffix = (set) => Object.entries(set).map(([k, v]) => `-${k}=${v}`).join('');
71
+
72
+ function report(findings) {
73
+ const mark = { pass: '✓', warn: '!', fail: '✗', info: '·' };
74
+ for (const r of findings) console.log(` ${mark[r.level]} ${r.text}`);
75
+ return findings.some((r) => r.level === 'fail');
76
+ }
77
+
78
+ async function main() {
79
+ const [cmd, ref] = args;
80
+ if (!cmd || cmd === 'help') return console.log(HELP);
81
+
82
+ if (cmd === 'new') {
83
+ const [project, name] = String(ref || '').split('/');
84
+ if (!/^[a-z][a-z0-9-]*$/.test(project || '') || !/^[a-z][a-z0-9-]*$/.test(name || '')) throw new Error('usage: cad new <project>/<object>, lower-case names with dashes');
85
+ const dir = paths.project(project), objects = path.join(dir, 'objects');
86
+ fs.mkdirSync(objects, { recursive: true });
87
+ const pj = path.join(dir, 'project.json');
88
+ const p = fs.existsSync(pj) ? JSON.parse(fs.readFileSync(pj, 'utf8')) : { name: project, description: '', material: 'PLA', objects: [] };
89
+ for (const ext of ['js', 'json']) {
90
+ const to = path.join(objects, `${name}.${ext}`);
91
+ if (fs.existsSync(to)) throw new Error(`${path.relative(WS, to)} already exists`);
92
+ fs.writeFileSync(to, fs.readFileSync(path.join(TOOL, 'templates', `object.${ext}`), 'utf8').replaceAll('NAME', name));
93
+ }
94
+ if (!p.objects.includes(name)) p.objects.push(name);
95
+ fs.writeFileSync(pj, JSON.stringify(p, null, 2) + '\n');
96
+ return console.log(`made projects/${project}/objects/${name}.{js,json}`);
97
+ }
98
+
99
+ if (cmd === 'status') return status(ref);
100
+ if (!ref) throw new Error(`usage: cad ${cmd} <project>/<object>`);
101
+ if (cmd === 'release') return ref.includes('/') ? releaseObject(locate(ref)) : opt('all') ? releaseAll(ref) : releaseProject(ref);
102
+ if (cmd === 'variants') return variants(locate(ref));
103
+ if (cmd === 'compare') return compareVariants(locate(ref), args[2], args[3]);
104
+ if (cmd === 'history') return history(ref);
105
+ if (cmd === 'diff') return diff(locate(ref), args[2], args[3] && !args[3].startsWith('--') ? args[3] : null);
106
+ if (cmd === 'printed') return printed(locate(ref));
107
+ if (isAssembly(ref)) return assembly(cmd, ref);
108
+ const [base, at] = ref.split('@');
109
+ const d = at ? materialise(locate(base), at) : locate(ref), set = sets();
110
+ const b = opt('part') ? pick(await build(d, set), { part: opt('part') }) : await build(d, set);
111
+ const tag = `${d.variant ? `-${d.variant}` : ''}${suffix(set)}${opt('part') ? `-${opt('part')}` : ''}`; // in file names
112
+ const title = `${b.object.title || b.object.name}${d.variant ? ` (${d.variant})` : ''}`, version = b.object.version || '0.0.0';
113
+
114
+ if (b.circuit && ['check', 'preview', 'export'].includes(cmd)) return board(cmd, d, b, { title, version, tag });
115
+
116
+ if (cmd === 'rules') {
117
+ for (const e of b.entries) console.log(`${e.name.padEnd(18)} ${`${fmt(e.value)} ${e.unit || ''}`.padEnd(14)} ${e.expr ? `= ${e.expr} ` : ''}${e.rule || ''}${e.source ? ` [${e.kind ? `${e.kind}: ` : ''}${e.source}]` : ''}${e.set ? ' (set)' : ''}`);
118
+ return;
119
+ }
120
+
121
+ if (cmd === 'check') {
122
+ console.log(`${title} ${version}${Object.keys(set).length ? ` (${suffix(set).slice(1)})` : ''}`);
123
+ const raw = lint(fs.readFileSync(d.js, 'utf8'));
124
+ const failed = report([
125
+ raw.length ? { level: 'fail', text: `numbers in ${path.basename(d.js)} that should be variables: ${raw.map((r) => `line ${r.line}: ${r.text}`).join(', ')}` } : { level: 'pass', text: 'every measurement is a variable' },
126
+ ...(b.object.role === 'reference'
127
+ ? [{ level: 'pass', text: `a reference (${(await import('@rulecad/printcheck')).bounds(b.triangles).size.map(fmt).join(' × ')} mm), not printed: no print checks` }]
128
+ : check(b.triangles, printer(b, d), { parts: b.parts })),
129
+ ]);
130
+ if (b.object.orientation) console.log(` orientation: ${b.object.orientation}`);
131
+ if (b.parts.length > 1) console.log(` parts: ${b.parts.map((p) => `${p.name}${p.role === 'support' ? ' (support)' : ''}`).join(', ')}`);
132
+ process.exitCode = failed ? 1 : 0;
133
+ return;
134
+ }
135
+
136
+ if (cmd === 'preview') {
137
+ const out = paths.preview(d.project, `${d.name}${tag}${opt('explode') ? '-exploded' : ''}.png`);
138
+ const size = (await import('@rulecad/printcheck')).bounds(b.triangles).size.map((n) => fmt(n)).join(' × ');
139
+ const parts = b.parts.length > 1 ? ` · ${b.parts.map((p) => (p.role === 'support' ? `${p.label} (faint)` : p.label)).join(', ')}` : '';
140
+ await preview(b.parts, { title: `${title} ${version}${opt('part') ? `: ${b.parts[0].label}` : ''}`, meta: [`${size} mm`, b.object.material].filter(Boolean).join(' · ') + parts, out, explode: !!opt('explode') });
141
+ return console.log(out);
142
+ }
143
+
144
+ if (cmd === 'export') {
145
+ const out = paths.exports(d.project, `${d.name}@${version}${tag}.3mf`);
146
+ fs.mkdirSync(path.dirname(out), { recursive: true });
147
+ const fil = filaments(d.project); // one object, a part per part, each in its filament
148
+ fs.writeFileSync(out, await threemf([{ name: `${d.name}${d.variant ? ` ${d.variant}` : ''}`, parts: b.parts.map((p) => ({ name: p.label, triangles: p.triangles, slot: slotOf(fil, p.filament) })) }], { filaments: slots(fil) }));
149
+ report(filamentNotes(b.parts, fil));
150
+ if (opt('open')) openInSlicer(out);
151
+ return console.log(out);
152
+ }
153
+
154
+ if (cmd === 'link') return await printLinks(await links(d, b, set));
155
+
156
+ if (cmd === 'show') {
157
+ const light = b.object.mirror && !opt('part') ? await build(d, set, { mirror: true }) : b; // the object's mirror settings
158
+ const shown = pick(light, { forMirror: true }); // the design itself, not what holds it up while printing, nor fine detail
159
+ // The rules card: the variables marked show, one line each
160
+ const items = b.entries.filter((e) => e.show).slice(0, 6).map((e) => `${e.label || e.name} ${fmt(e.value)} ${e.unit || ''}${e.set ? ' (set)' : ''}`.trim());
161
+ await (await target('show'))({ title: `${title} ${version}`, ttl: opt('ttl') || '10m', mesh: { polygons: shown.polygons }, card: { title: `${title} rules`, items } });
162
+ return await printLinks(await links(d, b, set));
163
+ }
164
+
165
+ console.log(HELP);
166
+ process.exitCode = 1;
167
+ }
168
+
169
+ // ---------- boards ----------
170
+ // A circuit board (engine pcb): its checks are the board's (design rules, the fab's limits, the matrix), its preview is
171
+ // drawn from above as well as in 3D, and its export is the fab's zip plus KiCad's files, not a print file.
172
+ async function board(cmd, d, b, { title, version, tag }) {
173
+ const pcb = await pcbEngine(), name = `${d.name}@${version}${tag}`;
174
+ if (cmd === 'check') {
175
+ console.log(`${title} ${version}`);
176
+ const raw = lint(fs.readFileSync(d.js, 'utf8'));
177
+ const failed = report([
178
+ raw.length ? { level: 'fail', text: `numbers in ${path.basename(d.js)} that should be variables: ${raw.map((r) => `line ${r.line}: ${r.text}`).join(', ')}` } : { level: 'pass', text: 'every measurement is a variable' },
179
+ ...(await pcb.checks(b.circuit, b.values, b.matrix)),
180
+ ]);
181
+ if (b.object.orientation) console.log(` ${b.object.orientation}`);
182
+ process.exitCode = failed ? 1 : 0;
183
+ return;
184
+ }
185
+ if (cmd === 'preview') {
186
+ const top = await pcb.png(b.circuit, paths.preview(d.project, `${d.name}${tag}-pcb.png`));
187
+ const sch = await pcb.schematicPng(b.circuit, paths.preview(d.project, `${d.name}${tag}-schematic.png`));
188
+ const size = (await import('@rulecad/printcheck')).bounds(b.triangles).size.map((n) => fmt(n)).join(' × ');
189
+ const out = paths.preview(d.project, `${d.name}${tag}.png`);
190
+ await preview(b.parts, { title: `${title} ${version}`, meta: `${size} mm · a circuit board`, out, explode: false });
191
+ return console.log(`${top}\n${sch}\n${out}`);
192
+ }
193
+ // export: exports/<project>/<name>-fab.zip (Gerbers, drills, BOM, placement: upload it to JLCPCB as it is), KiCad's
194
+ // board and schematic beside it, and the matrix for firmware
195
+ const dir = paths.exports(d.project), { zipSync, strToU8 } = await import('fflate');
196
+ fs.mkdirSync(dir, { recursive: true });
197
+ const fab = await pcb.fabFiles(b.circuit, d.name), zip = path.join(dir, `${name}-fab.zip`);
198
+ fs.writeFileSync(zip, zipSync(Object.fromEntries(Object.entries(fab).map(([f, t]) => [f, strToU8(t)])), { level: 6 }));
199
+ const written = [zip];
200
+ for (const [f, t] of Object.entries(await pcb.kicadFiles(b.circuit, name))) { fs.writeFileSync(path.join(dir, f), t); written.push(path.join(dir, f)); }
201
+ if (b.matrix) { const f = path.join(dir, `${name}-matrix.json`); fs.writeFileSync(f, JSON.stringify(b.matrix, null, 2) + '\n'); written.push(f); }
202
+ report([{ level: 'pass', text: `fab zip: ${Object.keys(fab).length} files (${Object.keys(fab).filter((f) => !/\.(csv|DRL)$/.test(f)).length} Gerber layers, 2 drill files, BOM, placement)` }]);
203
+ for (const f of written) console.log(f);
204
+ }
205
+
206
+ // ---------- assemblies ----------
207
+ async function assembly(cmd, ref) {
208
+ const s = await buildAssembly(ref), a = s.a, title = a.title || s.name;
209
+ const versions = [...new Map(s.objects.map((o) => [o.objName, `${o.objName} ${o.version}`])).values()].join(', ');
210
+ const rules = (ns) => Object.fromEntries(Object.keys(loadRuleSet(ns).rules).map((k) => [k, s.num(`${ns}.${k}`)]));
211
+ const printed = s.parts.filter((p) => p.role !== 'reference');
212
+
213
+ if (cmd === 'check') {
214
+ console.log(`${title} (${a.kind || 'fit'}: ${versions})`);
215
+ const findings = checkAssembly(s);
216
+ if (a.kind === 'plate') findings.push(...check(printed.flatMap((p) => p.triangles), rules(printerOf(s.project)), { parts: printed }));
217
+ process.exitCode = report(findings) ? 1 : 0;
218
+ return;
219
+ }
220
+ if (cmd === 'preview') {
221
+ const out = paths.preview(s.project, `${s.name}${opt('explode') ? '-exploded' : ''}.png`);
222
+ const size = (await import('@rulecad/printcheck')).bounds(s.triangles).size.map((n) => fmt(n)).join(' × ');
223
+ const faint = [...new Set(s.parts.filter((p) => p.role !== 'part').map((p) => `${p.object || p.item}${p.role === 'support' ? ' supports' : ''}`))]; // each kind once
224
+ await preview(s.parts, { title, meta: `${size} mm · ${versions}${faint.length ? ` · faint: ${faint.join(', ')}` : ''}`, out, explode: !!opt('explode') });
225
+ return console.log(out);
226
+ }
227
+ if (cmd === 'export') {
228
+ if (a.kind !== 'plate') throw new Error(`${ref} is a fit assembly, for checking: export its objects, or make a plate (kind: plate) to print them together`);
229
+ const out = paths.exports(s.project, `${s.name}.3mf`);
230
+ fs.mkdirSync(path.dirname(out), { recursive: true });
231
+ // One object per printed thing (a keycap with its legend and supports), a part per part, each in its filament
232
+ const fil = filaments(s.project), things = new Map();
233
+ for (const p of printed) { if (!things.has(p.leaf)) things.set(p.leaf, []); things.get(p.leaf).push({ name: p.label, triangles: p.triangles, slot: slotOf(fil, p.filament) }); }
234
+ fs.writeFileSync(out, await threemf([...things].map(([name, parts]) => ({ name, parts })), { filaments: slots(fil) }));
235
+ report(filamentNotes(printed, fil));
236
+ if (opt('open')) openInSlicer(out);
237
+ return console.log(out);
238
+ }
239
+ const link = async () => {
240
+ const fil = filaments(s.project);
241
+ const model = {
242
+ title, version: versions, src: `${s.project}/${s.name}`, // object versions are in version: the file name stays short orientation: a.note || (a.kind === 'plate' ? 'A print plate: print everything together.' : 'A fit check: references are faint.'),
243
+ rules: [],
244
+ // Its boards' 2D views (copper, schematic), so one link holds the whole build: tabs over the 3D model
245
+ ...(await (async () => {
246
+ const boards = [...new Map(s.objects.filter((o) => o.built?.circuit).map((o) => [o.objName, o])).values()];
247
+ if (!boards.length) return {};
248
+ const pcb = await pcbEngine(), views = [];
249
+ for (const o of boards) for (const [n, svg] of await pcb.views(o.built.circuit)) views.push([boards.length > 1 ? `${o.objName} ${n}` : n, svg]);
250
+ return { views };
251
+ })()),
252
+ // Each shape once and placed: the macropad's nine keys cost one keycap
253
+ ...fromShapes(shapes(s.parts).shapes.length ? (() => {
254
+ const placed = shapes(s.parts), tri = (polys) => polys.flatMap((t) => t.slice(1, -1).map((_, i) => [t[0], t[i + 1], t[i + 2]]));
255
+ return placed.place.map(([i, ...m], n) => { const p = placed.shapes[i].part; return { shape: i, role: p.role, label: p.object || p.label, slot: slotOf(fil, p.filament), triangles: tri(placed.shapes[i].triangles), matrix: m, name: s.parts[n].name }; });
256
+ })() : []),
257
+ };
258
+ const url = `${VIEW}#m=${await encode(model)}`;
259
+ return { view: url, stl: `${url}&get=stl`, '3mf': `${url}&get=3mf`, src: model.src, size: url.length, title, version: '' };
260
+ };
261
+ if (cmd === 'link') return await printLinks(await link());
262
+ if (cmd === 'show') {
263
+ // Each shape once, placed for every copy: nine keys cost one keycap in the mirror's link. Built with each object's
264
+ // mirror settings (fewer segments), so the display gets a lighter mesh; checks and links use the real one
265
+ const m = await buildAssembly(ref, [], {}, { mirror: true });
266
+ const placed = shapes(m.parts.filter((p) => p.role !== 'support' && p.mirror !== false));
267
+ // The card: what the checks found, one short line each
268
+ const items = checkAssembly(s).filter((x) => x.card).slice(0, 6).map((x) => x.card);
269
+ await (await target('show'))({ title, ttl: opt('ttl') || '10m', mesh: { shapes: placed.shapes.map((x) => x.polygons), place: placed.place },
270
+ card: { title: a.kind === 'plate' ? `${title}: ${versions}` : `${title}: checks`, items } });
271
+ return await printLinks(await link());
272
+ }
273
+ throw new Error(`cad ${cmd} doesn't work on an assembly (check, preview, export, show, link do)`);
274
+ }
275
+
276
+ // ---------- links ----------
277
+ // The model as a link: full print precision (triangles), the rules card, and exactly which version it is.
278
+ async function links(d, b, set) {
279
+ const s = d.tag ? { kind: null } : since(d), fil = filaments(d.project);
280
+ const version = d.version || b.object.version || '0.0.0';
281
+ const src = `${d.project}/${d.name}${d.variant ? `:${d.variant}` : ''}@${version}${s.kind && s.kind !== 'first' ? '+changes' : s.kind === 'first' ? '+unreleased' : ''}`;
282
+ const model = {
283
+ title: `${b.object.title || b.object.name}${d.variant ? ` (${d.variant})` : ''}`, version, src, ...(Object.keys(set).length ? { set: suffix(set).slice(1).replace(/-(?=\w+=)/g, ',') } : {}),
284
+ material: b.object.material, orientation: b.object.orientation,
285
+ rules: b.entries.filter((e) => e.show).map((e) => [e.label || e.name, fmt(e.value), e.unit || '']),
286
+ ...(b.circuit ? { views: await (await pcbEngine()).views(b.circuit) } : {}), // boards: the 2D board and schematic
287
+ ...fromParts(b.parts.map((p) => ({ name: p.name, role: p.role, label: p.label, slot: slotOf(fil, p.filament), triangles: p.triangles.flatMap((t) => t.slice(1, -1).map((_, i) => [t[0], t[i + 1], t[i + 2]])) }))),
288
+ };
289
+ const url = `${VIEW}#m=${await encode(model)}`;
290
+ return { view: url, stl: `${url}&get=stl`, '3mf': `${url}&get=3mf`, src, size: url.length, title: model.title, version };
291
+ }
292
+
293
+ // Short links: the model's data stored by the shorten target (a secret Gist), and a link that carries only its ID. The
294
+ // same model reuses its ID (.cache/links.json, by the data's hash). Without a shorten target, links stay long.
295
+ async function shorten(l) {
296
+ if (!hasTarget('shorten')) return l;
297
+ const token = l.view.split('#m=')[1], hash = (await import('node:crypto')).createHash('sha256').update(token).digest('hex').slice(0, 16);
298
+ const file = paths.cache('links.json'), cache = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
299
+ if (!cache[hash]) {
300
+ cache[hash] = await (await target('shorten'))(token, { desc: `model: ${l.src}` });
301
+ fs.mkdirSync(path.dirname(file), { recursive: true });
302
+ fs.writeFileSync(file, JSON.stringify(cache, null, 2));
303
+ }
304
+ const url = `${VIEW}#s=${cache[hash]}`;
305
+ return { ...l, view: url, stl: `${url}&get=stl`, '3mf': `${url}&get=3mf`, short: true };
306
+ }
307
+
308
+ // --md: one line of labelled Markdown links, the form agents paste into chat (CLAUDE.md, "Replies with links").
309
+ // Short (a Gist) unless --long; plain output is long (nothing uploaded) unless --short.
310
+ async function printLinks(l) {
311
+ if ((opt('md') && !opt('long')) || opt('short')) l = await shorten(l);
312
+ // One link: the page picks which parts go in a download (all of them, one key, only the case)
313
+ if (opt('md')) return console.log(`**${[l.title, l.version].filter(Boolean).join(' ')}**${l.src.includes('+') ? ' (not a release)' : ''}: [View and download](${l.view})`);
314
+ console.log(`${l.src}, ${l.short ? 'short links (data in a secret Gist)' : `${(l.size / 1024).toFixed(1)} KB links`}${l.src.includes('+') ? ' (not a release: the link shows it)' : ''}`);
315
+ // A long link is the whole model: printed only when asked for (--long), else written to a file (a link of a
316
+ // keyboard is megabytes: in a terminal or an agent's context it's only noise)
317
+ if (!l.short && !opt('long')) {
318
+ const f = paths.cache('links', `${l.src.replace(/[^\w@.+-]+/g, '-')}.txt`);
319
+ fs.mkdirSync(path.dirname(f), { recursive: true });
320
+ fs.writeFileSync(f, `view ${l.view}\nstl ${l.stl}\n3mf ${l['3mf']}\n`);
321
+ return console.log(` links in ${path.relative(WS, f)} (--md: one ${hasTarget('shorten') ? 'short ' : ''}link for chat; --long: print them)`);
322
+ }
323
+ console.log(` view ${l.view}`);
324
+ console.log(` stl ${l.stl}`);
325
+ console.log(` 3mf ${l['3mf']}`);
326
+ }
327
+
328
+ // ---------- versions ----------
329
+ const KINDS = ['patch', 'minor', 'major'];
330
+ const today = () => new Date().toISOString().slice(0, 10);
331
+
332
+ // Unreleased changes: compare with the last release. -> { last, kind, reasons } (kind null: nothing changed)
333
+ function since(d) {
334
+ const last = tags(d.project, d.name).at(-1);
335
+ if (!last) return { last: null, kind: 'first', reasons: [] };
336
+ return { last, ...compare(snapshot(materialise(d, last.version)), snapshot(d)) };
337
+ }
338
+
339
+ // Only the design's own files (and its project's changelog and print log) may have uncommitted changes.
340
+ // A release commits only its own files, so other work in progress can stay as it is (another object, an assembly, the
341
+ // docs). What it can't release past is uncommitted shared code (lib/, bin/) or a rule or layout it uses: those would
342
+ // be in the build but not in the release.
343
+ function onlyChanged(allowed) {
344
+ const dirty = git('status', '--porcelain', '--untracked-files=all').split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, ''));
345
+ const shared = dirty.filter((f) => !allowed.includes(f) && /^(lib|bin)\//.test(f));
346
+ if (shared.length) throw new Error(`commit shared code first (the release would build with it): ${shared.join(', ')}`);
347
+ }
348
+
349
+ function commitAndTag(paths, subject, tag, message) {
350
+ git('add', '--', ...paths);
351
+ git('commit', '-q', '-m', subject, ...(opt('trailer') ? ['--trailer', opt('trailer')] : []), '--', ...paths); // only these, whatever else is staged
352
+ git('tag', '-a', tag, '-m', message);
353
+ if (opt('push')) { git('push', '-q', 'origin', 'HEAD'); git('push', '-q', 'origin', tag); }
354
+ }
355
+
356
+ function changelog(dir, heading, message, reasons) {
357
+ const f = path.join(dir, 'CHANGELOG.md');
358
+ const old = fs.existsSync(f) ? fs.readFileSync(f, 'utf8').replace(/^# .*\n+/, '') : '';
359
+ const entry = `## ${heading}\n\n${message}\n${reasons.length ? `\n${reasons.map((r) => `- ${r.text} (${r.kind})`).join('\n')}\n` : ''}`;
360
+ fs.writeFileSync(f, `# ${path.basename(dir)} changelog\n\n${entry}\n${old}`);
361
+ return path.relative(WS, f);
362
+ }
363
+
364
+ async function releaseObject(d, { batch = false } = {}) {
365
+ const message = opt('m');
366
+ if (!message || message === true) throw new Error('say what changed: -m "Wider slot for a looser fit"');
367
+ const object = readObject(d), b = await build(d);
368
+ const raw = lint(fs.readFileSync(d.js, 'utf8')), fails = (b.circuit ? await (await pcbEngine()).checks(b.circuit, b.values, b.matrix) // boards: the board's checks
369
+ : b.object.role === 'reference' ? [] : check(b.triangles, printer(b, d), { parts: b.parts })).filter((r) => r.level === 'fail'); // references aren't printed
370
+ if (raw.length || fails.length) throw new Error(`fix the check first: ${[...raw.map((r) => `raw number ${r.text} on line ${r.line}`), ...fails.map((f) => f.text)].join('; ')}`);
371
+ let { last, kind, reasons } = since(d);
372
+ if (!kind) throw new Error(`nothing has changed since ${last.version}`);
373
+ const asked = opt('bump');
374
+ if (asked && last) {
375
+ if (!KINDS.includes(asked)) throw new Error('--bump is major, minor or patch');
376
+ if (bigger(asked, kind) !== asked && !opt('force')) throw new Error(`the changes need at least a ${kind} release (${reasons.filter((r) => r.kind === kind).map((r) => r.text).join('; ')}); --force to override`);
377
+ kind = asked;
378
+ }
379
+ const version = last ? bump(last.version, kind) : object.version || '0.1.0';
380
+ const tag = `${d.project}/${d.name}@${version}`;
381
+ console.log(`${tag} (${kind}${last ? `, from ${last.version}` : ''})`);
382
+ for (const r of reasons) console.log(` ${r.kind.padEnd(5)} ${r.text}`);
383
+ if (opt('dry-run')) return;
384
+ const allowed = [...files(d, object), `projects/${d.project}/CHANGELOG.md`, path.relative(WS, printsFile(d))];
385
+ if (!batch) onlyChanged(allowed); // releaseAll checked the whole tree first
386
+ fs.writeFileSync(d.json, fs.readFileSync(d.json, 'utf8').replace(/"version":\s*"[^"]*"/, `"version": "${version}"`));
387
+ const log = changelog(d.dir, `${d.name} ${version} (${kind}), ${today()}`, message, reasons);
388
+ commitAndTag([...files(d, object), log].filter((f) => fs.existsSync(path.join(WS, f))), `${tag}: ${message}`, tag, `${kind}: ${message}${b.circuit ? `\n\nengine: ${engineId()}` : ''}`);
389
+ console.log(`tagged ${tag}${opt('push') ? ' and pushed' : ' (not pushed: --push, or git push --follow-tags)'}`);
390
+ }
391
+
392
+ async function releaseProject(project) {
393
+ const message = opt('m');
394
+ if (!message || message === true) throw new Error('say what this release is: -m "First full set"');
395
+ const dir = paths.project(project), pj = path.join(dir, 'project.json');
396
+ if (!fs.existsSync(pj)) throw new Error(`no project "${project}"`);
397
+ const p = JSON.parse(fs.readFileSync(pj, 'utf8')), manifest = {};
398
+ for (const name of p.objects) {
399
+ const d = locate(`${project}/${name}`), s = since(d);
400
+ if (!s.last) throw new Error(`${name} has no release yet: cad release ${project}/${name} -m "…"`);
401
+ if (s.kind) throw new Error(`${name} has changes since ${s.last.version}: release it first`);
402
+ manifest[name] = s.last.version;
403
+ }
404
+ const last = tags(project).at(-1);
405
+ let kind = 'first', reasons = [];
406
+ if (last) {
407
+ const before = JSON.parse(git('cat-file', '-p', `refs/tags/${last.tag}`).split('\n\n').slice(1).join('\n\n').split('\n').find((l) => l.startsWith('{')) || '{}');
408
+ for (const [name, v] of Object.entries(manifest)) {
409
+ if (!before[name]) reasons.push({ kind: 'minor', text: `${name} ${v} added` });
410
+ else if (before[name] !== v) {
411
+ const k = tags(project, name).filter((t) => newer(t.version, before[name]) && !newer(t.version, v)).reduce((m, t) => bigger(m, t.kind === 'first' ? 'patch' : t.kind || 'patch'), 'patch');
412
+ reasons.push({ kind: k, text: `${name} ${before[name]} → ${v}` });
413
+ }
414
+ }
415
+ for (const name of Object.keys(before)) if (!manifest[name]) reasons.push({ kind: 'major', text: `${name} removed` });
416
+ if (!reasons.length) throw new Error(`nothing has changed since ${project}@${last.version}`);
417
+ kind = reasons.reduce((k, r) => bigger(k, r.kind), 'patch');
418
+ }
419
+ const version = last ? bump(last.version, kind) : p.version || '0.1.0';
420
+ console.log(`${project}@${version} (${kind}): ${Object.entries(manifest).map(([n, v]) => `${n}@${v}`).join(', ')}`);
421
+ if (opt('dry-run')) return;
422
+ onlyChanged([`projects/${project}/project.json`, `projects/${project}/CHANGELOG.md`, `projects/${project}/prints.jsonl`]);
423
+ p.version = version; p.release = manifest;
424
+ fs.writeFileSync(pj, JSON.stringify(p, null, 2) + '\n');
425
+ const log = changelog(dir, `${project} ${version} (${kind}), ${today()}`, message, reasons);
426
+ commitAndTag([`projects/${project}/project.json`, log], `${project}@${version}: ${message}`, `${project}@${version}`, `${kind}: ${message}\n\n${JSON.stringify(manifest)}`);
427
+ console.log(`tagged ${project}@${version}`);
428
+ }
429
+
430
+ // Release every object in a project that changed, then the project: the tree may hold changes only to their files.
431
+ async function releaseAll(project) {
432
+ const pj = JSON.parse(fs.readFileSync(path.join(paths.project(project), 'project.json'), 'utf8'));
433
+ const changed = pj.objects.map((n) => locate(`${project}/${n}`)).filter((d) => since(d).kind);
434
+ const allowed = new Set([`projects/${project}/project.json`, `projects/${project}/CHANGELOG.md`, `projects/${project}/prints.jsonl`,
435
+ ...changed.flatMap((d) => files(d, readObject(d)))]);
436
+ onlyChanged([...allowed]);
437
+ for (const d of changed) await releaseObject(d, { batch: true });
438
+ if (!opt('dry-run')) await releaseProject(project);
439
+ }
440
+
441
+ // The object's variants, one a line: name, then its inputs
442
+ function variants(d) {
443
+ const v = readObject(d).variants || {};
444
+ if (!Object.keys(v).length) return console.log(`${d.name} has no variants: add "variants": { "name": { …inputs } } to its .json`);
445
+ for (const [n, set] of Object.entries(v)) console.log(`${`${d.project}/${d.name}:${n}`.padEnd(32)} ${Object.entries(set).map(([k, x]) => `${k}=${x}`).join(', ')}`);
446
+ }
447
+
448
+ // Two variants or releases side by side in one preview, the second to the right of the first
449
+ async function compareVariants(d, a, b) {
450
+ if (!a || !b) throw new Error('usage: cad compare <project>/<object> <variant or version> <variant or version>');
451
+ const one = async (x) => build(/^\d+\.\d+\.\d+$/.test(x) ? materialise(d, x) : { ...d, variant: x }, sets());
452
+ const [A, B] = [await one(a), await one(b)];
453
+ const { bounds } = await import('@rulecad/printcheck');
454
+ const gap = (bounds(A.triangles).size[0] + bounds(B.triangles).size[0]) / 4, dx = bounds(A.triangles).hi[0] - bounds(B.triangles).lo[0] + gap;
455
+ const moved = (p) => ({ ...p, triangles: p.triangles.map((t) => t.map(([x, y, z]) => [x + dx, y, z])) });
456
+ const parts = [...A.parts.map((p) => ({ ...p, item: a, label: `${a}: ${p.label}` })), ...B.parts.map((p) => moved({ ...p, item: b, label: `${b}: ${p.label}` }))];
457
+ const out = paths.preview(d.project, `${d.name}-${a}-vs-${b}.png`);
458
+ await preview(parts, { title: `${A.object.title || d.name}: ${a} and ${b}`, meta: `${a} on the left, ${b} on the right`, out, explode: false });
459
+ console.log(out);
460
+ }
461
+
462
+ function status(project) {
463
+ for (const p of project ? [project] : fs.readdirSync(paths.projects)) {
464
+ const pj = JSON.parse(fs.readFileSync(path.join(paths.project(p), 'project.json'), 'utf8'));
465
+ console.log(`${p}${pj.version ? `@${pj.version}` : ''}`);
466
+ for (const name of pj.objects) {
467
+ const s = since(locate(`${p}/${name}`));
468
+ console.log(` ${name.padEnd(16)} ${s.last ? s.last.version.padEnd(8) : 'unreleased'} ${s.kind === 'first' ? '' : s.kind ? `changed since: needs a ${s.kind} release` : 'up to date'}`);
469
+ }
470
+ }
471
+ }
472
+
473
+ function history(ref) {
474
+ const [project, name] = ref.split('/');
475
+ if (!name) {
476
+ for (const t of tags(project).reverse()) console.log(`${t.tag} ${t.date} ${t.kind} ${t.message}`);
477
+ return status(project);
478
+ }
479
+ const d = locate(ref), done = prints(d);
480
+ const s = since(d);
481
+ if (s.kind && s.last) { console.log(`unreleased (needs ${s.kind}):`); for (const r of s.reasons) console.log(` ${r.text}`); }
482
+ for (const t of tags(d.project, d.name).reverse()) {
483
+ console.log(`${t.version.padEnd(8)} ${t.date} ${t.kind.padEnd(5)} ${t.message}`);
484
+ for (const p of done.filter((x) => x.version === t.version)) console.log(` printed ${p.date} in ${p.material}: ${p.result}${p.note ? `, ${p.note}` : ''}${p.set ? ` (${p.set})` : ''}`);
485
+ }
486
+ }
487
+
488
+ function diff(d, from, to) {
489
+ if (!from) throw new Error('usage: cad diff <project>/<object> <from> [<to>]');
490
+ const a = snapshot(materialise(d, from)), b = snapshot(to ? materialise(d, to) : d);
491
+ const r = compare(a, b);
492
+ console.log(`${d.project}/${d.name} ${from} → ${to || 'now'}: ${r.kind ? `a ${r.kind} change` : 'no changes'}`);
493
+ for (const x of r.reasons) console.log(` ${x.kind.padEnd(5)} ${x.text}`);
494
+ }
495
+
496
+ function printed(d) {
497
+ const result = opt('result');
498
+ if (!['ok', 'failed'].includes(result)) throw new Error('--result ok or --result failed (and --note "what happened")');
499
+ const object = readObject(d), version = opt('version') || tags(d.project, d.name).at(-1)?.version || object.version;
500
+ const entry = { date: today(), object: d.name, version, ...(opt('set') ? { set: opt('set') } : {}), material: opt('material') || object.material, result, ...(opt('note') ? { note: opt('note') } : {}) };
501
+ fs.appendFileSync(printsFile(d), JSON.stringify(entry) + '\n');
502
+ console.log(`logged in ${path.relative(WS, printsFile(d))}: ${d.name}@${version} ${result}`);
503
+ }
504
+
505
+ main().catch((e) => { console.error(e.message); process.exit(1); });