@wave3d/core 0.4.1 → 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.
Files changed (37) hide show
  1. package/README.md +2 -6
  2. package/dist/config/model.d.ts +53 -2
  3. package/dist/config/model.js +133 -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 +0 -1
  9. package/dist/presets.js +105 -0
  10. package/dist/presets.js.map +1 -1
  11. package/dist/renderer/WaveGeometry.d.ts +0 -1
  12. package/dist/renderer/WaveGeometry.js.map +1 -1
  13. package/dist/renderer/WaveRenderer.d.ts +62 -14
  14. package/dist/renderer/WaveRenderer.js +126 -17
  15. package/dist/renderer/WaveRenderer.js.map +1 -1
  16. package/dist/renderer/heroPalette.d.ts +0 -1
  17. package/dist/renderer/index.d.ts +2 -2
  18. package/dist/renderer/index.js +2 -2
  19. package/dist/renderer/interaction.d.ts +0 -2
  20. package/dist/renderer/interaction.js +9 -0
  21. package/dist/renderer/interaction.js.map +1 -1
  22. package/dist/renderer/palette.d.ts +0 -1
  23. package/dist/renderer/shaders.js +46 -0
  24. package/dist/renderer/shaders.js.map +1 -1
  25. package/dist/shell/createWave.d.ts +0 -1
  26. package/dist/standalone/wave3d.standalone.js +501 -294
  27. package/dist/standalone.d.ts +2 -3
  28. package/dist/standalone.js +2 -2
  29. package/dist/studio/StudioWaveRenderer.d.ts +4 -1
  30. package/dist/studio/StudioWaveRenderer.js +10 -3
  31. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  32. package/dist/studio/randomize.d.ts +0 -1
  33. package/dist/studio/thumbnail.d.ts +0 -1
  34. package/dist/studio/thumbnail.js +5 -1
  35. package/dist/studio/thumbnail.js.map +1 -1
  36. package/package.json +5 -3
  37. 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
 
@@ -26,6 +26,17 @@ type BasicGradientType = "linear" | "radial" | "conic";
26
26
  type GradientType = BasicGradientType | "mesh";
27
27
  type BackgroundMode = "color" | "gradient" | "image";
28
28
  type BackgroundImageFit = "cover" | "contain" | "stretch";
29
+ /** How the authored reference frame (FRAME_W × FRAME_H, a 16:9 rectangle centred on cameraTarget)
30
+ * is mapped onto a canvas of a different aspect:
31
+ * - `cover` — fill both axes, crop the overflow. The default and the hero look.
32
+ * - `contain` — fit the whole frame, revealing world beyond it on the long axis.
33
+ * - `width` — always bind on width, so the horizontal composition is identical at every aspect.
34
+ * Above 16:9 this is exactly `cover`; below it, it reveals vertically instead of cropping.
35
+ * - `height` — always bind on height (the mirror of `width`).
36
+ * Pair with `cameraMinVisibleWidth` to land between `cover` and `width`. */
37
+ type CameraFit = "cover" | "contain" | "width" | "height";
38
+ /** Runtime whitelist for {@link CameraFit} (validating imported/serialized configs). */
39
+ declare const CAMERA_FITS: readonly CameraFit[];
29
40
  /** What fills the 2D palette texture: the baked hero LUT, our editable stops, or
30
41
  * a named built-in map (see PALETTE_MAPS). Any string is allowed for forward-compat. */
31
42
  type PaletteSource = "hero" | "stops" | (string & {});
