@rulecad/cad 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +14 -1
- package/README.md +19 -3
- package/bin/cad.mjs +19 -13
- package/lib/assembly.mjs +29 -2
- package/lib/design.mjs +36 -3
- package/lib/versions.mjs +5 -4
- package/package.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -47,10 +47,23 @@ variable says which rule it comes from. Follow these steps; they're what makes a
|
|
|
47
47
|
with ranges from rules. An overlap or gap outside its range is a finding: report it with the numbers, and never
|
|
48
48
|
widen a range to make it pass without saying why.
|
|
49
49
|
- Build bigger things from checked assemblies used as items, rather than placing their objects again.
|
|
50
|
+
- A part is named in fits as `<item>.<part>`; an object that returns one unnamed shape has one part, `body`.
|
|
51
|
+
- A gap fit can't tell whether holes line up. Where a board screws into a part, add a holes fit
|
|
52
|
+
(`{ "holes": "pcb.board", "into": "case.body", "depth": "m2.insert_length" }`).
|
|
53
|
+
- A part built around another (a case around its board) reads it: `"uses": ["node/carrier"]`, then
|
|
54
|
+
`carrier.board_w` and `v.carrier.board_holes`. Never copy another object's numbers by hand.
|
|
55
|
+
|
|
56
|
+
## Circuit boards
|
|
57
|
+
- `"engine": "pcb"` in the .json; `main(v)` returns a board built with `v.pcb` (see @rulecad/pcb's README).
|
|
58
|
+
- Parts come from rule sets where one exists (`v.pcb.xiao`, `qwiic`, `jstph`, `header`), or any standard footprint by
|
|
59
|
+
name (`v.pcb.part('U2', 'soic8', x, y)`). Turn parts in quarter turns with `rotation`, held in a variable.
|
|
60
|
+
- Join pins with `nets` (`GND: ['U1.GND', 'J1.GND']`) and let the autorouter draw them; `cad check` runs the fab's
|
|
61
|
+
design rules. Look at the copper preview: connectors should face the edge their plug comes from.
|
|
50
62
|
|
|
51
63
|
## Talking to the person
|
|
52
64
|
- Give links as one labelled line (`npx cad link <project>/<object> --md`), never a raw URL. If it says
|
|
53
|
-
"(not a release)", say so.
|
|
65
|
+
"(not a release)", say so. If it says the link is too long for chat, don't paste it: tell the person, and offer
|
|
66
|
+
short links (a secret Gist), which upload the model, so ask first.
|
|
54
67
|
- Say what the checks found, with numbers. Ask before anything that leaves the computer: releases with `--push`,
|
|
55
68
|
ordering boards, publishing.
|
|
56
69
|
- Designs are code: only run designs you or the person trust. Links and rule files are data and never run.
|
package/README.md
CHANGED
|
@@ -69,9 +69,13 @@ fragment, which never reaches the web host, so the public page can open private
|
|
|
69
69
|
which version it holds (`keyboard/keycap@0.1.0`, or `+changes` if it isn't a release). The page is to become `@rulecad/viewer`;
|
|
70
70
|
`scripts/publish-view.sh "message"` publishes it (only that folder) to the public repo.
|
|
71
71
|
|
|
72
|
-
- **Short links** (`--short`, and the default for `--md`
|
|
73
|
-
|
|
74
|
-
GitHub's API. The same model reuses its Gist
|
|
72
|
+
- **Short links** (`--short`, and the default for `--md`, once `"shorten": "gist"` is in `cad.config.json`'s
|
|
73
|
+
`targets`): the link's data goes in a secret GitHub Gist (unlisted, but anyone with the link can open it) and the
|
|
74
|
+
link carries only its ID: `…/#s=<id>`. The page fetches it from GitHub's API. The same model reuses its Gist
|
|
75
|
+
(`.cache/links.json`). `npm create rulecad` asks which you want. Without it, links carry the model, and `cad link`
|
|
76
|
+
says when one is too long for chat or email (over 8,000 characters).
|
|
77
|
+
- **Boards** travel light: the board drawn from both sides (the page makes the front and back views from its layers)
|
|
78
|
+
and the schematic, with coordinates rounded to what a screen can show.
|
|
75
79
|
- **Parts panel:** the page lists parts by kind (keycap ×9), opening to each copy (key-1-1). Each row shows or hides
|
|
76
80
|
its parts and sets their style (solid, wireframe, x-ray, faint); the view has a mode, an explode slider, and
|
|
77
81
|
switches for supports and references. Click a part in the model to select its copy and see its size.
|
|
@@ -92,6 +96,12 @@ which version it holds (`keyboard/keycap@0.1.0`, or `+changes` if it isn't a rel
|
|
|
92
96
|
|
|
93
97
|
- `value`: a number, or arithmetic over numbers and names (`+ - * /`, brackets, `min`, `max`, `round`). Rule sets are
|
|
94
98
|
`<namespace>.<rule>`; the object's own variables are bare names. Parsed, never evaluated as code.
|
|
99
|
+
- **Other objects' numbers:** `"uses": ["node/carrier"]` reads another object as a rule set named after it:
|
|
100
|
+
`carrier.board_w` in an expression, `v.carrier.board_w` in code. It gets the object's variables and, from a board, its
|
|
101
|
+
outline (`board_x`, `board_y`, `board_w`, `board_d`) and, in code, its mounting holes (`v.carrier.board_holes`, each
|
|
102
|
+
`{ x, y, d }`). So a case follows its board: change the board and the case rebuilds around it. `"node/carrier@^0.2.0"`
|
|
103
|
+
also checks the version; `"node/carrier@0.2.0"` reads that release. `cad rules` shows each borrowed number with the
|
|
104
|
+
object and version it came from; two objects can't read each other.
|
|
95
105
|
- `show`: on the rules card `cad show` hands its target (six at most). `interface`: other parts depend on it (versioning will compare these).
|
|
96
106
|
- `tweak: { min, max }`: may be changed with `--set`. Variables without one are fixed by their rule.
|
|
97
107
|
- Inputs that hold names or text, not numbers (the design reads them; expressions can't), all settable with `--set`:
|
|
@@ -196,6 +206,10 @@ other items' variables, so a keycap can sit exactly where its slot meets the swi
|
|
|
196
206
|
- A fit with `*` applies to every copy: `{ "a": "plate", "b": "key-*.switch.bottom", … }`. With `*` on both sides,
|
|
197
207
|
each copy is checked against every other copy near it: `{ "a": "key-*.cap", "b": "key-*.cap", "min": 0.5 }`. Repeated fits are reported
|
|
198
208
|
together: the worst case, and how many of the copies are in range.
|
|
209
|
+
- **Holes fits** follow a board's mounting holes into what they screw into: `{ "holes": "pcb.board", "into":
|
|
210
|
+
"case.body", "depth": "m2.insert_length" }`. Each screw's path must be clear for `depth` mm and solid within `ring`
|
|
211
|
+
mm of it (default: the hole's diameter) half way down. A gap fit can't see this: a board that rests on its bosses
|
|
212
|
+
passes even when the bosses are in the wrong place. `"direction": "up"` for screws from below.
|
|
199
213
|
- `requires` pins versions (`^` same major, at least this version, 0.x included, since a minor release never breaks a
|
|
200
214
|
fit here; `~` same minor); an item can use a release: `"object": "keycap@0.2.1"`, or a variant: `"keycap:oem-r3"`.
|
|
201
215
|
- The check names any `estimate` rule an assembly relies on, through its positions or its objects, so guesses get
|
|
@@ -254,6 +268,8 @@ about 10 s, and a re-check after an edit measures only what the edit moved. The
|
|
|
254
268
|
recently used builds.
|
|
255
269
|
|
|
256
270
|
### Assemblies: values from other parts
|
|
271
|
+
(An object can also read another object directly, outside any assembly: see "Other objects' numbers" under Variables.
|
|
272
|
+
That suits a case built around one board; `from` suits values that only mean something where items meet.)
|
|
257
273
|
An item's `from` sets inputs from other items' values, in the assembly's names: `"from": { "usb_y":
|
|
258
274
|
"board.pcb.pico_y" }`. Every item is built before any is placed, so positions can refer to items listed after them
|
|
259
275
|
(the board placed under the case's top while the case is built from the board's Pico position). `"place": false`
|
package/bin/cad.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { fmt, loadRuleSet } from '../lib/rules.mjs';
|
|
|
7
7
|
import { WS, TOOL, paths, config, printerOf } from '../lib/workspace.mjs';
|
|
8
8
|
import { fromKLE } from '@rulecad/keyboard-layout';
|
|
9
9
|
import { target, hasTarget } from '../lib/targets.mjs';
|
|
10
|
-
import { locate, build, pick, lint, readObject, printer, pcbEngine } from '../lib/design.mjs';
|
|
10
|
+
import { locate, build, pick, lint, readObject, printer, pcbEngine, objectSets } from '../lib/design.mjs';
|
|
11
11
|
import { git, tags, bump, bigger, newer, compare, snapshot, materialise, files, prints, printsFile, engineId } from '../lib/versions.mjs';
|
|
12
12
|
import { check } from '@rulecad/printcheck';
|
|
13
13
|
import { preview } from '../lib/preview.mjs';
|
|
@@ -299,7 +299,7 @@ async function assembly(cmd, ref) {
|
|
|
299
299
|
// ---------- links ----------
|
|
300
300
|
// The model as a link: full print precision (triangles), the rules card, and exactly which version it is.
|
|
301
301
|
async function links(d, b, set) {
|
|
302
|
-
const s = d.tag ? { kind: null } : since(d), fil = filaments(d.project);
|
|
302
|
+
const s = d.tag ? { kind: null } : await since(d), fil = filaments(d.project);
|
|
303
303
|
const version = d.version || b.object.version || '0.0.0';
|
|
304
304
|
const src = `${d.project}/${d.name}${d.variant ? `:${d.variant}` : ''}@${version}${s.kind && s.kind !== 'first' ? '+changes' : s.kind === 'first' ? '+unreleased' : ''}`;
|
|
305
305
|
const model = {
|
|
@@ -330,8 +330,11 @@ async function shorten(l) {
|
|
|
330
330
|
|
|
331
331
|
// --md: one line of labelled Markdown links, the form agents paste into chat (CLAUDE.md, "Replies with links").
|
|
332
332
|
// Short (a Gist) unless --long; plain output is long (nothing uploaded) unless --short.
|
|
333
|
+
const TOO_LONG = 8000; // characters: past this, chat apps and email cut a link short or refuse it
|
|
333
334
|
async function printLinks(l) {
|
|
334
335
|
if ((opt('md') && !opt('long')) || opt('short')) l = await shorten(l);
|
|
336
|
+
if (!l.short && l.view.length > TOO_LONG) console.error(`this link is ${(l.view.length / 1024).toFixed(0)} KB, too long for chat or email. ${hasTarget('shorten')
|
|
337
|
+
? 'Leave out --long for a short one.' : 'For short links, add "shorten": "gist" to "targets" in cad.config.json: each model you share is then stored in a secret GitHub Gist (needs the gh command, signed in).'}`);
|
|
335
338
|
// One link: the page picks which parts go in a download (all of them, one key, only the case)
|
|
336
339
|
if (opt('md')) return console.log(`**${[l.title, l.version].filter(Boolean).join(' ')}**${l.src.includes('+') ? ' (not a release)' : ''}: [View and download](${l.view})`);
|
|
337
340
|
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)' : ''}`);
|
|
@@ -353,10 +356,12 @@ const KINDS = ['patch', 'minor', 'major'];
|
|
|
353
356
|
const today = () => new Date().toISOString().slice(0, 10);
|
|
354
357
|
|
|
355
358
|
// Unreleased changes: compare with the last release. -> { last, kind, reasons } (kind null: nothing changed)
|
|
356
|
-
|
|
359
|
+
// A design that reads other objects is resolved with their numbers (each side with the objects it named then).
|
|
360
|
+
const snap = async (d) => snapshot(d, (await objectSets(d, readObject(d))).sets);
|
|
361
|
+
async function since(d) {
|
|
357
362
|
const last = tags(d.project, d.name).at(-1);
|
|
358
363
|
if (!last) return { last: null, kind: 'first', reasons: [] };
|
|
359
|
-
return { last, ...compare(
|
|
364
|
+
return { last, ...compare(await snap(materialise(d, last.version)), await snap(d)) };
|
|
360
365
|
}
|
|
361
366
|
|
|
362
367
|
// Only the design's own files (and its project's changelog and print log) may have uncommitted changes.
|
|
@@ -391,7 +396,7 @@ async function releaseObject(d, { batch = false } = {}) {
|
|
|
391
396
|
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
|
|
392
397
|
: b.object.role === 'reference' ? [] : check(b.triangles, printer(b, d), { parts: b.parts })).filter((r) => r.level === 'fail'); // references aren't printed
|
|
393
398
|
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('; ')}`);
|
|
394
|
-
let { last, kind, reasons } = since(d);
|
|
399
|
+
let { last, kind, reasons } = await since(d);
|
|
395
400
|
if (!kind) throw new Error(`nothing has changed since ${last.version}`);
|
|
396
401
|
const asked = opt('bump');
|
|
397
402
|
if (asked && last) {
|
|
@@ -419,7 +424,7 @@ async function releaseProject(project) {
|
|
|
419
424
|
if (!fs.existsSync(pj)) throw new Error(`no project "${project}"`);
|
|
420
425
|
const p = JSON.parse(fs.readFileSync(pj, 'utf8')), manifest = {};
|
|
421
426
|
for (const name of p.objects) {
|
|
422
|
-
const d = locate(`${project}/${name}`), s = since(d);
|
|
427
|
+
const d = locate(`${project}/${name}`), s = await since(d);
|
|
423
428
|
if (!s.last) throw new Error(`${name} has no release yet: cad release ${project}/${name} -m "…"`);
|
|
424
429
|
if (s.kind) throw new Error(`${name} has changes since ${s.last.version}: release it first`);
|
|
425
430
|
manifest[name] = s.last.version;
|
|
@@ -453,7 +458,8 @@ async function releaseProject(project) {
|
|
|
453
458
|
// Release every object in a project that changed, then the project: the tree may hold changes only to their files.
|
|
454
459
|
async function releaseAll(project) {
|
|
455
460
|
const pj = JSON.parse(fs.readFileSync(path.join(paths.project(project), 'project.json'), 'utf8'));
|
|
456
|
-
const changed =
|
|
461
|
+
const changed = [];
|
|
462
|
+
for (const n of pj.objects) { const d = locate(`${project}/${n}`); if ((await since(d)).kind) changed.push(d); }
|
|
457
463
|
const allowed = new Set([`projects/${project}/project.json`, `projects/${project}/CHANGELOG.md`, `projects/${project}/prints.jsonl`,
|
|
458
464
|
...changed.flatMap((d) => files(d, readObject(d)))]);
|
|
459
465
|
onlyChanged([...allowed]);
|
|
@@ -482,25 +488,25 @@ async function compareVariants(d, a, b) {
|
|
|
482
488
|
console.log(out);
|
|
483
489
|
}
|
|
484
490
|
|
|
485
|
-
function status(project) {
|
|
491
|
+
async function status(project) {
|
|
486
492
|
for (const p of project ? [project] : fs.readdirSync(paths.projects)) {
|
|
487
493
|
const pj = JSON.parse(fs.readFileSync(path.join(paths.project(p), 'project.json'), 'utf8'));
|
|
488
494
|
console.log(`${p}${pj.version ? `@${pj.version}` : ''}`);
|
|
489
495
|
for (const name of pj.objects) {
|
|
490
|
-
const s = since(locate(`${p}/${name}`));
|
|
496
|
+
const s = await since(locate(`${p}/${name}`));
|
|
491
497
|
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'}`);
|
|
492
498
|
}
|
|
493
499
|
}
|
|
494
500
|
}
|
|
495
501
|
|
|
496
|
-
function history(ref) {
|
|
502
|
+
async function history(ref) {
|
|
497
503
|
const [project, name] = ref.split('/');
|
|
498
504
|
if (!name) {
|
|
499
505
|
for (const t of tags(project).reverse()) console.log(`${t.tag} ${t.date} ${t.kind} ${t.message}`);
|
|
500
506
|
return status(project);
|
|
501
507
|
}
|
|
502
508
|
const d = locate(ref), done = prints(d);
|
|
503
|
-
const s = since(d);
|
|
509
|
+
const s = await since(d);
|
|
504
510
|
if (s.kind && s.last) { console.log(`unreleased (needs ${s.kind}):`); for (const r of s.reasons) console.log(` ${r.text}`); }
|
|
505
511
|
for (const t of tags(d.project, d.name).reverse()) {
|
|
506
512
|
console.log(`${t.version.padEnd(8)} ${t.date} ${t.kind.padEnd(5)} ${t.message}`);
|
|
@@ -508,9 +514,9 @@ function history(ref) {
|
|
|
508
514
|
}
|
|
509
515
|
}
|
|
510
516
|
|
|
511
|
-
function diff(d, from, to) {
|
|
517
|
+
async function diff(d, from, to) {
|
|
512
518
|
if (!from) throw new Error('usage: cad diff <project>/<object> <from> [<to>]');
|
|
513
|
-
const a =
|
|
519
|
+
const a = await snap(materialise(d, from)), b = await snap(to ? materialise(d, to) : d);
|
|
514
520
|
const r = compare(a, b);
|
|
515
521
|
console.log(`${d.project}/${d.name} ${from} → ${to || 'now'}: ${r.kind ? `a ${r.kind} change` : 'no changes'}`);
|
|
516
522
|
for (const x of r.reasons) console.log(` ${x.kind.padEnd(5)} ${x.text}`);
|
package/lib/assembly.mjs
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// Positions are expressions over rules (mx.pitch) and items' variables (cap.lift); rotate is degrees about x, y, z,
|
|
13
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
14
|
// items or item.parts; a negative min allows a press fit to overlap.
|
|
15
|
+
// A holes fit follows each of a board's mounting holes down its screw's path into another part:
|
|
16
|
+
// { "holes": "pcb.board", "into": "case.body", "depth": "m2.insert_length", "label": "Screws find their inserts" }
|
|
17
|
+
// The path must be clear for depth mm (it lands in a hole) and solid within "ring" mm of it (default: the hole's
|
|
18
|
+
// diameter) half way down, so the screw has something to grip. "direction": "up" for screws from below.
|
|
15
19
|
import fs from 'node:fs';
|
|
16
20
|
import crypto from 'node:crypto';
|
|
17
21
|
import path from 'node:path';
|
|
@@ -21,7 +25,7 @@ import { locate, build, readObject, jscad } from './design.mjs';
|
|
|
21
25
|
import { materialise } from './versions.mjs';
|
|
22
26
|
import { satisfies } from '@rulecad/versions';
|
|
23
27
|
import { bounds } from '@rulecad/printcheck';
|
|
24
|
-
import { closest } from '@rulecad/fit';
|
|
28
|
+
import { closest, inside } from '@rulecad/fit';
|
|
25
29
|
import { keys as keyCentres, readLayout, hasLayout } from './layout.mjs';
|
|
26
30
|
|
|
27
31
|
export const assemblyPath = (ref) => { const [project, name] = String(ref).split('/'); return path.join(paths.project(project || ''), 'assemblies', `${name || ''}.json`); };
|
|
@@ -181,7 +185,8 @@ export async function buildAssembly(ref, stack = [], overrides = {}, opts = {})
|
|
|
181
185
|
}
|
|
182
186
|
// A fit with * applies to every copy: "key-*.switch.bottom" becomes one fit for key-0-0, key-0-1, …
|
|
183
187
|
// With * on both sides, each copy is checked against all the others: { "a": "key-*.cap", "b": "key-*.cap" }
|
|
184
|
-
for (
|
|
188
|
+
for (let x of a.fits || []) {
|
|
189
|
+
if (x.holes) x = { ...x, a: x.holes, b: x.into }; // a holes fit: a board's holes (a) over what its screws go into (b)
|
|
185
190
|
const star = [x.a, x.b].find((n) => n.includes('*'));
|
|
186
191
|
if (!star) { fits.push({ ...x, num }); continue; }
|
|
187
192
|
const match = (n) => { const re = new RegExp(`^${n.split('.')[0].replace(/[-]/g, '\\-').replace('*', '([\\w-]+)')}$`); return ids.filter((id) => re.test(id)); };
|
|
@@ -258,6 +263,7 @@ export function checkAssembly(s) {
|
|
|
258
263
|
const pick = (n) => (Array.isArray(n) ? n.flatMap(pick) : s.parts.filter((p) => named(p, n)));
|
|
259
264
|
const groups = new Map();
|
|
260
265
|
for (const x of s.fits) {
|
|
266
|
+
if (x.holes) { holesFit(x); continue; }
|
|
261
267
|
const A = pick(x.a), key = x.note || `${x.a} / ${x.b}`;
|
|
262
268
|
const lo = x.min != null ? x.num(x.min) : -Infinity, hi = x.max != null ? x.num(x.max) : Infinity;
|
|
263
269
|
const reach = Math.max(2, ...[lo, hi].filter(Number.isFinite)) + 1; // look past the range, so a gap near it is measured
|
|
@@ -281,6 +287,27 @@ export function checkAssembly(s) {
|
|
|
281
287
|
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
288
|
`${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
289
|
}
|
|
290
|
+
// Holes fits: each mounting hole's screw path, clear into a hole, with material round it
|
|
291
|
+
function holesFit(x) {
|
|
292
|
+
const A = pick(x.a), B = pick(x.b), label = x.label || `${x.a} into ${x.b}`;
|
|
293
|
+
const board = A.length && s.objects.find((o) => o.id === A[0].leaf)?.built;
|
|
294
|
+
if (!board?.circuit) return f('fail', `${label}: ${x.a} isn't a circuit board, so it has no mounting holes to follow`);
|
|
295
|
+
if (!B.length) return f('fail', `${label}: no such ${x.b}`);
|
|
296
|
+
const holes = board.circuit.filter((e) => e.type === 'pcb_hole' && !e.pcb_component_id);
|
|
297
|
+
if (!holes.length) return f('fail', `${label}: ${x.a} has no mounting holes`);
|
|
298
|
+
const depth = x.num(x.depth), down = x.direction === 'up' ? 1 : -1, faces = B.flatMap((p) => p.triangles);
|
|
299
|
+
const base = down < 0 ? 0 : board.values.thickness ?? 0, at = (h, dx, dy, t) => jscad.maths.vec3.transform([0, 0, 0], [h.x + dx, h.y + dy, base + down * t], A[0].m);
|
|
300
|
+
const steps = 8, misses = [];
|
|
301
|
+
for (const h of holes) {
|
|
302
|
+
const ring = x.ring != null ? x.num(x.ring) : h.hole_diameter, where = `hole at ${mm(h.x)}, ${mm(h.y)} mm`;
|
|
303
|
+
const blocked = Array.from({ length: steps }, (_, i) => (depth * (i + 0.5)) / steps).find((t) => inside(at(h, 0, 0, t), faces));
|
|
304
|
+
if (blocked != null) { misses.push(`${where}: solid ${mm(blocked)} mm along the screw's path`); continue; }
|
|
305
|
+
const round = Array.from({ length: steps }, (_, i) => (2 * Math.PI * i) / steps).filter((a) => !inside(at(h, ring * Math.cos(a), ring * Math.sin(a), depth / 2), faces));
|
|
306
|
+
if (round.length) misses.push(`${where}: no material ${mm(ring)} mm from the screw on ${round.length} of ${steps} sides`);
|
|
307
|
+
}
|
|
308
|
+
f(misses.length ? 'fail' : 'pass', misses.length ? `${label}: ${misses.join('; ')}` : `${label}: ${holes.length} of ${holes.length} holes clear for ${mm(depth)} mm, with material around each`,
|
|
309
|
+
`${label}: ${misses.length ? `${misses.length} of ${holes.length} miss` : `${holes.length} of ${holes.length} line up`}`);
|
|
310
|
+
}
|
|
284
311
|
// Plates print everything: no references
|
|
285
312
|
if (s.a.kind === 'plate') {
|
|
286
313
|
const refs = s.objects.filter((o) => o.role === 'reference' && !o.unplaced); // an unplaced item only lends its numbers
|
package/lib/design.mjs
CHANGED
|
@@ -23,7 +23,8 @@ import { resolve, loadRuleSet } from './rules.mjs';
|
|
|
23
23
|
import { WS, paths, printerOf } from './workspace.mjs';
|
|
24
24
|
import { readLayout, hasLayout, keys, width, depth } from './layout.mjs';
|
|
25
25
|
import * as shapes from '@rulecad/shapes';
|
|
26
|
-
import { engineId, packageVersion } from './versions.mjs';
|
|
26
|
+
import { engineId, packageVersion, materialise } from './versions.mjs';
|
|
27
|
+
import { satisfies } from '@rulecad/versions';
|
|
27
28
|
|
|
28
29
|
const require = createRequire(import.meta.url);
|
|
29
30
|
const jscad = require('@jscad/modeling');
|
|
@@ -44,6 +45,36 @@ export function locate(ref) {
|
|
|
44
45
|
return { project: path.basename(dir), name: path.basename(base), js: `${base}.js`, json: `${base}.json`, dir, ...(variant ? { variant } : {}) };
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// ---------- objects that read each other ----------
|
|
49
|
+
// "uses": ["node/carrier"] reads another object's numbers as a rule set named after it (carrier.board_w in an
|
|
50
|
+
// expression, v.carrier.board_w in code): its variables, and what a board tells the parts around it (board_x, board_y,
|
|
51
|
+
// board_w, board_d, and board_holes for code). The object is built (cached) first. "node/carrier@^0.2.0" also checks
|
|
52
|
+
// its version; "node/carrier@0.2.0" reads that release instead of the working copy.
|
|
53
|
+
export const isObjectRef = (u) => String(u).includes('/');
|
|
54
|
+
const building = new Set();
|
|
55
|
+
export async function objectSets(d, object) {
|
|
56
|
+
const refs = (object.uses || []).filter(isObjectRef), sets = {}, extra = {};
|
|
57
|
+
if (!refs.length) return { sets, extra };
|
|
58
|
+
const self = `${d.project}/${d.name}`;
|
|
59
|
+
building.add(self);
|
|
60
|
+
try {
|
|
61
|
+
for (const u of refs) {
|
|
62
|
+
const [ref, want] = u.split('@'), exact = /^\d+\.\d+\.\d+$/.test(want || '');
|
|
63
|
+
if (building.has(ref)) throw new Error(`${self} and ${ref} read each other's numbers: one of them has to give its numbers to the other, not both`);
|
|
64
|
+
const base = locate(ref), b = await build(exact ? materialise(base, want) : base), version = b.object.version;
|
|
65
|
+
if (want && !exact && !satisfies(version, want)) throw new Error(`${self} uses ${ref} ${want}, but ${ref} is ${version}: change the range, or the design to suit ${version}`);
|
|
66
|
+
const units = Object.fromEntries(Object.entries(b.object.variables || {}).map(([k, x]) => [k, x.unit]));
|
|
67
|
+
const rules = {};
|
|
68
|
+
for (const [k, v] of Object.entries(b.values)) if (typeof v === 'number' && Number.isFinite(v)) {
|
|
69
|
+
rules[k] = { value: v, unit: units[k] ?? 'mm', rule: `${ref}'s ${k}`, kind: 'design', source: `${ref}@${version}` };
|
|
70
|
+
}
|
|
71
|
+
sets[u] = { name: base.name, rules };
|
|
72
|
+
if (b.circuit) extra[base.name] = { board_holes: (await pcbEngine()).holes(b.circuit) };
|
|
73
|
+
}
|
|
74
|
+
} finally { building.delete(self); }
|
|
75
|
+
return { sets, extra };
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
// The layouts an object can pick, by name, from its layout variables' choices (built-ins like grid have no file)
|
|
48
79
|
export function layoutsOf(d, object) {
|
|
49
80
|
const out = {};
|
|
@@ -85,15 +116,17 @@ export const readObject = (d) => {
|
|
|
85
116
|
export async function build(d, set = {}, { mirror = false } = {}) {
|
|
86
117
|
const object = readObject(d);
|
|
87
118
|
set = variantSet(d, object, set);
|
|
88
|
-
const
|
|
119
|
+
const refs = await objectSets(d, object); // other objects' numbers, when it reads them
|
|
120
|
+
const sets = { ...(d.rulesDir ? Object.fromEntries((object.uses || []).filter((u) => !isObjectRef(u)).map((u) => [u, loadRuleSet(u, d.rulesDir)])) : {}), ...refs.sets }; // a release's own rules
|
|
89
121
|
const display = mirror ? object.mirror || {} : {};
|
|
90
122
|
const { values, entries } = resolve(object, { set, sets, display }); // mirror: lighter for the display
|
|
123
|
+
for (const [n, x] of Object.entries(refs.extra)) Object.assign(values[n], x);
|
|
91
124
|
const files = layoutsOf(d, object);
|
|
92
125
|
if (object.layouts) values.layouts = object.layouts; // older designs kept layouts in their .json
|
|
93
126
|
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) }]));
|
|
94
127
|
|
|
95
128
|
// The cache: everything the build is made from. A hit skips running the design (and its booleans) altogether
|
|
96
|
-
const ruleText = (object.uses || []).map((u) => JSON.stringify(sets[u] || loadRuleSet(u)));
|
|
129
|
+
const ruleText = [...(object.uses || []).map((u) => JSON.stringify(sets[u] || loadRuleSet(u))), JSON.stringify(refs.extra)];
|
|
97
130
|
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');
|
|
98
131
|
const hit = path.join(CACHE, `${key}.json`);
|
|
99
132
|
if (!process.env.CAD_NO_CACHE && fs.existsSync(hit)) {
|
package/lib/versions.mjs
CHANGED
|
@@ -39,7 +39,7 @@ const layoutNames = (d, object) => Object.values(object.variables || {}).filter(
|
|
|
39
39
|
// The files a design depends on, relative to the workspace (its git repo): its .js and .json, the rule sets and the layouts it uses.
|
|
40
40
|
// (A board's engine is the tool's code, not the workspace's: its release records engineId() instead.)
|
|
41
41
|
export function files(d, object) {
|
|
42
|
-
return [path.relative(WS, d.js), path.relative(WS, d.json), ...(object.uses || []).map((u) => `rules/${u}.json`),
|
|
42
|
+
return [path.relative(WS, d.js), path.relative(WS, d.json), ...(object.uses || []).filter((u) => !u.includes('/')).map((u) => `rules/${u}.json`),
|
|
43
43
|
...layoutNames(d, object).map((n) => `projects/${d.project}/layouts/${n}.json`)];
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -54,7 +54,7 @@ export function materialise(d, version) {
|
|
|
54
54
|
const js = path.join(base, `${d.name}.js`), json = path.join(base, `${d.name}.json`);
|
|
55
55
|
fs.writeFileSync(js, show(path.relative(WS, d.js)));
|
|
56
56
|
fs.writeFileSync(json, JSON.stringify(object, null, 2));
|
|
57
|
-
for (const u of object.uses || []) fs.writeFileSync(path.join(base, 'rules', `${u}.json`), show(`rules/${u}.json`));
|
|
57
|
+
for (const u of (object.uses || []).filter((x) => !x.includes('/'))) fs.writeFileSync(path.join(base, 'rules', `${u}.json`), show(`rules/${u}.json`)); // objects it reads are released on their own
|
|
58
58
|
// The layouts it could pick, as they were then (only files that existed in that release)
|
|
59
59
|
fs.mkdirSync(path.join(base, 'layouts'), { recursive: true });
|
|
60
60
|
for (const v of Object.values(object.variables || {})) if (v.type === 'layout') for (const n of v.choice || [v.value]) {
|
|
@@ -91,9 +91,10 @@ function taggedEngine(tag) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
// Resolve a design as it is on disk, or as materialised from a release.
|
|
94
|
-
|
|
94
|
+
// refs: the numbers of objects it reads (design.mjs objectSets), which a snapshot can't build itself.
|
|
95
|
+
export function snapshot(d, refs = {}) {
|
|
95
96
|
const object = JSON.parse(fs.readFileSync(d.json, 'utf8'));
|
|
96
|
-
const sets = d.rulesDir ? Object.fromEntries((object.uses || []).map((u) => [u, loadRuleSet(u, d.rulesDir)])) : {};
|
|
97
|
+
const sets = { ...(d.rulesDir ? Object.fromEntries((object.uses || []).filter((u) => !u.includes('/')).map((u) => [u, loadRuleSet(u, d.rulesDir)])) : {}), ...refs };
|
|
97
98
|
const layouts = Object.fromEntries(layoutNames(d, object).map((n) => [n, readLayout(d.project, n, d.layoutsDir)]));
|
|
98
99
|
// A board is also made by the board engine (footprints, the router): which engine, as recorded at the release
|
|
99
100
|
const engine = object.engine === 'pcb' ? (d.tag ? taggedEngine(d.tag) : engineId()) : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulecad/cad",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The cad command: JSCAD designs from traceable rules, checked for printing and fit, versioned by what fits, shared as links and exported as Bambu 3MF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,16 +46,16 @@
|
|
|
46
46
|
"@rulecad/variables": "^0.1.0",
|
|
47
47
|
"@rulecad/keyboard-layout": "^0.1.2",
|
|
48
48
|
"@rulecad/printcheck": "^0.1.0",
|
|
49
|
-
"@rulecad/fit": "^0.
|
|
49
|
+
"@rulecad/fit": "^0.2.0",
|
|
50
50
|
"@rulecad/model-link": "^0.1.2",
|
|
51
51
|
"@rulecad/versions": "^0.1.0",
|
|
52
52
|
"@rulecad/shapes": "^0.1.0",
|
|
53
|
-
"@rulecad/rules": "^0.
|
|
53
|
+
"@rulecad/rules": "^0.2.0",
|
|
54
54
|
"@jscad/modeling": "^2.12.0",
|
|
55
55
|
"three": "^0.186.1"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@rulecad/pcb": "^0.1.0"
|
|
58
|
+
"@rulecad/pcb": "^0.1.0 || ^0.2.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependenciesMeta": {
|
|
61
61
|
"@rulecad/pcb": {
|