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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +201 -0
  3. package/dist/browser/chunks/chunk-RCFWYW6O.js +2 -0
  4. package/dist/browser/chunks/chunk-RCFWYW6O.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 +614 -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,614 @@
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/core"
21
+ import { orbitProps, type OrbitTarget } from "@motionscript/core/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
+ @property() declare orbit: number
241
+ @property() declare elevation: number
242
+ @property() declare zoom: number
243
+
244
+ constructor(props?: NodeConfig<Graph3D, Graph3DProps>) {
245
+ super(props as NodeConfig<Canvas3D<Graph3DProps>, Graph3DProps>)
246
+ this.applyCameraDefaults(props)
247
+ // The backdrop is this node's own 2D fill: a `Canvas3D` composites its 3D
248
+ // pass over its fill layers, and there is no 3D background pass to reach
249
+ // for. Bound rather than copied so a tweened `background` carries, and so
250
+ // `transparentBackground` stays the *absence* of a fill — which is what
251
+ // lets the 2D scene behind actually show through.
252
+ if (props?.fill === undefined) {
253
+ this.applyProp("fill", () =>
254
+ this.transparentBackground ? [] : Fills.color(this.background)
255
+ )
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Frame the scene at a spherical camera placement. Every axis is optional,
261
+ * so "pull back" and "spin round" stay separate intentions — see
262
+ * {@link OrbitTarget}.
263
+ */
264
+ @command({
265
+ args: [
266
+ {
267
+ key: "target", kind: "orbit", properties: {
268
+ orbit: { kind: "number", step: 1, unit: "°" },
269
+ elevation: { kind: "number", min: -89, max: 89, step: 1, unit: "°" },
270
+ zoom: { kind: "number", min: 0.2, max: 24, step: 0.1, unit: "×" },
271
+ },
272
+ },
273
+ ],
274
+ })
275
+ orbitTo(args: CommandArgs<{ target: OrbitTarget }> & { duration: number }): Command<Graph3DProps> {
276
+ return this.to({
277
+ data: orbitProps(args.data?.target ?? {}) as Partial<Graph3DProps>,
278
+ duration: args.duration,
279
+ easing: args.easing ?? easeInOut(),
280
+ })
281
+ }
282
+
283
+ /**
284
+ * Seeds `orbit`/`elevation`/`zoom` from `camera.initialPosition`, so a caller
285
+ * who gives a raw position gets that framing and one who gives none gets
286
+ * {@link DEFAULT_POSITION} — without having to restate the other two axes.
287
+ *
288
+ * Bound reactively rather than copied, so the default *tracks* the camera and
289
+ * a camera signal keeps working. Writing any of the three (a `set`, or the
290
+ * first frame of a `to`) replaces the binding with the explicit value, which
291
+ * is exactly the handover wanted.
292
+ */
293
+ private applyCameraDefaults(
294
+ props?: NodeConfig<Graph3D, Graph3DProps>
295
+ ): void {
296
+ const seed = (): Orbit3D =>
297
+ orbitOf((this.camera as CameraResolved).initialPosition)
298
+
299
+ if (props?.orbit === undefined) {
300
+ this.applyProp("orbit", () => seed().orbit)
301
+ }
302
+ if (props?.elevation === undefined) {
303
+ this.applyProp("elevation", () => seed().elevation)
304
+ }
305
+ if (props?.zoom === undefined) {
306
+ this.applyProp("zoom", () => seed().distance)
307
+ }
308
+ }
309
+
310
+
311
+ // ---- Drawing ------------------------------------------------------------
312
+
313
+ protected override buildScene3D(): Scene3D {
314
+ const scene = new Scene3D()
315
+ const g3 = new Graphics3D()
316
+ const camera = this.camera as unknown as CameraResolved
317
+ const grid = this.grid as unknown as GridResolved
318
+ const equations = this.equations as unknown as EquationResolved[]
319
+
320
+ // Polar placement is the camera's own vocabulary now, so these three pass
321
+ // straight through; `cameraOrbit` is the quarter turn between the stored
322
+ // zero and the camera's. `near`/`far` come from the scene's own bounds.
323
+ scene.perspective({
324
+ fov: 45,
325
+ orbit: cameraOrbit(this.orbit),
326
+ elevation: this.elevation,
327
+ // Clamped here rather than in the mapper: `zoom` and the limits are
328
+ // separate props, so either can be tweened and the clamp still holds.
329
+ distance: Math.min(Math.max(this.zoom, camera.minZoom), camera.maxZoom),
330
+ })
331
+
332
+ // The backdrop is the node's own 2D fill — a `Canvas3D` composites its 3D
333
+ // pass over its fill layers, and there is no 3D background pass to reach
334
+ // for. `transparentBackground` is therefore the *absence* of a fill rather
335
+ // than a clear colour, which is what the props mapper already emits.
336
+
337
+ // A soft ambient fill plus a key light and a weaker back light, so the
338
+ // underside of a surface stays readable when the camera orbits beneath it.
339
+ // A light's own parameters and its *placement* are separate arguments now —
340
+ // placement is a transform, and a transform is not a property of the lamp.
341
+ scene
342
+ .light({ type: "ambient", intensity: 0.4 })
343
+ .light({ type: "directional", intensity: 0.6 }, { position: [10, 20, 10] })
344
+ .light(
345
+ { type: "directional", intensity: 0.4 },
346
+ { position: [-10, -20, -10] }
347
+ )
348
+
349
+ if (grid.showGrid) this.addGrid(g3, grid)
350
+ if (grid.showAxes) this.addAxes(g3, grid.axesSize)
351
+
352
+ const domain = this.domain
353
+ const segments = Math.max(1, Math.round(this.segments))
354
+ const maxHeight = this.maxHeight
355
+ equations.forEach((equation, index) => {
356
+ this.addSurface(g3, equation, index, domain, segments, maxHeight)
357
+ })
358
+
359
+ return scene.draw(g3)
360
+ }
361
+
362
+ /**
363
+ * One `z = f(x, y)` surface, keyed by equation id rather than by position in
364
+ * the op list — see {@link Graph3D.equations}.
365
+ */
366
+ private addSurface(
367
+ g3: Graphics3D,
368
+ equation: EquationResolved,
369
+ index: number,
370
+ domain: number,
371
+ segments: number,
372
+ maxHeight: number
373
+ ): void {
374
+ // Fully faded out (hidden, or the far end of a cross-fade): emit nothing.
375
+ // Safe to skip precisely because identity is the `key`, not the slot.
376
+ if (equation.opacity <= 0.001) return
377
+
378
+ const span = domain * 2
379
+ const { sample, opacity } = equation
380
+ const solid = opacity >= 1
381
+
382
+ g3.mesh(
383
+ Geo.parametric({
384
+ segments,
385
+ // The maths plane is (x, y) and the result is height, so maths y becomes
386
+ // the renderer's z and the result becomes its y.
387
+ vertex: (u, v) => {
388
+ const x = u * span - domain
389
+ const z = v * span - domain
390
+ return { x, y: sanitizeHeight(sample(x, z), maxHeight), z }
391
+ },
392
+ computeNormals: true, // without this the surface is flat-lit
393
+ // Exactly the three things the callback above closes over. Without it the
394
+ // renderer re-runs `vertex` over the whole grid every frame — ~6.6k calls
395
+ // per surface at the default `segments: 80`, plus normals and a buffer
396
+ // re-upload — and a frame is drawn for all sorts of reasons that leave the
397
+ // surface untouched: the camera orbiting, a gizmo drag on an unrelated
398
+ // node repainting at pointer rate, a tween elsewhere in the scene.
399
+ //
400
+ // A morph still re-evaluates as it must: `blendEquation` hands out a fresh
401
+ // closure per frame mid-tween, and tweening `domain` or `maxHeight` moves
402
+ // the number directly.
403
+ revision: surfaceRevision(sample, domain, maxHeight),
404
+ }),
405
+ Mat.standard({
406
+ color: equation.color,
407
+ faces: "both", // a surface is infinitely thin; show both faces
408
+ roughness: 0.4,
409
+ metalness: 0.1,
410
+ // No `transparent`: the renderer derives blending from the opacity, so
411
+ // the flag and the value it was meant to accompany cannot disagree.
412
+ opacity,
413
+ // The fix for surfaces tearing holes in each other mid-fade. A
414
+ // translucent mesh that still writes depth occludes everything drawn
415
+ // after it, so a surface at 5% opacity renders as a near-invisible
416
+ // cut-out through the surfaces behind it — and it resolves only once the
417
+ // fade lands. Depth is written by fully opaque surfaces only; the rest
418
+ // blend, which is what asking for an opacity below 1 means.
419
+ depthWrite: solid,
420
+ }),
421
+ {
422
+ key: `equation:${equation.id}`,
423
+ // The renderer sorts the transparent pass back-to-front by centroid
424
+ // distance — and every surface here is centred on the origin, so their
425
+ // sort keys are identical and the order is arbitrary, which flickers as
426
+ // the camera orbits. An explicit order makes it stable.
427
+ renderOrder: index,
428
+ }
429
+ )
430
+ }
431
+
432
+ /**
433
+ * The ground grid, as two line ops — the centre cross and everything else —
434
+ * because a line op carries a single colour and the centre lines are drawn
435
+ * differently from the rest.
436
+ */
437
+ private addGrid(g3: Graphics3D, grid: GridResolved): void {
438
+ const divisions = Math.max(1, grid.divisions)
439
+ const half = grid.size / 2
440
+ const step = grid.size / divisions
441
+
442
+ const centre: Vector3Input[] = []
443
+ const lines: Vector3Input[] = []
444
+
445
+ for (let i = 0; i <= divisions; i++) {
446
+ const offset = -half + i * step
447
+ // A line is a centre line when it passes through the origin. With an odd
448
+ // division count no line does, which is the conventional behaviour.
449
+ const isCentre = Math.abs(offset) < step / 1000
450
+ const target = isCentre ? centre : lines
451
+ target.push([-half, 0, offset], [half, 0, offset]) // along X
452
+ target.push([offset, 0, -half], [offset, 0, half]) // along Z
453
+ }
454
+
455
+ if (lines.length > 0) {
456
+ // No key: a conditionally-emitted op used to shift every later op's cache
457
+ // slot, and the reconciler keys a drawable by its content now.
458
+ g3.line({
459
+ points: lines,
460
+ segments: true,
461
+ stroke: { fill: grid.colorGrid },
462
+ })
463
+ }
464
+ if (centre.length > 0) {
465
+ g3.line({
466
+ points: centre,
467
+ segments: true,
468
+ stroke: { fill: grid.colorCenterLine },
469
+ })
470
+ }
471
+ }
472
+
473
+ /** X/Y/Z axes in red/green/blue, the conventional colouring. */
474
+ private addAxes(g3: Graphics3D, length: number): void {
475
+ const axes: Array<[Vector3Input, string]> = [
476
+ [[length, 0, 0], "#ff4d4d"],
477
+ [[0, length, 0], "#4ade80"],
478
+ [[0, 0, length], "#60a5fa"],
479
+ ]
480
+
481
+ for (const [end, color] of axes) {
482
+ g3.line({ points: [[0, 0, 0], end], stroke: { fill: color } })
483
+ }
484
+ }
485
+ }
486
+
487
+ // --- Mappers & tweens ------------------------------------------------------
488
+
489
+ /** Mapper for {@link Graph3D.equations}. */
490
+ function resolveEquations(
491
+ input: Graph3DEquation[] | undefined
492
+ ): EquationResolved[] {
493
+ if (!input) return []
494
+ const out: EquationResolved[] = []
495
+ for (const equation of input) {
496
+ const sample = compileExpressionCached(equation.expression)
497
+ if (!sample) continue // unparseable or empty — drop it, keep the rest
498
+ out.push({
499
+ id: String(equation.id),
500
+ sample,
501
+ color: resolveColor3D(equation.color),
502
+ opacity: equation.visible === false ? 0 : (equation.opacity ?? 1),
503
+ })
504
+ }
505
+ return out
506
+ }
507
+
508
+ /**
509
+ * Mapper for {@link Graph3D.camera}.
510
+ *
511
+ * Merges onto `previous` rather than onto the bare defaults, which is what makes
512
+ * a partial write mean "change this much and leave the rest". It matters most on
513
+ * a `to` command, whose target states only the fields that command touches: a
514
+ * tween that raises `maxZoom` must not silently hand the framing back to
515
+ * {@link DEFAULT_POSITION} on its way.
516
+ */
517
+ function resolveCamera(
518
+ input: Graph3DCamera | undefined,
519
+ previous?: CameraResolved
520
+ ): CameraResolved {
521
+ const base = previous ?? {
522
+ initialPosition: DEFAULT_POSITION,
523
+ minZoom: 0.1,
524
+ maxZoom: Number.POSITIVE_INFINITY,
525
+ }
526
+ return {
527
+ initialPosition: input?.initialPosition ?? base.initialPosition,
528
+ minZoom: input?.minZoom ?? base.minZoom,
529
+ maxZoom: input?.maxZoom ?? base.maxZoom,
530
+ }
531
+ }
532
+
533
+ /** Tween for {@link Graph3D.camera}. */
534
+ function lerpCamera(
535
+ from: CameraResolved,
536
+ to: CameraResolved,
537
+ t: number
538
+ ): CameraResolved {
539
+ return {
540
+ initialPosition: {
541
+ x: lerpNumber(from.initialPosition.x, to.initialPosition.x, t),
542
+ y: lerpNumber(from.initialPosition.y, to.initialPosition.y, t),
543
+ z: lerpNumber(from.initialPosition.z, to.initialPosition.z, t),
544
+ },
545
+ // `maxZoom` defaults to Infinity, which a plain lerp turns into NaN.
546
+ minZoom: lerpFinite(from.minZoom, to.minZoom, t),
547
+ maxZoom: lerpFinite(from.maxZoom, to.maxZoom, t),
548
+ }
549
+ }
550
+
551
+ /** The grid a node with nothing said about it draws. */
552
+ const DEFAULT_GRID: GridResolved = {
553
+ showGrid: true,
554
+ size: 20,
555
+ divisions: 20,
556
+ colorCenterLine: resolveColor3D("#4b5563"),
557
+ colorGrid: resolveColor3D("#1f2937"),
558
+ showAxes: true,
559
+ axesSize: 10,
560
+ }
561
+
562
+ /** Mapper for {@link Graph3D.grid}. Merges onto `previous` — see {@link resolveCamera}. */
563
+ function resolveGrid(
564
+ input: Graph3DGrid | undefined,
565
+ previous?: GridResolved
566
+ ): GridResolved {
567
+ const base = previous ?? DEFAULT_GRID
568
+ return {
569
+ showGrid: input?.showGrid ?? base.showGrid,
570
+ size: input?.size ?? base.size,
571
+ divisions: input?.divisions ?? base.divisions,
572
+ colorCenterLine:
573
+ input?.colorCenterLine === undefined
574
+ ? base.colorCenterLine
575
+ : resolveColor3D(input.colorCenterLine),
576
+ colorGrid:
577
+ input?.colorGrid === undefined
578
+ ? base.colorGrid
579
+ : resolveColor3D(input.colorGrid),
580
+ showAxes: input?.showAxes ?? base.showAxes,
581
+ axesSize: input?.axesSize ?? base.axesSize,
582
+ }
583
+ }
584
+
585
+ /** Tween for {@link Graph3D.grid}. */
586
+ function lerpGrid(from: GridResolved, to: GridResolved, t: number): GridResolved {
587
+ return {
588
+ showGrid: snapFlag(from.showGrid, to.showGrid, t),
589
+ size: lerpNumber(from.size, to.size, t),
590
+ divisions: lerpCount(from.divisions, to.divisions, t),
591
+ colorCenterLine: lerpColor3D(from.colorCenterLine, to.colorCenterLine, t),
592
+ colorGrid: lerpColor3D(from.colorGrid, to.colorGrid, t),
593
+ showAxes: snapFlag(from.showAxes, to.showAxes, t),
594
+ axesSize: lerpNumber(from.axesSize, to.axesSize, t),
595
+ }
596
+ }
597
+
598
+ /** A camera placement in spherical terms. */
599
+ interface Orbit3D {
600
+ orbit: number
601
+ elevation: number
602
+ distance: number
603
+ }
604
+
605
+ /** Decomposes a raw camera position into spherical terms. */
606
+ function orbitOf(position: { x: number; y: number; z: number }): Orbit3D {
607
+ const distance = Math.hypot(position.x, position.y, position.z) || 1
608
+ const sin = Math.min(1, Math.max(-1, position.y / distance))
609
+ return {
610
+ orbit: Math.atan2(position.z, position.x) * (180 / Math.PI),
611
+ elevation: Math.asin(sin) * (180 / Math.PI),
612
+ distance,
613
+ }
614
+ }
@@ -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";