@wave3d/core 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -40,9 +40,7 @@ const blob = await handle.snapshot(); // resolves null until running. options: {
40
40
  ```html
41
41
  <script type="module">
42
42
  import { mountWave } from "https://esm.sh/@wave3d/core/standalone";
43
- mountWave(document.getElementById("wave"), {
44
- /* your exported config */
45
- });
43
+ mountWave(document.getElementById("wave"), {/* your exported config */});
46
44
  </script>
47
45
  ```
48
46
 
@@ -76,9 +74,7 @@ createWave(el, {
76
74
  touch: false, // follow touch pointers too
77
75
  bindings: [{ source: "scroll", target: "timeOffset", to: 40 }], // scrub the whole wave with scroll
78
76
  },
79
- waves: [
80
- /* each wave carries its own `interaction` (hover / press / bindings) */
81
- ],
77
+ waves: [/* each wave carries its own `interaction` (hover / press / bindings) */],
82
78
  });
83
79
  ```
84
80
 
@@ -148,10 +148,34 @@ interface WaveConfig {
148
148
  twistFrequency: Vec3;
149
149
  twistPower: Vec3;
150
150
  twistMotion?: boolean;
151
+ /** Helix: sweep the ribbon around its OWN length axis, `helixTurns` full turns from end to end.
152
+ * The three twists above can't express this — their axes sit 45°/90° off the length, and
153
+ * `expStep` is a monotone falloff, so nothing they do ever repeats. This angle is periodic in the
154
+ * length coordinate instead, which is what makes a helix (and a DNA ladder) reachable.
155
+ * 0 turns still leaves the block inert unless radius or roll is non-zero. */
156
+ helixTurns?: number;
157
+ /** Carries the WHOLE ribbon around the axis at this radius (world units, pre-scale), keeping its
158
+ * orientation. A narrow ribbon then reads as a single strand — two waves half a turn apart in
159
+ * `helixPhase` are the two strands of a double helix. 0 = off. */
160
+ helixRadius?: number;
161
+ /** Rolls the ribbon's own cross-section about the axis as it advances, as a fraction of
162
+ * `helixTurns` (1 = rolls exactly in step, a rigid twisted ribbon). That swings the ribbon's two
163
+ * long edges onto opposite sides of the axis, so ONE wave becomes a ladder whose edges are both
164
+ * strands — pair it with `rungAmount` for the rungs between them. 0 = off. */
165
+ helixRoll?: number;
166
+ /** Phase offset in degrees — where along the turn the ribbon starts. The per-wave knob that puts
167
+ * a second wave on the opposite side of the same helix (180). */
168
+ helixPhase?: number;
151
169
  theme?: "solid" | "wireframe";
152
170
  lineAmount?: number;
153
171
  lineThickness?: number;
154
172
  lineDerivativePower?: number;
173
+ /** Wireframe RUNGS: a second line family carved at constant uv.y, so these run ACROSS the ribbon
174
+ * where `lineAmount`'s run along it — the two cross into a ladder. Frequency, like `lineAmount`
175
+ * (rungs ≈ amount / π). 0 = off, and the cross-wise path isn't compiled. */
176
+ rungAmount?: number;
177
+ /** Rung line width, in pixels (screen-space, so it holds at any zoom). */
178
+ rungThickness?: number;
155
179
  maxWidth?: number;
156
180
  position: Vec3;
157
181
  rotation: Vec3;
@@ -174,7 +198,7 @@ interface WaveConfig {
174
198
  type InteractionSource = "scroll" | "hover" | "pointerX" | "pointerY" | "pointerSpeed" | "press" | "scrollVelocity" | "appear" | `custom:${string}`;
175
199
  /** Per-WAVE params a binding may drive. Single source of truth for WAVE_APPLIERS in
176
200
  * renderer/interaction.ts (checked via `satisfies`) and validated by normalizeWaveInteraction. */
177
- declare const WAVE_TARGET_NAMES: readonly ["displaceAmount", "detailAmount", "twistPowerX", "twistPowerY", "twistPowerZ", "twistFrequencyX", "twistFrequencyY", "twistFrequencyZ", "hueShift", "gradientShift", "colorSaturation", "opacity", "lineThickness", "lineAmount", "fiberStrength", "sheen", "iridescence", "positionX", "positionY"];
201
+ declare const WAVE_TARGET_NAMES: readonly ["displaceAmount", "detailAmount", "twistPowerX", "twistPowerY", "twistPowerZ", "twistFrequencyX", "twistFrequencyY", "twistFrequencyZ", "helixPhase", "helixTurns", "helixRadius", "hueShift", "gradientShift", "colorSaturation", "opacity", "lineThickness", "lineAmount", "fiberStrength", "sheen", "iridescence", "positionX", "positionY"];
178
202
  /** A per-wave param a {@link WaveInteractionBinding} can drive. */
179
203
  type WaveInteractionTarget = (typeof WAVE_TARGET_NAMES)[number];
180
204
  /** SCENE params a binding may drive (post / camera / time — shared, not per wave). Single source of
@@ -119,6 +119,9 @@ const WAVE_TARGET_NAMES = [
119
119
  "twistFrequencyX",
120
120
  "twistFrequencyY",
121
121
  "twistFrequencyZ",
122
+ "helixPhase",
123
+ "helixTurns",
124
+ "helixRadius",
122
125
  "hueShift",
123
126
  "gradientShift",
124
127
  "colorSaturation",
@@ -263,10 +266,16 @@ function defaultWave() {
263
266
  z: 6.33
264
267
  },
265
268
  twistMotion: false,
269
+ helixTurns: 0,
270
+ helixRadius: 0,
271
+ helixRoll: 0,
272
+ helixPhase: 0,
266
273
  theme: "solid",
267
274
  lineAmount: 425,
268
275
  lineThickness: 1,
269
276
  lineDerivativePower: .95,
277
+ rungAmount: 0,
278
+ rungThickness: 1,
270
279
  maxWidth: 1232,
271
280
  position: {
272
281
  x: -24.3,
@@ -377,7 +386,8 @@ function createDefaultConfig() {
377
386
  * ColorStop[]; mesh points + texture transform are clamped). */
378
387
  function normalizeWaveColour(config) {
379
388
  const p = config.palette;
380
- if (p.length > 0 && typeof p[0] === "string") config.palette = makeStops(p);
389
+ if (!Array.isArray(p) || p.length === 0) config.palette = defaultWave().palette;
390
+ else if (typeof p[0] === "string") config.palette = makeStops(p);
381
391
  if (config.gradientType !== "radial" && config.gradientType !== "conic" && config.gradientType !== "mesh" && config.gradientType !== "linear") config.gradientType = "linear";
382
392
  const rawMeshPoints = config.meshGradientPoints;
383
393
  if (!Array.isArray(rawMeshPoints) || rawMeshPoints.length < 2) config.meshGradientPoints = createDefaultMeshPoints();
@@ -414,6 +424,8 @@ function normalizeWaveColour(config) {
414
424
  }
415
425
  /** Backfill background styling for states saved before gradient/image backgrounds existed. */
416
426
  function normalizeBackground(config) {
427
+ if (typeof config.background !== "string") config.background = "#ffffff";
428
+ if (typeof config.transparentBackground !== "boolean") config.transparentBackground = true;
417
429
  if (config.backgroundMode !== "gradient" && config.backgroundMode !== "image" && config.backgroundMode !== "color") config.backgroundMode = "color";
418
430
  const palette = config.backgroundPalette;
419
431
  if (!palette || palette.length < 2) config.backgroundPalette = makeStops([
@@ -428,11 +440,11 @@ function normalizeBackground(config) {
428
440
  if (!Array.isArray(bgMesh) || bgMesh.length < 2) config.backgroundMeshPoints = createDefaultMeshPoints();
429
441
  if (!Number.isFinite(config.backgroundMeshSoftness)) config.backgroundMeshSoftness = .62;
430
442
  config.backgroundMeshSoftness = clamp01(config.backgroundMeshSoftness);
431
- if (typeof config.backgroundGradientAngle !== "number") config.backgroundGradientAngle = 135;
443
+ if (!Number.isFinite(config.backgroundGradientAngle)) config.backgroundGradientAngle = 135;
432
444
  if (typeof config.backgroundGradientSource !== "string") config.backgroundGradientSource = "stops";
433
445
  if (typeof config.backgroundImageSource !== "string") config.backgroundImageSource = "vaporwave";
434
446
  if (config.backgroundImageFit !== "contain" && config.backgroundImageFit !== "stretch" && config.backgroundImageFit !== "cover") config.backgroundImageFit = "cover";
435
- if (typeof config.backgroundImageZoom !== "number") config.backgroundImageZoom = 1;
447
+ if (!Number.isFinite(config.backgroundImageZoom)) config.backgroundImageZoom = 1;
436
448
  config.backgroundImageZoom = clamp(config.backgroundImageZoom, .1, 8);
437
449
  if (!config.backgroundImagePosition) config.backgroundImagePosition = {
438
450
  x: 0,
@@ -455,45 +467,46 @@ function ensureCamera(config) {
455
467
  y: 0,
456
468
  z: 0
457
469
  };
458
- if (typeof config.cameraZoom !== "number") config.cameraZoom = 1;
470
+ if (!Number.isFinite(config.cameraZoom)) config.cameraZoom = 1;
459
471
  if (!CAMERA_FITS.includes(config.cameraFit)) config.cameraFit = "cover";
460
472
  config.cameraMinVisibleWidth = typeof config.cameraMinVisibleWidth === "number" ? clamp(config.cameraMinVisibleWidth, 0, 1) : 0;
461
473
  }
462
474
  /** Backfill/repair a wave so the renderer can consume it (covers partial wave-model JSON). */
463
475
  function normalizeWave(s) {
464
476
  normalizeWaveColour(s);
465
- if (typeof s.gradientAngle !== "number") s.gradientAngle = 90;
466
- if (typeof s.gradientShift !== "number") s.gradientShift = .15;
477
+ if (!Number.isFinite(s.gradientAngle)) s.gradientAngle = 90;
478
+ if (!Number.isFinite(s.gradientShift)) s.gradientShift = .15;
467
479
  if (typeof s.usePaletteTexture !== "boolean") s.usePaletteTexture = true;
468
480
  if (typeof s.paletteSource !== "string") s.paletteSource = "hero";
469
481
  if (typeof s.paletteEdgeColor !== "string") s.paletteEdgeColor = "#8e9dff";
470
- if (typeof s.paletteEdgeAmount !== "number") s.paletteEdgeAmount = .3;
471
- if (typeof s.paletteDriftX !== "number") s.paletteDriftX = 0;
472
- if (typeof s.paletteDriftY !== "number") s.paletteDriftY = 0;
473
- if (typeof s.hueShift !== "number") s.hueShift = 0;
474
- if (typeof s.colorContrast !== "number") s.colorContrast = 1;
475
- if (typeof s.colorSaturation !== "number") s.colorSaturation = 1;
476
- if (typeof s.fiberCount !== "number") s.fiberCount = 600;
477
- if (typeof s.fiberStrength !== "number") s.fiberStrength = .2;
482
+ if (!Number.isFinite(s.paletteEdgeAmount)) s.paletteEdgeAmount = .3;
483
+ if (!Number.isFinite(s.paletteDriftX)) s.paletteDriftX = 0;
484
+ if (!Number.isFinite(s.paletteDriftY)) s.paletteDriftY = 0;
485
+ if (!Number.isFinite(s.hueShift)) s.hueShift = 0;
486
+ if (!Number.isFinite(s.colorContrast)) s.colorContrast = 1;
487
+ if (!Number.isFinite(s.colorSaturation)) s.colorSaturation = 1;
488
+ if (!Number.isFinite(s.fiberCount)) s.fiberCount = 600;
489
+ if (!Number.isFinite(s.fiberStrength)) s.fiberStrength = .2;
478
490
  if (!Array.isArray(s.noiseBands)) s.noiseBands = [];
479
- if (typeof s.texture !== "number") s.texture = 0;
480
- if (typeof s.creaseLight !== "number") s.creaseLight = .6;
481
- if (typeof s.creaseSharpness !== "number") s.creaseSharpness = .589;
482
- if (typeof s.creaseSoftness !== "number") s.creaseSoftness = 1;
483
- if (typeof s.sheen !== "number") s.sheen = 0;
484
- if (typeof s.roundness !== "number") s.roundness = 0;
485
- if (typeof s.iridescence !== "number") s.iridescence = 0;
486
- if (typeof s.edgeFade !== "number") s.edgeFade = .04;
487
- if (typeof s.edgeFeather !== "number") s.edgeFeather = .1;
488
- if (typeof s.depthTint !== "number") s.depthTint = 0;
491
+ normalizeNoiseBands(s);
492
+ if (!Number.isFinite(s.texture)) s.texture = 0;
493
+ if (!Number.isFinite(s.creaseLight)) s.creaseLight = .6;
494
+ if (!Number.isFinite(s.creaseSharpness)) s.creaseSharpness = .589;
495
+ if (!Number.isFinite(s.creaseSoftness)) s.creaseSoftness = 1;
496
+ if (!Number.isFinite(s.sheen)) s.sheen = 0;
497
+ if (!Number.isFinite(s.roundness)) s.roundness = 0;
498
+ if (!Number.isFinite(s.iridescence)) s.iridescence = 0;
499
+ if (!Number.isFinite(s.edgeFade)) s.edgeFade = .04;
500
+ if (!Number.isFinite(s.edgeFeather)) s.edgeFeather = .1;
501
+ if (!Number.isFinite(s.depthTint)) s.depthTint = 0;
489
502
  if (typeof s.depthTintColor !== "string") s.depthTintColor = "#0a2540";
490
503
  if (!s.displaceFrequency) s.displaceFrequency = {
491
504
  x: .003234,
492
505
  y: .00799
493
506
  };
494
- if (typeof s.displaceAmount !== "number") s.displaceAmount = 6.051;
495
- if (typeof s.detailFrequency !== "number") s.detailFrequency = .04;
496
- if (typeof s.detailAmount !== "number") s.detailAmount = 0;
507
+ if (!Number.isFinite(s.displaceAmount)) s.displaceAmount = 6.051;
508
+ if (!Number.isFinite(s.detailFrequency)) s.detailFrequency = .04;
509
+ if (!Number.isFinite(s.detailAmount)) s.detailAmount = 0;
497
510
  if (!s.twistFrequency) s.twistFrequency = {
498
511
  x: -.055,
499
512
  y: .077,
@@ -504,11 +517,18 @@ function normalizeWave(s) {
504
517
  y: 5.85,
505
518
  z: 6.33
506
519
  };
520
+ if (typeof s.twistMotion !== "boolean") s.twistMotion = false;
521
+ if (!Number.isFinite(s.helixTurns)) s.helixTurns = 0;
522
+ if (!Number.isFinite(s.helixRadius)) s.helixRadius = 0;
523
+ if (!Number.isFinite(s.helixRoll)) s.helixRoll = 0;
524
+ if (!Number.isFinite(s.helixPhase)) s.helixPhase = 0;
507
525
  if (typeof s.theme !== "string") s.theme = "solid";
508
- if (typeof s.lineAmount !== "number") s.lineAmount = 425;
509
- if (typeof s.lineThickness !== "number") s.lineThickness = 1;
510
- if (typeof s.lineDerivativePower !== "number") s.lineDerivativePower = .95;
511
- if (typeof s.maxWidth !== "number") s.maxWidth = 1232;
526
+ if (!Number.isFinite(s.lineAmount)) s.lineAmount = 425;
527
+ if (!Number.isFinite(s.lineThickness)) s.lineThickness = 1;
528
+ if (!Number.isFinite(s.lineDerivativePower)) s.lineDerivativePower = .95;
529
+ if (!Number.isFinite(s.rungAmount)) s.rungAmount = 0;
530
+ if (!Number.isFinite(s.rungThickness)) s.rungThickness = 1;
531
+ if (!Number.isFinite(s.maxWidth)) s.maxWidth = 1232;
512
532
  if (!s.position) s.position = {
513
533
  x: 0,
514
534
  y: 0,
@@ -525,44 +545,46 @@ function normalizeWave(s) {
525
545
  z: 7
526
546
  };
527
547
  if (typeof s.blendMode !== "string") s.blendMode = "squared";
528
- if (typeof s.speed !== "number") s.speed = .04;
529
- if (typeof s.opacity !== "number") s.opacity = 1;
530
- if (typeof s.seed !== "number") s.seed = 0;
548
+ if (!Number.isFinite(s.speed)) s.speed = .04;
549
+ if (!Number.isFinite(s.opacity)) s.opacity = 1;
550
+ if (!Number.isFinite(s.seed)) s.seed = 0;
531
551
  if (s.interaction) normalizeWaveInteraction(s);
532
552
  }
533
553
  /** Backfill scene-level defaults (background/camera/post/lights/quality/mirror). */
534
554
  function ensureSceneDefaults(config) {
535
555
  normalizeBackground(config);
536
556
  ensureCamera(config);
537
- if (typeof config.ambient !== "number") config.ambient = .45;
557
+ if (!Number.isFinite(config.ambient)) config.ambient = .45;
538
558
  if (!Array.isArray(config.lights)) config.lights = [];
539
- if (typeof config.quality !== "number") config.quality = 1;
540
- if (typeof config.dprMax !== "number") config.dprMax = 2;
541
- if (typeof config.grain !== "number") config.grain = 1.1;
542
- if (typeof config.blur !== "number") config.blur = .02;
543
- if (typeof config.blurSamples !== "number") config.blurSamples = 6;
544
- if (typeof config.bloomStrength !== "number") config.bloomStrength = 0;
545
- if (typeof config.bloomRadius !== "number") config.bloomRadius = .4;
546
- if (typeof config.bloomThreshold !== "number") config.bloomThreshold = .85;
547
- if (typeof config.dither !== "number") config.dither = 0;
548
- if (typeof config.ditherScale !== "number") config.ditherScale = 2;
549
- if (typeof config.ditherSteps !== "number") config.ditherSteps = 4;
550
- if (typeof config.innerLight !== "number") config.innerLight = 0;
551
- if (typeof config.innerLightDensity !== "number") config.innerLightDensity = .5;
552
- if (typeof config.innerLightDecay !== "number") config.innerLightDecay = .95;
553
- if (typeof config.innerLightX !== "number") config.innerLightX = .5;
554
- if (typeof config.innerLightY !== "number") config.innerLightY = .15;
555
- if (typeof config.halftone !== "number") config.halftone = 0;
556
- if (typeof config.halftoneCell !== "number") config.halftoneCell = 6;
557
- if (typeof config.halftoneAngle !== "number") config.halftoneAngle = .4;
558
- if (typeof config.heatmap !== "number") config.heatmap = 0;
559
- if (typeof config.paperTexture !== "number") config.paperTexture = 0;
560
- if (typeof config.paperTextureScale !== "number") config.paperTextureScale = 2;
561
- if (typeof config.halftoneCmyk !== "number") config.halftoneCmyk = 0;
562
- if (typeof config.halftoneCmykCell !== "number") config.halftoneCmykCell = 6;
559
+ normalizeLights(config);
560
+ if (!Number.isFinite(config.quality)) config.quality = 1;
561
+ if (!Number.isFinite(config.dprMax)) config.dprMax = 2;
562
+ if (!Number.isFinite(config.grain)) config.grain = 1.1;
563
+ if (!Number.isFinite(config.blur)) config.blur = .02;
564
+ if (!Number.isFinite(config.blurSamples)) config.blurSamples = 6;
565
+ if (!Number.isFinite(config.bloomStrength)) config.bloomStrength = 0;
566
+ if (!Number.isFinite(config.bloomRadius)) config.bloomRadius = .4;
567
+ if (!Number.isFinite(config.bloomThreshold)) config.bloomThreshold = .85;
568
+ if (!Number.isFinite(config.dither)) config.dither = 0;
569
+ if (!Number.isFinite(config.ditherScale)) config.ditherScale = 2;
570
+ if (!Number.isFinite(config.ditherSteps)) config.ditherSteps = 4;
571
+ if (!Number.isFinite(config.innerLight)) config.innerLight = 0;
572
+ if (!Number.isFinite(config.innerLightDensity)) config.innerLightDensity = .5;
573
+ if (!Number.isFinite(config.innerLightDecay)) config.innerLightDecay = .95;
574
+ if (!Number.isFinite(config.innerLightX)) config.innerLightX = .5;
575
+ if (!Number.isFinite(config.innerLightY)) config.innerLightY = .15;
576
+ if (!Number.isFinite(config.halftone)) config.halftone = 0;
577
+ if (!Number.isFinite(config.halftoneCell)) config.halftoneCell = 6;
578
+ if (!Number.isFinite(config.halftoneAngle)) config.halftoneAngle = .4;
579
+ if (!Number.isFinite(config.heatmap)) config.heatmap = 0;
580
+ if (!Number.isFinite(config.paperTexture)) config.paperTexture = 0;
581
+ if (!Number.isFinite(config.paperTextureScale)) config.paperTextureScale = 2;
582
+ if (!Number.isFinite(config.halftoneCmyk)) config.halftoneCmyk = 0;
583
+ if (!Number.isFinite(config.halftoneCmykCell)) config.halftoneCmykCell = 6;
563
584
  if (typeof config.showCameraRig !== "boolean") config.showCameraRig = false;
564
585
  if (typeof config.paused !== "boolean") config.paused = false;
565
- if (typeof config.loopSeconds !== "number") config.loopSeconds = 0;
586
+ if (!Number.isFinite(config.timeOffset)) config.timeOffset = 0;
587
+ if (!Number.isFinite(config.loopSeconds)) config.loopSeconds = 0;
566
588
  if (typeof config.mirrorH !== "boolean") config.mirrorH = false;
567
589
  if (typeof config.mirrorV !== "boolean") config.mirrorV = false;
568
590
  }
@@ -571,6 +593,45 @@ function clampNumber(v, min, max, dflt) {
571
593
  const n = Number(v);
572
594
  return Number.isFinite(n) ? clamp(n, min, max) : dflt;
573
595
  }
596
+ /** Coerce an untrusted field to a finite number, falling back to `dflt`. Deliberately does NOT
597
+ * clamp — it repairs broken values without reinterpreting out-of-range ones that a saved config
598
+ * may legitimately rely on. */
599
+ function num(v, dflt) {
600
+ const n = Number(v);
601
+ return Number.isFinite(n) ? n : dflt;
602
+ }
603
+ /**
604
+ * Repair the ELEMENTS of the scene's `lights` and a wave's `noiseBands`. Both arrays are
605
+ * type-checked where they're backfilled, but their entries never were — and the studio binds every
606
+ * field below directly, so one hand-authored `"lights": [{}]` was enough to make the whole control
607
+ * panel unbuildable. Entries that aren't objects at all are dropped.
608
+ */
609
+ function normalizeLights(config) {
610
+ config.lights = config.lights.filter((l) => typeof l === "object" && l !== null);
611
+ for (const l of config.lights) {
612
+ if (typeof l.position !== "object" || l.position === null) l.position = { ...DEFAULT_LIGHT_POSITION };
613
+ l.position.x = num(l.position.x, DEFAULT_LIGHT_POSITION.x);
614
+ l.position.y = num(l.position.y, DEFAULT_LIGHT_POSITION.y);
615
+ l.position.z = num(l.position.z, DEFAULT_LIGHT_POSITION.z);
616
+ if (typeof l.color !== "string") l.color = "#ffffff";
617
+ l.intensity = num(l.intensity, 1);
618
+ }
619
+ }
620
+ function normalizeNoiseBands(s) {
621
+ s.noiseBands = s.noiseBands.filter((b) => typeof b === "object" && b !== null);
622
+ const d = createNoiseBand();
623
+ for (const b of s.noiseBands) {
624
+ b.startX = num(b.startX, d.startX);
625
+ b.endX = num(b.endX, d.endX);
626
+ b.startY = num(b.startY, d.startY);
627
+ b.endY = num(b.endY, d.endY);
628
+ b.feather = num(b.feather, d.feather);
629
+ b.strength = num(b.strength, d.strength);
630
+ b.frequency = num(b.frequency, d.frequency);
631
+ b.colorAttenuation = num(b.colorAttenuation, d.colorAttenuation);
632
+ b.parabolaPower = num(b.parabolaPower, d.parabolaPower);
633
+ }
634
+ }
574
635
  /** True for a valid interaction source string: a built-in name or a non-empty `custom:<name>`. */
575
636
  function isInteractionSource(v) {
576
637
  return typeof v === "string" && (INTERACTION_SOURCE_NAMES.includes(v) || v.startsWith("custom:") && v.length > 7);