@wave3d/core 0.5.0 → 0.7.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 (39) hide show
  1. package/README.md +2 -6
  2. package/dist/config/model.d.ts +97 -9
  3. package/dist/config/model.js +165 -61
  4. package/dist/config/model.js.map +1 -1
  5. package/dist/core-loader.d.ts +0 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +2 -2
  8. package/dist/presets.d.ts +8 -3
  9. package/dist/presets.js +542 -1
  10. package/dist/presets.js.map +1 -1
  11. package/dist/renderer/WaveGeometry.d.ts +22 -4
  12. package/dist/renderer/WaveGeometry.js +22 -3
  13. package/dist/renderer/WaveGeometry.js.map +1 -1
  14. package/dist/renderer/WaveRenderer.d.ts +22 -2
  15. package/dist/renderer/WaveRenderer.js +118 -3
  16. package/dist/renderer/WaveRenderer.js.map +1 -1
  17. package/dist/renderer/heroPalette.d.ts +0 -1
  18. package/dist/renderer/interaction.d.ts +0 -2
  19. package/dist/renderer/interaction.js +9 -0
  20. package/dist/renderer/interaction.js.map +1 -1
  21. package/dist/renderer/palette.d.ts +1 -2
  22. package/dist/renderer/palette.js +7 -5
  23. package/dist/renderer/palette.js.map +1 -1
  24. package/dist/renderer/particleField.d.ts +50 -0
  25. package/dist/renderer/particleField.js +220 -0
  26. package/dist/renderer/particleField.js.map +1 -0
  27. package/dist/renderer/shaders.js +271 -67
  28. package/dist/renderer/shaders.js.map +1 -1
  29. package/dist/shell/createWave.d.ts +0 -1
  30. package/dist/standalone/wave3d.standalone.js +2868 -1950
  31. package/dist/standalone.d.ts +2 -3
  32. package/dist/standalone.js +2 -2
  33. package/dist/studio/StudioWaveRenderer.d.ts +0 -1
  34. package/dist/studio/randomize.d.ts +0 -1
  35. package/dist/studio/thumbnail.d.ts +2 -3
  36. package/dist/studio/thumbnail.js +16 -3
  37. package/dist/studio/thumbnail.js.map +1 -1
  38. package/package.json +9 -5
  39. package/skills/wave3d/SKILL.md +30 -12
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
 
@@ -53,11 +53,14 @@ declare function createLight(position?: Vec3, intensity?: number): LightConfig;
53
53
  * rig always shows the light — even before one has been explicitly added. */
54
54
  declare const DEFAULT_LIGHT_POSITION: Vec3;
55
55
  /**
56
- * A noise band: inside a rectangular uv region (startX..endX along the length,
57
- * startY..endY across the width, softened by `feather`), the fiber streaks are
58
- * overridden strength, frequency (density), colourAttenuation (how much the local
59
- * colour suppresses them), and the end-weighting parabolaPower. Lets the fibers vary
56
+ * A noise band: inside a rectangular uv region, softened by `feather`, the fiber streaks
57
+ * are overridden strength, frequency (density), colourAttenuation (how much the local
58
+ * colour suppresses them), and the edge-weighting parabolaPower. Lets the fibers vary
60
59
  * per region instead of uniform.
60
+ *
61
+ * Mind the axes, which are the reverse of what the names suggest: the bounds gate on uv,
62
+ * and uv.x is the folded WIDTH while uv.y is the LENGTH (see WaveGeometry's UV AXES note).
63
+ * So startX..endX span the SHORT axis and startY..endY run end to end.
61
64
  */
