@rulecad/variables 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JoniBach and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # @rulecad/variables
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Design values you can trace. Every measurement is a variable, and every variable can say where it comes from: a
4
+ printer's spec, a standard, a community figure, or something you measured (`kind`: spec, community, measured or
5
+ estimate, with its `source`). Expressions are parsed, never `eval`'d, so a JSON file can't run code. No dependencies;
6
+ browsers and Node 18+.
7
+
8
+ ```js
9
+ import { resolve } from '@rulecad/variables';
10
+
11
+ const printer = { name: 'printer', rules: {
12
+ line: { value: 0.42, unit: 'mm', kind: 'spec', source: 'Bambu Lab wiki' },
13
+ wall: { value: 'line * 3' },
14
+ } };
15
+ const tray = {
16
+ uses: ['printer'],
17
+ variables: {
18
+ wall: { value: 'printer.wall', label: 'Wall' },
19
+ length: { value: 120, unit: 'mm', tweak: { min: 80, max: 160 } },
20
+ inner: { value: 'length - 2 * wall' },
21
+ },
22
+ };
23
+
24
+ const { values, entries } = resolve(tray, { sets: { printer }, set: { length: 140 } });
25
+ values.inner // 137.48
26
+ entries // every variable and rule with its value, source and whether anything used it
27
+ ```
28
+
29
+ - **Expressions:** `+ - * /`, brackets, `min`, `max`, `round(x, step)`, and `sind`/`cosd`/`tand` in degrees, over
30
+ numbers and names. `evaluate(src, lookup)` runs one on its own.
31
+ - **Rule sets** are handed in through `sets`, or fetched by name with `load: (name) => …` (from files, a URL, anywhere).
32
+ - **Tweaks:** only variables with a `tweak` range can be set, and only within it. Errors say which variable and why.
33
+ - **Names, not numbers:** `choice: [...]`, `type: 'text'` or `type: 'anchor'` (one of `ANCHORS`: tl tc tr / ml mc
34
+ mr / bl bc br) hold names, and they're checked too.
35
+ - **display:** values for a lighter on-screen copy (fewer segments, no fine detail). They aren't checked and they
36
+ don't change what prints.
37
+ - **`lint(source)`** lists the number literals in a design's code that should be variables. Only 0, 1 and 2 pass, plus
38
+ anything in comments or strings. It's how "every measurement is a variable" gets checked.
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@rulecad/variables",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Design values you can trace: every number says where it comes from, and expressions are parsed, never run.",
5
+ "type": "module",
6
+ "exports": {
7
+ "types": "./variables.d.mts",
8
+ "default": "./variables.mjs"
9
+ },
10
+ "files": [
11
+ "variables.mjs",
12
+ "variables.d.mts",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "license": "MIT",
17
+ "keywords": [
18
+ "parametric",
19
+ "cad",
20
+ "variables",
21
+ "expressions",
22
+ "provenance"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "sideEffects": false,
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/JoniBach/rulecad.git",
31
+ "directory": "packages/variables"
32
+ },
33
+ "scripts": {
34
+ "test": "node --test"
35
+ },
36
+ "types": "./variables.d.mts"
37
+ }
@@ -0,0 +1,73 @@
1
+ export function checkName(name: any, d: any, v: any): string;
2
+ /**
3
+ * One expression: + - * / and brackets, min, max, round, sind, cosd, tand, over numbers and names. Never runs code.
4
+ * @param {string | number} src
5
+ * @param {(name: string) => number} lookup the value of a name
6
+ * @returns {number}
7
+ */
8
+ export function evaluate(src: string | number, lookup: (name: string) => number): number;
9
+ /**
10
+ * Every variable's value, and an entry for each variable and rule for checks and rule cards.
11
+ * @param {DesignObject} object
12
+ * @param {{ sets?: Record<string, RuleSet>, set?: Record<string, number | string>, display?: Record<string, number | string>, load?: (name: string) => RuleSet }} [options]
13
+ * @returns {{ values: Record<string, any>, entries: Entry[] }}
14
+ */
15
+ export function resolve(object: DesignObject, { sets, set, display, load }?: {
16
+ sets?: Record<string, RuleSet>;
17
+ set?: Record<string, number | string>;
18
+ display?: Record<string, number | string>;
19
+ load?: (name: string) => RuleSet;
20
+ }): {
21
+ values: Record<string, any>;
22
+ entries: Entry[];
23
+ };
24
+ /**
25
+ * Number literals in a design's code other than 0, 1 and 2 (outside comments and strings).
26
+ * @param {string} source
27
+ * @returns {{ line: number, text: string }[]}
28
+ */
29
+ export function lint(source: string): {
30
+ line: number;
31
+ text: string;
32
+ }[];
33
+ /** @typedef {{ value: number | string, unit?: string, rule?: string, kind?: 'spec' | 'community' | 'default' | 'estimate' | 'measured', source?: string, checked?: string, label?: string, show?: boolean, interface?: boolean, tweak?: { min: number, max: number }, choice?: string[], type?: 'text' | 'anchor' | 'layout' }} Variable */
34
+ /** @typedef {{ name?: string, rules: Record<string, Variable> }} RuleSet */
35
+ /** @typedef {{ uses?: string[], variables?: Record<string, Variable>, [field: string]: any }} DesignObject */
36
+ /** @typedef {{ name: string, value: number | string, used?: boolean, set?: boolean, [field: string]: any }} Entry */
37
+ export const ANCHORS: string[];
38
+ export function isName(d: any): boolean;
39
+ export function options(d: any): any;
40
+ export function fmt(n: number | string): string;
41
+ export type Variable = {
42
+ value: number | string;
43
+ unit?: string;
44
+ rule?: string;
45
+ kind?: "spec" | "community" | "default" | "estimate" | "measured";
46
+ source?: string;
47
+ checked?: string;
48
+ label?: string;
49
+ show?: boolean;
50
+ interface?: boolean;
51
+ tweak?: {
52
+ min: number;
53
+ max: number;
54
+ };
55
+ choice?: string[];
56
+ type?: "text" | "anchor" | "layout";
57
+ };
58
+ export type RuleSet = {
59
+ name?: string;
60
+ rules: Record<string, Variable>;
61
+ };
62
+ export type DesignObject = {
63
+ uses?: string[];
64
+ variables?: Record<string, Variable>;
65
+ [field: string]: any;
66
+ };
67
+ export type Entry = {
68
+ name: string;
69
+ value: number | string;
70
+ used?: boolean;
71
+ set?: boolean;
72
+ [field: string]: any;
73
+ };
package/variables.mjs ADDED
@@ -0,0 +1,172 @@
1
+ // Rules and variables: every measurement a design uses, with what it says and where it comes from.
2
+ // rules/<set>.json shared facts (the printer, a standard), referred to as <set>.<rule>: x1c.line, mx.slot
3
+ // <object>.json variables the object's own values, by bare name: stem_arm. Values are numbers or expressions.
4
+ // Expressions are arithmetic only (+ - * / and brackets, min, max, round, and sind, cosd, tand of degrees) over numbers
5
+ // and names: parsed here, never
6
+ // eval'd, so a JSON file can't run code.
7
+ // No imports and no file access: it runs in a browser as well. Rule sets are handed in (sets) or fetched by name
8
+ // through load(name), such as one that reads rules/<name>.json.
9
+
10
+ /** @typedef {{ value: number | string, unit?: string, rule?: string, kind?: 'spec' | 'community' | 'default' | 'estimate' | 'measured', source?: string, checked?: string, label?: string, show?: boolean, interface?: boolean, tweak?: { min: number, max: number }, choice?: string[], type?: 'text' | 'anchor' | 'layout' }} Variable */
11
+ /** @typedef {{ name?: string, rules: Record<string, Variable> }} RuleSet */
12
+ /** @typedef {{ uses?: string[], variables?: Record<string, Variable>, [field: string]: any }} DesignObject */
13
+ /** @typedef {{ name: string, value: number | string, used?: boolean, set?: boolean, [field: string]: any }} Entry */
14
+
15
+ export const ANCHORS = ['tl', 'tc', 'tr', 'ml', 'mc', 'mr', 'bl', 'bc', 'br'];
16
+ // A variable that holds a name or text, and the names it may hold (null: any text)
17
+ export const isName = (d) => !!(d.choice || ['text', 'anchor', 'layout'].includes(d.type));
18
+ export const options = (d) => (d.type === 'anchor' ? ANCHORS : d.type === 'text' ? null : d.choice);
19
+ export function checkName(name, d, v) {
20
+ const s = String(v), opts = options(d);
21
+ if (opts && !opts.includes(s)) throw new Error(`${name} is one of ${opts.join(', ')} (got "${s}")`);
22
+ if (!opts && s.length > (d.max ?? 16)) throw new Error(`${name} is at most ${d.max ?? 16} characters`);
23
+ return s;
24
+ }
25
+ const DEG = Math.PI / 180;
26
+ const FNS = { min: Math.min, max: Math.max, round: (x, step = 1) => Math.round(x / step) * step,
27
+ sind: (d) => Math.sin(d * DEG), cosd: (d) => Math.cos(d * DEG), tand: (d) => Math.tan(d * DEG) }; // angles in degrees
28
+
29
+ // ---------- expressions ----------
30
+ function tokens(src) {
31
+ const out = [], re = /\s*(?:(\d+(?:\.\d+)?|\.\d+)|([a-z_][\w]*(?:\.[a-z_][\w]*)*)|([-+*/(),]))/giy;
32
+ let m, at = 0;
33
+ while (at < src.length) {
34
+ re.lastIndex = at;
35
+ if (!(m = re.exec(src))) { if (/^\s*$/.test(src.slice(at))) break; throw new Error(`can't read "${src.slice(at).trim()}"`); }
36
+ out.push(m[1] ? { n: Number(m[1]) } : m[2] ? { id: m[2] } : { op: m[3] });
37
+ at = re.lastIndex;
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // evaluate("line * 3", lookup) -> number. lookup(name) gives a name's value.
43
+ /**
44
+ * One expression: + - * / and brackets, min, max, round, sind, cosd, tand, over numbers and names. Never runs code.
45
+ * @param {string | number} src
46
+ * @param {(name: string) => number} lookup the value of a name
47
+ * @returns {number}
48
+ */
49
+ export function evaluate(src, lookup) {
50
+ if (typeof src === 'number') return src;
51
+ const t = tokens(String(src));
52
+ let i = 0;
53
+ const peek = () => t[i], take = (op) => (t[i]?.op === op ? (i++, true) : false);
54
+ const expr = () => { let v = term(); for (;;) { if (take('+')) v += term(); else if (take('-')) v -= term(); else return v; } };
55
+ const term = () => { let v = unary(); for (;;) { if (take('*')) v *= unary(); else if (take('/')) v /= unary(); else return v; } };
56
+ const unary = () => (take('-') ? -unary() : take('+') ? unary() : atom());
57
+ const atom = () => {
58
+ const k = t[i++];
59
+ if (!k) throw new Error('ends too soon');
60
+ if (k.n != null) return k.n;
61
+ if (k.op === '(') { const v = expr(); if (!take(')')) throw new Error('missing )'); return v; }
62
+ if (k.id && FNS[k.id] && take('(')) {
63
+ const args = [expr()];
64
+ while (take(',')) args.push(expr());
65
+ if (!take(')')) throw new Error('missing )');
66
+ return FNS[k.id](...args);
67
+ }
68
+ if (k.id) return lookup(k.id);
69
+ throw new Error(`unexpected "${k.op}"`);
70
+ };
71
+ const v = expr();
72
+ if (peek()) throw new Error(`unexpected "${peek().op ?? peek().id ?? peek().n}"`);
73
+ if (!Number.isFinite(v)) throw new Error('is not a finite number');
74
+ return v;
75
+ }
76
+
77
+
78
+
79
+ // resolve(object, { sets, set }) -> { values, entries }
80
+ // values: { stem_arm: 1.27, x1c: { line: 0.42, … }, mx: { … } } for the design's main()
81
+ // entries: every variable and every rule in the sets used, with its resolved value, for checks and the rules
82
+ // card; a rule's entry has used: true if a variable refers to it
83
+ // object: { uses: ['x1c', 'mx'], variables: { name: { value, unit, rule, source, label, show, interface, tweak } } }
84
+ // display: { name: value } for a lighter on-screen copy only (fewer segments, no fine detail): not checked, and it
85
+ // wins over set, since it only changes what a screen draws.
86
+ // set: { name: value } overrides tweakable variables, within their tweak range, and the inputs below.
87
+ // Variables are numbers unless they say otherwise; these hold names or text, which the design reads and expressions
88
+ // can't use:
89
+ // { "value": "grid", "choice": ["grid", "staggered-54"] } one of a list
90
+ // { "type": "text", "value": "", "max": 8 } free text, e.g. a keycap's legend
91
+ // { "type": "anchor", "value": "mc" } one of 9 places on a face: tl tc tr, ml mc mr, bl bc br
92
+ // { "type": "layout", "value": "grid", "choice": [...] } a layout file in the project's layouts/ (or a built-in)
93
+ /**
94
+ * Every variable's value, and an entry for each variable and rule for checks and rule cards.
95
+ * @param {DesignObject} object
96
+ * @param {{ sets?: Record<string, RuleSet>, set?: Record<string, number | string>, display?: Record<string, number | string>, load?: (name: string) => RuleSet }} [options]
97
+ * @returns {{ values: Record<string, any>, entries: Entry[] }}
98
+ */
99
+ export function resolve(object, { sets = {}, set = {}, display = {}, load } = {}) {
100
+ const ruleSets = Object.fromEntries((object.uses || []).map((u) => {
101
+ const s = sets[u] || (load ? load(u) : null);
102
+ if (!s) throw new Error(`no rule set "${u}" (hand it in as sets.${u}, or give a load function)`);
103
+ return [s.name || u, s];
104
+ }));
105
+ const vars = object.variables || {};
106
+ for (const [k, v] of Object.entries(set)) {
107
+ const d = vars[k];
108
+ if (!d) throw new Error(`no variable "${k}" to set (variables: ${Object.keys(vars).join(', ')})`);
109
+ if (isName(d)) { checkName(k, d, v); continue; }
110
+ if (!d.tweak) throw new Error(`"${k}" is fixed by its rule; only variables with a tweak range can be set`);
111
+ const n = Number(v);
112
+ if (!Number.isFinite(n) || n < d.tweak.min || n > d.tweak.max) throw new Error(`${k} must be a number from ${d.tweak.min} to ${d.tweak.max}`);
113
+ }
114
+
115
+ const done = new Map(), busy = new Set();
116
+ const lookup = (name) => {
117
+ if (done.has(name)) return done.get(name);
118
+ if (busy.has(name)) throw new Error(`"${name}" refers back to itself`);
119
+ busy.add(name);
120
+ let src, where;
121
+ if (name.includes('.')) {
122
+ const [s, r] = name.split('.');
123
+ if (!ruleSets[s]) throw new Error(`"${name}": rule set "${s}" isn't in uses (${Object.keys(ruleSets).join(', ') || 'none'})`);
124
+ if (!ruleSets[s].rules[r]) throw new Error(`"${name}": no rule "${r}" in ${s}`);
125
+ src = ruleSets[s].rules[r].value; where = (n) => (n.includes('.') ? n : `${s}.${n}`); // a rule's names are its own set's
126
+ } else {
127
+ if (!vars[name]) throw new Error(`no variable "${name}"`);
128
+ if (isName(vars[name])) { busy.delete(name); const c = checkName(name, vars[name], name in display ? display[name] : name in set ? set[name] : vars[name].value); done.set(name, c); return c; }
129
+ src = name in display ? display[name] : name in set ? Number(set[name]) : vars[name].value; where = (n) => n; // display: the screen's settings win
130
+ }
131
+ let v;
132
+ try { v = evaluate(src, (n) => lookup(where(n))); } catch (e) { busy.delete(name); throw e.message.startsWith('"') || e.message.startsWith('no ') ? e : new Error(`"${name}" = ${JSON.stringify(src)}: ${e.message}`); }
133
+ busy.delete(name); done.set(name, v);
134
+ return v;
135
+ };
136
+
137
+ const values = {}, entries = [];
138
+ for (const [name, d] of Object.entries(vars)) {
139
+ values[name] = lookup(name);
140
+ entries.push({ name, ...d, value: values[name], expr: typeof d.value === 'string' ? d.value : undefined, set: name in set });
141
+ }
142
+ const used = new Set(done.keys()); // rules the variables actually refer to (directly or through other rules)
143
+ for (const [s, rs] of Object.entries(ruleSets)) {
144
+ values[s] = {};
145
+ for (const [r, d] of Object.entries(rs.rules)) {
146
+ values[s][r] = lookup(`${s}.${r}`);
147
+ entries.push({ name: `${s}.${r}`, ...d, value: values[s][r], expr: typeof d.value === 'string' ? d.value : undefined, used: used.has(`${s}.${r}`) });
148
+ }
149
+ }
150
+ return { values, entries };
151
+ }
152
+
153
+ // Round for display: at most 2 decimals, no trailing zeros.
154
+ /** A number to two decimals at most; a name as it is. @param {number | string} n @returns {string} */
155
+ export const fmt = (n) => (typeof n === 'number' ? String(Number(n.toFixed(2))) : String(n)); // choices are names
156
+
157
+ // lint(source) -> [{ line, text }]: number literals in a design's code that should be variables. Every measurement
158
+ // belongs in a variable that says where it comes from; only 0, 1 and 2 (and numbers in comments and strings) pass.
159
+ /**
160
+ * Number literals in a design's code other than 0, 1 and 2 (outside comments and strings).
161
+ * @param {string} source
162
+ * @returns {{ line: number, text: string }[]}
163
+ */
164
+ export function lint(source) {
165
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')).replace(/\/\/.*$/gm, '')
166
+ .replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => m.replace(/./g, ' '));
167
+ const found = [];
168
+ code.split('\n').forEach((l, i) => {
169
+ for (const m of l.matchAll(/(?<![\w.])(\d+\.\d+|\.\d+|\d+)(?![\w.])/g)) if (!['0', '1', '2'].includes(m[1])) found.push({ line: i + 1, text: m[1] });
170
+ });
171
+ return found;
172
+ }