@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 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`): the link's data goes in a secret GitHub Gist (unlisted,
73
- but anyone with the link can open it) and the link carries only its ID: `…/#s=<id>`. The page fetches it from
74
- GitHub's API. The same model reuses its Gist (`.cache/links.json`).
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
- function since(d) {
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(snapshot(materialise(d, last.version)), snapshot(d)) };
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 = pj.objects.map((n) => locate(`${project}/${n}`)).filter((d) => since(d).kind);
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 = snapshot(materialise(d, from)), b = snapshot(to ? materialise(d, to) : d);
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 (const x of a.fits || []) {
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 sets = d.rulesDir ? Object.fromEntries((object.uses || []).map((u) => [u, loadRuleSet(u, d.rulesDir)])) : {}; // a release's own rules
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
- export function snapshot(d) {
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.4",
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.1.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.1.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": {