@motionscript/plot 0.0.0-stage → 0.1.0-alpha.3

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +201 -0
  3. package/dist/browser/chunks/chunk-37IWXGPH.js +2 -0
  4. package/dist/browser/chunks/chunk-37IWXGPH.js.map +7 -0
  5. package/dist/browser/index.js +84 -0
  6. package/dist/browser/index.js.map +7 -0
  7. package/dist/browser/kit.js +2 -0
  8. package/dist/browser/kit.js.map +7 -0
  9. package/dist/browser/manifest.json +12 -0
  10. package/dist/engine.d.ts +14 -0
  11. package/dist/engine.d.ts.map +1 -0
  12. package/dist/engine.js +14 -0
  13. package/dist/engine.js.map +1 -0
  14. package/dist/graph2d/curve-cache.d.ts +60 -0
  15. package/dist/graph2d/curve-cache.d.ts.map +1 -0
  16. package/dist/graph2d/curve-cache.js +79 -0
  17. package/dist/graph2d/curve-cache.js.map +1 -0
  18. package/dist/graph2d/curve-error.d.ts +12 -0
  19. package/dist/graph2d/curve-error.d.ts.map +1 -0
  20. package/dist/graph2d/curve-error.js +15 -0
  21. package/dist/graph2d/curve-error.js.map +1 -0
  22. package/dist/graph2d/curve.d.ts +104 -0
  23. package/dist/graph2d/curve.d.ts.map +1 -0
  24. package/dist/graph2d/curve.js +531 -0
  25. package/dist/graph2d/curve.js.map +1 -0
  26. package/dist/graph2d/graph2d.d.ts +164 -0
  27. package/dist/graph2d/graph2d.d.ts.map +1 -0
  28. package/dist/graph2d/graph2d.js +404 -0
  29. package/dist/graph2d/graph2d.js.map +1 -0
  30. package/dist/graph2d/index.d.ts +32 -0
  31. package/dist/graph2d/index.d.ts.map +1 -0
  32. package/dist/graph2d/index.js +32 -0
  33. package/dist/graph2d/index.js.map +1 -0
  34. package/dist/graph2d/plane-fill.d.ts +110 -0
  35. package/dist/graph2d/plane-fill.d.ts.map +1 -0
  36. package/dist/graph2d/plane-fill.js +248 -0
  37. package/dist/graph2d/plane-fill.js.map +1 -0
  38. package/dist/graph2d/plane.d.ts +179 -0
  39. package/dist/graph2d/plane.d.ts.map +1 -0
  40. package/dist/graph2d/plane.js +359 -0
  41. package/dist/graph2d/plane.js.map +1 -0
  42. package/dist/graph2d/shared.d.ts +40 -0
  43. package/dist/graph2d/shared.d.ts.map +1 -0
  44. package/dist/graph2d/shared.js +70 -0
  45. package/dist/graph2d/shared.js.map +1 -0
  46. package/dist/graph3d/expression.d.ts +30 -0
  47. package/dist/graph3d/expression.d.ts.map +1 -0
  48. package/dist/graph3d/expression.js +35 -0
  49. package/dist/graph3d/expression.js.map +1 -0
  50. package/dist/graph3d/graph3d.d.ts +186 -0
  51. package/dist/graph3d/graph3d.d.ts.map +1 -0
  52. package/dist/graph3d/graph3d.js +404 -0
  53. package/dist/graph3d/graph3d.js.map +1 -0
  54. package/dist/graph3d/index.d.ts +22 -0
  55. package/dist/graph3d/index.d.ts.map +1 -0
  56. package/dist/graph3d/index.js +22 -0
  57. package/dist/graph3d/index.js.map +1 -0
  58. package/dist/graph3d/shared.d.ts +61 -0
  59. package/dist/graph3d/shared.d.ts.map +1 -0
  60. package/dist/graph3d/shared.js +101 -0
  61. package/dist/graph3d/shared.js.map +1 -0
  62. package/dist/index.d.ts +4 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +4 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/kit/equations.d.ts +56 -0
  67. package/dist/kit/equations.d.ts.map +1 -0
  68. package/dist/kit/equations.js +63 -0
  69. package/dist/kit/equations.js.map +1 -0
  70. package/dist/kit/expression.d.ts +60 -0
  71. package/dist/kit/expression.d.ts.map +1 -0
  72. package/dist/kit/expression.js +268 -0
  73. package/dist/kit/expression.js.map +1 -0
  74. package/dist/kit/index.d.ts +15 -0
  75. package/dist/kit/index.d.ts.map +1 -0
  76. package/dist/kit/index.js +13 -0
  77. package/dist/kit/index.js.map +1 -0
  78. package/dist/nodes.d.ts +19 -0
  79. package/dist/nodes.d.ts.map +1 -0
  80. package/dist/nodes.js +19 -0
  81. package/dist/nodes.js.map +1 -0
  82. package/package.json +68 -3
  83. package/registry.json +34 -0
  84. package/src/engine.ts +13 -0
  85. package/src/graph2d/curve-cache.ts +103 -0
  86. package/src/graph2d/curve-error.ts +19 -0
  87. package/src/graph2d/curve.ts +657 -0
  88. package/src/graph2d/graph2d.ts +586 -0
  89. package/src/graph2d/index.ts +31 -0
  90. package/src/graph2d/plane-fill.ts +308 -0
  91. package/src/graph2d/plane.ts +457 -0
  92. package/src/graph2d/shared.ts +103 -0
  93. package/src/graph3d/expression.ts +51 -0
  94. package/src/graph3d/graph3d.ts +615 -0
  95. package/src/graph3d/index.ts +21 -0
  96. package/src/graph3d/shared.ts +143 -0
  97. package/src/index.ts +3 -0
  98. package/src/kit/equations.ts +102 -0
  99. package/src/kit/expression.ts +323 -0
  100. package/src/kit/index.ts +29 -0
  101. package/src/nodes.ts +19 -0
  102. package/README.md +0 -4
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The mappers, tweens and small maths the {@link Graph3D} node is built from.
3
+ *
4
+ * These exist because a `@property` is only as good as the `mapper`/`tween` it
5
+ * is declared with: the mapper turns a loose authored value (a CSS colour, a
6
+ * partly-filled options bag) into one canonical internal shape, and the tween is
7
+ * what makes `node.to({ … })` *interpolate* that shape instead of holding the
8
+ * old value and snapping at the end. Core's own attributes are built the same
9
+ * way — see `cornerRadiusOps` behind `Rect.cornerRadius`.
10
+ *
11
+ * The colour, number and camera helpers this used to define now live in
12
+ * `nodes/view3d-kit`, because the Protein node needs the same six and none of
13
+ * them were ever about graphs. The equation list's shape and its tween moved the
14
+ * same way, into `nodes/graph-kit`, once the 2D graph needed both. They are
15
+ * re-exported here so this stays the one import a graph3d module reaches for.
16
+ */
17
+
18
+ import { clamp } from "@motionscript/sdk"
19
+
20
+ import {
21
+ lerpGraphEquations,
22
+ type GraphEquationResolved,
23
+ } from "../kit/equations"
24
+
25
+ export {
26
+ lerpColor3D,
27
+ lerpCount,
28
+ lerpFinite,
29
+ cameraOrbit,
30
+ resolveColor3D,
31
+ snapFlag,
32
+ type Orbit,
33
+ } from "@motionscript/sdk/component"
34
+
35
+ import type { CompiledExpression } from "./expression"
36
+
37
+ // --- Sampling --------------------------------------------------------------
38
+
39
+ /**
40
+ * Makes a sampled height safe to put in a vertex buffer.
41
+ *
42
+ * `NaN` (from `sqrt` of a negative, `log` of zero) poisons a whole triangle and
43
+ * leaves a hole; an asymptote (`1/x`, `tan`) produces values large enough to
44
+ * stretch the mesh off screen and wreck the camera framing. Both are flattened
45
+ * rather than dropped, so the surface stays continuous.
46
+ */
47
+ export function sanitizeHeight(value: number, maxHeight: number): number {
48
+ if (!Number.isFinite(value)) return 0
49
+ return clamp(value, -maxHeight, maxHeight)
50
+ }
51
+
52
+ // --- Equations -------------------------------------------------------------
53
+
54
+ /**
55
+ * One surface after {@link Graph3D}'s mapper has run: compiled, resolved and
56
+ * fully defaulted, so the per-frame builder never re-derives anything and the
57
+ * tween never tests for an absent field.
58
+ */
59
+ export type EquationResolved = GraphEquationResolved<CompiledExpression>
60
+
61
+ /**
62
+ * Tween for the equation list — the shared match-by-id walk, with the one thing
63
+ * a surface does differently: mid-morph its *height* is sampled from both
64
+ * functions and blended, so it deforms into the new one rather than popping.
65
+ *
66
+ * The blend deliberately returns a **fresh closure** per frame, which is what
67
+ * {@link surfaceRevision} reads as "re-evaluate this mesh".
68
+ */
69
+ export function lerpEquations(
70
+ from: EquationResolved[],
71
+ to: EquationResolved[],
72
+ t: number
73
+ ): EquationResolved[] {
74
+ return lerpGraphEquations(from, to, t, blendSurface)
75
+ }
76
+
77
+ /** How two surfaces' heights blend mid-tween. */
78
+ function blendSurface(
79
+ a: CompiledExpression,
80
+ b: CompiledExpression,
81
+ t: number
82
+ ): CompiledExpression {
83
+ return (x, y) => {
84
+ const start = a(x, y)
85
+ return start + (b(x, y) - start) * t
86
+ }
87
+ }
88
+
89
+ /**
90
+ * A small integer standing for a compiled expression's *identity*.
91
+ *
92
+ * `Geo.parametric`'s `revision` is a number, and what a surface's vertex callback
93
+ * actually depends on includes a function — so the function has to be reduced to
94
+ * something numeric. Identity is the right comparison here rather than the source
95
+ * text: `compileExpressionCached` memoises by source, so two equal sources are
96
+ * already `===`, and a mid-morph blend (see {@link blendEquation}) deliberately
97
+ * builds a *fresh* closure each frame, which is exactly when the surface must be
98
+ * re-evaluated.
99
+ *
100
+ * A `WeakMap` so retiring an expression doesn't pin it, and ids are never reused
101
+ * — a recycled id could make a changed surface look unchanged.
102
+ */
103
+ const expressionIds = new WeakMap<CompiledExpression, number>()
104
+ let nextExpressionId = 1
105
+
106
+ function expressionId(sample: CompiledExpression): number {
107
+ let id = expressionIds.get(sample)
108
+ if (id === undefined) {
109
+ id = nextExpressionId++
110
+ expressionIds.set(sample, id)
111
+ }
112
+ return id
113
+ }
114
+
115
+ /**
116
+ * The `revision` for one surface: a value that changes exactly when its vertex
117
+ * callback would return something different.
118
+ *
119
+ * The callback in `Graph3D.addSurface` closes over three things — the compiled
120
+ * expression, the domain (as `span`/`domain`) and `maxHeight` — so those three
121
+ * are what this covers, and nothing else. `segments` is deliberately absent: it
122
+ * is plain data on the descriptor that the renderer already compares itself.
123
+ *
124
+ * Mixed by FNV-1a over the three values' text rather than by arithmetic, because
125
+ * `domain` and `maxHeight` are tweenable floats and there is no cheap numeric
126
+ * combination of two floats and an int that doesn't collide somewhere. A
127
+ * collision here would show as a surface that refuses to update, which is a
128
+ * miserable bug to find; the string is a few dozen characters per surface per
129
+ * frame, against the ~6.6k vertex evaluations it exists to avoid.
130
+ */
131
+ export function surfaceRevision(
132
+ sample: CompiledExpression,
133
+ domain: number,
134
+ maxHeight: number
135
+ ): number {
136
+ const key = `${expressionId(sample)}|${domain}|${maxHeight}`
137
+ let hash = 0x811c9dc5
138
+ for (let i = 0; i < key.length; i++) {
139
+ hash ^= key.charCodeAt(i)
140
+ hash = Math.imul(hash, 0x01000193)
141
+ }
142
+ return hash >>> 0
143
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./graph2d";
2
+ export * from "./graph3d";
3
+ export { NODES } from "./nodes";
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What a resolved equation is, and how a list of them interpolates — shared by
3
+ * both graph nodes because the answer doesn't depend on how many dimensions the
4
+ * marks are drawn in.
5
+ *
6
+ * The tween is the part worth sharing. Matching two lists by `id` is what makes
7
+ * an equation that stays *morph* rather than pop, one that leaves fade out as
8
+ * itself, and one that arrives fade in; getting that wrong is invisible until
9
+ * somebody animates an equation list, at which point every surface deforms into
10
+ * its neighbour. One implementation, one set of tests.
11
+ *
12
+ * What is *not* shared is how two sampled functions blend into one — a surface
13
+ * blends `f(x, y)` and a curve blends `f(x)` — so that is the one thing a caller
14
+ * passes in. See `graph3d/impl/shared` and `graph2d/impl/shared`, which are each
15
+ * three lines because of it.
16
+ */
17
+
18
+ import { lerpNumber, type NormalizedColor } from "@motionscript/sdk"
19
+
20
+ import { lerpColor3D } from "@motionscript/sdk/component"
21
+
22
+ /**
23
+ * One equation after a node's mapper has run: compiled, resolved and fully
24
+ * defaulted, so the per-frame builder never re-derives anything and the tween
25
+ * never tests for an absent field.
26
+ *
27
+ * Generic in the sampled function because that is the only part that differs:
28
+ * `(x, y) => z` for a surface, `(x) => y` for a curve.
29
+ */
30
+ export interface GraphEquationResolved<Sample> {
31
+ id: string
32
+ /** The compiled expression — compiled once at *write* time, not per sample. */
33
+ sample: Sample
34
+ color: NormalizedColor
35
+ /** `enabled: false` is folded in here as 0, so hiding is a fade. */
36
+ opacity: number
37
+ }
38
+
39
+ /**
40
+ * How two sampled functions blend mid-tween.
41
+ *
42
+ * Handed in rather than inferred, and it must return a **fresh closure per
43
+ * call** when the two differ: that freshness is what a node keyed on the
44
+ * function's identity reads as "this needs re-evaluating" (see
45
+ * `surfaceRevision`).
46
+ */
47
+ export type BlendSample<Sample> = (a: Sample, b: Sample, t: number) => Sample
48
+
49
+ /**
50
+ * Tween for an equation list: match by `id`, then morph / fade in / fade out.
51
+ *
52
+ * Three outcomes, and each is the one that reads correctly:
53
+ *
54
+ * - present in both → the mark **morphs**, because its value is sampled from
55
+ * both functions and blended, so it deforms into the new one rather than
56
+ * popping;
57
+ * - only in the old list → it fades out *still sampling its own expression*, so
58
+ * it leaves as itself;
59
+ * - only in the new list → it fades in.
60
+ */
61
+ export function lerpGraphEquations<Sample>(
62
+ from: GraphEquationResolved<Sample>[],
63
+ to: GraphEquationResolved<Sample>[],
64
+ t: number,
65
+ blendSample: BlendSample<Sample>
66
+ ): GraphEquationResolved<Sample>[] {
67
+ if (t <= 0) return from
68
+ if (t >= 1) return to
69
+
70
+ const arriving = new Map(to.map((equation) => [equation.id, equation]))
71
+ const out: GraphEquationResolved<Sample>[] = from.map((a) => {
72
+ const b = arriving.get(a.id)
73
+ return b
74
+ ? blendEquation(a, b, t, blendSample)
75
+ : { ...a, opacity: a.opacity * (1 - t) }
76
+ })
77
+
78
+ const held = new Set(from.map((equation) => equation.id))
79
+ for (const b of to) {
80
+ if (!held.has(b.id)) out.push({ ...b, opacity: b.opacity * t })
81
+ }
82
+ return out
83
+ }
84
+
85
+ /** Interpolates one equation that exists on both sides of the tween. */
86
+ function blendEquation<Sample>(
87
+ a: GraphEquationResolved<Sample>,
88
+ b: GraphEquationResolved<Sample>,
89
+ t: number,
90
+ blendSample: BlendSample<Sample>
91
+ ): GraphEquationResolved<Sample> {
92
+ return {
93
+ id: b.id,
94
+ // Same id, different expression → morph the *geometry*. The double
95
+ // evaluation is only paid mid-tween, and the identity check skips it
96
+ // entirely when the source didn't change: the expression cache memoises by
97
+ // source, so equal source is `===`.
98
+ sample: a.sample === b.sample ? b.sample : blendSample(a.sample, b.sample, t),
99
+ color: lerpColor3D(a.color, b.color, t),
100
+ opacity: lerpNumber(a.opacity, b.opacity, t),
101
+ }
102
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * The little language every graph node's equation tiles are written in — the
3
+ * one binding behind `z = f(x, y)` on the 3D graph and `y = f(x)` on the 2D one.
4
+ *
5
+ * **The grammar is not here.** It is the engine's `expressionOps.parse`
6
+ * (from `@motionscript/sdk`), shared with the `"=4[g]+3"` expressions a scene
7
+ * document writes in props. There used to be two parsers, and they agreed on
8
+ * every rule they documented while disagreeing everywhere they hadn't: this one
9
+ * scanned `1.2.3` as a single token that `Number` turned into a `NaN` which
10
+ * parsed, compiled and rendered an invisible surface with no diagnostic
11
+ * anywhere; it treated `\r` as an unexpected character; and it checked no arity
12
+ * on its variadic functions, so `atan2(1)` was a silent `NaN` too.
13
+ *
14
+ * What is genuinely this language's own is a **dialect** and a back end:
15
+ *
16
+ * - {@link PLOT_DIALECT} — trig in **radians** (a plot of `sin(x)` is a plot of
17
+ * `sin(x)`, where a document's `rotation` is in degrees like every other angle
18
+ * in the library), the hyperbolics and log bases a plot wants, `phi`, and
19
+ * references spelled as bare identifiers rather than `[id]`.
20
+ * - {@link compileExpression} — the emitter, which turns the shared AST into a
21
+ * two-argument closure rather than into the signal binding a document prop
22
+ * needs.
23
+ *
24
+ * **Deliberately not `new Function(...)`.** An expression is a value typed into
25
+ * the inspector, stored in a scene document and shared with whoever opens it
26
+ * next; compiling it as JavaScript would make every scene file an execution
27
+ * vector. The shared parser accepts a fixed grammar and can only ever produce
28
+ * arithmetic.
29
+ *
30
+ * ## Which letters are variables is the caller's business
31
+ *
32
+ * What differs between the two graphs is *how many free variables there are*,
33
+ * and that matters more than it sounds. A 2D curve compiled with `x` alone
34
+ * rejects `y` as an unknown identifier, so typing `y = x + y` says so in the
35
+ * tile instead of silently plotting `x` — and a caller cannot forget to check,
36
+ * because the check is the same one that catches `alert(1)`.
37
+ */
38
+ import { expressionOps } from "@motionscript/sdk"
39
+ import type { ExpressionDialect, ExpressionNode } from "@motionscript/sdk"
40
+
41
+ /**
42
+ * A compiled expression: pure, and safe to call thousands of times per frame.
43
+ *
44
+ * Always two arguments regardless of how many variables it was compiled with,
45
+ * because the arity is a *parse-time* fact and the signature is a hot path — a
46
+ * variadic form would allocate an array per sample. A one-variable expression
47
+ * simply never reads the second, so a curve calls it as `sample(x, 0)`.
48
+ */
49
+ export type CompiledExpression = (x: number, y: number) => number
50
+
51
+ /** The two free variables a `z = f(x, y)` surface is sampled over. */
52
+ export const SURFACE_VARIABLES = ["x", "y"] as const
53
+
54
+ /** The one free variable a `y = f(x)` curve is sampled over. */
55
+ export const CURVE_VARIABLES = ["x"] as const
56
+
57
+ /**
58
+ * The names bound to the two argument slots, in order.
59
+ *
60
+ * At most two, because {@link CompiledExpression} has two argument slots — see
61
+ * the note there on why that is fixed rather than variadic.
62
+ */
63
+ export type ExpressionVariables = readonly string[]
64
+
65
+ /** Single-argument functions available to an expression. */
66
+ const UNARY_FNS: Record<string, (v: number) => number> = {
67
+ sin: Math.sin,
68
+ cos: Math.cos,
69
+ tan: Math.tan,
70
+ asin: Math.asin,
71
+ acos: Math.acos,
72
+ atan: Math.atan,
73
+ sinh: Math.sinh,
74
+ cosh: Math.cosh,
75
+ tanh: Math.tanh,
76
+ sqrt: Math.sqrt,
77
+ cbrt: Math.cbrt,
78
+ abs: Math.abs,
79
+ exp: Math.exp,
80
+ log: Math.log,
81
+ ln: Math.log,
82
+ log2: Math.log2,
83
+ log10: Math.log10,
84
+ floor: Math.floor,
85
+ ceil: Math.ceil,
86
+ round: Math.round,
87
+ sign: Math.sign,
88
+ trunc: Math.trunc,
89
+ }
90
+
91
+ /** Multi-argument functions. */
92
+ const NARY_FNS: Record<string, (...args: number[]) => number> = {
93
+ atan2: Math.atan2,
94
+ pow: Math.pow,
95
+ hypot: Math.hypot,
96
+ min: Math.min,
97
+ max: Math.max,
98
+ mod: (a, b) => a % b,
99
+ }
100
+
101
+ /**
102
+ * How many arguments each of {@link NARY_FNS} takes.
103
+ *
104
+ * Written from what this language already accepts, not copied from core's table:
105
+ * `hypot(3)` and `min(3)` are meaningful and have always compiled, so they stay
106
+ * `[1, Infinity]` where the document dialect has `hypot: [2, Infinity]`. What
107
+ * the arity check is actually for is the other end — `atan2(1)` and `min()`
108
+ * used to compile to a silent `NaN` and an `Infinity`, which rendered as
109
+ * nothing at all and said nothing about why.
110
+ */
111
+ const NARY_ARITY: Record<string, [number, number]> = {
112
+ atan2: [2, 2],
113
+ pow: [2, 2],
114
+ mod: [2, 2],
115
+ hypot: [1, Infinity],
116
+ min: [1, Infinity],
117
+ max: [1, Infinity],
118
+ }
119
+
120
+ const CONSTANTS: Record<string, number> = {
121
+ pi: Math.PI,
122
+ PI: Math.PI,
123
+ e: Math.E,
124
+ E: Math.E,
125
+ tau: Math.PI * 2,
126
+ phi: (1 + Math.sqrt(5)) / 2,
127
+ }
128
+
129
+ /** The equation language, as a dialect of the shared grammar. */
130
+ export const PLOT_DIALECT: ExpressionDialect = {
131
+ functions: {
132
+ ...Object.fromEntries(
133
+ Object.keys(UNARY_FNS).map((name) => [name, { arity: [1, 1] as [number, number] }])
134
+ ),
135
+ ...Object.fromEntries(
136
+ Object.entries(NARY_ARITY).map(([name, arity]) => [name, { arity }])
137
+ ),
138
+ },
139
+ constants: CONSTANTS,
140
+ refs: "bare",
141
+ unknownName: (name) => `Unknown identifier '${name}'`,
142
+ }
143
+
144
+ /** Everything an expression may name, minus the variables. */
145
+ const NAMEABLE = {
146
+ functions: [...Object.keys(UNARY_FNS), ...Object.keys(NARY_FNS)].sort(),
147
+ /** Lower-case spellings only; `PI`/`E` are accepted but not advertised twice. */
148
+ constants: ["pi", "e", "tau", "phi"] as const,
149
+ }
150
+
151
+ /** What a tile's help popover lists, for a node with these free variables. */
152
+ export interface ExpressionVocabulary {
153
+ variables: readonly string[]
154
+ functions: readonly string[]
155
+ constants: readonly string[]
156
+ }
157
+
158
+ /**
159
+ * Everything an expression over `variables` may name, for the inspector's
160
+ * "what can I write here" hint.
161
+ *
162
+ * Derived from the parser's own tables rather than re-listed on the client, so
163
+ * the help can never claim a function the grammar doesn't have — which is the
164
+ * way this kind of reference always rots.
165
+ */
166
+ export function expressionVocabulary(
167
+ variables: ExpressionVariables
168
+ ): ExpressionVocabulary {
169
+ return { variables, ...NAMEABLE }
170
+ }
171
+
172
+ /** {@link expressionVocabulary} for the 3D graph's `z = f(x, y)` surfaces. */
173
+ export const EXPRESSION_VOCABULARY = expressionVocabulary(SURFACE_VARIABLES)
174
+
175
+ /** {@link expressionVocabulary} for the 2D graph's `y = f(x)` curves. */
176
+ export const CURVE_VOCABULARY = expressionVocabulary(CURVE_VARIABLES)
177
+
178
+ /**
179
+ * Compiles `source` into a callable over `variables`. Throws on a syntax error,
180
+ * so a caller rendering user-typed text should catch and skip that equation —
181
+ * see {@link compileExpressionCached}, which does exactly that.
182
+ */
183
+ export function compileExpression(
184
+ source: string,
185
+ variables: ExpressionVariables = SURFACE_VARIABLES
186
+ ): CompiledExpression {
187
+ // The parser folds a constant before it will yield a reference, so a name that
188
+ // is both would silently stop being the variable it was passed as.
189
+ for (const name of variables) {
190
+ if (name in CONSTANTS) {
191
+ throw new Error(`'${name}' is a constant, so it cannot also be a free variable`)
192
+ }
193
+ }
194
+ return emit(expressionOps.parse(source, PLOT_DIALECT).root, variables)
195
+ }
196
+
197
+ /** The shared AST into this language's two-argument closure. */
198
+ function emit(
199
+ node: ExpressionNode,
200
+ variables: ExpressionVariables
201
+ ): CompiledExpression {
202
+ switch (node.kind) {
203
+ case "const": {
204
+ const { value } = node
205
+ return () => value
206
+ }
207
+ case "ref": {
208
+ // Which argument slot this name reads, or -1 for a name that isn't a
209
+ // variable *here* — the whole point of taking the list: `y` in a curve is
210
+ // an unknown identifier, not a silent zero.
211
+ const slot = variables.indexOf(node.name)
212
+ if (slot === 0) return (x) => x
213
+ if (slot === 1) return (_x, y) => y
214
+ throw new Error(PLOT_DIALECT.unknownName(node.name))
215
+ }
216
+ case "unary": {
217
+ const operand = emit(node.operand, variables)
218
+ return (x, y) => -operand(x, y)
219
+ }
220
+ case "binary": {
221
+ const l = emit(node.left, variables)
222
+ const r = emit(node.right, variables)
223
+ switch (node.op) {
224
+ case "+": return (x, y) => l(x, y) + r(x, y)
225
+ case "-": return (x, y) => l(x, y) - r(x, y)
226
+ case "*": return (x, y) => l(x, y) * r(x, y)
227
+ case "/": return (x, y) => l(x, y) / r(x, y)
228
+ case "%": return (x, y) => l(x, y) % r(x, y)
229
+ case "^": return (x, y) => Math.pow(l(x, y), r(x, y))
230
+ }
231
+ break
232
+ }
233
+ case "call": {
234
+ const args = node.args.map((arg) => emit(arg, variables))
235
+ const unary = UNARY_FNS[node.name]
236
+ if (unary) {
237
+ const [a] = args
238
+ return (x, y) => unary(a(x, y))
239
+ }
240
+ const nary = NARY_FNS[node.name]
241
+ // Specialise the common arity so the hot path doesn't allocate an array
242
+ // per sample.
243
+ if (args.length === 2) {
244
+ const [a, b] = args
245
+ return (x, y) => nary(a(x, y), b(x, y))
246
+ }
247
+ return (x, y) => nary(...args.map((arg) => arg(x, y)))
248
+ }
249
+ }
250
+ throw new Error("Unsupported expression node")
251
+ }
252
+
253
+ /**
254
+ * Why an expression won't compile, in the terms the tile shows: the message and
255
+ * nothing else. `null` means it compiles.
256
+ *
257
+ * Separate from {@link compileExpressionCached} because the two callers want
258
+ * opposite things — the renderer wants the function or nothing, the inspector
259
+ * wants the *reason* — and both must agree, which they do by going through the
260
+ * same parser.
261
+ */
262
+ export function expressionError(
263
+ source: string,
264
+ variables: ExpressionVariables = SURFACE_VARIABLES
265
+ ): string | null {
266
+ const trimmed = source.trim()
267
+ // Empty is not an error: it is a tile nobody has filled in yet, which the
268
+ // panel already shows as a placeholder rather than as a mistake.
269
+ if (trimmed === "") return null
270
+ try {
271
+ compileExpression(trimmed, variables)
272
+ return null
273
+ } catch (error) {
274
+ return error instanceof Error ? error.message : String(error)
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Compiles with memoisation, returning `null` for anything that doesn't parse.
280
+ *
281
+ * Both halves matter here. A node's builder re-runs every frame, so an uncached
282
+ * compile would re-parse every equation sixty times a second; and the inspector
283
+ * commits on every keystroke, so half-typed source reaches the renderer
284
+ * constantly and must be *skipped* rather than thrown out of the render pass.
285
+ *
286
+ * The cache is also what makes the equation tween cheap: equal source compiles
287
+ * to the identical closure, so "did this equation change" is an `===`. Note it
288
+ * stores a failure as `null` and tests `hit !== undefined`, so unparseable
289
+ * source is not re-parsed on every frame either.
290
+ *
291
+ * Keyed by the variable list as well as the source, because the same text means
292
+ * different things under different bindings — `x + y` compiles for a surface and
293
+ * is an error for a curve, and one cache would hand the surface's answer to the
294
+ * curve that asked second.
295
+ */
296
+ const caches = new Map<string, Map<string, CompiledExpression | null>>()
297
+
298
+ export function compileExpressionCached(
299
+ source: string,
300
+ variables: ExpressionVariables = SURFACE_VARIABLES
301
+ ): CompiledExpression | null {
302
+ const trimmed = source.trim()
303
+ if (trimmed === "") return null
304
+
305
+ const key = variables.join(",")
306
+ let cache = caches.get(key)
307
+ if (!cache) {
308
+ cache = new Map()
309
+ caches.set(key, cache)
310
+ }
311
+
312
+ const hit = cache.get(trimmed)
313
+ if (hit !== undefined) return hit
314
+
315
+ let compiled: CompiledExpression | null
316
+ try {
317
+ compiled = compileExpression(trimmed, variables)
318
+ } catch {
319
+ compiled = null
320
+ }
321
+ cache.set(trimmed, compiled)
322
+ return compiled
323
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The parts both graph nodes are built from — the expression language their
3
+ * tiles are written in, and what a resolved equation list is.
4
+ *
5
+ * A kit rather than a base class, for the same reason `view3d-kit` is one: the
6
+ * two graphs share a *vocabulary*, not a shape. One draws lit meshes through a
7
+ * 3D pass and the other draws stroked polylines into the 2D scene, and the only
8
+ * things they genuinely agree on are what an author is allowed to type and what
9
+ * happens to a list of those when it is animated.
10
+ */
11
+
12
+ export {
13
+ compileExpression,
14
+ compileExpressionCached,
15
+ expressionError,
16
+ expressionVocabulary,
17
+ CURVE_VARIABLES,
18
+ CURVE_VOCABULARY,
19
+ EXPRESSION_VOCABULARY,
20
+ SURFACE_VARIABLES,
21
+ } from "./expression"
22
+ export type {
23
+ CompiledExpression,
24
+ ExpressionVariables,
25
+ ExpressionVocabulary,
26
+ } from "./expression"
27
+
28
+ export { lerpGraphEquations } from "./equations"
29
+ export type { BlendSample, GraphEquationResolved } from "./equations"
package/src/nodes.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { Graph2D } from "./graph2d";
2
+ import { Graph3D } from "./graph3d";
3
+
4
+ /**
5
+ * Every node type this package publishes.
6
+ *
7
+ * A host registers these by handing the array to an engine —
8
+ * `new Engine(platform, { nodes: NODES })` — which reads each class's `@node()`
9
+ * key. The decorator only *declares* that key: nothing is registered by the mere
10
+ * act of importing a module, so a registry holds exactly what its owner asked
11
+ * for and two engines in one process can differ about it.
12
+ *
13
+ * Listing class **values** is also what survives bundling. This package declares
14
+ * `sideEffects: false`, which licenses a bundler to drop a module nothing
15
+ * imports a value from, and a document names a node type by string — so
16
+ * `import "./graph2d"` for its side effect is exactly what gets shaken out, where an
17
+ * array of bindings is a data dependency that cannot be.
18
+ */
19
+ export const NODES = [Graph2D, Graph3D];
package/README.md DELETED
@@ -1,4 +0,0 @@
1
- # Temporary Holding Version
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.
4
- If no other versions are published within 30 days, this package and version will be deleted.