@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,615 @@
1
+ import {
2
+ command,
3
+ easeInOut,
4
+ node,
5
+ type Command,
6
+ type CommandArgs,
7
+ Canvas3D,
8
+ Fills,
9
+ Geo,
10
+ Graphics3D,
11
+ Mat,
12
+ Scene3D,
13
+ lerpNumber,
14
+ property,
15
+ type Color,
16
+ type NodeConfig,
17
+ type NormalizedColor,
18
+ type Canvas3DProps,
19
+ type Vector3Input,
20
+ } from "@motionscript/sdk"
21
+ import { orbitProps, type OrbitTarget } from "@motionscript/sdk/component"
22
+
23
+ import { compileExpressionCached } from "./expression"
24
+ import {
25
+ lerpColor3D,
26
+ lerpCount,
27
+ lerpEquations,
28
+ lerpFinite,
29
+ cameraOrbit,
30
+ resolveColor3D,
31
+ sanitizeHeight,
32
+ snapFlag,
33
+ surfaceRevision,
34
+ type EquationResolved,
35
+ } from "./shared"
36
+
37
+ // --- Authored props --------------------------------------------------------
38
+ // The loose shapes a caller writes. Each has a matching `*Resolved` type below:
39
+ // what the `@property` mapper turns it into and what the tween interpolates.
40
+
41
+ /** One plotted surface, `z = f(x, y)`. */
42
+ export interface Graph3DEquation {
43
+ /** Identity across list changes — see {@link Graph3D.equations}. */
44
+ id: string | number
45
+ /** The expression, evaluated as `z = f(x, y)` (e.g. `sin(x) + cos(y)`). */
46
+ expression: string
47
+ /** The mesh's colour: any {@link Color} — a CSS string or an RGBA tuple. */
48
+ color: Color
49
+ /** Whether the surface is drawn. Defaults to true. */
50
+ visible?: boolean
51
+ /**
52
+ * How solid the mesh is, 0–1. Defaults to 1.
53
+ *
54
+ * Anything below 1 makes the surface blend rather than write depth, so
55
+ * overlapping surfaces layer instead of occluding each other. Left at 1 the
56
+ * surface is genuinely opaque and reads crisp — which is why that is the
57
+ * default, and why fading one in or out still looks right on the way through.
58
+ */
59
+ opacity?: number
60
+ }
61
+
62
+ /**
63
+ * Where the camera may go.
64
+ *
65
+ * Deliberately *not* OrbitControls. Those drive a camera from live mouse input,
66
+ * and a rendered timeline has no pointer — worse, their damping is an
67
+ * accumulator, so a scrubbed frame would differ from a played one and export
68
+ * would stop being deterministic. What is honoured here is the framing
69
+ * ({@link initialPosition}) and the limits; to *move* the camera, animate
70
+ * {@link Graph3DProps.orbit} / {@link Graph3DProps.elevation} /
71
+ * {@link Graph3DProps.zoom} from the timeline, which stays seekable.
72
+ */
73
+ export interface Graph3DCamera {
74
+ /** Initial camera position, which seeds the three spherical props. */
75
+ initialPosition?: { x: number; y: number; z: number }
76
+ /** Closest the camera may come to the origin. */
77
+ minZoom?: number
78
+ /** Furthest it may pull back. */
79
+ maxZoom?: number
80
+ }
81
+
82
+ /** The helper grid and the axes drawn under the surfaces. */
83
+ export interface Graph3DGrid {
84
+ /** Whether to draw the ground grid at all. Defaults to true. */
85
+ showGrid?: boolean
86
+ /** Total width and depth of the grid. Defaults to 20. */
87
+ size?: number
88
+ /** How many cells across. Defaults to 20. */
89
+ divisions?: number
90
+ /** Colour of the two lines through the origin. */
91
+ colorCenterLine?: Color
92
+ /** Colour of every other grid line. */
93
+ colorGrid?: Color
94
+ /** Whether to draw the X/Y/Z axes. Defaults to true. */
95
+ showAxes?: boolean
96
+ /** How far each axis reaches from the origin. Defaults to 10. */
97
+ axesSize?: number
98
+ }
99
+
100
+ export interface Graph3DProps extends Canvas3DProps {
101
+ /** The surfaces to plot, each evaluated as `z = f(x, y)` over the domain. */
102
+ equations: Graph3DEquation[]
103
+ camera: Graph3DCamera
104
+ grid: Graph3DGrid
105
+ /** Half-width of the plotted domain: x and y run `-domain … +domain`. Default 10. */
106
+ domain: number
107
+ /**
108
+ * Surface resolution per axis. Default 80, i.e. ~6.4k vertices per equation.
109
+ * Lower it when plotting several surfaces at once.
110
+ */
111
+ segments: number
112
+ /**
113
+ * Clamp on `|z|`. Default 20. Without it a function with an asymptote (`1/x`,
114
+ * `tan`) produces near-infinite vertices that stretch the mesh across the
115
+ * whole scene and wreck the framing.
116
+ */
117
+ maxHeight: number
118
+ /** Camera orbit about the vertical axis, in **degrees**. Animate this to spin. */
119
+ orbit: number
120
+ /** Camera elevation above the ground plane, in **degrees**. */
121
+ elevation: number
122
+ /** Camera distance from the origin, clamped by the camera's zoom limits. */
123
+ zoom: number
124
+ /** The 3D scene's background. */
125
+ background: Color
126
+ /** Skips the background entirely, letting the 2D scene behind show through. */
127
+ transparentBackground: boolean
128
+ }
129
+
130
+ // --- Resolved props --------------------------------------------------------
131
+ // Canonical, fully defaulted, interpolatable. Nothing here is optional, so the
132
+ // builder never re-derives a default and the tweens never test for one.
133
+
134
+ /** {@link Graph3DCamera} after {@link resolveCamera}. */
135
+ interface CameraResolved {
136
+ initialPosition: { x: number; y: number; z: number }
137
+ minZoom: number
138
+ maxZoom: number
139
+ }
140
+
141
+ /** {@link Graph3DGrid} after {@link resolveGrid}. */
142
+ interface GridResolved {
143
+ showGrid: boolean
144
+ size: number
145
+ divisions: number
146
+ colorCenterLine: NormalizedColor
147
+ colorGrid: NormalizedColor
148
+ showAxes: boolean
149
+ axesSize: number
150
+ }
151
+
152
+ const DEFAULT_POSITION = { x: 15, y: 12, z: 18 }
153
+
154
+ /**
155
+ * A 3D function grapher — any number of `z = f(x, y)` surfaces over a helper
156
+ * grid, with axes.
157
+ *
158
+ * Extends {@link Canvas3D} rather than `Node3D`: a grapher is a *viewport* on a
159
+ * scene it owns entirely, not a thing placed inside somebody else's. `Canvas3D`
160
+ * owns the bridge from the 2D scene graph into the 3D renderer, so subclassing
161
+ * it means this class only has to describe *what* to draw. It overrides
162
+ * `buildScene3D`, the single seam both the real render and the asset
163
+ * declaration pass go through.
164
+ *
165
+ * <Graph3D
166
+ * width="fill" height="fill"
167
+ * equations={[{ id: 1, expression: "sin(sqrt(x^2 + y^2))", color: "#3B82F6" }]}
168
+ * />
169
+ *
170
+ * Every prop is a `@property`, so a constant, a `() => signal()` binding, and
171
+ * `set()`/`to()` on a ref all work and agree. The non-scalar props each declare
172
+ * a `mapper` (loose authored shape → one canonical internal shape) and a `tween`
173
+ * (how that shape interpolates) — without the tween, `to({ grid: … })` would
174
+ * hold the old value and snap at the end, which is the standard trap for an
175
+ * object-valued attribute.
176
+ *
177
+ * **There is no OrbitControls and no pointer.** The camera is driven by the
178
+ * node's own `orbit`/`elevation`/`zoom` props, which is what keeps every frame
179
+ * reproducible under scrubbing and export — see {@link Graph3DCamera}.
180
+ */
181
+ @node({
182
+ key: "graph3d",
183
+ parentKey: "node",
184
+ forkable: true,
185
+ layout: {
186
+ children: "freeform",
187
+ defaultWidthMode: "fixed",
188
+ defaultHeightMode: "fixed",
189
+ acceptsChildren: true,
190
+ },
191
+ seed: {
192
+ width: 960,
193
+ height: 540,
194
+ },
195
+ })
196
+ export class Graph3D extends Canvas3D<Graph3DProps> {
197
+ /**
198
+ * The plotted surfaces.
199
+ *
200
+ * The mapper compiles each expression **once per write** (memoised by source),
201
+ * so the builder — which re-runs every frame — never re-parses; and it folds
202
+ * `visible`/`opacity` into a single number, so hiding is expressible as a
203
+ * fade. An expression that doesn't parse is dropped and the rest still draw,
204
+ * which is what lets the inspector commit on every keystroke.
205
+ *
206
+ * The tween matches the two lists by `id` — see `lerpEquations`.
207
+ */
208
+ @property({ default: [], mapper: resolveEquations, tween: lerpEquations })
209
+ declare equations: Graph3DEquation[]
210
+
211
+ /** The camera limits, and the framing that seeds the three spherical props. */
212
+ @property({ default: {}, mapper: resolveCamera, tween: lerpCamera })
213
+ declare camera: Graph3DCamera
214
+
215
+ /** The helper grid and axes. Flags snap at the end of a tween; the rest lerp. */
216
+ @property({ default: {}, mapper: resolveGrid, tween: lerpGrid })
217
+ declare grid: Graph3DGrid
218
+
219
+ @property({ default: 10 }) declare domain: number
220
+ /**
221
+ * Surface resolution. Tweenable, but *structural*: a geometry is immutable, so
222
+ * every intermediate value reallocates each surface's buffers. Set it once
223
+ * unless the resolution change is itself the point.
224
+ */
225
+ @property({ default: 80 }) declare segments: number
226
+ @property({ default: 20 }) declare maxHeight: number
227
+
228
+ @property({
229
+ default: "#111827",
230
+ mapper: resolveColor3D,
231
+ tween: lerpColor3D,
232
+ })
233
+ declare background: Color
234
+
235
+ @property({ default: false, tween: snapFlag })
236
+ declare transparentBackground: boolean
237
+
238
+ // No `default:` — the baseline is derived from `camera.initialPosition` in
239
+ // `applyCameraDefaults` below, which can't be expressed as a static default.
240
+ // So the control's kind is stated rather than inferred from one.
241
+ @property({ kind: "number" }) declare orbit: number
242
+ @property({ kind: "number" }) declare elevation: number
243
+ @property({ kind: "number" }) declare zoom: number
244
+
245
+ constructor(props?: NodeConfig<Graph3D, Graph3DProps>) {
246
+ super(props as NodeConfig<Canvas3D<Graph3DProps>, Graph3DProps>)
247
+ this.applyCameraDefaults(props)
248
+ // The backdrop is this node's own 2D fill: a `Canvas3D` composites its 3D
249
+ // pass over its fill layers, and there is no 3D background pass to reach
250
+ // for. Bound rather than copied so a tweened `background` carries, and so
251
+ // `transparentBackground` stays the *absence* of a fill — which is what
252
+ // lets the 2D scene behind actually show through.
253
+ if (props?.fill === undefined) {
254
+ this.applyProp("fill", () =>
255
+ this.transparentBackground ? [] : Fills.color(this.background)
256
+ )
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Frame the scene at a spherical camera placement. Every axis is optional,
262
+ * so "pull back" and "spin round" stay separate intentions — see
263
+ * {@link OrbitTarget}.
264
+ */
265
+ @command({
266
+ args: [
267
+ {
268
+ key: "target", kind: "orbit", properties: {
269
+ orbit: { kind: "number", step: 1, unit: "°" },
270
+ elevation: { kind: "number", min: -89, max: 89, step: 1, unit: "°" },
271
+ zoom: { kind: "number", min: 0.2, max: 24, step: 0.1, unit: "×" },
272
+ },
273
+ },
274
+ ],
275
+ })
276
+ orbitTo(args: CommandArgs<{ target: OrbitTarget }> & { duration: number }): Command<Graph3DProps> {
277
+ return this.to({
278
+ data: orbitProps(args.data?.target ?? {}) as Partial<Graph3DProps>,
279
+ duration: args.duration,
280
+ easing: args.easing ?? easeInOut(),
281
+ })
282
+ }
283
+
284
+ /**
285
+ * Seeds `orbit`/`elevation`/`zoom` from `camera.initialPosition`, so a caller
286
+ * who gives a raw position gets that framing and one who gives none gets
287
+ * {@link DEFAULT_POSITION} — without having to restate the other two axes.
288
+ *
289
+ * Bound reactively rather than copied, so the default *tracks* the camera and
290
+ * a camera signal keeps working. Writing any of the three (a `set`, or the
291
+ * first frame of a `to`) replaces the binding with the explicit value, which
292
+ * is exactly the handover wanted.
293
+ */
294
+ private applyCameraDefaults(
295
+ props?: NodeConfig<Graph3D, Graph3DProps>
296
+ ): void {
297
+ const seed = (): Orbit3D =>
298
+ orbitOf((this.camera as CameraResolved).initialPosition)
299
+
300
+ if (props?.orbit === undefined) {
301
+ this.applyProp("orbit", () => seed().orbit)
302
+ }
303
+ if (props?.elevation === undefined) {
304
+ this.applyProp("elevation", () => seed().elevation)
305
+ }
306
+ if (props?.zoom === undefined) {
307
+ this.applyProp("zoom", () => seed().distance)
308
+ }
309
+ }
310
+
311
+
312
+ // ---- Drawing ------------------------------------------------------------
313
+
314
+ protected override buildScene3D(): Scene3D {
315
+ const scene = new Scene3D()
316
+ const g3 = new Graphics3D()
317
+ const camera = this.camera as unknown as CameraResolved
318
+ const grid = this.grid as unknown as GridResolved
319
+ const equations = this.equations as unknown as EquationResolved[]
320
+
321
+ // Polar placement is the camera's own vocabulary now, so these three pass
322
+ // straight through; `cameraOrbit` is the quarter turn between the stored
323
+ // zero and the camera's. `near`/`far` come from the scene's own bounds.
324
+ scene.perspective({
325
+ fov: 45,
326
+ orbit: cameraOrbit(this.orbit),
327
+ elevation: this.elevation,
328
+ // Clamped here rather than in the mapper: `zoom` and the limits are
329
+ // separate props, so either can be tweened and the clamp still holds.
330
+ distance: Math.min(Math.max(this.zoom, camera.minZoom), camera.maxZoom),
331
+ })
332
+
333
+ // The backdrop is the node's own 2D fill — a `Canvas3D` composites its 3D
334
+ // pass over its fill layers, and there is no 3D background pass to reach
335
+ // for. `transparentBackground` is therefore the *absence* of a fill rather
336
+ // than a clear colour, which is what the props mapper already emits.
337
+
338
+ // A soft ambient fill plus a key light and a weaker back light, so the
339
+ // underside of a surface stays readable when the camera orbits beneath it.
340
+ // A light's own parameters and its *placement* are separate arguments now —
341
+ // placement is a transform, and a transform is not a property of the lamp.
342
+ scene
343
+ .light({ type: "ambient", intensity: 0.4 })
344
+ .light({ type: "directional", intensity: 0.6 }, { position: [10, 20, 10] })
345
+ .light(
346
+ { type: "directional", intensity: 0.4 },
347
+ { position: [-10, -20, -10] }
348
+ )
349
+
350
+ if (grid.showGrid) this.addGrid(g3, grid)
351
+ if (grid.showAxes) this.addAxes(g3, grid.axesSize)
352
+
353
+ const domain = this.domain
354
+ const segments = Math.max(1, Math.round(this.segments))
355
+ const maxHeight = this.maxHeight
356
+ equations.forEach((equation, index) => {
357
+ this.addSurface(g3, equation, index, domain, segments, maxHeight)
358
+ })
359
+
360
+ return scene.draw(g3)
361
+ }
362
+
363
+ /**
364
+ * One `z = f(x, y)` surface, keyed by equation id rather than by position in
365
+ * the op list — see {@link Graph3D.equations}.
366
+ */
367
+ private addSurface(
368
+ g3: Graphics3D,
369
+ equation: EquationResolved,
370
+ index: number,
371
+ domain: number,
372
+ segments: number,
373
+ maxHeight: number
374
+ ): void {
375
+ // Fully faded out (hidden, or the far end of a cross-fade): emit nothing.
376
+ // Safe to skip precisely because identity is the `key`, not the slot.
377
+ if (equation.opacity <= 0.001) return
378
+
379
+ const span = domain * 2
380
+ const { sample, opacity } = equation
381
+ const solid = opacity >= 1
382
+
383
+ g3.mesh(
384
+ Geo.parametric({
385
+ segments,
386
+ // The maths plane is (x, y) and the result is height, so maths y becomes
387
+ // the renderer's z and the result becomes its y.
388
+ vertex: (u, v) => {
389
+ const x = u * span - domain
390
+ const z = v * span - domain
391
+ return { x, y: sanitizeHeight(sample(x, z), maxHeight), z }
392
+ },
393
+ computeNormals: true, // without this the surface is flat-lit
394
+ // Exactly the three things the callback above closes over. Without it the
395
+ // renderer re-runs `vertex` over the whole grid every frame — ~6.6k calls
396
+ // per surface at the default `segments: 80`, plus normals and a buffer
397
+ // re-upload — and a frame is drawn for all sorts of reasons that leave the
398
+ // surface untouched: the camera orbiting, a gizmo drag on an unrelated
399
+ // node repainting at pointer rate, a tween elsewhere in the scene.
400
+ //
401
+ // A morph still re-evaluates as it must: `blendEquation` hands out a fresh
402
+ // closure per frame mid-tween, and tweening `domain` or `maxHeight` moves
403
+ // the number directly.
404
+ revision: surfaceRevision(sample, domain, maxHeight),
405
+ }),
406
+ Mat.standard({
407
+ color: equation.color,
408
+ faces: "both", // a surface is infinitely thin; show both faces
409
+ roughness: 0.4,
410
+ metalness: 0.1,
411
+ // No `transparent`: the renderer derives blending from the opacity, so
412
+ // the flag and the value it was meant to accompany cannot disagree.
413
+ opacity,
414
+ // The fix for surfaces tearing holes in each other mid-fade. A
415
+ // translucent mesh that still writes depth occludes everything drawn
416
+ // after it, so a surface at 5% opacity renders as a near-invisible
417
+ // cut-out through the surfaces behind it — and it resolves only once the
418
+ // fade lands. Depth is written by fully opaque surfaces only; the rest
419
+ // blend, which is what asking for an opacity below 1 means.
420
+ depthWrite: solid,
421
+ }),
422
+ {
423
+ key: `equation:${equation.id}`,
424
+ // The renderer sorts the transparent pass back-to-front by centroid
425
+ // distance — and every surface here is centred on the origin, so their
426
+ // sort keys are identical and the order is arbitrary, which flickers as
427
+ // the camera orbits. An explicit order makes it stable.
428
+ renderOrder: index,
429
+ }
430
+ )
431
+ }
432
+
433
+ /**
434
+ * The ground grid, as two line ops — the centre cross and everything else —
435
+ * because a line op carries a single colour and the centre lines are drawn
436
+ * differently from the rest.
437
+ */
438
+ private addGrid(g3: Graphics3D, grid: GridResolved): void {
439
+ const divisions = Math.max(1, grid.divisions)
440
+ const half = grid.size / 2
441
+ const step = grid.size / divisions
442
+
443
+ const centre: Vector3Input[] = []
444
+ const lines: Vector3Input[] = []
445
+
446
+ for (let i = 0; i <= divisions; i++) {
447
+ const offset = -half + i * step
448
+ // A line is a centre line when it passes through the origin. With an odd
449
+ // division count no line does, which is the conventional behaviour.
450
+ const isCentre = Math.abs(offset) < step / 1000
451
+ const target = isCentre ? centre : lines
452
+ target.push([-half, 0, offset], [half, 0, offset]) // along X
453
+ target.push([offset, 0, -half], [offset, 0, half]) // along Z
454
+ }
455
+
456
+ if (lines.length > 0) {
457
+ // No key: a conditionally-emitted op used to shift every later op's cache
458
+ // slot, and the reconciler keys a drawable by its content now.
459
+ g3.line({
460
+ points: lines,
461
+ segments: true,
462
+ stroke: { fill: grid.colorGrid },
463
+ })
464
+ }
465
+ if (centre.length > 0) {
466
+ g3.line({
467
+ points: centre,
468
+ segments: true,
469
+ stroke: { fill: grid.colorCenterLine },
470
+ })
471
+ }
472
+ }
473
+
474
+ /** X/Y/Z axes in red/green/blue, the conventional colouring. */
475
+ private addAxes(g3: Graphics3D, length: number): void {
476
+ const axes: Array<[Vector3Input, string]> = [
477
+ [[length, 0, 0], "#ff4d4d"],
478
+ [[0, length, 0], "#4ade80"],
479
+ [[0, 0, length], "#60a5fa"],
480
+ ]
481
+
482
+ for (const [end, color] of axes) {
483
+ g3.line({ points: [[0, 0, 0], end], stroke: { fill: color } })
484
+ }
485
+ }
486
+ }
487
+
488
+ // --- Mappers & tweens ------------------------------------------------------
489
+
490
+ /** Mapper for {@link Graph3D.equations}. */
491
+ function resolveEquations(
492
+ input: Graph3DEquation[] | undefined
493
+ ): EquationResolved[] {
494
+ if (!input) return []
495
+ const out: EquationResolved[] = []
496
+ for (const equation of input) {
497
+ const sample = compileExpressionCached(equation.expression)
498
+ if (!sample) continue // unparseable or empty — drop it, keep the rest
499
+ out.push({
500
+ id: String(equation.id),
501
+ sample,
502
+ color: resolveColor3D(equation.color),
503
+ opacity: equation.visible === false ? 0 : (equation.opacity ?? 1),
504
+ })
505
+ }
506
+ return out
507
+ }
508
+
509
+ /**
510
+ * Mapper for {@link Graph3D.camera}.
511
+ *
512
+ * Merges onto `previous` rather than onto the bare defaults, which is what makes
513
+ * a partial write mean "change this much and leave the rest". It matters most on
514
+ * a `to` command, whose target states only the fields that command touches: a
515
+ * tween that raises `maxZoom` must not silently hand the framing back to
516
+ * {@link DEFAULT_POSITION} on its way.
517
+ */
518
+ function resolveCamera(
519
+ input: Graph3DCamera | undefined,
520
+ previous?: CameraResolved
521
+ ): CameraResolved {
522
+ const base = previous ?? {
523
+ initialPosition: DEFAULT_POSITION,
524
+ minZoom: 0.1,
525
+ maxZoom: Number.POSITIVE_INFINITY,
526
+ }
527
+ return {
528
+ initialPosition: input?.initialPosition ?? base.initialPosition,
529
+ minZoom: input?.minZoom ?? base.minZoom,
530
+ maxZoom: input?.maxZoom ?? base.maxZoom,
531
+ }
532
+ }
533
+
534
+ /** Tween for {@link Graph3D.camera}. */
535
+ function lerpCamera(
536
+ from: CameraResolved,
537
+ to: CameraResolved,
538
+ t: number
539
+ ): CameraResolved {
540
+ return {
541
+ initialPosition: {
542
+ x: lerpNumber(from.initialPosition.x, to.initialPosition.x, t),
543
+ y: lerpNumber(from.initialPosition.y, to.initialPosition.y, t),
544
+ z: lerpNumber(from.initialPosition.z, to.initialPosition.z, t),
545
+ },
546
+ // `maxZoom` defaults to Infinity, which a plain lerp turns into NaN.
547
+ minZoom: lerpFinite(from.minZoom, to.minZoom, t),
548
+ maxZoom: lerpFinite(from.maxZoom, to.maxZoom, t),
549
+ }
550
+ }
551
+
552
+ /** The grid a node with nothing said about it draws. */
553
+ const DEFAULT_GRID: GridResolved = {
554
+ showGrid: true,
555
+ size: 20,
556
+ divisions: 20,
557
+ colorCenterLine: resolveColor3D("#4b5563"),
558
+ colorGrid: resolveColor3D("#1f2937"),
559
+ showAxes: true,
560
+ axesSize: 10,
561
+ }
562
+
563
+ /** Mapper for {@link Graph3D.grid}. Merges onto `previous` — see {@link resolveCamera}. */
564
+ function resolveGrid(
565
+ input: Graph3DGrid | undefined,
566
+ previous?: GridResolved
567
+ ): GridResolved {
568
+ const base = previous ?? DEFAULT_GRID
569
+ return {
570
+ showGrid: input?.showGrid ?? base.showGrid,
571
+ size: input?.size ?? base.size,
572
+ divisions: input?.divisions ?? base.divisions,
573
+ colorCenterLine:
574
+ input?.colorCenterLine === undefined
575
+ ? base.colorCenterLine
576
+ : resolveColor3D(input.colorCenterLine),
577
+ colorGrid:
578
+ input?.colorGrid === undefined
579
+ ? base.colorGrid
580
+ : resolveColor3D(input.colorGrid),
581
+ showAxes: input?.showAxes ?? base.showAxes,
582
+ axesSize: input?.axesSize ?? base.axesSize,
583
+ }
584
+ }
585
+
586
+ /** Tween for {@link Graph3D.grid}. */
587
+ function lerpGrid(from: GridResolved, to: GridResolved, t: number): GridResolved {
588
+ return {
589
+ showGrid: snapFlag(from.showGrid, to.showGrid, t),
590
+ size: lerpNumber(from.size, to.size, t),
591
+ divisions: lerpCount(from.divisions, to.divisions, t),
592
+ colorCenterLine: lerpColor3D(from.colorCenterLine, to.colorCenterLine, t),
593
+ colorGrid: lerpColor3D(from.colorGrid, to.colorGrid, t),
594
+ showAxes: snapFlag(from.showAxes, to.showAxes, t),
595
+ axesSize: lerpNumber(from.axesSize, to.axesSize, t),
596
+ }
597
+ }
598
+
599
+ /** A camera placement in spherical terms. */
600
+ interface Orbit3D {
601
+ orbit: number
602
+ elevation: number
603
+ distance: number
604
+ }
605
+
606
+ /** Decomposes a raw camera position into spherical terms. */
607
+ function orbitOf(position: { x: number; y: number; z: number }): Orbit3D {
608
+ const distance = Math.hypot(position.x, position.y, position.z) || 1
609
+ const sin = Math.min(1, Math.max(-1, position.y / distance))
610
+ return {
611
+ orbit: Math.atan2(position.z, position.x) * (180 / Math.PI),
612
+ elevation: Math.asin(sin) * (180 / Math.PI),
613
+ distance,
614
+ }
615
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The Graph 3D node — a **native** node type (see `node-types.ts` /
3
+ * `NODE_CLASS_BY_KEY`) like the chart family beside it: built, themed and
4
+ * animated from the inspector rather than loaded as a code package.
5
+ *
6
+ * The one node in the app that draws in three dimensions. It plots any number of
7
+ * `z = f(x, y)` surfaces over a helper grid, sampling each from an expression
8
+ * the author types — which is why its own little language lives here
9
+ * (`expression.ts`) rather than reaching for a maths library: an expression is a
10
+ * value stored in a scene document, so compiling it as JavaScript would make
11
+ * every scene file an execution vector, and a 100×100 surface is ten thousand
12
+ * evaluations per equation per frame, which a general evaluator would not
13
+ * survive.
14
+ *
15
+ * There is no OrbitControls here and no pointer: the camera is three tweenable
16
+ * numbers, so every frame is reproducible under scrubbing and export. See
17
+ * {@link Graph3DCamera}.
18
+ */
19
+ export * from "./expression";
20
+ export * from "./graph3d";
21
+ export * from "./shared";