@rulecad/cad 0.1.4 → 0.2.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/AGENTS.md CHANGED
@@ -47,6 +47,18 @@ 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
package/README.md CHANGED
@@ -92,6 +92,12 @@ which version it holds (`keyboard/keycap@0.1.0`, or `+changes` if it isn't a rel
92
92
 
93
93
  - `value`: a number, or arithmetic over numbers and names (`+ - * /`, brackets, `min`, `max`, `round`). Rule sets are
94
94
  `<namespace>.<rule>`; the object's own variables are bare names. Parsed, never evaluated as code.
95
+ - **Other objects' numbers:** `"uses": ["node/carrier"]` reads another object as a rule set named after it:
96
+ `carrier.board_w` in an expression, `v.carrier.board_w` in code. It gets the object's variables and, from a board, its
97
+ outline (`board_x`, `board_y`, `board_w`, `board_d`) and, in code, its mounting holes (`v.carrier.board_holes`, each
98
+ `{ x, y, d }`). So a case follows its board: change the board and the case rebuilds around it. `"node/carrier@^0.2.0"`
99
+ also checks the version; `"node/carrier@0.2.0"` reads that release. `cad rules` shows each borrowed number with the
100
+ object and version it came from; two objects can't read each other.
95
101
  - `show`: on the rules card `cad show` hands its target (six at most). `interface`: other parts depend on it (versioning will compare these).
96
102
  - `tweak: { min, max }`: may be changed with `--set`. Variables without one are fixed by their rule.
97
103
  - Inputs that hold names or text, not numbers (the design reads them; expressions can't), all settable with `--set`:
@@ -196,6 +202,10 @@ other items' variables, so a keycap can sit exactly where its slot meets the swi
196
202
  - A fit with `*` applies to every copy: `{ "a": "plate", "b": "key-*.switch.bottom", … }`. With `*` on both sides,
197
203
  each copy is checked against every other copy near it: `{ "a": "key-*.cap", "b": "key-*.cap", "min": 0.5 }`. Repeated fits are reported
198
204
  together: the worst case, and how many of the copies are in range.
205
+ - **Holes fits** follow a board's mounting holes into what they screw into: `{ "holes": "pcb.board", "into":
206
+ "case.body", "depth": "m2.insert_length" }`. Each screw's path must be clear for `depth` mm and solid within `ring`
207
+ 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
208
+ passes even when the bosses are in the wrong place. `"direction": "up"` for screws from below.
199
209
  - `requires` pins versions (`^` same major, at least this version, 0.x included, since a minor release never breaks a
200
210
  fit here; `~` same minor); an item can use a release: `"object": "keycap@0.2.1"`, or a variant: `"keycap:oem-r3"`.
201
211
  - The check names any `estimate` rule an assembly relies on, through its positions or its objects, so guesses get
@@ -254,6 +264,8 @@ about 10 s, and a re-check after an edit measures only what the edit moved. The
254
264
  recently used builds.
255
265
 
256
266
  ### Assemblies: values from other parts
267
+ (An object can also read another object directly, outside any assembly: see "Other objects' numbers" under Variables.
268
+ That suits a case built around one board; `from` suits values that only mean something where items meet.)
257
269
  An item's `from` sets inputs from other items' values, in the assembly's names: `"from": { "usb_y":
258
270
  "board.pcb.pico_y" }`. Every item is built before any is placed, so positions can refer to items listed after them
259
271
  (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 = {
@@ -353,10 +353,12 @@ const KINDS = ['patch', 'minor', 'major'];
353
353
  const today = () => new Date().toISOString().slice(0, 10);
354
354
 
355
355
  // Unreleased changes: compare with the last release. -> { last, kind, reasons } (kind null: nothing changed)
356
- function since(d) {
356
+ // A design that reads other objects is resolved with their numbers (each side with the objects it named then).
357
+ const snap = async (d) => snapshot(d, (await objectSets(d, readObject(d))).sets);
358
+ async function since(d) {
357
359
  const last = tags(d.project, d.name).at(-1);
358
360
  if (!last) return { last: null, kind: 'first', reasons: [] };
359
- return { last, ...compare(snapshot(materialise(d, last.version)), snapshot(d)) };
361
+ return { last, ...compare(await snap(materialise(d, last.version)), await snap(d)) };
360
362
  }
361
363
 
362
364
  // Only the design's own files (and its project's changelog and print log) may have uncommitted changes.
@@ -391,7 +393,7 @@ async function releaseObject(d, { batch = false } = {}) {
391
393
  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
394
  : b.object.role === 'reference' ? [] : check(b.triangles, printer(b, d), { parts: b.parts })).filter((r) => r.level === 'fail'); // references aren't printed
393
395
  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);
396
+ let { last, kind, reasons } = await since(d);
395
397
  if (!kind) throw new Error(`nothing has changed since ${last.version}`);
396
398
  const asked = opt('bump');
397
399
  if (asked && last) {
@@ -419,7 +421,7 @@ async function releaseProject(project) {
419
421
  if (!fs.existsSync(pj)) throw new Error(`no project "${project}"`);
420
422
  const p = JSON.parse(fs.readFileSync(pj, 'utf8')), manifest = {};
421
423
  for (const name of p.objects) {
422
- const d = locate(`${project}/${name}`), s = since(d);
424
+ const d = locate(`${project}/${name}`), s = await since(d);
423
425
  if (!s.last) throw new Error(`${name} has no release yet: cad release ${project}/${name} -m "…"`);
424
426
  if (s.kind) throw new Error(`${name} has changes since ${s.last.version}: release it first`);
425
427
  manifest[name] = s.last.version;
@@ -453,7 +455,8 @@ async function releaseProject(project) {
453
455
  // Release every object in a project that changed, then the project: the tree may hold changes only to their files.
454
456
  async function releaseAll(project) {
455
457
  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);
458
+ const changed = [];
459
+ for (const n of pj.objects) { const d = locate(`${project}/${n}`); if ((await since(d)).kind) changed.push(d); }
457
460
  const allowed = new Set([`projects/${project}/project.json`, `projects/${project}/CHANGELOG.md`, `projects/${project}/prints.jsonl`,
458
461
  ...changed.flatMap((d) => files(d, readObject(d)))]);
459
462
  onlyChanged([...allowed]);
@@ -482,25 +485,25 @@ async function compareVariants(d, a, b) {
482
485
  console.log(out);
483
486
  }
484
487
 
485
- function status(project) {
488
+ async function status(project) {
486
489
  for (const p of project ? [project] : fs.readdirSync(paths.projects)) {
487
490
  const pj = JSON.parse(fs.readFileSync(path.join(paths.project(p), 'project.json'), 'utf8'));
488
491
  console.log(`${p}${pj.version ? `@${pj.version}` : ''}`);
489
492
  for (const name of pj.objects) {
490
- const s = since(locate(`${p}/${name}`));
493
+ const s = await since(locate(`${p}/${name}`));
491
494
  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
495
  }
493
496
  }
494
497
  }
495
498
 
496
- function history(ref) {
499
+ async function history(ref) {
497
500
  const [project, name] = ref.split('/');
498
501
  if (!name) {
499
502
  for (const t of tags(project).reverse()) console.log(`${t.tag} ${t.date} ${t.kind} ${t.message}`);
500
503
  return status(project);
501
504
  }
502
505
  const d = locate(ref), done = prints(d);
503
- const s = since(d);
506
+ const s = await since(d);
504
507
  if (s.kind && s.last) { console.log(`unreleased (needs ${s.kind}):`); for (const r of s.reasons) console.log(` ${r.text}`); }
505
508
  for (const t of tags(d.project, d.name).reverse()) {
506
509
  console.log(`${t.version.padEnd(8)} ${t.date} ${t.kind.padEnd(5)} ${t.message}`);
@@ -508,9 +511,9 @@ function history(ref) {
508
511
  }
509
512
  }
510
513
 
511
- function diff(d, from, to) {
514
+ async function diff(d, from, to) {
512
515
  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);
516
+ const a = await snap(materialise(d, from)), b = await snap(to ? materialise(d, to) : d);
514
517
  const r = compare(a, b);
515
518
  console.log(`${d.project}/${d.name} ${from} → ${to || 'now'}: ${r.kind ? `a ${r.kind} change` : 'no changes'}`);
516
519
  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.0",
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": {