@@ -137,10 +148,34 @@ interface WaveConfig {
137
148
  twistFrequency: Vec3;
138
149
  twistPower: Vec3;
139
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;
140
169
  theme?: "solid" | "wireframe";
141
170
  lineAmount?: number;
142
171
  lineThickness?: number;
143
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;
144
179
  maxWidth?: number;
145
180
  position: Vec3;
146
181
  rotation: Vec3;
@@ -163,7 +198,7 @@ interface WaveConfig {
163
198
  type InteractionSource = "scroll" | "hover" | "pointerX" | "pointerY" | "pointerSpeed" | "press" | "scrollVelocity" | "appear" | `custom:${string}`;
164
199
  /** Per-WAVE params a binding may drive. Single source of truth for WAVE_APPLIERS in
165
200
  * renderer/interaction.ts (checked via `satisfies`) and validated by normalizeWaveInteraction. */
166
- 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"];
167
202
  /** A per-wave param a {@link WaveInteractionBinding} can drive. */
168
203
  type WaveInteractionTarget = (typeof WAVE_TARGET_NAMES)[number];
169
204
  /** SCENE params a binding may drive (post / camera / time — shared, not per wave). Single source of
@@ -286,6 +321,22 @@ interface SceneConfig {
286
321
  cameraZoom: number;
287
322
  cameraPosition: Vec3;
288
323
  cameraTarget: Vec3;
324
+ /** How the authored reference frame maps onto the canvas when their aspects differ.
325
+ * Default `"cover"`. See {@link CameraFit}. */
326
+ cameraFit?: CameraFit;
327
+ /** Floor on how much of the authored frame's WIDTH stays on screen, as a fraction (0..1).
328
+ * A ceiling on zoom applied AFTER {@link cameraFit}, so the two compose instead of fighting:
329
+ * it only ever zooms out, and is inert for fits that already do (`contain`, `width`).
330
+ *
331
+ * This is the narrow-screen crop control. `cameraFit: "cover"` binds on height once the canvas
332
+ * is narrower than the 16:9 reference, so a portrait phone (390×844 @ dpr 2) zooms 2.25× and
333
+ * shows only ~26% of the authored width. `0.6` holds 60% of it on screen; `1` is equivalent to
334
+ * `"width"`. Default 0 (off) — existing configs frame exactly as before.
335
+ *
336
+ * It clamps the BASE zoom, before the cameraZoom multiplier, which makes the fraction read
337
+ * against your own composition rather than the raw constant: `1` shows exactly the horizontal
338
+ * span you see at 16:9 whatever cameraZoom you authored at, and `0.6` shows 60% of that. */
339
+ cameraMinVisibleWidth?: number;
289
340
  /** Film grain amount (post pass). */
290
341
  grain: number;
291
342
  /** Soft-focus / spin blur amount (post pass). */
@@ -373,5 +424,5 @@ declare function normalizeSceneInteraction(config: StudioConfig): void;
373
424
  * well as freshly loaded save-states / share links. */
374
425
  declare function ensureStudioConfig(input: StudioConfig): StudioConfig;
375
426
  //#endregion
376
- export { BackgroundImageFit, BackgroundMode, BasicGradientType, BlendMode, 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 };
427
+ 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 };
377
428
  //# sourceMappingURL=model.d.ts.map
@@ -12,6 +12,13 @@ const MAX_LIGHTS = 8;
12
12
  const MAX_NOISE_BANDS = 4;
13
13
  /** Cap on stacked waves (keeps total geometry bounded — see WaveRenderer segment scaling). */
14
14
  const MAX_WAVES = 6;
15
+ /** Runtime whitelist for {@link CameraFit} (validating imported/serialized configs). */
16
+ const CAMERA_FITS = [
17
+ "cover",
18
+ "contain",
19
+ "width",
20
+ "height"
21
+ ];
15
22
  /** A default light; pass overrides for added fill lights. */
16
23
  function createLight(position = {
17
24
  x: 300,
@@ -112,6 +119,9 @@ const WAVE_TARGET_NAMES = [
112
119
  "twistFrequencyX",
113
120
  "twistFrequencyY",
114
121
  "twistFrequencyZ",
122
+ "helixPhase",
123
+ "helixTurns",
124
+ "helixRadius",
115
125
  "hueShift",
116
126
  "gradientShift",
117
127
  "colorSaturation",
@@ -256,10 +266,16 @@ function defaultWave() {
256
266
  z: 6.33
257
267
  },
258
268
  twistMotion: false,
269
+ helixTurns: 0,
270
+ helixRadius: 0,
271
+ helixRoll: 0,
272
+ helixPhase: 0,
259
273
  theme: "solid",
260
274
  lineAmount: 425,
261
275
  lineThickness: 1,
262
276
  lineDerivativePower: .95,
277
+ rungAmount: 0,
278
+ rungThickness: 1,
263
279
  maxWidth: 1232,
264
280
  position: {
265
281
  x: -24.3,
@@ -338,6 +354,8 @@ function createDefaultConfig() {
338
354
  z: 0
339
355
  },
340
356
  cameraZoom: 1,
357
+ cameraFit: "cover",
358
+ cameraMinVisibleWidth: 0,
341
359
  grain: 1.1,
342
360
  blur: .02,
343
361
  blurSamples: 6,
@@ -368,7 +386,8 @@ function createDefaultConfig() {
368
386
  * ColorStop[]; mesh points + texture transform are clamped). */
369
387
  function normalizeWaveColour(config) {
370
388
  const p = config.palette;
371
- 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);
372
391
  if (config.gradientType !== "radial" && config.gradientType !== "conic" && config.gradientType !== "mesh" && config.gradientType !== "linear") config.gradientType = "linear";
373
392
  const rawMeshPoints = config.meshGradientPoints;
374
393
  if (!Array.isArray(rawMeshPoints) || rawMeshPoints.length < 2) config.meshGradientPoints = createDefaultMeshPoints();
@@ -405,6 +424,8 @@ function normalizeWaveColour(config) {
405
424
  }
406
425
  /** Backfill background styling for states saved before gradient/image backgrounds existed. */
407
426
  function normalizeBackground(config) {
427
+ if (typeof config.background !== "string") config.background = "#ffffff";
428
+ if (typeof config.transparentBackground !== "boolean") config.transparentBackground = true;
408
429
  if (config.backgroundMode !== "gradient" && config.backgroundMode !== "image" && config.backgroundMode !== "color") config.backgroundMode = "color";
409
430
  const palette = config.backgroundPalette;
410
431
  if (!palette || palette.length < 2) config.backgroundPalette = makeStops([
@@ -419,11 +440,11 @@ function normalizeBackground(config) {
419
440
  if (!Array.isArray(bgMesh) || bgMesh.length < 2) config.backgroundMeshPoints = createDefaultMeshPoints();
420
441
  if (!Number.isFinite(config.backgroundMeshSoftness)) config.backgroundMeshSoftness = .62;
421
442
  config.backgroundMeshSoftness = clamp01(config.backgroundMeshSoftness);
422
- if (typeof config.backgroundGradientAngle !== "number") config.backgroundGradientAngle = 135;
443
+ if (!Number.isFinite(config.backgroundGradientAngle)) config.backgroundGradientAngle = 135;
423
444
  if (typeof config.backgroundGradientSource !== "string") config.backgroundGradientSource = "stops";
424
445
  if (typeof config.backgroundImageSource !== "string") config.backgroundImageSource = "vaporwave";
425
446
  if (config.backgroundImageFit !== "contain" && config.backgroundImageFit !== "stretch" && config.backgroundImageFit !== "cover") config.backgroundImageFit = "cover";
426
- if (typeof config.backgroundImageZoom !== "number") config.backgroundImageZoom = 1;
447
+ if (!Number.isFinite(config.backgroundImageZoom)) config.backgroundImageZoom = 1;
427
448
  config.backgroundImageZoom = clamp(config.backgroundImageZoom, .1, 8);
428
449
  if (!config.backgroundImagePosition) config.backgroundImagePosition = {
429
450
  x: 0,
@@ -446,43 +467,46 @@ function ensureCamera(config) {
446
467
  y: 0,
447
468
  z: 0
448
469
  };
449
- if (typeof config.cameraZoom !== "number") config.cameraZoom = 1;
470
+ if (!Number.isFinite(config.cameraZoom)) config.cameraZoom = 1;
471
+ if (!CAMERA_FITS.includes(config.cameraFit)) config.cameraFit = "cover";
472
+ config.cameraMinVisibleWidth = typeof config.cameraMinVisibleWidth === "number" ? clamp(config.cameraMinVisibleWidth, 0, 1) : 0;
450
473
  }
451
474
  /** Backfill/repair a wave so the renderer can consume it (covers partial wave-model JSON). */
452
475
  function normalizeWave(s) {
453
476
  normalizeWaveColour(s);
454
- if (typeof s.gradientAngle !== "number") s.gradientAngle = 90;
455
- 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;
456
479
  if (typeof s.usePaletteTexture !== "boolean") s.usePaletteTexture = true;
457
480
  if (typeof s.paletteSource !== "string") s.paletteSource = "hero";
458
481
  if (typeof s.paletteEdgeColor !== "string") s.paletteEdgeColor = "#8e9dff";
459
- if (typeof s.paletteEdgeAmount !== "number") s.paletteEdgeAmount = .3;
460
- if (typeof s.paletteDriftX !== "number") s.paletteDriftX = 0;
461
- if (typeof s.paletteDriftY !== "number") s.paletteDriftY = 0;
462
- if (typeof s.hueShift !== "number") s.hueShift = 0;
463
- if (typeof s.colorContrast !== "number") s.colorContrast = 1;
464
- if (typeof s.colorSaturation !== "number") s.colorSaturation = 1;
465
- if (typeof s.fiberCount !== "number") s.fiberCount = 600;
466
- 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;
467
490
  if (!Array.isArray(s.noiseBands)) s.noiseBands = [];
468
- if (typeof s.texture !== "number") s.texture = 0;
469
- if (typeof s.creaseLight !== "number") s.creaseLight = .6;
470
- if (typeof s.creaseSharpness !== "number") s.creaseSharpness = .589;
471
- if (typeof s.creaseSoftness !== "number") s.creaseSoftness = 1;
472
- if (typeof s.sheen !== "number") s.sheen = 0;
473
- if (typeof s.roundness !== "number") s.roundness = 0;
474
- if (typeof s.iridescence !== "number") s.iridescence = 0;
475
- if (typeof s.edgeFade !== "number") s.edgeFade = .04;
476
- if (typeof s.edgeFeather !== "number") s.edgeFeather = .1;
477
- 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;
478
502
  if (typeof s.depthTintColor !== "string") s.depthTintColor = "#0a2540";
479
503
  if (!s.displaceFrequency) s.displaceFrequency = {
480
504
  x: .003234,
481
505
  y: .00799
482
506
  };
483
- if (typeof s.displaceAmount !== "number") s.displaceAmount = 6.051;
484
- if (typeof s.detailFrequency !== "number") s.detailFrequency = .04;
485
- 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;
486
510
  if (!s.twistFrequency) s.twistFrequency = {
487
511
  x: -.055,
488
512
  y: .077,
@@ -493,11 +517,18 @@ function normalizeWave(s) {
493
517
  y: 5.85,
494
518
  z: 6.33
495
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;
496
525
  if (typeof s.theme !== "string") s.theme = "solid";
497
- if (typeof s.lineAmount !== "number") s.lineAmount = 425;
498
- if (typeof s.lineThickness !== "number") s.lineThickness = 1;
499
- if (typeof s.lineDerivativePower !== "number") s.lineDerivativePower = .95;
500
- 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;
501
532
  if (!s.position) s.position = {
502
533
  x: 0,
503
534
  y: 0,
@@ -514,44 +545,46 @@ function normalizeWave(s) {
514
545
  z: 7
515
546
  };
516
547
  if (typeof s.blendMode !== "string") s.blendMode = "squared";
517
- if (typeof s.speed !== "number") s.speed = .04;
518
- if (typeof s.opacity !== "number") s.opacity = 1;
519
- 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;
520
551
  if (s.interaction) normalizeWaveInteraction(s);
521
552
  }
522
553
  /** Backfill scene-level defaults (background/camera/post/lights/quality/mirror). */
523
554
  function ensureSceneDefaults(config) {
524
555
  normalizeBackground(config);
525
556
  ensureCamera(config);
526
- if (typeof config.ambient !== "number") config.ambient = .45;
557
+ if (!Number.isFinite(config.ambient)) config.ambient = .45;
527
558
  if (!Array.isArray(config.lights)) config.lights = [];
528
- if (typeof config.quality !== "number") config.quality = 1;
529
- if (typeof config.dprMax !== "number") config.dprMax = 2;
530
- if (typeof config.grain !== "number") config.grain = 1.1;
531
- if (typeof config.blur !== "number") config.blur = .02;
532
- if (typeof config.blurSamples !== "number") config.blurSamples = 6;
533
- if (typeof config.bloomStrength !== "number") config.bloomStrength = 0;
534
- if (typeof config.bloomRadius !== "number") config.bloomRadius = .4;
535
- if (typeof config.bloomThreshold !== "number") config.bloomThreshold = .85;
536
- if (typeof config.dither !== "number") config.dither = 0;
537
- if (typeof config.ditherScale !== "number") config.ditherScale = 2;
538
- if (typeof config.ditherSteps !== "number") config.ditherSteps = 4;
539
- if (typeof config.innerLight !== "number") config.innerLight = 0;
540
- if (typeof config.innerLightDensity !== "number") config.innerLightDensity = .5;
541
- if (typeof config.innerLightDecay !== "number") config.innerLightDecay = .95;
542
- if (typeof config.innerLightX !== "number") config.innerLightX = .5;
543
- if (typeof config.innerLightY !== "number") config.innerLightY = .15;
544
- if (typeof config.halftone !== "number") config.halftone = 0;
545
- if (typeof config.halftoneCell !== "number") config.halftoneCell = 6;
546
- if (typeof config.halftoneAngle !== "number") config.halftoneAngle = .4;
547
- if (typeof config.heatmap !== "number") config.heatmap = 0;
548
- if (typeof config.paperTexture !== "number") config.paperTexture = 0;
549
- if (typeof config.paperTextureScale !== "number") config.paperTextureScale = 2;
550
- if (typeof config.halftoneCmyk !== "number") config.halftoneCmyk = 0;
551
- 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;
552
584
  if (typeof config.showCameraRig !== "boolean") config.showCameraRig = false;
553
585
  if (typeof config.paused !== "boolean") config.paused = false;
554
- 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;
555
588
  if (typeof config.mirrorH !== "boolean") config.mirrorH = false;
556
589
  if (typeof config.mirrorV !== "boolean") config.mirrorV = false;
557
590
  }
@@ -560,6 +593,45 @@ function clampNumber(v, min, max, dflt) {
560
593
  const n = Number(v);
561
594
  return Number.isFinite(n) ? clamp(n, min, max) : dflt;
562
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
+ }
563
635
  /** True for a valid interaction source string: a built-in name or a non-empty `custom:<name>`. */
564
636
  function isInteractionSource(v) {
565
637
  return typeof v === "string" && (INTERACTION_SOURCE_NAMES.includes(v) || v.startsWith("custom:") && v.length > 7);
@@ -634,6 +706,6 @@ function ensureStudioConfig(input) {
634
706
  return config;
635
707
  }
636
708
  //#endregion
637
- export { 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 };
709
+ 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 };
638
710
 
639
711
  //# sourceMappingURL=model.js.map