exprforge 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,90 @@
1
+ // exprforge/emitters/rust.js
2
+ //
3
+ // Rust's f64 math is method-call syntax (x.sqrt()), not free functions
4
+ // (sqrt(x)) like every other target here. The base Emitter's `calls` table
5
+ // just holds string templates, so this needs no special-casing in base.js —
6
+ // it's the reason `calls` was designed as arbitrary templates instead of a
7
+ // plain "language function name" lookup.
8
+ const Emitter = require("./base.js");
9
+
10
+ function method0(name) {
11
+ return ([x]) => `(${x}).${name}()`;
12
+ }
13
+
14
+ function method1(name) {
15
+ return ([recv, arg]) => `(${recv}).${name}(${arg})`;
16
+ }
17
+
18
+ const emitter = new Emitter({
19
+ ext: "rs",
20
+ // Explicit f64 suffix: a bare literal like `5.0` is only usable as a
21
+ // method-call receiver (`5.0.sqrt()`) once its type is unambiguous, and
22
+ // rustc won't always infer it from context (E0689).
23
+ formatNumber: (v) => (Number.isInteger(v) ? `${v}.0f64` : `${v}f64`),
24
+ calls: {
25
+ sqrt: method0("sqrt"), abs: method0("abs"), sin: method0("sin"), cos: method0("cos"),
26
+ tan: method0("tan"), asin: method0("asin"), acos: method0("acos"), atan: method0("atan"),
27
+ ln: method0("ln"), log2: method0("log2"), log10: method0("log10"), exp: method0("exp"),
28
+ floor: method0("floor"), ceil: method0("ceil"), round: method0("round"), trunc: method0("trunc"),
29
+ log: method0("ln"), // Math.log is natural log; Rust spells it ln()
30
+ // NOT .signum(): Rust's docs specify 1.0 at positive zero (it only
31
+ // reads the sign bit), unlike JS's Math.sign(0) === 0 (and C's/
32
+ // Java's sign, which both special-case zero). Found by the
33
+ // kitchen-sink conformance test at exactly x - y == 0.
34
+ sign: ([x]) => `(if ${x} > 0.0 { 1.0f64 } else if ${x} < 0.0 { -1.0f64 } else { 0.0f64 })`,
35
+ pow: method1("powf"), atan2: method1("atan2"), min: method1("min"),
36
+ max: method1("max"), hypot: method1("hypot"),
37
+ },
38
+ // Rust has no C-style ?: ternary; `if` is itself an expression instead
39
+ // (`if cond { a } else { b }`), and it's just as short-circuiting.
40
+ emitSelect: function (condNode, thenStr, elseStr) {
41
+ const L = this.emitExpr(condNode.left);
42
+ const R = this.emitExpr(condNode.right);
43
+ return `(if ${L} ${condNode.op} ${R} { ${thenStr} } else { ${elseStr} })`;
44
+ },
45
+ formatFunction: (fn, body, letBindings = []) => {
46
+ const params = fn.params.map((p) => `${p}: f64`).join(", ");
47
+ const lets = letBindings.map(({ name, valueStr }) => ` let ${name}: f64 = ${valueStr};`).join("\n");
48
+ const letsBlock = lets ? lets + "\n" : "";
49
+ // Param/function names come from the AST author (may not be snake_case,
50
+ // e.g. "P0"), every bin node keeps explicit parens by design (see
51
+ // header), and a shared let-chain can bind more names than one
52
+ // function's body reads (e.g. ux/uy/uz computed for three sibling
53
+ // single-component functions) — all trip default rustc lints
54
+ // without being bugs, so silence them rather than let a
55
+ // `-D warnings` build choke on them.
56
+ return `// AUTO-GENERATED by ExprForge -- do not hand-edit.\n` +
57
+ `#[allow(non_snake_case, unused_parens, unused_variables)]\n` +
58
+ `pub fn ${fn.name}(${params}) -> f64 {\n` +
59
+ letsBlock +
60
+ ` ${body}\n` +
61
+ `}\n`;
62
+ },
63
+ // Multiple named outputs from one call: Rust has no native multi-return
64
+ // with names (tuples are positional and easy to mix up for 6 fields),
65
+ // so this emits a small struct alongside the function and constructs it
66
+ // directly — the idiomatic Rust shape for "several named values out."
67
+ formatSuite: (fn, outputStrs, letBindings = []) => {
68
+ const params = fn.params.map((p) => `${p}: f64`).join(", ");
69
+ const lets = letBindings.map(({ name, valueStr }) => ` let ${name}: f64 = ${valueStr};`).join("\n");
70
+ const letsBlock = lets ? lets + "\n" : "";
71
+ const outputNames = Object.keys(outputStrs);
72
+ const structName = `${capitalize(fn.name)}Result`;
73
+ const structFields = outputNames.map((n) => ` pub ${n}: f64,`).join("\n");
74
+ const initFields = outputNames.map((n) => `${n}: ${outputStrs[n]}`).join(", ");
75
+ return `// AUTO-GENERATED by ExprForge -- do not hand-edit.\n` +
76
+ `#[allow(non_snake_case)]\n` +
77
+ `pub struct ${structName} {\n${structFields}\n}\n\n` +
78
+ `#[allow(non_snake_case, unused_parens, unused_variables)]\n` +
79
+ `pub fn ${fn.name}(${params}) -> ${structName} {\n` +
80
+ letsBlock +
81
+ ` ${structName} { ${initFields} }\n` +
82
+ `}\n`;
83
+ },
84
+ });
85
+
86
+ function capitalize(s) {
87
+ return s.charAt(0).toUpperCase() + s.slice(1);
88
+ }
89
+
90
+ module.exports = emitter;
@@ -0,0 +1,63 @@
1
+ // exprforge/emitters/typescript.js
2
+ //
3
+ // Almost identical to js.js at the expression level -- same Math.* calls,
4
+ // same number literal syntax, native ternary for select() (no override
5
+ // needed) -- since TS numbers are JS numbers. What's different is purely
6
+ // surface: typed signatures, ESM `export` instead of CommonJS
7
+ // `module.exports` (this targets a frontend build tool, not Node), and a
8
+ // named `interface` for multi-output suites instead of an untyped object.
9
+ const Emitter = require("./base.js");
10
+
11
+ function fn1(name) {
12
+ return ([x]) => `Math.${name}(${x})`;
13
+ }
14
+
15
+ function fn2(name) {
16
+ return ([a, b]) => `Math.${name}(${a}, ${b})`;
17
+ }
18
+
19
+ const emitter = new Emitter({
20
+ ext: "ts",
21
+ formatNumber: (v) => String(v),
22
+ calls: {
23
+ sqrt: fn1("sqrt"), abs: fn1("abs"), sin: fn1("sin"), cos: fn1("cos"), tan: fn1("tan"),
24
+ asin: fn1("asin"), acos: fn1("acos"), atan: fn1("atan"), log: fn1("log"),
25
+ log2: fn1("log2"), log10: fn1("log10"), exp: fn1("exp"), floor: fn1("floor"),
26
+ ceil: fn1("ceil"), round: fn1("round"), trunc: fn1("trunc"), sign: fn1("sign"),
27
+ pow: fn2("pow"), atan2: fn2("atan2"), min: fn2("min"), max: fn2("max"), hypot: fn2("hypot"),
28
+ },
29
+ formatFunction: (fn, body, letBindings = []) => {
30
+ const params = fn.params.map((p) => `${p}: number`).join(", ");
31
+ const lets = letBindings.map(({ name, valueStr }) => ` const ${name} = ${valueStr};`).join("\n");
32
+ const letsBlock = lets ? lets + "\n" : "";
33
+ return `// AUTO-GENERATED by ExprForge — do not hand-edit.\n` +
34
+ `export function ${fn.name}(${params}): number {\n` +
35
+ letsBlock +
36
+ ` return ${body};\n` +
37
+ `}\n`;
38
+ },
39
+ // Multiple named outputs from one call: a named interface, returned as
40
+ // an object literal — TS's structural typing means this is checked at
41
+ // every call site, unlike JS's untyped equivalent.
42
+ formatSuite: (fn, outputStrs, letBindings = []) => {
43
+ const params = fn.params.map((p) => `${p}: number`).join(", ");
44
+ const lets = letBindings.map(({ name, valueStr }) => ` const ${name} = ${valueStr};`).join("\n");
45
+ const letsBlock = lets ? lets + "\n" : "";
46
+ const outputNames = Object.keys(outputStrs);
47
+ const interfaceName = `${capitalize(fn.name)}Result`;
48
+ const interfaceFields = outputNames.map((n) => ` ${n}: number;`).join("\n");
49
+ const objectFields = outputNames.map((n) => ` ${n}: ${outputStrs[n]},`).join("\n");
50
+ return `// AUTO-GENERATED by ExprForge — do not hand-edit.\n` +
51
+ `export interface ${interfaceName} {\n${interfaceFields}\n}\n\n` +
52
+ `export function ${fn.name}(${params}): ${interfaceName} {\n` +
53
+ letsBlock +
54
+ ` return {\n${objectFields}\n };\n` +
55
+ `}\n`;
56
+ },
57
+ });
58
+
59
+ function capitalize(s) {
60
+ return s.charAt(0).toUpperCase() + s.slice(1);
61
+ }
62
+
63
+ module.exports = emitter;
package/index.js ADDED
@@ -0,0 +1,44 @@
1
+ // exprforge/index.js
2
+ const { num, v, bin, call, add, mul, sub, div, neg, letIn, cmp, select, outputs, collectLets } = require("./ast.js");
3
+ const { forComponents } = require("./util.js");
4
+ const emitters = require("./emitters/registry.js");
5
+ const { catmullRomAst } = require("./samples/catmull-rom.js");
6
+ const { fibonacciAst } = require("./samples/fibonacci.js");
7
+ const { splineFrameAsts } = require("./samples/spline-frame.js");
8
+ const { kitchenSinkAst } = require("./samples/kitchen-sink.js");
9
+
10
+ /**
11
+ * Run every registered emitter against one AST function definition.
12
+ * Returns { [lang]: { ext, source } }.
13
+ */
14
+ function emitAll(fn) {
15
+ const result = {};
16
+ for (const [lang, emitter] of Object.entries(emitters)) {
17
+ result[lang] = { ext: emitter.ext, source: emitter.emitFunction(fn) };
18
+ }
19
+ return result;
20
+ }
21
+
22
+ module.exports = {
23
+ // AST builders — use these to define your own formulas.
24
+ num, v, bin, call, add, mul, sub, div, neg, letIn, cmp, select, outputs, collectLets,
25
+ // Authoring convenience — not an AST primitive, see util.js.
26
+ forComponents,
27
+ // Built-in example formulas — see samples/ for the source.
28
+ catmullRomAst,
29
+ fibonacciAst,
30
+ splineFrameAsts,
31
+ // Not a worked example -- a conformance-test fixture that calls every
32
+ // supported Math function once. See samples/kitchen-sink.js.
33
+ kitchenSinkAst,
34
+ samples: {
35
+ catmullRom: catmullRomAst,
36
+ fibonacci: fibonacciAst,
37
+ splineFrame: splineFrameAsts,
38
+ kitchenSink: kitchenSinkAst,
39
+ },
40
+ // Per-language emitter instances, keyed by name (js, qb64, c, java, go, rust).
41
+ emitters,
42
+ // Convenience: run every emitter at once.
43
+ emitAll,
44
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "exprforge",
3
+ "version": "0.1.0",
4
+ "description": "Author a math expression once as an AST, emit identical-behavior implementations in JS, TypeScript, Python, C#, Lua, QB64, C, Java, Go, and Rust.",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "files": [
8
+ "index.js",
9
+ "ast.js",
10
+ "util.js",
11
+ "build.js",
12
+ "emitters/",
13
+ "samples/"
14
+ ],
15
+ "scripts": {
16
+ "build": "node build.js",
17
+ "test": "node --test",
18
+ "prepublishOnly": "npm test"
19
+ },
20
+ "keywords": [
21
+ "codegen",
22
+ "ast",
23
+ "math",
24
+ "cross-language",
25
+ "transpiler",
26
+ "qb64"
27
+ ],
28
+ "license": "MIT",
29
+ "author": {
30
+ "name": "Don Smith",
31
+ "url": "https://github.com/theraccoonbear"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/theraccoonbear/exprforge.git"
39
+ },
40
+ "homepage": "https://github.com/theraccoonbear/exprforge#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/theraccoonbear/exprforge/issues"
43
+ }
44
+ }
@@ -0,0 +1,26 @@
1
+ // exprforge/samples/catmull-rom.js
2
+ // Uniform Catmull-Rom spline interpolation, one scalar component at a time
3
+ // (call once per x/y/z with P0..P3 = that axis' control values).
4
+ const { num, v, mul, add, sub } = require("../ast.js");
5
+
6
+ // P(t) = 0.5 * ( 2*P1 + (-P0+P2)*t + (2*P0-5*P1+4*P2-P3)*t^2 + (-P0+3*P1-3*P2+P3)*t^3 )
7
+ const P0 = v("P0");
8
+ const P1 = v("P1");
9
+ const P2 = v("P2");
10
+ const P3 = v("P3");
11
+ const t = v("t");
12
+ const t2 = mul(t, t);
13
+ const t3 = mul(t, t, t);
14
+
15
+ const term0 = mul(num(2), P1);
16
+ const term1 = mul(sub(P2, P0), t);
17
+ const term2 = mul(add(mul(num(2), P0), mul(num(-5), P1), mul(num(4), P2), mul(num(-1), P3)), t2);
18
+ const term3 = mul(add(mul(num(-1), P0), mul(num(3), P1), mul(num(-3), P2), P3), t3);
19
+
20
+ const catmullRomAst = {
21
+ name: "catmullRom1D",
22
+ params: ["P0", "P1", "P2", "P3", "t"],
23
+ body: mul(num(0.5), add(term0, term1, term2, term3)),
24
+ };
25
+
26
+ module.exports = { catmullRomAst };
@@ -0,0 +1,24 @@
1
+ // exprforge/samples/fibonacci.js
2
+ // nth Fibonacci number via the closed form (Binet's formula), not recursion
3
+ // or a loop — exprforge has no control flow (see README), so this is the
4
+ // shape a "fibonacci" example has to take here:
5
+ //
6
+ // F(n) = (phi^n - psi^n) / sqrt(5)
7
+ // phi = (1 + sqrt(5)) / 2 (golden ratio)
8
+ // psi = (1 - sqrt(5)) / 2
9
+ //
10
+ // float64 throughout, so exact only up to about n=70 before precision drifts.
11
+ const { num, v, call, sub, add, div } = require("../ast.js");
12
+
13
+ const sqrt5 = call("sqrt", num(5));
14
+ const phi = div(add(num(1), sqrt5), num(2));
15
+ const psi = div(sub(num(1), sqrt5), num(2));
16
+ const n = v("n");
17
+
18
+ const fibonacciAst = {
19
+ name: "fibonacci",
20
+ params: ["n"],
21
+ body: div(sub(call("pow", phi, n), call("pow", psi, n)), sqrt5),
22
+ };
23
+
24
+ module.exports = { fibonacciAst };
@@ -0,0 +1,56 @@
1
+ // exprforge/samples/kitchen-sink.js
2
+ // Not a worked example like the other samples/ — a synthetic function with
3
+ // no real-world meaning, built to call every supported Math function
4
+ // (see README's "Supported Math functions") at least once in a single
5
+ // expression. Exists purely as a conformance-test fixture: the other
6
+ // samples only exercise 5 of the 22 functions between them, so most of
7
+ // the `calls` tables in emitters/*.js — several with hand-rolled,
8
+ // easy-to-get-wrong expansions (QB64's asin/acos as ATN+SQR identities,
9
+ // C's ternary-based sign, Java's log2/round/trunc, Go's Copysign-based
10
+ // sign) — had never actually been run through a compiler.
11
+ //
12
+ // x is kept in (0, 1) by every caller (see test/conformance.test.js's
13
+ // inputs for this sample) so sqrt/log/log2/log10/asin/acos all stay in
14
+ // their valid domain simultaneously. d = x - y is free to land anywhere
15
+ // -- positive, negative, or exactly zero -- and carries the functions
16
+ // that care about sign: floor/ceil/round/trunc/sign. (Test inputs
17
+ // deliberately avoid exact .5 fractional values for d: JS/Java round
18
+ // half-up while C/Go/Rust round half-away-from-zero, which disagree at
19
+ // exact .5 for negative numbers -- a real cross-language difference in
20
+ // round() itself, not an emitter bug, and out of scope to fix here.)
21
+ const { v, call, add, sub } = require("../ast.js");
22
+
23
+ const x = v("x");
24
+ const y = v("y");
25
+ const d = sub(x, y);
26
+
27
+ const kitchenSinkAst = {
28
+ name: "kitchenSink",
29
+ params: ["x", "y"],
30
+ body: add(
31
+ call("sqrt", x),
32
+ call("abs", d),
33
+ call("pow", x, y),
34
+ call("sin", x),
35
+ call("cos", x),
36
+ call("tan", x),
37
+ call("asin", x),
38
+ call("acos", x),
39
+ call("atan", x),
40
+ call("atan2", y, x),
41
+ call("log", x),
42
+ call("log2", x),
43
+ call("log10", x),
44
+ call("exp", x),
45
+ call("floor", d),
46
+ call("ceil", d),
47
+ call("round", d),
48
+ call("trunc", d),
49
+ call("sign", d),
50
+ call("min", x, y),
51
+ call("max", x, y),
52
+ call("hypot", x, y),
53
+ ),
54
+ };
55
+
56
+ module.exports = { kitchenSinkAst };
@@ -0,0 +1,184 @@
1
+ // exprforge/samples/spline-frame.js
2
+ // Gram-Schmidt frame construction and related ops for Catmull-Rom spline
3
+ // paths — the motivating use case for the let/cmp/select AST additions,
4
+ // and now for outputs() (see ast.js). Mirrors math from a private game
5
+ // project's spline_path.bi / spline.ts.
6
+ //
7
+ // Each group below shares one let-chain across several *related* outputs
8
+ // (e.g. SpMakeFrame's R and U vectors) and is emitted as ONE suite —
9
+ // computed once — instead of one function per output. That used to be six
10
+ // separate functions here, each independently re-deriving the same
11
+ // 9-step chain (including a sqrt) from scratch; a caller wanting the
12
+ // whole frame paid for that chain six times over for one tangent vector.
13
+ // See docs/planned-additions.md and the outputs() doc comment in ast.js.
14
+ //
15
+ // IMPORTANT for QB64: function/SUB names must be unique across the entire
16
+ // QB64 compilation unit. The SpEf prefix (SplineExprforge) exists to avoid
17
+ // collisions with hand-written code elsewhere in that project.
18
+ const { num, v, call, add, mul, sub, div, neg, letIn, select, cmp, outputs } = require("../ast.js");
19
+
20
+ // ── Helpers ──────────────────────────────────────────────────────────────
21
+ const PI = num(3.141592653589793);
22
+ const degToRad = (degVar) => mul(degVar, div(PI, num(180)));
23
+ const dot3 = (ax, ay, az, bx, by, bz) => add(mul(ax, bx), mul(ay, by), mul(az, bz));
24
+ const len3 = (x, y, z) => call("sqrt", dot3(x, y, z, x, y, z));
25
+
26
+ // Epsilon guard: normalize when len is big enough to trust, else fallback.
27
+ //
28
+ // select() always evaluates both branches (see ast.js), so this does NOT
29
+ // divide by lenVar directly — that would be undefined right when the guard
30
+ // is supposed to matter. Instead the denominator is clamped to a safe,
31
+ // always-nonzero value by its own select first, so the div() itself never
32
+ // sees anything near zero on any target, no matter which logical branch
33
+ // "wins".
34
+ const EPS = num(0.000001);
35
+ function safeDiv(component, lenVar, fallback) {
36
+ const isSafe = cmp(v(lenVar), ">", EPS);
37
+ const safeLen = select(isSafe, v(lenVar), num(1));
38
+ return select(isSafe, div(component, safeLen), fallback);
39
+ }
40
+
41
+ // ── SpMakeFrame ──────────────────────────────────────────────────────────
42
+ // Gram-Schmidt frame from normalized tangent (tx, ty, tz).
43
+ // worldUp = (0,0,1) when |ty|>0.98, else (0,1,0). worldUp.x is always 0.
44
+ // R = normalize(T × worldUp) U = R × T
45
+ //
46
+ // One shared let chain, six named outputs (rx,ry,rz,ux,uy,uz), computed
47
+ // once per call instead of once per output. The pre-normalization cross
48
+ // product is named crossX/Y/Z, deliberately NOT rx/ry/rz, even though
49
+ // that's its usual name here: it would collide with the "rx"/"ry"/"rz"
50
+ // *output* field names below in any language whose multi-return mechanism
51
+ // shares a namespace with local variables (this collided for real in Go's
52
+ // named-return-values form before that was changed to avoid relying on it —
53
+ // kept renamed anyway, both for clarity and because QB64's output-param
54
+ // SUBs are structurally the same risk and can't be verified here).
55
+ // wy = select(|ty|>0.98, 0, 1)
56
+ // wz = select(|ty|>0.98, 1, 0)
57
+ // crossX = ty*wz - tz*wy
58
+ // crossY = -tx*wz (worldUp.x=0 collapses two terms)
59
+ // crossZ = tx*wy
60
+ // rLen = sqrt(crossX²+crossY²+crossZ²)
61
+ // rxN = safeDiv(crossX, rLen, 0) — fallback 0
62
+ // ryN = safeDiv(crossY, rLen, 0)
63
+ // rzN = safeDiv(crossZ, rLen, 1) — fallback z=1 keeps a valid frame
64
+
65
+ const MF_PARAMS = ["tx", "ty", "tz"];
66
+ const nearVert = cmp(call("abs", v("ty")), ">", num(0.98));
67
+
68
+ function mfLetChain(body) {
69
+ return letIn("wy", select(nearVert, num(0), num(1)),
70
+ letIn("wz", select(nearVert, num(1), num(0)),
71
+ letIn("crossX", sub(mul(v("ty"), v("wz")), mul(v("tz"), v("wy"))),
72
+ letIn("crossY", neg(mul(v("tx"), v("wz"))),
73
+ letIn("crossZ", mul(v("tx"), v("wy")),
74
+ letIn("rLen", len3(v("crossX"), v("crossY"), v("crossZ")),
75
+ letIn("rxN", safeDiv(v("crossX"), "rLen", num(0)),
76
+ letIn("ryN", safeDiv(v("crossY"), "rLen", num(0)),
77
+ letIn("rzN", safeDiv(v("crossZ"), "rLen", num(1)),
78
+ body
79
+ )))))))));
80
+ }
81
+
82
+ // U = R × T (using normalized R) — a cyclic permutation, not one formula,
83
+ // so it's tabulated by axis rather than derived generically.
84
+ const uX = sub(mul(v("ryN"), v("tz")), mul(v("rzN"), v("ty")));
85
+ const uY = sub(mul(v("rzN"), v("tx")), mul(v("rxN"), v("tz")));
86
+ const uZ = sub(mul(v("rxN"), v("ty")), mul(v("ryN"), v("tx")));
87
+
88
+ const SpEfMkFrame = {
89
+ name: "SpEfMkFrame",
90
+ params: MF_PARAMS,
91
+ body: mfLetChain(outputs({
92
+ rx: v("rxN"), ry: v("ryN"), rz: v("rzN"),
93
+ ux: uX, uy: uY, uz: uZ,
94
+ })),
95
+ };
96
+
97
+ // ── SpActualPos ──────────────────────────────────────────────────────────
98
+ // actual = wire + standoff*(cos(pathRoll)*U + sin(pathRoll)*R)
99
+ // Params: wx, wy_wire, wz_wire, tx, ty, tz, prDeg, so
100
+ // (wy_wire to avoid colliding with the 'wy' let-binding name inside the chain)
101
+ const AP_PARAMS = ["wx", "wy_wire", "wz_wire", "tx", "ty", "tz", "prDeg", "so"];
102
+
103
+ function apLetChain(body) {
104
+ return letIn("wy", select(nearVert, num(0), num(1)),
105
+ letIn("wz", select(nearVert, num(1), num(0)),
106
+ letIn("crossX", sub(mul(v("ty"), v("wz")), mul(v("tz"), v("wy"))),
107
+ letIn("crossY", neg(mul(v("tx"), v("wz"))),
108
+ letIn("crossZ", mul(v("tx"), v("wy")),
109
+ letIn("rLen", len3(v("crossX"), v("crossY"), v("crossZ")),
110
+ letIn("rxN", safeDiv(v("crossX"), "rLen", num(0)),
111
+ letIn("ryN", safeDiv(v("crossY"), "rLen", num(0)),
112
+ letIn("rzN", safeDiv(v("crossZ"), "rLen", num(1)),
113
+ letIn("ux", sub(mul(v("ryN"), v("tz")), mul(v("rzN"), v("ty"))),
114
+ letIn("uy", sub(mul(v("rzN"), v("tx")), mul(v("rxN"), v("tz"))),
115
+ letIn("uz", sub(mul(v("rxN"), v("ty")), mul(v("ryN"), v("tx"))),
116
+ letIn("rad", degToRad(v("prDeg")),
117
+ letIn("c", call("cos", v("rad")),
118
+ letIn("s", call("sin", v("rad")),
119
+ body
120
+ )))))))))))))));
121
+ }
122
+
123
+ const SpEfActualPos = {
124
+ name: "SpEfActualPos",
125
+ params: AP_PARAMS,
126
+ body: apLetChain(outputs({
127
+ x: add(v("wx"), mul(v("so"), add(mul(v("c"), v("ux")), mul(v("s"), v("rxN"))))),
128
+ y: add(v("wy_wire"), mul(v("so"), add(mul(v("c"), v("uy")), mul(v("s"), v("ryN"))))),
129
+ z: add(v("wz_wire"), mul(v("so"), add(mul(v("c"), v("uz")), mul(v("s"), v("rzN"))))),
130
+ })),
131
+ };
132
+
133
+ // ── SpRollFrame ──────────────────────────────────────────────────────────
134
+ // rolledU = cos(rad)*U - sin(rad)*R
135
+ // rolledR = sin(rad)*U + cos(rad)*R
136
+ // Params: ux, uy, uz, rx, ry, rz, crDeg
137
+ // Output field names are prefixed (rolledUx, not ux) since ux/uy/uz/rx/ry/rz
138
+ // are already taken by the *input* params.
139
+ const RF_PARAMS = ["ux", "uy", "uz", "rx", "ry", "rz", "crDeg"];
140
+
141
+ function rfLetChain(body) {
142
+ return letIn("rad", degToRad(v("crDeg")),
143
+ letIn("c", call("cos", v("rad")),
144
+ letIn("s", call("sin", v("rad")),
145
+ body)));
146
+ }
147
+
148
+ const SpEfRollFrame = {
149
+ name: "SpEfRollFrame",
150
+ params: RF_PARAMS,
151
+ body: rfLetChain(outputs({
152
+ rolledUx: sub(mul(v("c"), v("ux")), mul(v("s"), v("rx"))),
153
+ rolledUy: sub(mul(v("c"), v("uy")), mul(v("s"), v("ry"))),
154
+ rolledUz: sub(mul(v("c"), v("uz")), mul(v("s"), v("rz"))),
155
+ rolledRx: add(mul(v("s"), v("ux")), mul(v("c"), v("rx"))),
156
+ rolledRy: add(mul(v("s"), v("uy")), mul(v("c"), v("ry"))),
157
+ rolledRz: add(mul(v("s"), v("uz")), mul(v("c"), v("rz"))),
158
+ })),
159
+ };
160
+
161
+ // ── CR basis weights ────────────────────────────────────────────────────
162
+ // Already expressible without let/select, but kept here so all spline
163
+ // math lives in one sample. Shares t2/t3 across all four weights.
164
+ // Params: t
165
+ const CRW_PARAMS = ["t"];
166
+
167
+ const SpEfCrWeights = {
168
+ name: "SpEfCrWeights",
169
+ params: CRW_PARAMS,
170
+ body: letIn("t2", mul(v("t"), v("t")),
171
+ letIn("t3", mul(v("t2"), v("t")),
172
+ outputs({
173
+ w0: mul(num(0.5), add(neg(v("t3")), mul(num(2), v("t2")), neg(v("t")))),
174
+ w1: mul(num(0.5), add(mul(num(3), v("t3")), mul(num(-5), v("t2")), num(2))),
175
+ w2: mul(num(0.5), add(mul(num(-3), v("t3")), mul(num(4), v("t2")), v("t"))),
176
+ w3: mul(num(0.5), add(v("t3"), neg(v("t2")))),
177
+ })
178
+ )),
179
+ };
180
+
181
+ // ── Exports ──────────────────────────────────────────────────────────────
182
+ const splineFrameAsts = [SpEfMkFrame, SpEfActualPos, SpEfRollFrame, SpEfCrWeights];
183
+
184
+ module.exports = { splineFrameAsts };
package/util.js ADDED
@@ -0,0 +1,15 @@
1
+ // exprforge/util.js
2
+ // Authoring conveniences — NOT AST primitives (see ast.js for those). These
3
+ // don't return Nodes and the emitters never see them; they just help you
4
+ // write the { name, params, body } function definitions exprforge already
5
+ // understands, without hand-duplicating near-identical ones.
6
+
7
+ // Expands a template into one function definition per axis. Purely
8
+ // `axes.map(templateFn)` under a name that signals intent: this is a vector
9
+ // operation expanded component-wise, not an arbitrary loop. See
10
+ // samples/spline-frame.js for a real call site (SpMakeFrame's R/U vectors).
11
+ function forComponents(axes, templateFn) {
12
+ return axes.map(templateFn);
13
+ }
14
+
15
+ module.exports = { forComponents };