62
65
  interface NoiseBand {
63
66
  startX: number;
@@ -72,7 +75,8 @@ interface NoiseBand {
72
75
  }
73
76
  /** A default band: a strong, coarse streak region over the first half. */
74
77
  declare function createNoiseBand(): NoiseBand;
75
- /** One gradient stop: a colour at a normalized position (0–1) across the width. */
78
+ /** One gradient stop: a colour at a normalized position (0–1) along the gradient ramp, whose
79
+ * direction on the ribbon is set by `gradientType` / `gradientAngle`. */
76
80
  interface ColorStop {
77
81
  color: string;
78
82
  pos: number;
@@ -133,8 +137,9 @@ interface WaveConfig {
133
137
  /** Thin-film / holographic hue response that shifts with view angle (0 = off). */
134
138
  iridescence: number;
135
139
  edgeFade: number;
136
- /** Softness of the ribbon's long edges (smoothstep width across uv.y). 0.1 = the original
137
- * hardcoded value; smaller = razor-crisp graphic ribbons, larger = soft vapor. */
140
+ /** Softness of the ribbon's two ENDS it smoothsteps on uv.y, which is the length, not the
141
+ * long edges. 0.1 = the original hardcoded value; smaller = razor-crisp graphic ribbons,
142
+ * larger = soft vapor. */
138
143
  edgeFeather: number;
139
144
  /** Depth tint (solid theme): fade far fragments toward depthTintColor for atmospheric
140
145
  * separation in multi-wave stacks (0 = off). */
@@ -148,10 +153,44 @@ interface WaveConfig {
148
153
  twistFrequency: Vec3;
149
154
  twistPower: Vec3;
150
155
  twistMotion?: boolean;
156
+ /** Helix: sweep the ribbon around its OWN length axis, `helixTurns` full turns from end to end.
157
+ * The three twists above can't express this — their axes sit 45°/90° off the length, and
158
+ * `expStep` is a monotone falloff, so nothing they do ever repeats. This angle is periodic in the
159
+ * length coordinate instead, which is what makes a helix (and a DNA ladder) reachable.
160
+ * 0 turns still leaves the block inert unless radius or roll is non-zero. */
161
+ helixTurns?: number;
162
+ /** Carries the WHOLE ribbon around the axis at this radius (world units, pre-scale), keeping its
163
+ * orientation. A narrow ribbon then reads as a single strand — two waves half a turn apart in
164
+ * `helixPhase` are the two strands of a double helix. 0 = off. */
165
+ helixRadius?: number;
166
+ /** Rolls the ribbon's own cross-section about the axis as it advances, as a fraction of
167
+ * `helixTurns` (1 = rolls exactly in step, a rigid twisted ribbon). That swings the ribbon's two
168
+ * long edges onto opposite sides of the axis, so ONE wave becomes a ladder whose edges are both
169
+ * strands — pair it with `rungAmount` for the rungs between them. 0 = off. */
170
+ helixRoll?: number;
171
+ /** Phase offset in degrees — where along the turn the ribbon starts. The per-wave knob that puts
172
+ * a second wave on the opposite side of the same helix (180). */
173
+ helixPhase?: number;
174
+ /** Radial fan (optional): sweep the ribbon's length into a plume/peacock spread from the local
175
+ * origin. The three twists and the helix can't reach it — this maps the ribbon to polar so the
176
+ * combed fibers ({@link fiberCount}) read as the individual radial strands. Placement is the wave's
177
+ * `position` transform (there is no separate pivot). Runs AFTER displace/helix/twist, behind
178
+ * `#ifdef RADIAL`, so `radialAmount` 0 leaves the block uncompiled and the wave byte-identical. */
179
+ radialAmount?: number;
180
+ radialArc?: number;
181
+ radialSpread?: number;
182
+ radialRadius?: number;
183
+ radialCenter?: number;
151
184
  theme?: "solid" | "wireframe";
152
185
  lineAmount?: number;
153
186
  lineThickness?: number;
154
187
  lineDerivativePower?: number;
188
+ /** Wireframe RUNGS: a second line family carved at constant uv.y, so these run ACROSS the ribbon
189
+ * where `lineAmount`'s run along it — the two cross into a ladder. Frequency, like `lineAmount`
190
+ * (rungs ≈ amount / π). 0 = off, and the cross-wise path isn't compiled. */
191
+ rungAmount?: number;
192
+ /** Rung line width, in pixels (screen-space, so it holds at any zoom). */
193
+ rungThickness?: number;
155
194
  maxWidth?: number;
156
195
  position: Vec3;
157
196
  rotation: Vec3;
@@ -166,6 +205,9 @@ interface WaveConfig {
166
205
  /** Optional per-wave interactivity: how THIS wave reacts to the shared pointer + inputs (hover
167
206
  * field, click ripples, param bindings). ABSENT = this wave is inert / byte-identical. */
168
207
  interaction?: WaveInteractionConfig;
208
+ /** Optional per-wave particle / dust field emitted off THIS wave's deformed surface / edge.
209
+ * ABSENT ⇒ off (no THREE.Points for this wave, byte-identical). See {@link ParticlesConfig}. */
210
+ particles?: ParticlesConfig;
169
211
  }
170
212
  /**
171
213
  * An interaction INPUT: a normalized signal that can smoothly drive config params through an
@@ -174,7 +216,7 @@ interface WaveConfig {
174
216
  type InteractionSource = "scroll" | "hover" | "pointerX" | "pointerY" | "pointerSpeed" | "press" | "scrollVelocity" | "appear" | `custom:${string}`;
175
217
  /** Per-WAVE params a binding may drive. Single source of truth for WAVE_APPLIERS in
176
218
  * 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"];
219
+ 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
220
  /** A per-wave param a {@link WaveInteractionBinding} can drive. */
179
221
  type WaveInteractionTarget = (typeof WAVE_TARGET_NAMES)[number];
180
222
  /** SCENE params a binding may drive (post / camera / time — shared, not per wave). Single source of
@@ -259,6 +301,46 @@ interface SceneInteractionConfig {
259
301
  /** Input→param bindings driving SCENE params (timeOffset, cameraZoom, blur, grain). */
260
302
  bindings?: SceneInteractionBinding[];
261
303
  }
304
+ /**
305
+ * A WAVE's field of additive GPU sprites (dust / sparkle). Lives on {@link WaveConfig} — particles
306
+ * belong to a wave, not the scene. ABSENT ⇒ off: no THREE.Points node is created for that wave and its
307
+ * render is byte-identical; present ⇒ {@link normalizeParticles} clamps it. Every particle's motion is a
308
+ * pure function of `uTime` + a per-particle seed baked from `seed`, so it is deterministic (timeOffset
309
+ * scrub / loopSeconds / paused all hold). Particles spawn on the OWNING wave's DEFORMED surface / edge
310
+ * (via the shared waveShape) and drift outward from the wave centre.
311
+ */
312
+ /** How each particle sprite is drawn (a per-field render style, not per-particle). */
313
+ type ParticleShape = "glitter" | "soft" | "ring" | "star" | "streak";
314
+ declare const PARTICLE_SHAPES: readonly ParticleShape[];
315
+ interface ParticlesConfig {
316
+ count: number;
317
+ size: number;
318
+ seed: number;
319
+ sizeJitter?: number;
320
+ color?: string;
321
+ /** Second sprite colour — particles interpolate between `color` and this by their seed (two-tone dust). */
322
+ color2?: string;
323
+ life?: number;
324
+ /** Motion-speed multiplier for the dust: how fast particles cycle + drift (1 = default, 0 = frozen).
325
+ * Independent of the wave's own `speed` (which animates the surface the dust rides). Under a seamless
326
+ * `loopSeconds` it snaps to a whole number of cycles so the loop stays seamless. */
327
+ speed?: number;
328
+ twinkle?: number;
329
+ /** Where on the wave particles spawn: 0 = across the whole SURFACE, 1 = the outer rim / EDGE only. */
330
+ edgeBias?: number;
331
+ /** How far particles drift outward from the wave centre as they age (world units). */
332
+ drift?: number;
333
+ /** −1..1 skew of the spawn along the edge width toward one flank (0 = even, −1 → one side, +1 → other). */
334
+ bias?: number;
335
+ /** Screen-vertical buoyancy over a lifetime, world units: + rises (embers), − falls (snow / ash). */
336
+ rise?: number;
337
+ /** Orbit around the wave centre in the screen plane, turns per lifetime (swirls the dust). */
338
+ swirl?: number;
339
+ /** Curl-noise turbulence: particles meander (fireflies / motes) instead of moving in straight lines. */
340
+ wander?: number;
341
+ /** Sprite render style. Default "glitter" (the soft round additive disc). */
342
+ shape?: ParticleShape;
343
+ }
262
344
  /**
263
345
  * Scene-level settings shared by every wave: output/background/camera/lights, the post-fx
264
346
  * pass (grain/blur), playback, quality, and the whole-composition mirror. Everything that
@@ -395,10 +477,16 @@ declare function normalizeWaveInteraction(wave: WaveConfig): void;
395
477
  /** Present-only normalizer for the SCENE interaction block: clamp the shared pointer inputs and drop
396
478
  * invalid scene bindings. NEVER call when the block is absent. */
397
479
  declare function normalizeSceneInteraction(config: StudioConfig): void;
480
+ /**
481
+ * Present-only normalizer for the scene PARTICLES block: clamp the numerics that are present and
482
+ * repair the required fields, leaving absent optionals absent (so the block stays lean). NEVER call
483
+ * when the block is absent — absence is off and byte-identical (ensureStudioConfig gates on presence).
484
+ */
485
+ declare function normalizeParticles(wave: WaveConfig): void;
398
486
  /** Normalize an ingested config to the wave model: backfill the scene + every wave, and drop in
399
487
  * a default wave if none are present. Idempotent, so it is safe on the renderer's own config as
400
488
  * well as freshly loaded save-states / share links. */
401
489
  declare function ensureStudioConfig(input: StudioConfig): StudioConfig;
402
490
  //#endregion
403
- export { BackgroundImageFit, BackgroundMode, BasicGradientType, BlendMode, CAMERA_FITS, CameraFit, ColorStop, DEFAULT_LIGHT_POSITION, GradientType, InteractionSource, LightConfig, MAX_COLORS, MAX_LIGHTS, MAX_MESH_POINTS, MAX_NOISE_BANDS, MAX_WAVES, MeshGradientPoint, NoiseBand, PaletteSource, SceneConfig, SceneInteractionBinding, SceneInteractionConfig, SceneInteractionTarget, StudioConfig, Vec2, Vec3, WaveConfig, WaveHoverConfig, WaveInteractionBinding, WaveInteractionConfig, WaveInteractionTarget, WavePressConfig, createDefaultConfig, createDefaultMeshPoints, createLight, createNoiseBand, ensureCamera, ensureSceneDefaults, ensureStudioConfig, makeStops, makeWave, makeWaveSpread, normalizeBackground, normalizeSceneInteraction, normalizeWave, normalizeWaveInteraction, resizeWaves };
491
+ export { BackgroundImageFit, BackgroundMode, BasicGradientType, BlendMode, CAMERA_FITS, CameraFit, ColorStop, DEFAULT_LIGHT_POSITION, GradientType, InteractionSource, LightConfig, MAX_COLORS, MAX_LIGHTS, MAX_MESH_POINTS, MAX_NOISE_BANDS, MAX_WAVES, MeshGradientPoint, NoiseBand, PARTICLE_SHAPES, PaletteSource, ParticleShape, ParticlesConfig, SceneConfig, SceneInteractionBinding, SceneInteractionConfig, SceneInteractionTarget, StudioConfig, Vec2, Vec3, WaveConfig, WaveHoverConfig, WaveInteractionBinding, WaveInteractionConfig, WaveInteractionTarget, WavePressConfig, createDefaultConfig, createDefaultMeshPoints, createLight, createNoiseBand, ensureCamera, ensureSceneDefaults, ensureStudioConfig, makeStops, makeWave, makeWaveSpread, normalizeBackground, normalizeParticles, normalizeSceneInteraction, normalizeWave, normalizeWaveInteraction, resizeWaves };
404
492
  //# sourceMappingURL=model.d.ts.map
@@ -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",
@@ -139,6 +142,13 @@ const SCENE_TARGET_NAMES = [
139
142
  "blur",
140
143
  "grain"
141
144
  ];
145
+ const PARTICLE_SHAPES = [
146
+ "glitter",
147
+ "soft",
148
+ "ring",
149
+ "star",
150
+ "streak"
151
+ ];
142
152
  /** Spread a base wave into `count` overlapping waves — each with a slightly varied hue, width,
143
153
  * speed, phase, vertical offset and roll so a stack reads as one composition. `count === 1`
144
154
  * returns the base unchanged. Used to author multi-wave presets. */
@@ -263,10 +273,21 @@ function defaultWave() {
263
273
  z: 6.33
264
274
  },
265
275
  twistMotion: false,
276
+ helixTurns: 0,
277
+ helixRadius: 0,
278
+ helixRoll: 0,
279
+ helixPhase: 0,
280
+ radialAmount: 0,
281
+ radialArc: 160,
282
+ radialSpread: 1,
283
+ radialRadius: 40,
284
+ radialCenter: 0,
266
285
  theme: "solid",
267
286
  lineAmount: 425,
268
287
  lineThickness: 1,
269
288
  lineDerivativePower: .95,
289
+ rungAmount: 0,
290
+ rungThickness: 1,
270
291
  maxWidth: 1232,
271
292
  position: {
272
293
  x: -24.3,
@@ -377,7 +398,8 @@ function createDefaultConfig() {
377
398
  * ColorStop[]; mesh points + texture transform are clamped). */
378
399
  function normalizeWaveColour(config) {
379
400
  const p = config.palette;
380
- if (p.length > 0 && typeof p[0] === "string") config.palette = makeStops(p);
401
+ if (!Array.isArray(p) || p.length === 0) config.palette = defaultWave().palette;
402
+ else if (typeof p[0] === "string") config.palette = makeStops(p);
381
403
  if (config.gradientType !== "radial" && config.gradientType !== "conic" && config.gradientType !== "mesh" && config.gradientType !== "linear") config.gradientType = "linear";
382
404
  const rawMeshPoints = config.meshGradientPoints;
383
405
  if (!Array.isArray(rawMeshPoints) || rawMeshPoints.length < 2) config.meshGradientPoints = createDefaultMeshPoints();
@@ -414,6 +436,8 @@ function normalizeWaveColour(config) {
414
436
  }
415
437
  /** Backfill background styling for states saved before gradient/image backgrounds existed. */
416
438
  function normalizeBackground(config) {
439
+ if (typeof config.background !== "string") config.background = "#ffffff";
440
+ if (typeof config.transparentBackground !== "boolean") config.transparentBackground = true;
417
441
  if (config.backgroundMode !== "gradient" && config.backgroundMode !== "image" && config.backgroundMode !== "color") config.backgroundMode = "color";
418
442
  const palette = config.backgroundPalette;
419
443
  if (!palette || palette.length < 2) config.backgroundPalette = makeStops([
@@ -428,11 +452,11 @@ function normalizeBackground(config) {
428
452
  if (!Array.isArray(bgMesh) || bgMesh.length < 2) config.backgroundMeshPoints = createDefaultMeshPoints();
429
453
  if (!Number.isFinite(config.backgroundMeshSoftness)) config.backgroundMeshSoftness = .62;
430
454
  config.backgroundMeshSoftness = clamp01(config.backgroundMeshSoftness);
431
- if (typeof config.backgroundGradientAngle !== "number") config.backgroundGradientAngle = 135;
455
+ if (!Number.isFinite(config.backgroundGradientAngle)) config.backgroundGradientAngle = 135;
432
456
  if (typeof config.backgroundGradientSource !== "string") config.backgroundGradientSource = "stops";
433
457
  if (typeof config.backgroundImageSource !== "string") config.backgroundImageSource = "vaporwave";
434
458
  if (config.backgroundImageFit !== "contain" && config.backgroundImageFit !== "stretch" && config.backgroundImageFit !== "cover") config.backgroundImageFit = "cover";
435
- if (typeof config.backgroundImageZoom !== "number") config.backgroundImageZoom = 1;
459
+ if (!Number.isFinite(config.backgroundImageZoom)) config.backgroundImageZoom = 1;
436
460
  config.backgroundImageZoom = clamp(config.backgroundImageZoom, .1, 8);
437
461
  if (!config.backgroundImagePosition) config.backgroundImagePosition = {
438
462
  x: 0,
@@ -455,45 +479,46 @@ function ensureCamera(config) {
455
479
  y: 0,
456
480
  z: 0
457
481
  };
458
- if (typeof config.cameraZoom !== "number") config.cameraZoom = 1;
482
+ if (!Number.isFinite(config.cameraZoom)) config.cameraZoom = 1;
459
483
  if (!CAMERA_FITS.includes(config.cameraFit)) config.cameraFit = "cover";
460
484
  config.cameraMinVisibleWidth = typeof config.cameraMinVisibleWidth === "number" ? clamp(config.cameraMinVisibleWidth, 0, 1) : 0;
461
485
  }
462
486
  /** Backfill/repair a wave so the renderer can consume it (covers partial wave-model JSON). */
463
487
  function normalizeWave(s) {
464
488
  normalizeWaveColour(s);
465
- if (typeof s.gradientAngle !== "number") s.gradientAngle = 90;
466
- if (typeof s.gradientShift !== "number") s.gradientShift = .15;
489
+ if (!Number.isFinite(s.gradientAngle)) s.gradientAngle = 90;
490
+ if (!Number.isFinite(s.gradientShift)) s.gradientShift = .15;
467
491
  if (typeof s.usePaletteTexture !== "boolean") s.usePaletteTexture = true;
468
492
  if (typeof s.paletteSource !== "string") s.paletteSource = "hero";
469
493
  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;
494
+ if (!Number.isFinite(s.paletteEdgeAmount)) s.paletteEdgeAmount = .3;
495
+ if (!Number.isFinite(s.paletteDriftX)) s.paletteDriftX = 0;
496
+ if (!Number.isFinite(s.paletteDriftY)) s.paletteDriftY = 0;
497
+ if (!Number.isFinite(s.hueShift)) s.hueShift = 0;
498
+ if (!Number.isFinite(s.colorContrast)) s.colorContrast = 1;
499
+ if (!Number.isFinite(s.colorSaturation)) s.colorSaturation = 1;
500
+ if (!Number.isFinite(s.fiberCount)) s.fiberCount = 600;
501
+ if (!Number.isFinite(s.fiberStrength)) s.fiberStrength = .2;
478
502
  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;
503
+ normalizeNoiseBands(s);
504
+ if (!Number.isFinite(s.texture)) s.texture = 0;
505
+ if (!Number.isFinite(s.creaseLight)) s.creaseLight = .6;
506
+ if (!Number.isFinite(s.creaseSharpness)) s.creaseSharpness = .589;
507
+ if (!Number.isFinite(s.creaseSoftness)) s.creaseSoftness = 1;
508
+ if (!Number.isFinite(s.sheen)) s.sheen = 0;
509
+ if (!Number.isFinite(s.roundness)) s.roundness = 0;
510
+ if (!Number.isFinite(s.iridescence)) s.iridescence = 0;
511
+ if (!Number.isFinite(s.edgeFade)) s.edgeFade = .04;
512
+ if (!Number.isFinite(s.edgeFeather)) s.edgeFeather = .1;
513
+ if (!Number.isFinite(s.depthTint)) s.depthTint = 0;
489
514
  if (typeof s.depthTintColor !== "string") s.depthTintColor = "#0a2540";
490
515
  if (!s.displaceFrequency) s.displaceFrequency = {
491
516
  x: .003234,
492
517
  y: .00799
493
518
  };
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;
519
+ if (!Number.isFinite(s.displaceAmount)) s.displaceAmount = 6.051;
520
+ if (!Number.isFinite(s.detailFrequency)) s.detailFrequency = .04;
521
+ if (!Number.isFinite(s.detailAmount)) s.detailAmount = 0;
497
522
  if (!s.twistFrequency) s.twistFrequency = {
498
523
  x: -.055,
499
524
  y: .077,
@@ -504,11 +529,23 @@ function normalizeWave(s) {
504
529
  y: 5.85,
505
530
  z: 6.33
506
531
  };
532
+ if (typeof s.twistMotion !== "boolean") s.twistMotion = false;
533
+ if (!Number.isFinite(s.helixTurns)) s.helixTurns = 0;
534
+ if (!Number.isFinite(s.helixRadius)) s.helixRadius = 0;
535
+ if (!Number.isFinite(s.helixRoll)) s.helixRoll = 0;
536
+ if (!Number.isFinite(s.helixPhase)) s.helixPhase = 0;
537
+ if (!Number.isFinite(s.radialAmount)) s.radialAmount = 0;
538
+ if (!Number.isFinite(s.radialArc)) s.radialArc = 160;
539
+ if (!Number.isFinite(s.radialSpread)) s.radialSpread = 1;
540
+ if (!Number.isFinite(s.radialRadius)) s.radialRadius = 40;
541
+ if (!Number.isFinite(s.radialCenter)) s.radialCenter = 0;
507
542
  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;
543
+ if (!Number.isFinite(s.lineAmount)) s.lineAmount = 425;
544
+ if (!Number.isFinite(s.lineThickness)) s.lineThickness = 1;
545
+ if (!Number.isFinite(s.lineDerivativePower)) s.lineDerivativePower = .95;
546
+ if (!Number.isFinite(s.rungAmount)) s.rungAmount = 0;
547
+ if (!Number.isFinite(s.rungThickness)) s.rungThickness = 1;
548
+ if (!Number.isFinite(s.maxWidth)) s.maxWidth = 1232;
512
549
  if (!s.position) s.position = {
513
550
  x: 0,
514
551
  y: 0,
@@ -525,44 +562,47 @@ function normalizeWave(s) {
525
562
  z: 7
526
563
  };
527
564
  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;
565
+ if (!Number.isFinite(s.speed)) s.speed = .04;
566
+ if (!Number.isFinite(s.opacity)) s.opacity = 1;
567
+ if (!Number.isFinite(s.seed)) s.seed = 0;
531
568
  if (s.interaction) normalizeWaveInteraction(s);
569
+ if (s.particles) normalizeParticles(s);
532
570
  }
533
571
  /** Backfill scene-level defaults (background/camera/post/lights/quality/mirror). */
534
572
  function ensureSceneDefaults(config) {
535
573
  normalizeBackground(config);
536
574
  ensureCamera(config);
537
- if (typeof config.ambient !== "number") config.ambient = .45;
575
+ if (!Number.isFinite(config.ambient)) config.ambient = .45;
538
576
  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;
577
+ normalizeLights(config);
578
+ if (!Number.isFinite(config.quality)) config.quality = 1;
579
+ if (!Number.isFinite(config.dprMax)) config.dprMax = 2;
580
+ if (!Number.isFinite(config.grain)) config.grain = 1.1;
581
+ if (!Number.isFinite(config.blur)) config.blur = .02;
582
+ if (!Number.isFinite(config.blurSamples)) config.blurSamples = 6;
583
+ if (!Number.isFinite(config.bloomStrength)) config.bloomStrength = 0;
584
+ if (!Number.isFinite(config.bloomRadius)) config.bloomRadius = .4;
585
+ if (!Number.isFinite(config.bloomThreshold)) config.bloomThreshold = .85;
586
+ if (!Number.isFinite(config.dither)) config.dither = 0;
587
+ if (!Number.isFinite(config.ditherScale)) config.ditherScale = 2;
588
+ if (!Number.isFinite(config.ditherSteps)) config.ditherSteps = 4;
589
+ if (!Number.isFinite(config.innerLight)) config.innerLight = 0;
590
+ if (!Number.isFinite(config.innerLightDensity)) config.innerLightDensity = .5;
591
+ if (!Number.isFinite(config.innerLightDecay)) config.innerLightDecay = .95;
592
+ if (!Number.isFinite(config.innerLightX)) config.innerLightX = .5;
593
+ if (!Number.isFinite(config.innerLightY)) config.innerLightY = .15;
594
+ if (!Number.isFinite(config.halftone)) config.halftone = 0;
595
+ if (!Number.isFinite(config.halftoneCell)) config.halftoneCell = 6;
596
+ if (!Number.isFinite(config.halftoneAngle)) config.halftoneAngle = .4;
597
+ if (!Number.isFinite(config.heatmap)) config.heatmap = 0;
598
+ if (!Number.isFinite(config.paperTexture)) config.paperTexture = 0;
599
+ if (!Number.isFinite(config.paperTextureScale)) config.paperTextureScale = 2;
600
+ if (!Number.isFinite(config.halftoneCmyk)) config.halftoneCmyk = 0;
601
+ if (!Number.isFinite(config.halftoneCmykCell)) config.halftoneCmykCell = 6;
563
602
  if (typeof config.showCameraRig !== "boolean") config.showCameraRig = false;
564
603
  if (typeof config.paused !== "boolean") config.paused = false;
565
- if (typeof config.loopSeconds !== "number") config.loopSeconds = 0;
604
+ if (!Number.isFinite(config.timeOffset)) config.timeOffset = 0;
605
+ if (!Number.isFinite(config.loopSeconds)) config.loopSeconds = 0;
566
606
  if (typeof config.mirrorH !== "boolean") config.mirrorH = false;
567
607
  if (typeof config.mirrorV !== "boolean") config.mirrorV = false;
568
608
  }
@@ -571,6 +611,45 @@ function clampNumber(v, min, max, dflt) {
571
611
  const n = Number(v);
572
612
  return Number.isFinite(n) ? clamp(n, min, max) : dflt;
573
613
  }
614
+ /** Coerce an untrusted field to a finite number, falling back to `dflt`. Deliberately does NOT
615
+ * clamp — it repairs broken values without reinterpreting out-of-range ones that a saved config
616
+ * may legitimately rely on. */
617
+ function num(v, dflt) {
618
+ const n = Number(v);
619
+ return Number.isFinite(n) ? n : dflt;
620
+ }
621
+ /**
622
+ * Repair the ELEMENTS of the scene's `lights` and a wave's `noiseBands`. Both arrays are
623
+ * type-checked where they're backfilled, but their entries never were — and the studio binds every
624
+ * field below directly, so one hand-authored `"lights": [{}]` was enough to make the whole control
625
+ * panel unbuildable. Entries that aren't objects at all are dropped.
626
+ */
627
+ function normalizeLights(config) {
628
+ config.lights = config.lights.filter((l) => typeof l === "object" && l !== null);
629
+ for (const l of config.lights) {
630
+ if (typeof l.position !== "object" || l.position === null) l.position = { ...DEFAULT_LIGHT_POSITION };
631
+ l.position.x = num(l.position.x, DEFAULT_LIGHT_POSITION.x);
632
+ l.position.y = num(l.position.y, DEFAULT_LIGHT_POSITION.y);
633
+ l.position.z = num(l.position.z, DEFAULT_LIGHT_POSITION.z);
634
+ if (typeof l.color !== "string") l.color = "#ffffff";
635
+ l.intensity = num(l.intensity, 1);
636
+ }
637
+ }
638
+ function normalizeNoiseBands(s) {
639
+ s.noiseBands = s.noiseBands.filter((b) => typeof b === "object" && b !== null);
640
+ const d = createNoiseBand();
641
+ for (const b of s.noiseBands) {
642
+ b.startX = num(b.startX, d.startX);
643
+ b.endX = num(b.endX, d.endX);
644
+ b.startY = num(b.startY, d.startY);
645
+ b.endY = num(b.endY, d.endY);
646
+ b.feather = num(b.feather, d.feather);
647
+ b.strength = num(b.strength, d.strength);
648
+ b.frequency = num(b.frequency, d.frequency);
649
+ b.colorAttenuation = num(b.colorAttenuation, d.colorAttenuation);
650
+ b.parabolaPower = num(b.parabolaPower, d.parabolaPower);
651
+ }
652
+ }
574
653
  /** True for a valid interaction source string: a built-in name or a non-empty `custom:<name>`. */
575
654
  function isInteractionSource(v) {
576
655
  return typeof v === "string" && (INTERACTION_SOURCE_NAMES.includes(v) || v.startsWith("custom:") && v.length > 7);
@@ -632,6 +711,31 @@ function normalizeSceneInteraction(config) {
632
711
  if (it.ribbonFlow !== void 0) it.ribbonFlow = clampNumber(it.ribbonFlow, 0, 1, .8);
633
712
  if (it.bindings !== void 0) it.bindings = cleanBindings(it.bindings, SCENE_TARGET_NAMES);
634
713
  }
714
+ /**
715
+ * Present-only normalizer for the scene PARTICLES block: clamp the numerics that are present and
716
+ * repair the required fields, leaving absent optionals absent (so the block stays lean). NEVER call
717
+ * when the block is absent — absence is off and byte-identical (ensureStudioConfig gates on presence).
718
+ */
719
+ function normalizeParticles(wave) {
720
+ const p = wave.particles;
721
+ if (!p) return;
722
+ p.count = clampNumber(p.count, 0, 4e4, 0);
723
+ p.size = clampNumber(p.size, 0, 200, 2);
724
+ p.seed = num(p.seed, 0);
725
+ if (p.sizeJitter !== void 0) p.sizeJitter = clampNumber(p.sizeJitter, 0, 1, 0);
726
+ if (p.color !== void 0 && typeof p.color !== "string") p.color = "#ffcf8a";
727
+ if (p.life !== void 0) p.life = clampNumber(p.life, .1, 60, 6);
728
+ if (p.speed !== void 0) p.speed = clampNumber(p.speed, 0, 8, 1);
729
+ if (p.twinkle !== void 0) p.twinkle = clampNumber(p.twinkle, 0, 1, 0);
730
+ if (p.color2 !== void 0 && typeof p.color2 !== "string") p.color2 = "#ffcf8a";
731
+ if (p.edgeBias !== void 0) p.edgeBias = clampNumber(p.edgeBias, 0, 1, 1);
732
+ if (p.drift !== void 0) p.drift = num(p.drift, 0);
733
+ if (p.bias !== void 0) p.bias = clampNumber(p.bias, -1, 1, 0);
734
+ if (p.rise !== void 0) p.rise = num(p.rise, 0);
735
+ if (p.swirl !== void 0) p.swirl = num(p.swirl, 0);
736
+ if (p.wander !== void 0) p.wander = num(p.wander, 0);
737
+ if (p.shape !== void 0 && !PARTICLE_SHAPES.includes(p.shape)) p.shape = "glitter";
738
+ }
635
739
  /** Normalize an ingested config to the wave model: backfill the scene + every wave, and drop in
636
740
  * a default wave if none are present. Idempotent, so it is safe on the renderer's own config as
637
741
  * well as freshly loaded save-states / share links. */
@@ -645,6 +749,6 @@ function ensureStudioConfig(input) {
645
749
  return config;
646
750
  }
647
751
  //#endregion
648
- export { CAMERA_FITS, DEFAULT_LIGHT_POSITION, MAX_COLORS, MAX_LIGHTS, MAX_MESH_POINTS, MAX_NOISE_BANDS, MAX_WAVES, createDefaultConfig, createDefaultMeshPoints, createLight, createNoiseBand, ensureCamera, ensureSceneDefaults, ensureStudioConfig, makeStops, makeWave, makeWaveSpread, normalizeBackground, normalizeSceneInteraction, normalizeWave, normalizeWaveInteraction, resizeWaves };
752
+ export { CAMERA_FITS, DEFAULT_LIGHT_POSITION, MAX_COLORS, MAX_LIGHTS, MAX_MESH_POINTS, MAX_NOISE_BANDS, MAX_WAVES, PARTICLE_SHAPES, createDefaultConfig, createDefaultMeshPoints, createLight, createNoiseBand, ensureCamera, ensureSceneDefaults, ensureStudioConfig, makeStops, makeWave, makeWaveSpread, normalizeBackground, normalizeParticles, normalizeSceneInteraction, normalizeWave, normalizeWaveInteraction, resizeWaves };
649
753
 
650
754
  //# sourceMappingURL=model.js.map