@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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +201 -0
- package/dist/browser/chunks/chunk-37IWXGPH.js +2 -0
- package/dist/browser/chunks/chunk-37IWXGPH.js.map +7 -0
- package/dist/browser/index.js +84 -0
- package/dist/browser/index.js.map +7 -0
- package/dist/browser/kit.js +2 -0
- package/dist/browser/kit.js.map +7 -0
- package/dist/browser/manifest.json +12 -0
- package/dist/engine.d.ts +14 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +14 -0
- package/dist/engine.js.map +1 -0
- package/dist/graph2d/curve-cache.d.ts +60 -0
- package/dist/graph2d/curve-cache.d.ts.map +1 -0
- package/dist/graph2d/curve-cache.js +79 -0
- package/dist/graph2d/curve-cache.js.map +1 -0
- package/dist/graph2d/curve-error.d.ts +12 -0
- package/dist/graph2d/curve-error.d.ts.map +1 -0
- package/dist/graph2d/curve-error.js +15 -0
- package/dist/graph2d/curve-error.js.map +1 -0
- package/dist/graph2d/curve.d.ts +104 -0
- package/dist/graph2d/curve.d.ts.map +1 -0
- package/dist/graph2d/curve.js +531 -0
- package/dist/graph2d/curve.js.map +1 -0
- package/dist/graph2d/graph2d.d.ts +164 -0
- package/dist/graph2d/graph2d.d.ts.map +1 -0
- package/dist/graph2d/graph2d.js +404 -0
- package/dist/graph2d/graph2d.js.map +1 -0
- package/dist/graph2d/index.d.ts +32 -0
- package/dist/graph2d/index.d.ts.map +1 -0
- package/dist/graph2d/index.js +32 -0
- package/dist/graph2d/index.js.map +1 -0
- package/dist/graph2d/plane-fill.d.ts +110 -0
- package/dist/graph2d/plane-fill.d.ts.map +1 -0
- package/dist/graph2d/plane-fill.js +248 -0
- package/dist/graph2d/plane-fill.js.map +1 -0
- package/dist/graph2d/plane.d.ts +179 -0
- package/dist/graph2d/plane.d.ts.map +1 -0
- package/dist/graph2d/plane.js +359 -0
- package/dist/graph2d/plane.js.map +1 -0
- package/dist/graph2d/shared.d.ts +40 -0
- package/dist/graph2d/shared.d.ts.map +1 -0
- package/dist/graph2d/shared.js +70 -0
- package/dist/graph2d/shared.js.map +1 -0
- package/dist/graph3d/expression.d.ts +30 -0
- package/dist/graph3d/expression.d.ts.map +1 -0
- package/dist/graph3d/expression.js +35 -0
- package/dist/graph3d/expression.js.map +1 -0
- package/dist/graph3d/graph3d.d.ts +186 -0
- package/dist/graph3d/graph3d.d.ts.map +1 -0
- package/dist/graph3d/graph3d.js +404 -0
- package/dist/graph3d/graph3d.js.map +1 -0
- package/dist/graph3d/index.d.ts +22 -0
- package/dist/graph3d/index.d.ts.map +1 -0
- package/dist/graph3d/index.js +22 -0
- package/dist/graph3d/index.js.map +1 -0
- package/dist/graph3d/shared.d.ts +61 -0
- package/dist/graph3d/shared.d.ts.map +1 -0
- package/dist/graph3d/shared.js +101 -0
- package/dist/graph3d/shared.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/kit/equations.d.ts +56 -0
- package/dist/kit/equations.d.ts.map +1 -0
- package/dist/kit/equations.js +63 -0
- package/dist/kit/equations.js.map +1 -0
- package/dist/kit/expression.d.ts +60 -0
- package/dist/kit/expression.d.ts.map +1 -0
- package/dist/kit/expression.js +268 -0
- package/dist/kit/expression.js.map +1 -0
- package/dist/kit/index.d.ts +15 -0
- package/dist/kit/index.d.ts.map +1 -0
- package/dist/kit/index.js +13 -0
- package/dist/kit/index.js.map +1 -0
- package/dist/nodes.d.ts +19 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +19 -0
- package/dist/nodes.js.map +1 -0
- package/package.json +68 -3
- package/registry.json +34 -0
- package/src/engine.ts +13 -0
- package/src/graph2d/curve-cache.ts +103 -0
- package/src/graph2d/curve-error.ts +19 -0
- package/src/graph2d/curve.ts +657 -0
- package/src/graph2d/graph2d.ts +586 -0
- package/src/graph2d/index.ts +31 -0
- package/src/graph2d/plane-fill.ts +308 -0
- package/src/graph2d/plane.ts +457 -0
- package/src/graph2d/shared.ts +103 -0
- package/src/graph3d/expression.ts +51 -0
- package/src/graph3d/graph3d.ts +615 -0
- package/src/graph3d/index.ts +21 -0
- package/src/graph3d/shared.ts +143 -0
- package/src/index.ts +3 -0
- package/src/kit/equations.ts +102 -0
- package/src/kit/expression.ts +323 -0
- package/src/kit/index.ts +29 -0
- package/src/nodes.ts +19 -0
- 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,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
|
+
}
|
package/src/kit/index.ts
ADDED
|
@@ -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.
|