@rulecad/cad 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ // preview(parts, { title, meta, out, explode }) -> out: a 1800 × 1200 PNG of six views (front, side, 3/4, top, bottom, 3/4 below),
2
+ // parts: [{ name, role, label, item, triangles }]; supports and references are drawn faint (lines only); explode drops
3
+ // supports below and, in an assembly, lifts each kind of object above the one before (plate, switches, keycaps).
4
+ // rendered by headless Chrome from lib/preview/page.html, served on this Mac only for the few seconds it takes.
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import http from 'node:http';
9
+ import { spawn } from 'node:child_process';
10
+ import { createRequire } from 'node:module';
11
+ import { TOOL } from './workspace.mjs';
12
+
13
+ // Any Chrome or Chromium: $CAD_CHROME, else the usual places on macOS, Linux and Windows
14
+ const CHROME = process.env.CAD_CHROME || [
15
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium',
16
+ '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/opt/pw-browsers/chromium',
17
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
18
+ ].find((f) => fs.existsSync(f));
19
+ const THREE = path.dirname(createRequire(import.meta.url).resolve('three')); // wherever npm put it
20
+
21
+ export async function preview(parts, { title = '', meta = '', out, explode = false }) {
22
+ const flat = (tris) => { const positions = []; for (const t of tris) for (let i = 1; i < t.length - 1; i++) positions.push(...t[0], ...t[i], ...t[i + 1]); return positions; };
23
+ const data = JSON.stringify({ title, meta, explode, parts: parts.map((p) => ({ name: p.name, role: p.role, label: p.label, item: p.object || p.item, positions: flat(p.triangles) })) });
24
+ const server = http.createServer((req, res) => {
25
+ const u = new URL(req.url, 'http://x').pathname;
26
+ const send = (type, body) => { res.writeHead(200, { 'Content-Type': type }); res.end(body); };
27
+ if (u === '/') return send('text/html', fs.readFileSync(path.join(TOOL, 'lib/preview/page.html')));
28
+ if (u === '/data.json') return send('application/json', data);
29
+ const f = path.join(THREE, path.basename(u)); // only three's build files
30
+ if (u.startsWith('/three/') && fs.existsSync(f)) return send('text/javascript', fs.readFileSync(f));
31
+ res.writeHead(404); res.end();
32
+ });
33
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
34
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'cad-preview-'));
35
+ fs.mkdirSync(path.dirname(out), { recursive: true });
36
+ try {
37
+ await new Promise((resolve, reject) => {
38
+ if (!CHROME) return reject(new Error('no Chrome or Chromium found for previews: set CAD_CHROME to one'));
39
+ const p = spawn(CHROME, ['--headless=new', `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-device-scale-factor=1',
40
+ '--window-size=1800,1200', '--virtual-time-budget=8000', `--screenshot=${out}`, `http://127.0.0.1:${server.address().port}/`], { stdio: 'ignore' });
41
+ const t = setTimeout(() => { p.kill(); reject(new Error('Chrome took too long to render the preview')); }, 60000);
42
+ p.on('exit', () => { clearTimeout(t); fs.existsSync(out) ? resolve() : reject(new Error('Chrome made no screenshot')); });
43
+ });
44
+ } finally {
45
+ server.close();
46
+ fs.rmSync(profile, { recursive: true, force: true });
47
+ }
48
+ return out;
49
+ }
package/lib/rules.mjs ADDED
@@ -0,0 +1,25 @@
1
+ // Rules from files: the variables and expressions of @rulecad/variables, with rule sets read from the workspace's rules/
2
+ // (then the tool's) by name. Everything but loading is in @rulecad/variables, which needs no files and runs anywhere.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { RULE_DIRS, WS } from './workspace.mjs';
6
+ import { resolve as resolveWith } from '@rulecad/variables';
7
+
8
+ export { ANCHORS, isName, options, checkName, evaluate, fmt } from '@rulecad/variables';
9
+
10
+ // dir: one folder (a release's own rules), else the workspace's rules, then the tool's. A rule set the tool provides
11
+ // is copied into the workspace the first time it's used: the workspace keeps every rule its designs depend on (so a
12
+ // release can record them in git), and your own measurements are edits to your copy.
13
+ export function loadRuleSet(name, dir) {
14
+ const file = (dir ? [dir] : RULE_DIRS).map((d) => path.join(d, `${name}.json`)).find((f) => fs.existsSync(f));
15
+ if (!file) throw new Error(`no rule set "${name}" (rules/${name}.json)`);
16
+ const own = path.join(WS, 'rules', `${name}.json`);
17
+ if (!dir && file !== own) {
18
+ fs.mkdirSync(path.dirname(own), { recursive: true });
19
+ fs.copyFileSync(file, own);
20
+ console.error(`copied the ${name} rule set into rules/${name}.json: yours to keep and edit`);
21
+ }
22
+ return JSON.parse(fs.readFileSync(own, 'utf8'));
23
+ }
24
+ // resolve() as @rulecad/variables describes it, loading the rule sets it isn't handed
25
+ export const resolve = (object, opts = {}) => resolveWith(object, { load: loadRuleSet, ...opts });
@@ -0,0 +1,7 @@
1
+ // Short links through a secret GitHub Gist (unlisted: only people with the link find it), made with the gh command.
2
+ import { execFileSync } from 'node:child_process';
3
+
4
+ export async function shorten(data, { desc }) {
5
+ const out = execFileSync('gh', ['gist', 'create', '--filename', 'model.txt', '--desc', desc, '-'], { input: data, encoding: 'utf8' }).trim();
6
+ return out.split('/').pop();
7
+ }
@@ -0,0 +1,21 @@
1
+ // Targets: optional extras the command hands work to, named in cad.config.json's "targets". A name is one of lib/targets/
2
+ // (gist) or a module path relative to the workspace, so anyone can add their own (a smart mirror, a slicer, a printer).
3
+ // show: export async function show({ title, ttl, mesh, card }) puts a model somewhere to look at.
4
+ // mesh: { polygons } (one solid) or { shapes, place } (each shape once, then a 3 × 4 transform per copy);
5
+ // card: { title, items } (short lines) to go with it
6
+ // shorten: export async function shorten(data, { desc }) stores a model link's data and returns an ID for #s=<id>
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { WS, config } from './workspace.mjs';
11
+
12
+ export const hasTarget = (kind) => !!config.targets?.[kind];
13
+
14
+ export async function target(kind) {
15
+ const name = config.targets?.[kind];
16
+ if (!name) throw new Error(`no "${kind}" target: name one in cad.config.json ("targets": { "${kind}": … })`);
17
+ const own = new URL(`./targets/${name}.mjs`, import.meta.url);
18
+ const mod = await import(fs.existsSync(own) ? own : pathToFileURL(path.resolve(WS, name)).href);
19
+ if (typeof mod[kind] !== 'function') throw new Error(`target ${name} has no ${kind}()`);
20
+ return mod[kind];
21
+ }
@@ -0,0 +1,7 @@
1
+ // Print files: the 3MF writer the model page uses too (bambu3mf in @rulecad/model-link, which describes the layout), so
2
+ // a file from `cad export` and one downloaded from the page are the same kind of file.
3
+ // await threemf([{ name, parts: [{ name, triangles, slot }] }], { centre }) -> Uint8Array (a zip)
4
+ export { bambu3mf as threemf, mesh } from '@rulecad/model-link';
5
+
6
+ // The AMS slot for a part, from the project's filaments ({ "base": { "slot": 1 }, "legend": { "slot": 2 } })
7
+ export const slotOf = (filaments = {}, name) => filaments[name || 'base']?.slot || filaments.base?.slot || 1;
@@ -0,0 +1,103 @@
1
+ // Versions: semver per object, kept as git tags (keyboard/keycap@1.2.0), so any release can be rebuilt exactly.
2
+ // major: an earlier print may no longer fit: an interface variable changed or went away
3
+ // minor: something added that doesn't break fits: a new interface variable or a new tweakable option
4
+ // patch: anything else (print quality, tolerances that aren't interface, the shape's code, the rules it uses)
5
+ // A project release (keyboard@1.0.0) records which version of each object it contains.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { createRequire } from 'node:module';
10
+ import crypto from 'node:crypto';
11
+ import { resolve, loadRuleSet } from './rules.mjs';
12
+ import { WS, paths } from './workspace.mjs';
13
+ import { readLayout, hasLayout } from './layout.mjs';
14
+ import { newer } from '@rulecad/versions';
15
+
16
+ export const git = (...args) => execFileSync('git', args, { cwd: WS, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
17
+
18
+ // ---------- semver: @rulecad/versions ----------
19
+ export { parse, bump, newer, bigger, compare } from '@rulecad/versions';
20
+
21
+ // ---------- tags ----------
22
+ // tags('keyboard', 'keycap') -> releases, oldest first: { version, tag, date, kind, message }. tags('keyboard') -> project releases.
23
+ export function tags(project, name) {
24
+ const prefix = name ? `${project}/${name}@` : `${project}@`;
25
+ let out;
26
+ try { out = git('for-each-ref', `refs/tags/${prefix}*`, '--format=%(refname:strip=2)%09%(creatordate:short)%09%(contents:subject)'); }
27
+ catch (e) { if (/not a git repository/.test(e.stderr || e.message)) return []; throw e; } // a workspace outside git: no releases yet
28
+ return out.split('\n').filter(Boolean).map((l) => {
29
+ const [tag, date, subject = ''] = l.split('\t'), [, kind, message] = subject.match(/^(major|minor|patch|first): (.*)$/) || [, '', subject];
30
+ return { tag, version: tag.slice(prefix.length), date, kind, message };
31
+ }).filter((t) => /^\d+\.\d+\.\d+$/.test(t.version)).sort((a, b) => (newer(a.version, b.version) ? 1 : -1));
32
+ }
33
+
34
+ // The layout files an object can pick (its layout variables' choices that are files), by name
35
+ const layoutNames = (d, object) => Object.values(object.variables || {}).filter((v) => v.type === 'layout')
36
+ .flatMap((v) => v.choice || [v.value]).filter((n) => hasLayout(d.project, n, d.layoutsDir));
37
+
38
+ // 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.
39
+ // (A board's engine is the tool's code, not the workspace's: its release records engineId() instead.)
40
+ export function files(d, object) {
41
+ return [path.relative(WS, d.js), path.relative(WS, d.json), ...(object.uses || []).map((u) => `rules/${u}.json`),
42
+ ...layoutNames(d, object).map((n) => `projects/${d.project}/layouts/${n}.json`)];
43
+ }
44
+
45
+ // A released version's files, written under .versions/ so its imports still find node_modules. -> a design to build
46
+ export function materialise(d, version) {
47
+ const tag = `${d.project}/${d.name}@${version}`;
48
+ try { git('rev-parse', '--verify', `refs/tags/${tag}`); } catch { throw new Error(`no release ${tag} (releases: ${tags(d.project, d.name).map((t) => t.version).join(', ') || 'none'})`); }
49
+ const base = paths.versions(d.project, `${d.name}@${version}`);
50
+ const show = (rel) => git('show', `${tag}:${rel}`);
51
+ const object = JSON.parse(show(path.relative(WS, d.json)));
52
+ fs.mkdirSync(path.join(base, 'rules'), { recursive: true });
53
+ const js = path.join(base, `${d.name}.js`), json = path.join(base, `${d.name}.json`);
54
+ fs.writeFileSync(js, show(path.relative(WS, d.js)));
55
+ fs.writeFileSync(json, JSON.stringify(object, null, 2));
56
+ for (const u of object.uses || []) fs.writeFileSync(path.join(base, 'rules', `${u}.json`), show(`rules/${u}.json`));
57
+ // The layouts it could pick, as they were then (only files that existed in that release)
58
+ fs.mkdirSync(path.join(base, 'layouts'), { recursive: true });
59
+ for (const v of Object.values(object.variables || {})) if (v.type === 'layout') for (const n of v.choice || [v.value]) {
60
+ try { fs.writeFileSync(path.join(base, 'layouts', `${n}.json`), show(`projects/${d.project}/layouts/${n}.json`)); } catch {} // a built-in (grid)
61
+ }
62
+ return { ...d, js, json, rulesDir: path.join(base, 'rules'), layoutsDir: path.join(base, 'layouts'), version, tag };
63
+ }
64
+
65
+ // ---------- what changed: compare() is @rulecad/versions' (exported above) ----------
66
+
67
+ // The board engine's identity: its code and the tscircuit it runs on. A board's release tag records it ("engine: …").
68
+ // A package's version, from the package.json above its entry point (some packages don't export their package.json)
69
+ export function packageVersion(name) {
70
+ for (let dir = path.dirname(createRequire(import.meta.url).resolve(name)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
71
+ const f = path.join(dir, 'package.json');
72
+ if (fs.existsSync(f)) { const pj = JSON.parse(fs.readFileSync(f, 'utf8')); if (pj.name === name) return pj.version; }
73
+ }
74
+ return '';
75
+ }
76
+ const tscircuitVersion = () => packageVersion('tscircuit');
77
+ const engineHash = (code) => crypto.createHash('sha256').update(`${code.trimEnd()}\n${tscircuitVersion()}`).digest('hex').slice(0, 16);
78
+ export const engineId = () => engineHash(fs.readFileSync(new URL(import.meta.resolve('@rulecad/pcb')), 'utf8'));
79
+ // From a tag's message, or for releases before tags recorded it, from the engine's code in the repo at the tag
80
+ function taggedEngine(tag) {
81
+ const m = git('tag', '-l', '--format=%(contents)', tag).match(/^engine: (\w+)$/m);
82
+ if (m) return m[1];
83
+ try { return engineHash(git('show', `${tag}:lib/pcb.mjs`)); } catch { return ''; } // (the engine once lived in the workspace)
84
+ }
85
+
86
+ // Resolve a design as it is on disk, or as materialised from a release.
87
+ export function snapshot(d) {
88
+ const object = JSON.parse(fs.readFileSync(d.json, 'utf8'));
89
+ const sets = d.rulesDir ? Object.fromEntries((object.uses || []).map((u) => [u, loadRuleSet(u, d.rulesDir)])) : {};
90
+ const layouts = Object.fromEntries(layoutNames(d, object).map((n) => [n, readLayout(d.project, n, d.layoutsDir)]));
91
+ // A board is also made by the board engine (footprints, the router): which engine, as recorded at the release
92
+ const engine = object.engine === 'pcb' ? (d.tag ? taggedEngine(d.tag) : engineId()) : null;
93
+ return { object, entries: resolve(object, { sets }).entries, js: fs.readFileSync(d.js, 'utf8'), layouts, ...(engine != null ? { engine } : {}) };
94
+ }
95
+
96
+ // ---------- prints log ----------
97
+ // projects/<project>/prints.jsonl: one JSON object a line: { date, object, version, set, material, result, note }
98
+ export const printsFile = (d) => path.join(d.dir, 'prints.jsonl');
99
+ export function prints(d) {
100
+ const f = printsFile(d);
101
+ if (!fs.existsSync(f)) return [];
102
+ return fs.readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)).filter((p) => !d.name || p.object === d.name);
103
+ }
@@ -0,0 +1,45 @@
1
+ // Where things are. The tool (TOOL: this package: lib/, templates/) and the workspace (WS: your projects, your rules,
2
+ // and what's generated) are separate folders.
3
+ // The workspace is $CAD_WORKSPACE, else the nearest folder at or above the current one with a cad.config.json, else
4
+ // the current folder.
5
+ // cad.config.json: { "printer": "x1c", "viewUrl": "https://…/", "targets": { "show": "targets/show.mjs", "shorten": "gist" } }
6
+ // printer the rule set a print is checked against (bed, overhang, bridges); a project.json's "printer" wins
7
+ // viewUrl the model page that links open (the public one by default: it reads links as data, nothing is uploaded)
8
+ // targets optional extras: show (put a model on display: a module of your own) and shorten (short links: "gist")
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ export const TOOL = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
14
+
15
+ function find() {
16
+ if (process.env.CAD_WORKSPACE) return path.resolve(process.env.CAD_WORKSPACE);
17
+ for (let dir = process.cwd(); ; dir = path.dirname(dir)) {
18
+ if (fs.existsSync(path.join(dir, 'cad.config.json'))) return dir;
19
+ if (dir === path.dirname(dir)) return process.cwd(); // none: where you are
20
+ }
21
+ }
22
+
23
+ export const WS = find();
24
+ export const config = (() => {
25
+ const f = path.join(WS, 'cad.config.json');
26
+ return { printer: 'x1c', viewUrl: 'https://jonibach.github.io/mirror-cad-view/', targets: {}, ...(fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : {}) };
27
+ })();
28
+
29
+ // Rule sets: the workspace's own first (your copies, with your measurements), then the published ones (@rulecad/rules).
30
+ export const RULE_DIRS = [...new Set([path.join(WS, 'rules'), path.dirname(fileURLToPath(import.meta.resolve('@rulecad/rules/x1c.json')))])];
31
+
32
+ export const paths = {
33
+ projects: path.join(WS, 'projects'),
34
+ project: (p) => path.join(WS, 'projects', p),
35
+ cache: (...p) => path.join(WS, '.cache', ...p),
36
+ preview: (...p) => path.join(WS, '.preview', ...p),
37
+ exports: (...p) => path.join(WS, 'exports', ...p),
38
+ versions: (...p) => path.join(WS, '.versions', ...p),
39
+ };
40
+
41
+ // The printer a project's prints are checked against
42
+ export const printerOf = (project) => {
43
+ const f = project && path.join(paths.project(project), 'project.json');
44
+ return (f && fs.existsSync(f) && JSON.parse(fs.readFileSync(f, 'utf8')).printer) || config.printer;
45
+ };
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@rulecad/cad",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "cad": "bin/cad.mjs"
8
+ },
9
+ "exports": {
10
+ "./workspace": "./lib/workspace.mjs",
11
+ "./rules": "./lib/rules.mjs",
12
+ "./design": "./lib/design.mjs",
13
+ "./layout": "./lib/layout.mjs",
14
+ "./assembly": "./lib/assembly.mjs",
15
+ "./versions": "./lib/versions.mjs",
16
+ "./preview": "./lib/preview.mjs",
17
+ "./targets": "./lib/targets.mjs",
18
+ "./threemf": "./lib/threemf.mjs"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "lib",
23
+ "templates",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "license": "MIT",
28
+ "keywords": [
29
+ "cad",
30
+ "jscad",
31
+ "3d-printing",
32
+ "bambu-lab",
33
+ "parametric",
34
+ "cli"
35
+ ],
36
+ "engines": {
37
+ "node": ">=20.6"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/JoniBach/rulecad.git",
42
+ "directory": "packages/cad"
43
+ },
44
+ "dependencies": {
45
+ "@rulecad/variables": "^0.1.0",
46
+ "@rulecad/keyboard-layout": "^0.1.0",
47
+ "@rulecad/printcheck": "^0.1.0",
48
+ "@rulecad/fit": "^0.1.0",
49
+ "@rulecad/model-link": "^0.1.0",
50
+ "@rulecad/versions": "^0.1.0",
51
+ "@rulecad/shapes": "^0.1.0",
52
+ "@rulecad/rules": "^0.1.0",
53
+ "@jscad/modeling": "^2.12.0",
54
+ "fflate": "^0.8.2",
55
+ "three": "^0.186.1"
56
+ },
57
+ "peerDependencies": {
58
+ "@rulecad/pcb": "^0.1.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "@rulecad/pcb": {
62
+ "optional": true
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,9 @@
1
+ // NAME. Every measurement is a variable in NAME.json; this file only says how they combine.
2
+ // Z is up and the bed is z = 0: build the shape the way it will print.
3
+ import jscad from '@jscad/modeling'
4
+ const { cuboid } = jscad.primitives
5
+ const { translate } = jscad.transforms
6
+
7
+ export function main (v) {
8
+ return translate([0, 0, v.height / 2], cuboid({ size: [v.width, v.depth, v.height] }))
9
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "NAME",
3
+ "title": "NAME",
4
+ "version": "0.1.0",
5
+ "uses": ["x1c"],
6
+ "material": "PLA",
7
+ "orientation": "Flat side down on the bed.",
8
+ "variables": {
9
+ "width": { "value": 40, "unit": "mm", "rule": "Overall width", "label": "Width", "show": true, "tweak": { "min": 10, "max": 200 } },
10
+ "depth": { "value": 30, "unit": "mm", "rule": "Overall depth", "label": "Depth", "show": true, "tweak": { "min": 10, "max": 200 } },
11
+ "height": { "value": "x1c.layer * 50", "unit": "mm", "rule": "Overall height: a whole number of layers", "label": "Height", "show": true },
12
+ "chamfer": { "value": "x1c.chamfer", "unit": "mm", "rule": "Bottom edges are chamfered, not rounded" }
13
+ }
14
+ }