@rulecad/variables 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.
- package/LICENSE +21 -0
- package/README.md +37 -2
- package/package.json +30 -4
- package/variables.mjs +149 -0
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
|
-
#
|
|
1
|
+
# @rulecad/variables
|
|
2
2
|
|
|
3
|
-
|
|
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,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulecad/variables",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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": "./variables.mjs",
|
|
7
|
+
"files": [
|
|
8
|
+
"variables.mjs",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"parametric",
|
|
15
|
+
"cad",
|
|
16
|
+
"variables",
|
|
17
|
+
"expressions",
|
|
18
|
+
"provenance"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/JoniBach/rulecad.git",
|
|
27
|
+
"directory": "packages/variables"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/variables.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
export const ANCHORS = ['tl', 'tc', 'tr', 'ml', 'mc', 'mr', 'bl', 'bc', 'br'];
|
|
11
|
+
// A variable that holds a name or text, and the names it may hold (null: any text)
|
|
12
|
+
export const isName = (d) => !!(d.choice || ['text', 'anchor', 'layout'].includes(d.type));
|
|
13
|
+
export const options = (d) => (d.type === 'anchor' ? ANCHORS : d.type === 'text' ? null : d.choice);
|
|
14
|
+
export function checkName(name, d, v) {
|
|
15
|
+
const s = String(v), opts = options(d);
|
|
16
|
+
if (opts && !opts.includes(s)) throw new Error(`${name} is one of ${opts.join(', ')} (got "${s}")`);
|
|
17
|
+
if (!opts && s.length > (d.max ?? 16)) throw new Error(`${name} is at most ${d.max ?? 16} characters`);
|
|
18
|
+
return s;
|
|
19
|
+
}
|
|
20
|
+
const DEG = Math.PI / 180;
|
|
21
|
+
const FNS = { min: Math.min, max: Math.max, round: (x, step = 1) => Math.round(x / step) * step,
|
|
22
|
+
sind: (d) => Math.sin(d * DEG), cosd: (d) => Math.cos(d * DEG), tand: (d) => Math.tan(d * DEG) }; // angles in degrees
|
|
23
|
+
|
|
24
|
+
// ---------- expressions ----------
|
|
25
|
+
function tokens(src) {
|
|
26
|
+
const out = [], re = /\s*(?:(\d+(?:\.\d+)?|\.\d+)|([a-z_][\w]*(?:\.[a-z_][\w]*)*)|([-+*/(),]))/giy;
|
|
27
|
+
let m, at = 0;
|
|
28
|
+
while (at < src.length) {
|
|
29
|
+
re.lastIndex = at;
|
|
30
|
+
if (!(m = re.exec(src))) { if (/^\s*$/.test(src.slice(at))) break; throw new Error(`can't read "${src.slice(at).trim()}"`); }
|
|
31
|
+
out.push(m[1] ? { n: Number(m[1]) } : m[2] ? { id: m[2] } : { op: m[3] });
|
|
32
|
+
at = re.lastIndex;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// evaluate("line * 3", lookup) -> number. lookup(name) gives a name's value.
|
|
38
|
+
export function evaluate(src, lookup) {
|
|
39
|
+
if (typeof src === 'number') return src;
|
|
40
|
+
const t = tokens(String(src));
|
|
41
|
+
let i = 0;
|
|
42
|
+
const peek = () => t[i], take = (op) => (t[i]?.op === op ? (i++, true) : false);
|
|
43
|
+
const expr = () => { let v = term(); for (;;) { if (take('+')) v += term(); else if (take('-')) v -= term(); else return v; } };
|
|
44
|
+
const term = () => { let v = unary(); for (;;) { if (take('*')) v *= unary(); else if (take('/')) v /= unary(); else return v; } };
|
|
45
|
+
const unary = () => (take('-') ? -unary() : take('+') ? unary() : atom());
|
|
46
|
+
const atom = () => {
|
|
47
|
+
const k = t[i++];
|
|
48
|
+
if (!k) throw new Error('ends too soon');
|
|
49
|
+
if (k.n != null) return k.n;
|
|
50
|
+
if (k.op === '(') { const v = expr(); if (!take(')')) throw new Error('missing )'); return v; }
|
|
51
|
+
if (k.id && FNS[k.id] && take('(')) {
|
|
52
|
+
const args = [expr()];
|
|
53
|
+
while (take(',')) args.push(expr());
|
|
54
|
+
if (!take(')')) throw new Error('missing )');
|
|
55
|
+
return FNS[k.id](...args);
|
|
56
|
+
}
|
|
57
|
+
if (k.id) return lookup(k.id);
|
|
58
|
+
throw new Error(`unexpected "${k.op}"`);
|
|
59
|
+
};
|
|
60
|
+
const v = expr();
|
|
61
|
+
if (peek()) throw new Error(`unexpected "${peek().op ?? peek().id ?? peek().n}"`);
|
|
62
|
+
if (!Number.isFinite(v)) throw new Error('is not a finite number');
|
|
63
|
+
return v;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
// resolve(object, { sets, set }) -> { values, entries }
|
|
69
|
+
// values: { stem_arm: 1.27, x1c: { line: 0.42, … }, mx: { … } } for the design's main()
|
|
70
|
+
// entries: every variable and every rule in the sets used, with its resolved value, for checks and the rules
|
|
71
|
+
// card; a rule's entry has used: true if a variable refers to it
|
|
72
|
+
// object: { uses: ['x1c', 'mx'], variables: { name: { value, unit, rule, source, label, show, interface, tweak } } }
|
|
73
|
+
// display: { name: value } for a lighter on-screen copy only (fewer segments, no fine detail): not checked, and it
|
|
74
|
+
// wins over set, since it only changes what a screen draws.
|
|
75
|
+
// set: { name: value } overrides tweakable variables, within their tweak range, and the inputs below.
|
|
76
|
+
// Variables are numbers unless they say otherwise; these hold names or text, which the design reads and expressions
|
|
77
|
+
// can't use:
|
|
78
|
+
// { "value": "grid", "choice": ["grid", "staggered-54"] } one of a list
|
|
79
|
+
// { "type": "text", "value": "", "max": 8 } free text, e.g. a keycap's legend
|
|
80
|
+
// { "type": "anchor", "value": "mc" } one of 9 places on a face: tl tc tr, ml mc mr, bl bc br
|
|
81
|
+
// { "type": "layout", "value": "grid", "choice": [...] } a layout file in the project's layouts/ (or a built-in)
|
|
82
|
+
export function resolve(object, { sets = {}, set = {}, display = {}, load } = {}) {
|
|
83
|
+
const ruleSets = Object.fromEntries((object.uses || []).map((u) => {
|
|
84
|
+
const s = sets[u] || (load ? load(u) : null);
|
|
85
|
+
if (!s) throw new Error(`no rule set "${u}" (hand it in as sets.${u}, or give a load function)`);
|
|
86
|
+
return [s.name || u, s];
|
|
87
|
+
}));
|
|
88
|
+
const vars = object.variables || {};
|
|
89
|
+
for (const [k, v] of Object.entries(set)) {
|
|
90
|
+
const d = vars[k];
|
|
91
|
+
if (!d) throw new Error(`no variable "${k}" to set (variables: ${Object.keys(vars).join(', ')})`);
|
|
92
|
+
if (isName(d)) { checkName(k, d, v); continue; }
|
|
93
|
+
if (!d.tweak) throw new Error(`"${k}" is fixed by its rule; only variables with a tweak range can be set`);
|
|
94
|
+
const n = Number(v);
|
|
95
|
+
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}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const done = new Map(), busy = new Set();
|
|
99
|
+
const lookup = (name) => {
|
|
100
|
+
if (done.has(name)) return done.get(name);
|
|
101
|
+
if (busy.has(name)) throw new Error(`"${name}" refers back to itself`);
|
|
102
|
+
busy.add(name);
|
|
103
|
+
let src, where;
|
|
104
|
+
if (name.includes('.')) {
|
|
105
|
+
const [s, r] = name.split('.');
|
|
106
|
+
if (!ruleSets[s]) throw new Error(`"${name}": rule set "${s}" isn't in uses (${Object.keys(ruleSets).join(', ') || 'none'})`);
|
|
107
|
+
if (!ruleSets[s].rules[r]) throw new Error(`"${name}": no rule "${r}" in ${s}`);
|
|
108
|
+
src = ruleSets[s].rules[r].value; where = (n) => (n.includes('.') ? n : `${s}.${n}`); // a rule's names are its own set's
|
|
109
|
+
} else {
|
|
110
|
+
if (!vars[name]) throw new Error(`no variable "${name}"`);
|
|
111
|
+
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; }
|
|
112
|
+
src = name in display ? display[name] : name in set ? Number(set[name]) : vars[name].value; where = (n) => n; // display: the screen's settings win
|
|
113
|
+
}
|
|
114
|
+
let v;
|
|
115
|
+
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}`); }
|
|
116
|
+
busy.delete(name); done.set(name, v);
|
|
117
|
+
return v;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const values = {}, entries = [];
|
|
121
|
+
for (const [name, d] of Object.entries(vars)) {
|
|
122
|
+
values[name] = lookup(name);
|
|
123
|
+
entries.push({ name, ...d, value: values[name], expr: typeof d.value === 'string' ? d.value : undefined, set: name in set });
|
|
124
|
+
}
|
|
125
|
+
const used = new Set(done.keys()); // rules the variables actually refer to (directly or through other rules)
|
|
126
|
+
for (const [s, rs] of Object.entries(ruleSets)) {
|
|
127
|
+
values[s] = {};
|
|
128
|
+
for (const [r, d] of Object.entries(rs.rules)) {
|
|
129
|
+
values[s][r] = lookup(`${s}.${r}`);
|
|
130
|
+
entries.push({ name: `${s}.${r}`, ...d, value: values[s][r], expr: typeof d.value === 'string' ? d.value : undefined, used: used.has(`${s}.${r}`) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { values, entries };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Round for display: at most 2 decimals, no trailing zeros.
|
|
137
|
+
export const fmt = (n) => (typeof n === 'number' ? String(Number(n.toFixed(2))) : String(n)); // choices are names
|
|
138
|
+
|
|
139
|
+
// lint(source) -> [{ line, text }]: number literals in a design's code that should be variables. Every measurement
|
|
140
|
+
// belongs in a variable that says where it comes from; only 0, 1 and 2 (and numbers in comments and strings) pass.
|
|
141
|
+
export function lint(source) {
|
|
142
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')).replace(/\/\/.*$/gm, '')
|
|
143
|
+
.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => m.replace(/./g, ' '));
|
|
144
|
+
const found = [];
|
|
145
|
+
code.split('\n').forEach((l, i) => {
|
|
146
|
+
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] });
|
|
147
|
+
});
|
|
148
|
+
return found;
|
|
149
|
+
}
|